Жизненный цикл: claim-токен распознавания, индексация хешей, retry сломанного торрента
Три мелких фикса из docs/backlog/review-lifecycle-minor.md (ревью Fable 2026-07-08). MINOR-9 (I/O под глобальным w.mu) осознанно waive для one-user home-сервера — не трогаем. MINOR-8: claim-токен распознавания. recognizeOne фиксирует updated_at на момент claim (перечитывая запись после перехода в recognizing), а finishRecognition коммитит результат, только если токен совпал. Иначе за время LLM-вызова задачу увели из recognizing и вернули обратно (cancel → relink revive) — это уже другой эпизод, устаревший результат отбрасываем, задача остаётся в recognizing для перезапуска поллингом. NIT-11: lookup-мапы (byHash/live/torrentByInfohash) больше не индексируют усечённый 40-hex t.Hash v2-only торрентов. Новый хелпер torrentIndexHashes зеркалит выбор torrentHashes: t.Hash берём только при отсутствии обоих infohash_v1/v2. Убирает теоретический ложный матч по коллизии длины. NIT-12: retry живого, но сломанного торрента (error/missingFiles) теперь отклоняется с подсказкой починить раздачу (recheck) в qBittorrent, вместо бессмысленной переотдачи источника (сверка тут же вернула бы задачу в failed). Повторный Add — только когда раздачи в qBittorrent нет. Меняет спеку state-reconciliation → дельта openspec/changes/2026-07-17-retry-reject-broken-torrent (не архивировал). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,30 @@ func torrentHashes(t qbt.Torrent) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// torrentIndexHashes — хеши, под которыми раздачу индексируют lookup-мапы
|
||||
// (byHash/live) и поиск по infohash. Зеркалит выбор torrentHashes: t.Hash берём
|
||||
// только когда qBittorrent не отдал infohash_v1/v2 (старый API). У v2-only
|
||||
// раздачи t.Hash — усечённый до 40 hex v2-хеш; индексировать его нельзя — по длине
|
||||
// он неотличим от v1 и мог бы ложно сматчить задачу с таким же v1-хешем (NIT-11),
|
||||
// хотя для этого нужна невозможная на практике 160-битная коллизия. В отличие от
|
||||
// torrentHashes хеши не нормализует и не дедуплицирует — ключ приводит вызывающий.
|
||||
func torrentIndexHashes(t qbt.Torrent) []string {
|
||||
if t.InfohashV1 == "" && t.InfohashV2 == "" {
|
||||
if t.Hash == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{t.Hash}
|
||||
}
|
||||
out := make([]string, 0, 2)
|
||||
if t.InfohashV1 != "" {
|
||||
out = append(out, t.InfohashV1)
|
||||
}
|
||||
if t.InfohashV2 != "" {
|
||||
out = append(out, t.InfohashV2)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
|
||||
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
|
||||
// быть полноразмерным (torrentHashes усечённые не отдаёт).
|
||||
|
||||
@@ -180,6 +180,28 @@ func TestTorrentHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTorrentIndexHashes — NIT-11: lookup-мапы индексируют раздачу по её хешам,
|
||||
// но усечённый 40-hex t.Hash v2-only торрента исключаем (по длине неотличим от v1).
|
||||
func TestTorrentIndexHashes(t *testing.T) {
|
||||
// Гибрид: индексируем оба infohash, t.Hash игнорируем.
|
||||
got := torrentIndexHashes(qbt.Torrent{Hash: "trunc", InfohashV1: "v1", InfohashV2: "v2"})
|
||||
if len(got) != 2 || got[0] != "v1" || got[1] != "v2" {
|
||||
t.Errorf("гибрид: got %v, want [v1 v2] (t.Hash исключён)", got)
|
||||
}
|
||||
// Старый qBittorrent без infohash_v1/v2 — берём hash (это полный v1).
|
||||
if got := torrentIndexHashes(qbt.Torrent{Hash: "legacy"}); len(got) != 1 || got[0] != "legacy" {
|
||||
t.Errorf("legacy: got %v, want [legacy]", got)
|
||||
}
|
||||
// v2-only: усечённый t.Hash не индексируем, только полный v2.
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
if got := torrentIndexHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2}); len(got) != 1 || got[0] != v2 {
|
||||
t.Errorf("v2-only: got %v, want [%s] (усечённый t.Hash исключён)", got, v2)
|
||||
}
|
||||
if got := torrentIndexHashes(qbt.Torrent{}); len(got) != 0 {
|
||||
t.Errorf("пустой: got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
|
||||
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
|
||||
func TestDiscover_AdoptsV2Only(t *testing.T) {
|
||||
|
||||
@@ -9,15 +9,16 @@ import (
|
||||
)
|
||||
|
||||
// TestPollBuildsLiveSnapshot: после Poll снимок несёт телеметрию качающейся и
|
||||
// сидирующей задач (с верным Seeding), доступную по любому из трёх хэшей;
|
||||
// сидирующей задач (с верным Seeding), доступную по любому из infohash;
|
||||
// неизвестный/пустой infohash → ok=false.
|
||||
func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||
// Качается (Category пуст → discover не усыновляет, store не мешает).
|
||||
{Hash: "aaa", State: "downloading", Progress: 0.5, Dlspeed: 1000, Eta: 120, TotalSize: 4096},
|
||||
// Сидирует, торрент v2 (три ключа).
|
||||
// Сидирует, гибридный торрент: t.Hash дублирует v1 (как отдаёт qBittorrent),
|
||||
// индексируем по infohash_v1/v2 (усечённый t.Hash не индексируем — NIT-11).
|
||||
{
|
||||
Hash: "bbb", InfohashV1: "bbb1", InfohashV2: "BBB2",
|
||||
Hash: "bbb1", InfohashV1: "bbb1", InfohashV2: "BBB2",
|
||||
State: "uploading", Progress: 1.0, TotalSize: 8192,
|
||||
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
|
||||
},
|
||||
@@ -42,8 +43,8 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||
t.Errorf("полный размер качающейся задачи: got %d, want 4096", dl.TotalSize)
|
||||
}
|
||||
|
||||
// Сидирующая задача находится по любому из трёх хэшей (lowercase).
|
||||
for _, h := range []string{"bbb", "bbb1", "BBB2", "bbb2"} {
|
||||
// Сидирующая задача находится по любому из своих infohash (lowercase).
|
||||
for _, h := range []string{"bbb1", "BBB2", "bbb2"} {
|
||||
sd, ok := w.Live(h)
|
||||
if !ok {
|
||||
t.Fatalf("нет телеметрии раздачи по ключу %q", h)
|
||||
|
||||
@@ -69,7 +69,18 @@ func (w *Worker) recognizeOne(ctx context.Context, id string) {
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
|
||||
if d.State == store.StateCompleted {
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
// Перечитываем ради свежего updated_at: он служит claim-токеном этой
|
||||
// попытки (MINOR-8). Токен фиксирует конкретный recognizing-эпизод; если
|
||||
// задачу позже уведут из recognizing и вернут обратно (cancel → relink),
|
||||
// updated_at сменится, и finishRecognition отбросит устаревший результат.
|
||||
d, err = w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
w.mu.Unlock()
|
||||
logctx.From(ctx).Warn("recognition reload after claim failed", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
claim := d.UpdatedAt
|
||||
w.mu.Unlock()
|
||||
|
||||
result, savePath, err := w.runRecognize(ctx, *d)
|
||||
@@ -81,7 +92,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id string) {
|
||||
Reasons: []string{"распознавание не удалось: " + err.Error()},
|
||||
}}
|
||||
}
|
||||
w.finishRecognition(ctx, id, result, savePath)
|
||||
w.finishRecognition(ctx, id, claim, result, savePath)
|
||||
}
|
||||
|
||||
// runRecognize собирает сигналы из qBittorrent и накопленные подсказки,
|
||||
@@ -127,7 +138,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
||||
|
||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id, claim string, res recognize.Result, savePath string) {
|
||||
log := logctx.From(ctx)
|
||||
planJSON, err := json.Marshal(res.Plan)
|
||||
if err != nil {
|
||||
@@ -169,7 +180,23 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize
|
||||
}
|
||||
if d.State != store.StateRecognizing {
|
||||
// За время вызова LLM задачу увели (cancel/defer) — результат не нужен.
|
||||
log.Info("recognition result discarded", "state", d.State)
|
||||
log.Info("recognition result discarded", "reason", "state_changed", "state", d.State)
|
||||
return
|
||||
}
|
||||
// Claim-токен (MINOR-8): состояние снова recognizing, но за время вызова LLM
|
||||
// задачу могли увести из recognizing и вернуть обратно (cancel → relink revive).
|
||||
// Тогда это УЖЕ другой recognizing-эпизод (updated_at сменился), а наш результат
|
||||
// принадлежит прежней попытке — отбрасываем. Задача остаётся в recognizing, и
|
||||
// поллинг-цикл (recognizePending) перезапустит распознавание свежей попыткой.
|
||||
//
|
||||
// Токен — updated_at секундной точности; сравниваем на строгое равенство, поэтому
|
||||
// отбрасываем при ЛЮБОй его смене. В recognizing-эпизоде метку бьют только переходы
|
||||
// состояния (не-переходные мутации задачи в этом состоянии не идут), так что валидный
|
||||
// результат ложно не теряется; а редкий холостой сброс безопасен — распознавание
|
||||
// просто повторится. Остаточное окно — revive целиком в ту же секунду, что и исходный
|
||||
// claim (человеческий темп cancel→relink это исключает).
|
||||
if d.UpdatedAt != claim {
|
||||
log.Info("recognition result discarded", "reason", "stale_claim")
|
||||
return
|
||||
}
|
||||
recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
|
||||
@@ -1405,8 +1432,8 @@ func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.To
|
||||
want[store.NormalizeHash(h)] = true
|
||||
}
|
||||
for _, t := range torrents {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" && want[strings.ToLower(h)] {
|
||||
for _, h := range torrentIndexHashes(t) {
|
||||
if want[strings.ToLower(h)] {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +315,10 @@ type memStore struct {
|
||||
// Инъекция сбоев (для тестов устойчивости раскладки).
|
||||
failCreateLinks error // CreateFileLinks вернёт эту ошибку
|
||||
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
|
||||
|
||||
// clock — монотонный счётчик для updated_at (как реальный store бьёт метку на
|
||||
// каждом переходе); питает проверку claim-токена распознавания (MINOR-8).
|
||||
clock int
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
@@ -450,6 +454,8 @@ func (m *memStore) SetDownloadState(_ context.Context, id string, st store.State
|
||||
d.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
d.ErrorMsg = store.NullString(msg)
|
||||
m.clock++
|
||||
d.UpdatedAt = itoa(m.clock) // свежая метка перехода — claim-токен (MINOR-8)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -810,6 +816,35 @@ func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecognizeOne_DiscardsStaleClaim — MINOR-8: если во время вызова LLM задачу
|
||||
// увели из recognizing и вернули обратно (cancel → relink revive), это уже другой
|
||||
// recognizing-эпизод (updated_at сменился). Устаревший результат прежнего прогона
|
||||
// отбрасывается, задача остаётся в recognizing для перезапуска поллингом.
|
||||
func TestRecognizeOne_DiscardsStaleClaim(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}},
|
||||
}
|
||||
// Во время вызова LLM задачу уводят из recognizing и возвращают обратно
|
||||
// (симуляция cancel → relink): состояние снова recognizing, но updated_at сменился.
|
||||
rec := &fakeRecognizer{result: seriesResult(), onCall: func() {
|
||||
_ = st.SetDownloadState(context.Background(), "1", store.StateCancelled, "", "")
|
||||
_ = st.SetDownloadState(context.Background(), "1", store.StateRecognizing, "", "")
|
||||
}}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads["1"].State != store.StateRecognizing {
|
||||
t.Errorf("state = %q, want recognizing (устаревший результат отброшен, ждём перезапуска)", st.downloads["1"].State)
|
||||
}
|
||||
if cur, _ := st.GetCurrentRecognition(context.Background(), "1"); cur != nil {
|
||||
t.Error("recognition устаревшей попытки не должна сохраняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload("1"))
|
||||
|
||||
+19
-17
@@ -524,10 +524,8 @@ func (w *Worker) promoteExisting(ctx context.Context, d store.Download, t qbt.To
|
||||
func torrentsByHash(torrents []qbt.Torrent) map[string]qbt.Torrent {
|
||||
byHash := make(map[string]qbt.Torrent, len(torrents)*2)
|
||||
for _, t := range torrents {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" {
|
||||
byHash[strings.ToLower(h)] = t
|
||||
}
|
||||
for _, h := range torrentIndexHashes(t) {
|
||||
byHash[strings.ToLower(h)] = t
|
||||
}
|
||||
}
|
||||
return byHash
|
||||
@@ -585,12 +583,10 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
live := make(map[string]Live, len(torrents)*2)
|
||||
for _, t := range torrents {
|
||||
l := liveFrom(t)
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" {
|
||||
key := strings.ToLower(h)
|
||||
byHash[key] = t
|
||||
live[key] = l
|
||||
}
|
||||
for _, h := range torrentIndexHashes(t) {
|
||||
key := strings.ToLower(h)
|
||||
byHash[key] = t
|
||||
live[key] = l
|
||||
}
|
||||
}
|
||||
// Снимок зависит только от torrents — свопаем сразу, до store-операций
|
||||
@@ -964,12 +960,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
|
||||
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add —
|
||||
// когда источника в qBittorrent нет ИЛИ он в состоянии ошибки: перецепка к
|
||||
// сломанному торренту (error/missingFiles) бессмысленна — reconcile тут же
|
||||
// вернул бы задачу в failed, поэтому пробуем повторно отдать источник
|
||||
// (NIT-12). Базис таймаута сбрасывается ниже через retried_at, поэтому
|
||||
// возврат в downloading не роняет задачу снова на ближайшем тике (MAJOR-1).
|
||||
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда
|
||||
// источника в qBittorrent нет. Базис таймаута сбрасывается ниже через
|
||||
// retried_at, поэтому возврат в downloading не роняет задачу снова на ближайшем
|
||||
// тике (MAJOR-1).
|
||||
reAdd := true
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
var t qbt.Torrent
|
||||
@@ -978,7 +972,15 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
reAdd = !alive || classify(t.State) == classErrored
|
||||
if alive && classify(t.State) == classErrored {
|
||||
// Живой, но сломанный торрент (error/missingFiles): повторный Add его не
|
||||
// чинит (qBittorrent отверг бы дубль), а простой возврат в downloading
|
||||
// тут же снова упал бы classErrored на ближайшем тике сверки (+ дебаунс
|
||||
// уведомления) — retry выглядел бы сломанным. Отклоняем с подсказкой
|
||||
// починить раздачу в qBittorrent (recheck/восстановить файлы) — NIT-12.
|
||||
return fmt.Errorf("retry: раздача в qBittorrent в состоянии ошибки (%s) — почини её (recheck) в qBittorrent и повтори: %w", t.State, ErrConflict)
|
||||
}
|
||||
reAdd = !alive
|
||||
}
|
||||
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
||||
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
||||
|
||||
@@ -578,6 +578,55 @@ func TestRetryRejectsActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryRejectsLiveErroredTorrent — NIT-12: живой, но сломанный торрент
|
||||
// (error/missingFiles) при retry не перецепляем и не возвращаем в downloading —
|
||||
// иначе сверка тут же вернула бы задачу в failed (+дебаунс уведомления), retry
|
||||
// выглядел бы сломанным. Отклоняем с ErrConflict, состояние не трогаем, повторный
|
||||
// Add не делаем — пользователю подсказано починить раздачу (recheck) в qBittorrent.
|
||||
func TestRetryRejectsLiveErroredTorrent(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
for _, state := range []string{"missingFiles", "error"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("qbit_error")
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: state}}}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("err = %v, want ErrConflict", err)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("сломанный живой торрент не переотдаём: %d Add", len(qb.added))
|
||||
}
|
||||
if got := st.downloads["1"].State; got != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed (не тронуто)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryReattachesLiveHealthyTorrent — контроль к NIT-12: живой ЗДОРОВЫЙ торрент
|
||||
// перецепляем без повторного Add (регрессия — errored-ветка не должна задеть
|
||||
// здоровый путь).
|
||||
func TestRetryReattachesLiveHealthyTorrent(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateStuck
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "stalledUP"}}}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("живой здоровый торрент не переотдаём: %d Add", len(qb.added))
|
||||
}
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
cases := map[string]class{
|
||||
"uploading": classReady,
|
||||
|
||||
Reference in New Issue
Block a user