Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,13 @@ import (
|
||||
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
||||
|
||||
func emptyStore() *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{}}
|
||||
return &fakeStore{downloads: map[string]*store.Download{}}
|
||||
}
|
||||
|
||||
// findByInfohash возвращает усыновлённую задачу по infohash.
|
||||
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
||||
for _, d := range st.downloads {
|
||||
if d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, []string{infohash}) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
||||
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
||||
t.Errorf("adopted = %+v", d)
|
||||
}
|
||||
if d.IdempotencyKey.String != ihDisc {
|
||||
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
||||
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 {
|
||||
t.Errorf("infohashes = %+v", d.Infohashes)
|
||||
}
|
||||
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
||||
// время добавления (added_on) как базис сортировки.
|
||||
@@ -79,8 +79,8 @@ func TestDiscover_SkipsUntracked(t *testing.T) {
|
||||
func TestDiscover_SkipsExisting(t *testing.T) {
|
||||
st := emptyStore()
|
||||
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
||||
st.downloads[1] = &store.Download{
|
||||
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
|
||||
st.downloads["1"] = &store.Download{
|
||||
ID: "1", State: store.StateDone, Infohashes: hashesOf("1", ihDisc),
|
||||
}
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
@@ -104,9 +104,9 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
||||
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
||||
st := &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
|
||||
st := &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohashes: hashesOf("1", ih)},
|
||||
}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
||||
@@ -116,7 +116,7 @@ func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
|
||||
if d := st.downloads["1"]; !d.SourceAddedAt.Valid {
|
||||
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
||||
}
|
||||
}
|
||||
@@ -160,14 +160,60 @@ func TestHasTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstInfohash(t *testing.T) {
|
||||
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
|
||||
t.Errorf("got %q", got)
|
||||
func TestTorrentHashes(t *testing.T) {
|
||||
got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"})
|
||||
if len(got) != 2 || got[0] != "abc" || got[1] != "def" {
|
||||
t.Errorf("got %v, want [abc def] (lowercase, без дублей, v1 первым)", got)
|
||||
}
|
||||
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
|
||||
t.Errorf("got %q", got)
|
||||
if got := torrentHashes(qbt.Torrent{}); len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
if got := firstInfohash(qbt.Torrent{}); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
// Старый qBittorrent без infohash_v1/v2 — берём hash.
|
||||
if got := torrentHashes(qbt.Torrent{Hash: "ABC"}); len(got) != 1 || got[0] != "abc" {
|
||||
t.Errorf("legacy hash: got %v, want [abc]", got)
|
||||
}
|
||||
// v2-only: t.Hash — УСЕЧЁННЫЙ v2 (40 hex), хранить его нельзя.
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
got = torrentHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2})
|
||||
if len(got) != 1 || got[0] != v2 {
|
||||
t.Errorf("v2-only: got %v, want только полный v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
|
||||
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
|
||||
func TestDiscover_AdoptsV2Only(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
st := emptyStore()
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
{Hash: v2[:40], InfohashV2: v2, Name: "V2Only", Category: "jellybit", State: "downloading"},
|
||||
})
|
||||
d := findByInfohash(st, v2)
|
||||
if d == nil {
|
||||
t.Fatal("v2-only раздача не усыновлена")
|
||||
}
|
||||
if d.SourceRef != "magnet:?xt=urn:btmh:1220"+v2 {
|
||||
t.Errorf("SourceRef = %q, want btmh с полным v2", d.SourceRef)
|
||||
}
|
||||
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV2 {
|
||||
t.Errorf("infohashes = %+v, want один v2 (усечённый не хранится)", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление гибридного торрента записывает оба хеша.
|
||||
func TestDiscover_AdoptsBothHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
st := emptyStore()
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
{Hash: ihDisc, InfohashV1: ihDisc, InfohashV2: v2, Name: "Hybrid", Category: "jellybit", State: "downloading"},
|
||||
})
|
||||
d := findByInfohash(st, v2)
|
||||
if d == nil {
|
||||
t.Fatal("гибридная раздача не находится по v2-хешу")
|
||||
}
|
||||
if len(d.Infohashes) != 2 {
|
||||
t.Errorf("infohashes = %+v, want v1+v2", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user