Идентичность на 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user