Идентичность на 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
+22 -29
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/logctx"
@@ -57,11 +56,11 @@ func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torr
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
// дебаунсом) и цели, выводит состояние и переходит при изменении.
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return // нечем сопоставить источник
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
_, sourceSeen := torrentFor(d, byHash)
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
// идущих промахов; любое появление сбрасывает счётчик.
@@ -104,7 +103,7 @@ func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSee
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
// считается присутствующей, только если существуют ВСЕ ссылки последнего
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
func (w *Worker) targetPresent(ctx context.Context, id string) (bool, error) {
batch, err := w.store.LatestBatchID(ctx, id)
if err != nil {
return false, fmt.Errorf("latest batch: %w", err)
@@ -180,10 +179,10 @@ func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.To
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
// присутствует и продвинулся за условие падения.
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
if !d.Infohash.Valid {
if len(d.Infohashes) == 0 {
return
}
t, ok := byHash[strings.ToLower(d.Infohash.String)]
t, ok := torrentFor(d, byHash)
if !ok {
return // источника нет — оставляем как есть (вернёт ручной retry)
}
@@ -194,28 +193,22 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
if want == "" {
return // переходное состояние qBit (moving/checking) — ждём
}
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
// взять другая активная задача (idempotency_key снят при падении). Оба
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
// и оставляем старую задачу в failed.
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
return
}
if other != nil && other.ID != d.ID {
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
"conflict_download_id", other.ID)
return
}
// Возврат в активное состояние — только через атомарный гард инварианта:
// пока задача лежала в failed, тем же infohash могла завладеть другая
// активная задача (новый приём). Тогда старую оставляем в failed.
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
w.transition(ctx, d, want, "", "")
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
if errors.Is(err, store.ErrInfohashTaken) {
logctx.From(ctx).Info("recovery skipped, infohash taken by active download", "error", err)
return
}
logctx.From(ctx).Warn("recovery activate failed", "error", err)
return
}
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
}
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
@@ -256,10 +249,10 @@ func recoveredState(state string) store.State {
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
if !d.Infohash.Valid {
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
if len(d.Infohashes) == 0 {
return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
}
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
_, ok, err := w.torrentByInfohash(ctx, d.HashList())
if err != nil {
return fmt.Errorf("%s: %w", op, err)
}