Идентичность на 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:
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
@@ -19,29 +20,39 @@ const sampleInfohash = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
type fakeStore struct {
|
||||
active *store.Download
|
||||
created []store.Download
|
||||
nextID int64
|
||||
hashes [][]string
|
||||
toppedUp []string
|
||||
stateCalls []stateCall
|
||||
}
|
||||
|
||||
type stateCall struct {
|
||||
id int64
|
||||
id string
|
||||
state store.State
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ string) (*store.Download, error) {
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
|
||||
return f.active, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
f.nextID++
|
||||
d.ID = f.nextID
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if f.active != nil {
|
||||
return f.active, nil
|
||||
}
|
||||
d.ID = ident.NewID()
|
||||
f.created = append(f.created, *d)
|
||||
return f.nextID, nil
|
||||
f.hashes = append(f.hashes, hashes)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
||||
f.toppedUp = append(f.toppedUp, hashes...)
|
||||
_ = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
f.stateCalls = append(f.stateCalls, stateCall{id, st, code, msg})
|
||||
return nil
|
||||
}
|
||||
@@ -90,8 +101,8 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if res.Infohash != sampleInfohash {
|
||||
t.Errorf("infohash = %q", res.Infohash)
|
||||
if len(res.Infohashes) != 1 || res.Infohashes[0] != sampleInfohash {
|
||||
t.Errorf("infohashes = %v", res.Infohashes)
|
||||
}
|
||||
if res.State != store.StateDownloading || res.Deduplicated {
|
||||
t.Errorf("res = %+v", res)
|
||||
@@ -99,9 +110,12 @@ func TestIngestHappyPath(t *testing.T) {
|
||||
if len(fs.created) != 1 {
|
||||
t.Fatalf("создано задач: %d, want 1", len(fs.created))
|
||||
}
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" || got.Infohash.String != sampleInfohash {
|
||||
if got := fs.created[0]; got.Context != "Дюна 2" {
|
||||
t.Errorf("сохранённая задача: %+v", got)
|
||||
}
|
||||
if len(fs.hashes) != 1 || len(fs.hashes[0]) != 1 || fs.hashes[0][0] != sampleInfohash {
|
||||
t.Errorf("хеши задачи: %v", fs.hashes)
|
||||
}
|
||||
if len(fq.added) != 1 {
|
||||
t.Fatalf("вызовов qbt.Add: %d, want 1", len(fq.added))
|
||||
}
|
||||
@@ -149,15 +163,15 @@ func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIngestIdempotent(t *testing.T) {
|
||||
existing := &store.Download{ID: 7, State: store.StateDownloading}
|
||||
existing := &store.Download{ID: "01hzzzexisting000000000000", State: store.StateDownloading}
|
||||
fs := &fakeStore{active: existing}
|
||||
fq := &fakeQbt{}
|
||||
res, err := newService(fs, fq).Ingest(context.Background(), Request{Source: sampleMagnet})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated || res.DownloadID != 7 {
|
||||
t.Errorf("ожидалось присоединение к задаче 7: %+v", res)
|
||||
if !res.Deduplicated || res.DownloadID != existing.ID {
|
||||
t.Errorf("ожидалось присоединение к существующей задаче: %+v", res)
|
||||
}
|
||||
if len(fs.created) != 0 {
|
||||
t.Error("не должно создаваться новой задачи")
|
||||
@@ -167,6 +181,29 @@ func TestIngestIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Быстрый дедуп-путь доносит существующей задаче недостающие хеши
|
||||
// гибридного magnet (иначе последующий приём по второму хешу создал бы
|
||||
// вторую активную задачу).
|
||||
func TestIngestDedupTopsUpHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
existing := &store.Download{
|
||||
ID: "01hzzzexisting000000000000", State: store.StateDownloading,
|
||||
Infohashes: []store.Infohash{{DownloadID: "01hzzzexisting000000000000", Infohash: sampleInfohash, Kind: store.HashV1}},
|
||||
}
|
||||
fs := &fakeStore{active: existing}
|
||||
res, err := newService(fs, &fakeQbt{}).Ingest(context.Background(),
|
||||
Request{Source: sampleMagnet + "&xt=urn:btmh:1220" + v2})
|
||||
if err != nil {
|
||||
t.Fatalf("Ingest: %v", err)
|
||||
}
|
||||
if !res.Deduplicated {
|
||||
t.Fatalf("ожидался дедуп: %+v", res)
|
||||
}
|
||||
if len(fs.toppedUp) != 2 {
|
||||
t.Errorf("хеши не донесены существующей задаче: %v", fs.toppedUp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestQbitErrorMarksFailed(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
@@ -186,16 +223,16 @@ func TestIngestQbitErrorNotifies(t *testing.T) {
|
||||
fs := &fakeStore{}
|
||||
fq := &fakeQbt{err: errors.New("connection refused")}
|
||||
svc := newService(fs, fq)
|
||||
got := make(chan int64, 1)
|
||||
svc.SetFailureNotifier(func(id int64) { got <- id })
|
||||
got := make(chan string, 1)
|
||||
svc.SetFailureNotifier(func(id string) { got <- id })
|
||||
|
||||
if _, err := svc.Ingest(context.Background(), Request{Source: sampleMagnet}); err == nil {
|
||||
t.Fatal("ожидалась ошибка")
|
||||
}
|
||||
select {
|
||||
case id := <-got:
|
||||
if id == 0 {
|
||||
t.Errorf("уведомление с нулевым id")
|
||||
if id == "" {
|
||||
t.Errorf("уведомление с пустым id")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("уведомление о падении приёма не пришло")
|
||||
|
||||
Reference in New Issue
Block a user