Идентичность на 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:
+93
-42
@@ -13,12 +13,15 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
@@ -39,34 +42,35 @@ const (
|
||||
type Store interface {
|
||||
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
// Discovery (усыновление раздач по категории/тегу).
|
||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
|
||||
// Ф3: распознавание, ревью, раскладка.
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
||||
AddHint(ctx context.Context, downloadID int64, text string) error
|
||||
ListHints(ctx context.Context, downloadID int64) ([]string, error)
|
||||
SetOverride(ctx context.Context, downloadID int64, field, value string) error
|
||||
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (string, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID string) (*store.Recognition, error)
|
||||
AddHint(ctx context.Context, downloadID string, text string) error
|
||||
ListHints(ctx context.Context, downloadID string) ([]string, error)
|
||||
SetOverride(ctx context.Context, downloadID string, field, value string) error
|
||||
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
|
||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
|
||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
|
||||
LatestBatchID(ctx context.Context, downloadID string) (string, error)
|
||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||
|
||||
// Кандидаты базы метаданных (ручной выбор в review).
|
||||
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
|
||||
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
|
||||
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
|
||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
|
||||
ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]store.MetadataCandidate, error)
|
||||
GetCandidate(ctx context.Context, id string) (*store.MetadataCandidate, error)
|
||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error
|
||||
}
|
||||
|
||||
// QBittorrent — нужная worker часть клиента qBittorrent.
|
||||
@@ -111,7 +115,7 @@ const (
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
|
||||
Notify(ctx context.Context, downloadID string, event NotifyEvent)
|
||||
}
|
||||
|
||||
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
|
||||
@@ -196,7 +200,7 @@ type Worker struct {
|
||||
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
||||
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
||||
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
|
||||
failNotified map[int64]time.Time
|
||||
failNotified map[string]time.Time
|
||||
}
|
||||
|
||||
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
|
||||
@@ -221,7 +225,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
||||
log: log,
|
||||
now: time.Now,
|
||||
newID: defaultBatchID,
|
||||
failNotified: map[int64]time.Time{},
|
||||
failNotified: map[string]time.Time{},
|
||||
live: map[string]Live{},
|
||||
}
|
||||
}
|
||||
@@ -246,15 +250,16 @@ func (w *Worker) setLive(snap map[string]Live) {
|
||||
w.liveMu.Unlock()
|
||||
}
|
||||
|
||||
// defaultBatchID — уникальный идентификатор батча раскладки.
|
||||
// defaultBatchID — идентификатор батча раскладки (ULID, единая точка
|
||||
// генерации id — internal/ident; сортируем по времени, удобен в логах).
|
||||
func defaultBatchID() string {
|
||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
||||
return ident.NewID()
|
||||
}
|
||||
|
||||
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
|
||||
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
|
||||
// ключи на каждую запись сами — без ручного доклеивания download_id.
|
||||
func (w *Worker) scoped(ctx context.Context, capability string, id int64, infohash string) context.Context {
|
||||
func (w *Worker) scoped(ctx context.Context, capability string, id string, infohash string) context.Context {
|
||||
log := w.log.With("capability", capability, "download_id", id)
|
||||
if infohash != "" {
|
||||
log = log.With("infohash", infohash)
|
||||
@@ -325,15 +330,16 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
return fmt.Errorf("poll: list active: %w", err)
|
||||
}
|
||||
for _, d := range active {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
|
||||
}
|
||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||
t, ok := torrentFor(d, byHash)
|
||||
if !ok {
|
||||
w.log.Warn("active download not found in qbittorrent",
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
|
||||
continue
|
||||
}
|
||||
w.captureInfohashes(ctx, d, t)
|
||||
w.captureSourceAddedAt(ctx, d, t)
|
||||
w.reconcile(ctx, d, t)
|
||||
}
|
||||
@@ -351,7 +357,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
||||
// w.mu.
|
||||
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
switch classify(t.State) {
|
||||
case classReady:
|
||||
w.transition(ctx, d, store.StateCompleted, "", "")
|
||||
@@ -397,6 +403,39 @@ func (w *Worker) captureSourceAddedAt(ctx context.Context, d store.Download, t q
|
||||
}
|
||||
}
|
||||
|
||||
// torrentFor ищет торрент загрузки в карте byHash по любому из её хешей.
|
||||
func torrentFor(d store.Download, byHash map[string]qbt.Torrent) (qbt.Torrent, bool) {
|
||||
for _, h := range d.HashList() {
|
||||
if t, ok := byHash[h]; ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return qbt.Torrent{}, false
|
||||
}
|
||||
|
||||
// captureInfohashes дописывает загрузке хеши, которые qBittorrent знает, а мы
|
||||
// ещё нет (гибридный торрент раскрывает v1+v2 после получения метаданных).
|
||||
// Хеши собирает torrentHashes (усечённый t.Hash v2-only раздач отсеян).
|
||||
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
|
||||
// дописан не будет (ErrInfohashTaken). Учётная операция: сбой не двигает
|
||||
// задачу, лишь логируем WARN. Под w.mu.
|
||||
func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||
known := d.HashList()
|
||||
var missing []string
|
||||
for _, h := range torrentHashes(t) {
|
||||
if !slices.Contains(known, h) {
|
||||
missing = append(missing, h)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return
|
||||
}
|
||||
if err := w.store.AddInfohashes(ctx, d.ID, missing); err != nil {
|
||||
w.log.Warn("capture infohashes failed",
|
||||
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
||||
// отдал added_on.
|
||||
@@ -453,7 +492,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
||||
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
||||
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.Infohash.String)
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||
}
|
||||
}
|
||||
@@ -462,7 +501,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
|
||||
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||
// флаппинг.
|
||||
func (w *Worker) shouldNotifyFail(id int64) bool {
|
||||
func (w *Worker) shouldNotifyFail(id string) bool {
|
||||
now := w.now()
|
||||
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
|
||||
return false
|
||||
@@ -479,7 +518,7 @@ func (w *Worker) shouldNotifyFail(id int64) bool {
|
||||
|
||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||
// раздачу (источник неприкосновенен).
|
||||
func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Cancel(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -488,18 +527,18 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("cancel: download %d is already terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download cancelled", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
||||
// qBittorrent и возвращает в downloading.
|
||||
func (w *Worker) Retry(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -508,32 +547,44 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
||||
}
|
||||
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
|
||||
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
||||
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
||||
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
||||
alive := false
|
||||
if d.Infohash.Valid {
|
||||
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
_, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
}
|
||||
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
||||
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
||||
// отказываем, не добавив торрент повторно (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
return fmt.Errorf("retry: для этого торрента уже есть другая активная задача: %w", ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if !alive && d.SourceType == store.SourceMagnet {
|
||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
}); err != nil {
|
||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||
}
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user