Идентичность на 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:
av
2026-07-02 21:25:00 +03:00
co-authored by Claude Fable 5
parent b808ceff25
commit 37f2f6481a
53 changed files with 3640 additions and 1035 deletions
+21 -21
View File
@@ -31,11 +31,11 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
}
st := newMemStore()
d := completedDownload(1)
d := completedDownload("1")
d.State = state
st.put(d)
st.links = append(st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
Kind: "video", Status: "linked",
})
@@ -65,7 +65,7 @@ func TestReconcileMatrix(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != tc.want {
if got := f.st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -78,7 +78,7 @@ func TestReconcileHealing(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done (healing)", got)
}
}
@@ -88,13 +88,13 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
f := newReconcileFixture(t, store.StateDone, true, true)
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
f.st.links = append(f.st.links, store.FileLink{
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
Kind: "subtitle", Status: "linked",
})
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
}
}
@@ -106,7 +106,7 @@ func TestReconcileSkipsDeleted(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDeleted {
if got := f.st.downloads["1"].State; got != store.StateDeleted {
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
}
}
@@ -121,17 +121,17 @@ func TestReconcileDebounce(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll %d: %v", i, err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
}
if got := f.st.downloads[1].SourceMissCount; got != i {
if got := f.st.downloads["1"].SourceMissCount; got != i {
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
}
}
if err := f.w.Poll(context.Background()); err != nil { // третий промах
t.Fatalf("Poll 3: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
}
@@ -140,10 +140,10 @@ func TestReconcileDebounce(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll heal: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDone {
if got := f.st.downloads["1"].State; got != store.StateDone {
t.Errorf("state = %q, want done (источник вернулся)", got)
}
if got := f.st.downloads[1].SourceMissCount; got != 0 {
if got := f.st.downloads["1"].SourceMissCount; got != 0 {
t.Errorf("miss = %d, want 0 (сброс)", got)
}
}
@@ -154,18 +154,18 @@ func TestReconcileSkipsActiveStates(t *testing.T) {
if err := f.w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateDownloading {
if got := f.st.downloads["1"].State; got != store.StateDownloading {
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
}
}
func TestUndoRejectedForOrphaned(t *testing.T) {
f := newReconcileFixture(t, store.StateOrphaned, false, true)
err := f.w.Undo(context.Background(), 1)
err := f.w.Undo(context.Background(), "1")
if err == nil {
t.Fatal("ожидали отказ Undo для orphaned")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (без изменений)", got)
}
}
@@ -173,14 +173,14 @@ func TestUndoRejectedForOrphaned(t *testing.T) {
func TestRelinkFromTargetMissing(t *testing.T) {
// target_missing + источник на месте → relink ведёт в recognizing.
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
if err := f.w.Relink(context.Background(), 1); err != nil {
if err := f.w.Relink(context.Background(), "1"); err != nil {
t.Fatalf("Relink: %v", err)
}
if got := f.st.downloads[1].State; got != store.StateRecognizing {
if got := f.st.downloads["1"].State; got != store.StateRecognizing {
t.Errorf("state = %q, want recognizing", got)
}
if f.st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
if f.st.overrides["1"][ovrForceReview] != "1" {
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
}
}
@@ -189,10 +189,10 @@ func TestPreflightFixesStaleState(t *testing.T) {
// а цель на месте: relink немедленно приводит состояние к orphaned, не
// дожидаясь фоновой сверки.
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
if err := f.w.Relink(context.Background(), 1); err == nil {
if err := f.w.Relink(context.Background(), "1"); err == nil {
t.Fatal("ожидали отказ relink при пропавшем источнике")
}
if got := f.st.downloads[1].State; got != store.StateOrphaned {
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
}
}