Идентичность на 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
+23 -23
View File
@@ -14,13 +14,13 @@ import (
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{
1: {
ID: 1,
return &fakeStore{downloads: map[string]*store.Download{
"1": {
ID: "1",
State: state,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
Infohashes: hashesOf("1", infohash),
ErrorCode: store.NullString(code),
CreatedAt: createdAt,
},
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
if got := st.downloads[1].State; got != tc.want {
if got := st.downloads["1"].State; got != tc.want {
t.Errorf("state = %q, want %q", got, tc.want)
}
})
@@ -68,8 +68,8 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
}
}
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
st.downloads["2"] = &store.Download{
ID: "2",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
Infohashes: hashesOf("2", ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
@@ -90,8 +90,8 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
}
}
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
st.downloads[2] = &store.Download{
ID: 2,
st.downloads["2"] = &store.Download{
ID: "2",
State: store.StateDownloading,
SourceType: store.SourceMagnet,
Infohash: store.NullString(ih),
Infohashes: hashesOf("2", ih),
CreatedAt: timeRecent,
}
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
@@ -113,8 +113,8 @@ func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateFailed {
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
}
}
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
w := newTestWorker(st, &fakeQbt{})
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
d := *st.downloads[1]
d := *st.downloads["1"]
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
if e := waitNotify(t, n); e.ev != EventFailed {
@@ -151,11 +151,11 @@ func TestRetryReattachesNoReadd(t *testing.T) {
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), 1); err != nil {
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads[1].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
}
if len(qb.added) != 0 {
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
@@ -164,7 +164,7 @@ func TestRetryReattachesNoReadd(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
}
}