Идентичность на 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:
+49
-31
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
||||
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
||||
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
|
||||
// её ведёт обычный reconcile. Вызывается под w.mu.
|
||||
//
|
||||
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
||||
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
||||
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
||||
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
||||
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
|
||||
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
|
||||
// write-транзакции; при столкновении второй участник получает существующую
|
||||
// активную задачу и просто пропускает.
|
||||
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
||||
for _, t := range torrents {
|
||||
if w.tracked(t) {
|
||||
@@ -35,13 +36,13 @@ func (w *Worker) tracked(t qbt.Torrent) bool {
|
||||
|
||||
// adopt заводит задачу под торрент, если его ещё не видели.
|
||||
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
infohash := firstInfohash(t)
|
||||
if infohash == "" {
|
||||
hashes := torrentHashes(t)
|
||||
if len(hashes) == 0 {
|
||||
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
||||
}
|
||||
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
||||
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
|
||||
if err != nil {
|
||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
@@ -49,33 +50,29 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
}
|
||||
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
Infohash: store.NullString(infohash),
|
||||
IdempotencyKey: store.NullString(infohash),
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: magnetURN(hashes[0]),
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
id, err := w.store.CreateDownload(ctx, d)
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
||||
if err != nil {
|
||||
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
||||
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
||||
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
||||
return
|
||||
}
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
|
||||
}
|
||||
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
||||
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
||||
if t.AddedOn > 0 {
|
||||
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
|
||||
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
|
||||
w.log.Warn("adopt set source_added_at failed",
|
||||
"capability", capIngest, "download_id", id, "error", err)
|
||||
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||
}
|
||||
}
|
||||
w.log.Info("discover adopted torrent",
|
||||
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", hashes[0], "name", t.Name,
|
||||
"category", t.Category, "tags", t.Tags)
|
||||
}
|
||||
|
||||
@@ -92,12 +89,33 @@ func hasTag(tags, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
||||
func firstInfohash(t qbt.Torrent) string {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" {
|
||||
return strings.ToLower(h)
|
||||
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
|
||||
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
|
||||
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
|
||||
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
|
||||
// УСЕЧЁННЫЙ до 40 hex v2-хеш, хранить его нельзя (по длине он неотличим от
|
||||
// v1 и порождает битые btih-magnet при retry).
|
||||
func torrentHashes(t qbt.Torrent) []string {
|
||||
cands := []string{t.InfohashV1, t.InfohashV2}
|
||||
if t.InfohashV1 == "" && t.InfohashV2 == "" {
|
||||
cands = append(cands, t.Hash)
|
||||
}
|
||||
var out []string
|
||||
for _, h := range cands {
|
||||
h = store.NormalizeHash(h)
|
||||
if h != "" && !slices.Contains(out, h) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return out
|
||||
}
|
||||
|
||||
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
|
||||
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
|
||||
// быть полноразмерным (torrentHashes усечённые не отдаёт).
|
||||
func magnetURN(h string) string {
|
||||
if store.HashKind(h) == store.HashV2 {
|
||||
return "magnet:?xt=urn:btmh:1220" + h
|
||||
}
|
||||
return "magnet:?xt=urn:btih:" + h
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user