Идентичность на 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
+165 -47
View File
@@ -2,6 +2,7 @@ package worker
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
@@ -20,12 +21,12 @@ const (
)
type fakeStore struct {
downloads map[int64]*store.Download
downloads map[string]*store.Download
transitions []transition
}
type transition struct {
id int64
id string
state store.State
}
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
return out, nil
}
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
d, ok := f.downloads[id]
if !ok {
return nil, fmt.Errorf("download %d not found", id)
return nil, fmt.Errorf("download %s not found", id)
}
cp := *d
return &cp, nil
}
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
func hasAnyHash(d *store.Download, hashes []string) bool {
for _, own := range d.Infohashes {
for _, h := range hashes {
if own.Infohash == store.NormalizeHash(h) {
return true
}
}
}
return false
}
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
if hasAnyHash(d, hashes) {
return true, nil
}
}
return false, nil
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
return nil, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1)
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
return existing, nil
}
id := fmt.Sprintf("%d", len(f.downloads)+1)
cp := *d
cp.ID = id
for _, h := range hashes {
h = store.NormalizeHash(h)
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
f.downloads[id] = &cp
return id, nil
d.ID = id
d.Infohashes = cp.Infohashes
return nil, nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
for _, other := range f.downloads {
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
}
}
return f.SetDownloadState(ctx, id, st, code, msg)
}
func hashList(d *store.Download) []string {
out := make([]string, len(d.Infohashes))
for i, h := range d.Infohashes {
out[i] = h.Infohash
}
return out
}
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
for _, h := range hashes {
h = store.NormalizeHash(h)
if !hasAnyHash(d, []string{h}) {
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
}
return nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
}
d.State = st
d.ErrorCode = store.NullString(code)
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
return nil
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
d.SourceMissCount = n
return nil
}
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %d not found", id)
return fmt.Errorf("download %s not found", id)
}
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
d.SourceAddedAt = store.NullString(store.FormatTime(t))
@@ -128,23 +185,23 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) e
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
return 0, nil
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
return "", nil
}
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) {
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
return nil, nil
}
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
return nil, nil
}
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
return nil
}
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
return nil, nil
}
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
type fakeQbt struct {
torrents []qbt.Torrent
added []qbt.AddRequest
addErr error
files []qbt.File
}
@@ -184,6 +242,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
if f.addErr != nil {
return f.addErr
}
f.added = append(f.added, ar)
return nil
}
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
}
func oneDownloading(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: store.StateDownloading,
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
Infohashes: hashesOf("1", infohash),
CreatedAt: createdAt,
},
}}
}
// hashesOf — срез хешей загрузки для литералов фикстур.
func hashesOf(id string, hashes ...string) []store.Infohash {
out := make([]store.Infohash, 0, len(hashes))
for _, h := range hashes {
h = store.NormalizeHash(h)
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
}
return out
}
func TestPollTransitions(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
tests := []struct {
@@ -242,7 +313,7 @@ func TestPollTransitions(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)
}
})
@@ -257,8 +328,8 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateCompleted {
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateCompleted {
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
}
}
@@ -269,46 +340,93 @@ func TestPollIgnoresMissingTorrent(t *testing.T) {
if err := w.Poll(context.Background()); err != nil {
t.Fatal(err)
}
if st.downloads[1].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
}
}
func TestCancel(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
w := newTestWorker(st, &fakeQbt{})
if err := w.Cancel(context.Background(), 1); err != nil {
if err := w.Cancel(context.Background(), "1"); err != nil {
t.Fatalf("Cancel: %v", err)
}
if st.downloads[1].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
if st.downloads["1"].State != store.StateCancelled {
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
}
// Повторная отмена терминальной задачи — ошибка.
if err := w.Cancel(context.Background(), 1); err == nil {
if err := w.Cancel(context.Background(), "1"); err == nil {
t.Error("ожидалась ошибка при отмене терминальной задачи")
}
}
func TestRetry(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
st.downloads[1].State = store.StateStuck
st.downloads["1"].State = store.StateStuck
qb := &fakeQbt{}
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.Errorf("state = %q, want downloading", st.downloads[1].State)
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
if len(qb.added) != 1 {
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
}
}
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
// добавляется в qBittorrent повторно.
func TestRetryConflictNoAdd(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
// Хешем владеет другая активная задача.
st.downloads["2"] = &store.Download{
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
}
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
t.Fatalf("ожидался ErrConflict, получили %v", err)
}
if len(qb.added) != 0 {
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %s, want failed", st.downloads["1"].State)
}
}
// Если после активации повторный Add в qBittorrent упал — задача
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
func TestRetryRollsBackOnAddFailure(t *testing.T) {
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
st := oneDownloading(ih, timeRecent)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
w := newTestWorker(st, qb)
if err := w.Retry(context.Background(), "1"); err == nil {
t.Fatal("ожидалась ошибка Add")
}
if st.downloads["1"].State != store.StateFailed {
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
}
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
}
}
func TestRetryRejectsActive(t *testing.T) {
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
w := newTestWorker(st, &fakeQbt{})
if err := w.Retry(context.Background(), 1); err == nil {
if err := w.Retry(context.Background(), "1"); err == nil {
t.Error("retry активной (downloading) задачи должен отклоняться")
}
}