Идентичность на 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
+64 -60
View File
@@ -50,7 +50,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
// под блокировкой фиксируем результат — но только если задачу за это время
// не увели в другое состояние (cancel/defer).
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
func (w *Worker) recognizeOne(ctx context.Context, id string) {
w.mu.Lock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
@@ -62,7 +62,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
w.mu.Unlock()
return
}
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
if d.State == store.StateCompleted {
w.transition(ctx, *d, store.StateRecognizing, "", "")
}
@@ -84,10 +84,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
// затем зовёт распознаватель. Возвращает также savePath для маппинга
// относительных путей файлов в абсолютные при раскладке.
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return recognize.Result{}, "", fmt.Errorf("no infohash")
}
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return recognize.Result{}, "", err
}
@@ -123,7 +123,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
log := logctx.From(ctx)
planJSON, err := json.Marshal(res.Plan)
if err != nil {
@@ -174,6 +174,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
log.Error("recognition persist failed", "error", err)
return
}
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
log.Info("recognition persisted", "recognition_id", recID,
"provider", provider, "provider_id", providerID)
// Кандидаты базы — для ручного выбора в review.
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
if err := w.store.CreateCandidates(ctx, cands); err != nil {
@@ -189,7 +192,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
forceReview := overrides[ovrForceReview] == "1"
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
w.transition(lctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
@@ -200,7 +203,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
}
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]string {
o, err := w.store.ListOverrides(ctx, id)
if err != nil {
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
@@ -213,7 +216,7 @@ func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
func (w *Worker) Apply(ctx context.Context, id int64) error {
func (w *Worker) Apply(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -225,15 +228,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: %w", err)
}
if d.State != store.StateReview && d.State != store.StateDeferred {
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
return fmt.Errorf("apply: download %s is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
plan, tag, err := w.effectivePlan(ctx, id)
if err != nil {
return fmt.Errorf("apply: %w", err)
}
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return fmt.Errorf("apply: lookup torrent: %w", err)
}
@@ -308,7 +311,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
}
w.transition(ctx, *d, store.StateDone, "", "")
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
logctx.From(ctx).Info("layout linked", "batch_id", batch, "links", len(fl))
return nil
}
@@ -317,7 +320,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте.
func (w *Worker) Relink(ctx context.Context, id int64) error {
func (w *Worker) Relink(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -326,28 +329,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
return fmt.Errorf("relink: %w", err)
}
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
}
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
// его отсутствии приводим состояние к реальности (orphaned/deleted).
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
return err
}
// Вернуть задачу в активную обработку можно, только если другой активной
// задачи на этот infohash нет (partial unique index по idempotency_key).
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if active != nil {
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
}
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err)
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
w.transition(ctx, *d, store.StateRecognizing, "", "")
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Возврат в активную обработку — только через атомарный гард инварианта
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
return fmt.Errorf("relink: для этого торрента уже есть активная задача: %w", ErrConflict)
}
return fmt.Errorf("relink: %w", err)
}
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
return nil
}
@@ -355,7 +356,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -366,14 +367,14 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
logctx.From(ctx).Info("review re-recognizing without hint")
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
hint = strings.TrimSpace(hint)
if hint == "" {
return fmt.Errorf("refine: empty hint")
@@ -388,7 +389,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err)
}
@@ -399,7 +400,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
// — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: invalid type %q", mediaType)
}
@@ -413,7 +414,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
return fmt.Errorf("set type: %w", err)
}
@@ -430,7 +431,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
// превью пересчитается с учётом правки.
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
src = strings.TrimSpace(src)
if src == "" {
return fmt.Errorf("ignore: empty path")
@@ -454,12 +455,12 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
return fmt.Errorf("ignore: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review file ignored", "src", src)
return nil
}
// Defer паркует задачу в deferred (вернётся в ревью по действию).
func (w *Worker) Defer(ctx context.Context, id int64) error {
func (w *Worker) Defer(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -468,9 +469,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
return fmt.Errorf("defer: %w", err)
}
if d.State.IsTerminal() {
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
}
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
w.transition(ctx, *d, store.StateDeferred, "", "")
return nil
}
@@ -479,7 +480,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
func (w *Worker) Undo(ctx context.Context, id int64) error {
func (w *Worker) Undo(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.layouter == nil {
@@ -496,9 +497,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
}
if d.State != store.StateDone {
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
return fmt.Errorf("undo: download %s is in state %s (expected done): %w", id, d.State, ErrConflict)
}
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return fmt.Errorf("undo: %w", err)
@@ -528,18 +529,18 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: %w", err)
}
w.transition(ctx, *d, store.StateReverted, "", "")
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
logctx.From(ctx).Info("layout reverted", "batch_id", batch, "removed", n)
return nil
}
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) {
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return nil, fmt.Errorf("%s: %w", op, err)
}
if d.State != store.StateReview && d.State != store.StateDeferred {
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
return nil, fmt.Errorf("%s: download %s is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
}
return d, nil
}
@@ -549,7 +550,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -566,7 +567,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
}
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
@@ -584,13 +585,13 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
"provider", cand.Provider, "provider_id", cand.ProviderID)
return nil
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
@@ -614,13 +615,13 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
return fmt.Errorf("set provider: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
"provider", provider, "provider_id", providerID)
return nil
}
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
w.mu.Lock()
defer w.mu.Unlock()
@@ -634,7 +635,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
return nil
}
@@ -654,12 +655,12 @@ type ReviewData struct {
}
// ReviewData собирает данные ревью по загрузке.
func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) {
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return nil, fmt.Errorf("review data: %w", err)
}
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
log := logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash()))
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return nil, fmt.Errorf("review data: %w", err)
@@ -709,7 +710,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
// effectivePlan загружает текущий план, применяет правки и возвращает
// provider-тег для имени папки (под mu).
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return recognize.Plan{}, "", err
@@ -772,7 +773,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
// toStoreCandidates переводит кандидатов распознавания в строки БД,
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
func toStoreCandidates(recognitionID string, cands []metadata.Candidate) []store.MetadataCandidate {
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
prov, id := recognize.CandidateTag(c)
@@ -863,19 +864,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
}
}
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
// торренты (а не только свою категорию): раздача могла быть усыновлена по
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
// (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
// бы потерял (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
torrents, err := w.qbt.Torrents(ctx, "")
if err != nil {
return qbt.Torrent{}, false, err
}
want := strings.ToLower(infohash)
want := make(map[string]bool, len(hashes))
for _, h := range hashes {
want[store.NormalizeHash(h)] = true
}
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" && strings.ToLower(h) == want {
if h != "" && want[strings.ToLower(h)] {
return t, true, nil
}
}