Идентичность на 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
+61 -24
View File
@@ -24,9 +24,17 @@ const errCodeQbitAdd = "qbit_add"
// Store — нужная ingest часть хранилища.
type Store interface {
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
// FindActiveByInfohash — быстрый читающий дедуп-чек (до вызова LLM-namer);
// авторитетная проверка — внутри CreateDownloadIfNoActive.
FindActiveByInfohash(ctx context.Context, hashes ...string) (*store.Download, error)
// CreateDownloadIfNoActive атомарно проверяет инвариант «одна активная
// загрузка на infohash» и заводит задачу; вернувшаяся existing ≠ nil —
// дедуп на активную задачу (недостающие хеши вызова метод доносит сам).
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
// AddInfohashes доносит задаче недостающие хеши (guarded). Нужен на
// быстром дедуп-пути, который не доходит до CreateDownloadIfNoActive.
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
}
// QBittorrent — нужная ingest часть клиента qBittorrent.
@@ -58,7 +66,7 @@ type Service struct {
// не удалось). Closure, а не worker.Notifier: приёмное падение в qBit не
// попадает в поллинг-цикл worker (раздачи нет), поэтому уведомляет ingest
// сам; closure избавляет ядро приёма от зависимости на пакет worker.
notifyFailed func(downloadID int64)
notifyFailed func(downloadID string)
}
// New собирает сервис приёма. namer опционален (nil → отображаемое имя не
@@ -68,7 +76,7 @@ func New(st Store, qb QBittorrent, namer Namer, cfg Config, log *slog.Logger) *S
}
// SetFailureNotifier подключает пинг о падении приёма (до начала работы).
func (s *Service) SetFailureNotifier(fn func(downloadID int64)) { s.notifyFailed = fn }
func (s *Service) SetFailureNotifier(fn func(downloadID string)) { s.notifyFailed = fn }
// Request — входной запрос приёма.
type Request struct {
@@ -78,8 +86,8 @@ type Request struct {
// Result — итог приёма.
type Result struct {
DownloadID int64
Infohash string
DownloadID string
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
State store.State
Deduplicated bool // присоединились к уже активной задаче, нового добавления не было
}
@@ -100,16 +108,14 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
log := s.log.With("capability", capIngest, "infohash", info.Infohash)
ctx = logctx.With(ctx, log)
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohash); err != nil {
// Быстрый дедуп-чек до дорогого LLM-namer; авторитетная (атомарная)
// проверка — внутри CreateDownloadIfNoActive ниже. Дедуп — по ЛЮБОМУ из
// хешей источника: гибридный magnet несёт и v1, и v2.
if existing, err := s.store.FindActiveByInfohash(ctx, info.Infohashes...); err != nil {
return Result{}, fmt.Errorf("ingest: lookup active: %w", err)
} else if existing != nil {
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
return Result{
DownloadID: existing.ID,
Infohash: info.Infohash,
State: existing.State,
Deduplicated: true,
}, nil
return s.attached(ctx, info, existing), nil
}
// Отображаемое имя для списка qBit — best-effort: не валит приём.
@@ -122,18 +128,30 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
}
d := &store.Download{
SourceType: store.SourceMagnet,
SourceRef: source,
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
Context: req.Context,
Infohash: store.NullString(info.Infohash),
IdempotencyKey: store.NullString(info.Infohash),
State: store.StateDownloading,
SourceType: store.SourceMagnet,
SourceRef: source,
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
Context: req.Context,
State: store.StateDownloading,
}
id, err := s.store.CreateDownload(ctx, d)
// Все хеши из magnet (гибридный несёт v1 и v2); kind store выведет по длине.
existing, err := s.store.CreateDownloadIfNoActive(ctx, d, info.Infohashes)
if err != nil {
return Result{}, fmt.Errorf("ingest: create download: %w", err)
}
if existing != nil {
// Гонка с параллельным приёмом/discover: активная задача появилась
// после быстрого чека — присоединяемся к ней (хеши донёс сам
// CreateDownloadIfNoActive).
log.Info("download attached to active", "download_id", existing.ID, "state", existing.State)
return Result{
DownloadID: existing.ID,
Infohashes: info.Infohashes,
State: existing.State,
Deduplicated: true,
}, nil
}
id := d.ID
log = log.With("download_id", id)
ctx = logctx.With(ctx, log)
@@ -156,14 +174,33 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
// сами, чтобы приёмные провалы тоже доходили до автора.
go s.notifyFailed(id)
}
return Result{DownloadID: id, Infohash: info.Infohash, State: store.StateFailed},
return Result{DownloadID: id, Infohashes: info.Infohashes, State: store.StateFailed},
fmt.Errorf("ingest: add to qbittorrent: %w", addErr)
}
log.Info("download accepted", "category", s.cfg.Category)
return Result{
DownloadID: id,
Infohash: info.Infohash,
Infohashes: info.Infohashes,
State: store.StateDownloading,
}, nil
}
// attached — итог дедупа на быстром чеке: присоединились к уже активной
// задаче и доносим ей недостающие хеши источника (гибридный magnet мог
// принести хеш, которого задача ещё не знает; guarded-путь через
// CreateDownloadIfNoActive сюда не доходит). Донос — best-effort: конфликт
// хеша с другой активной задачей логируется, приём не валится.
func (s *Service) attached(ctx context.Context, info magnet.Info, existing *store.Download) Result {
if len(info.Infohashes) > len(existing.Infohashes) {
if err := s.store.AddInfohashes(ctx, existing.ID, info.Infohashes); err != nil {
logctx.FromOr(ctx, s.log).Warn("ingest top-up infohashes failed", "error", err)
}
}
return Result{
DownloadID: existing.ID,
Infohashes: info.Infohashes,
State: existing.State,
Deduplicated: true,
}
}