Идентичность на 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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user