Идентичность на 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
+317 -41
View File
@@ -2,6 +2,9 @@ package store
import (
"context"
"errors"
"strings"
"sync"
"testing"
)
@@ -15,24 +18,39 @@ func newTestStore(t *testing.T) *Store {
return st
}
func newDownloading(infohash string) *Download {
func newDownloading() *Download {
return &Download{
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Context: "ctx",
Infohash: NullString(infohash),
IdempotencyKey: NullString(infohash),
State: StateDownloading,
SourceType: SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:test",
Context: "ctx",
State: StateDownloading,
}
}
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
// существующую активную — ошибка теста.
func mustCreate(t *testing.T, st *Store, infohash string) string {
t.Helper()
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
if err != nil {
t.Fatalf("create: %v", err)
}
if existing != nil {
t.Fatalf("unexpected dedup to %s", existing.ID)
}
return d.ID
}
func TestCreateAndGetDownload(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
if err != nil {
t.Fatalf("create: %v", err)
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
id := mustCreate(t, st, ih)
if len(id) != 26 || id != strings.ToLower(id) {
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
}
got, err := st.GetDownload(ctx, id)
@@ -48,6 +66,12 @@ func TestCreateAndGetDownload(t *testing.T) {
if got.CreatedAt == "" {
t.Error("created_at пуст")
}
if len(got.Infohashes) != 1 {
t.Fatalf("infohashes = %v, want 1", got.Infohashes)
}
if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
t.Errorf("infohash = %+v, want lowercase v1", h)
}
}
func TestFindActiveByInfohash(t *testing.T) {
@@ -59,10 +83,7 @@ func TestFindActiveByInfohash(t *testing.T) {
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
}
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, st, ih)
d, err := st.FindActiveByInfohash(ctx, ih)
if err != nil {
t.Fatal(err)
@@ -70,6 +91,9 @@ func TestFindActiveByInfohash(t *testing.T) {
if d == nil || d.ID != id {
t.Fatalf("активная задача не найдена: %v", d)
}
if len(d.Infohashes) != 1 {
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
}
}
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
@@ -81,12 +105,9 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
t.Run(string(st), func(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
ih := "33333333333333333333333333333333333333" + string(st[0:2])
const ih = "3333333333333333333333333333333333333333"
id, err := store.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, store, ih)
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
t.Fatal(err)
}
@@ -97,17 +118,15 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
}
}
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
// тот же infohash заново (повторная закачка спустя время).
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
// новой задачей (повторная закачка спустя время) — активность выводится
// только из state.
func TestTerminalReleasesInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "2222222222222222222222222222222222222222"
id, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatal(err)
}
id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
t.Fatal(err)
}
@@ -116,39 +135,291 @@ func TestTerminalReleasesInfohash(t *testing.T) {
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
}
// Ключ идемпотентности снят.
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if got.IdempotencyKey.Valid {
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
}
if got.ErrorCode.String != "qbit_add" {
t.Errorf("error_code = %q", got.ErrorCode.String)
}
// Тот же infohash заводится заново — unique index не мешает.
id2, err := st.CreateDownload(ctx, newDownloading(ih))
if err != nil {
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
}
// Тот же infohash заводится заново новой задачей.
id2 := mustCreate(t, st, ih)
if id2 == id {
t.Error("ожидалась новая задача")
}
}
// Две активные задачи с одним ключом идемпотентности недопустимы.
func TestActiveDuplicateRejected(t *testing.T) {
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
// создаёт строку, а возвращает существующую активную задачу.
func TestActiveDuplicateDeduplicated(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "3333333333333333333333333333333333333333"
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
id := mustCreate(t, st, ih)
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
t.Error("ожидалось нарушение уникальности idempotency_key")
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
}
if len(existing.Infohashes) != 1 {
t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
}
}
// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
// новый приём приходит только с v2.
func TestDedupByAnyHash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const v1 = "4444444444444444444444444444444444444444"
const v2 = "6666666666666666666666666666666666666666666666666666666666666666"
id := mustCreate(t, st, v1)
if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
t.Fatal(err)
}
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
if err != nil {
t.Fatal(err)
}
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
}
// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
// идемпотентно.
if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
t.Fatal(err)
}
got, err := st.GetDownload(ctx, id)
if err != nil {
t.Fatal(err)
}
if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
}
if got.PrimaryInfohash() != v1 {
t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
}
}
// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
// задача, и пропускает, когда владелец ушёл в терминал.
func TestActivateIfNoOtherActive(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "5555555555555555555555555555555555555555"
id1 := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
t.Fatal(err)
}
// Хеш перехватила новая активная задача.
id2 := mustCreate(t, st, ih)
err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
}
if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
t.Fatalf("задача не должна была активироваться: %s", d.State)
}
// Владелец завершился → активация проходит.
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
t.Fatal(err)
}
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
t.Fatalf("активация после ухода владельца: %v", err)
}
if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
t.Fatalf("state = %s, want downloading", d.State)
}
}
// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
func TestCreateDedupTopsUpHashes(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const v1 = "aaaa111111111111111111111111111111111111"
const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"
id := mustCreate(t, st, v1)
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
if err != nil {
t.Fatal(err)
}
if existing == nil || existing.ID != id {
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
}
if len(existing.Infohashes) != 2 {
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
}
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
if err != nil {
t.Fatal(err)
}
if byV2 == nil || byV2.ID != id {
t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
}
}
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
// не дописывается — возвращается ErrInfohashTaken.
func TestAddInfohashesGuard(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const h1 = "cccc111111111111111111111111111111111111"
const h2 = "dddd222222222222222222222222222222222222"
a := mustCreate(t, st, h1)
_ = mustCreate(t, st, h2) // активный владелец h2
err := st.AddInfohashes(ctx, a, []string{h2})
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
}
got, _ := st.GetDownload(ctx, a)
if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
}
// Хеш терминального владельца дописывается свободно.
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
t.Fatal(err)
}
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
}
}
// Гард активации не маскирует конфликт самой активируемой задачей: stuck
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
func TestActivateExcludesSelf(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const h = "ffff111111111111111111111111111111111111"
older := mustCreate(t, st, h) // активный владелец, id старше
newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
t.Fatal(err)
}
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
// такого не создаст, но обязан не маскировать).
if _, err := st.DB.ExecContext(ctx,
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, 'v1')`,
newer, h); err != nil {
t.Fatal(err)
}
err := st.ActivateIfNoOtherActive(ctx, newer, StateDownloading, "", "")
if !errors.Is(err, ErrInfohashTaken) {
t.Fatalf("гард замаскирован self-строкой: ожидался ErrInfohashTaken, получили %v", err)
}
_ = older
}
// Механический бэкстоп: публичный SetDownloadState не оживляет терминальную
// задачу — возврат в активное только через ActivateIfNoOtherActive.
func TestSetDownloadStateRejectsRevive(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id := mustCreate(t, st, "1234511111111111111111111111111111111111")
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
t.Fatal(err)
}
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
t.Fatal("терминал→активное мимо гарда должно отклоняться")
}
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
t.Fatalf("state = %s, want failed (без изменений)", d.State)
}
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
t.Fatalf("терминал→терминал должен проходить: %v", err)
}
// Штатный путь оживления работает.
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
t.Fatalf("оживление через гард: %v", err)
}
}
// Конкурентные создания одного infohash сериализуются write-транзакциями
// (_txlock=immediate): ровно одна задача создаётся, остальные дедупятся.
func TestConcurrentCreateDedup(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "abcde11111111111111111111111111111111111"
const n = 8
ids := make(chan string, n)
errs := make(chan error, n)
var wg sync.WaitGroup
for range n {
wg.Add(1)
go func() {
defer wg.Done()
d := newDownloading()
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
if err != nil {
errs <- err
return
}
if existing != nil {
ids <- existing.ID
} else {
ids <- d.ID
}
}()
}
wg.Wait()
close(ids)
close(errs)
for err := range errs {
t.Fatalf("конкурентное создание упало: %v", err)
}
uniq := map[string]bool{}
for id := range ids {
uniq[id] = true
}
if len(uniq) != 1 {
t.Fatalf("создано %d разных задач на один infohash, want 1: %v", len(uniq), uniq)
}
all, _ := st.ListDownloads(ctx)
if len(all) != 1 {
t.Fatalf("в БД %d строк, want 1", len(all))
}
}
func TestExistsByInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "7777777777777777777777777777777777777777"
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || ok {
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
}
id := mustCreate(t, st, ih)
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
t.Fatal(err)
}
// Exists видит и терминальные (в отличие от FindActive).
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
}
}
@@ -156,8 +427,8 @@ func TestListAndByState(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
t.Fatal(err)
}
@@ -169,6 +440,11 @@ func TestListAndByState(t *testing.T) {
if len(all) != 2 {
t.Fatalf("ListDownloads = %d, want 2", len(all))
}
for _, d := range all {
if len(d.Infohashes) != 1 {
t.Fatalf("у %s не подгружены хеши", d.ID)
}
}
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
if err != nil {