Все сущности переехали с 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>
171 lines
7.6 KiB
Go
171 lines
7.6 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
|
)
|
|
|
|
// addedRecent — added_on торрента «минуту назад» относительно зафиксированного
|
|
// в newTestWorker now (2026-06-14 10:00:00 UTC).
|
|
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
|
|
|
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
|
return &fakeStore{downloads: map[string]*store.Download{
|
|
"1": {
|
|
ID: "1",
|
|
State: state,
|
|
SourceType: store.SourceMagnet,
|
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
|
Infohashes: hashesOf("1", infohash),
|
|
ErrorCode: store.NullString(code),
|
|
CreatedAt: createdAt,
|
|
},
|
|
}}
|
|
}
|
|
|
|
func TestRecovery(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
tests := []struct {
|
|
name string
|
|
state store.State
|
|
code string
|
|
qbitState string
|
|
want store.State
|
|
}{
|
|
{"метаданные пришли → downloading", store.StateFailed, errCodeMagnetTimeout, "downloading", store.StateDownloading},
|
|
{"торрент готов → completed", store.StateFailed, errCodeMagnetTimeout, "uploading", store.StateCompleted},
|
|
{"всё ещё metaDL → остаётся failed", store.StateFailed, errCodeMagnetTimeout, "metaDL", store.StateFailed},
|
|
{"stalled ожил → downloading", store.StateStuck, errCodeStalled, "downloading", store.StateDownloading},
|
|
{"stalled всё ещё stalledDL → остаётся stuck", store.StateStuck, errCodeStalled, "stalledDL", store.StateStuck},
|
|
{"qbit_error не восстанавливается", store.StateFailed, errCodeQbitError, "downloading", store.StateFailed},
|
|
{"ошибка торрента не восстанавливает", store.StateFailed, errCodeMagnetTimeout, "error", store.StateFailed},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
st := oneFailed(tc.state, tc.code, ih, timeOld)
|
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState, AddedOn: addedRecent}}}
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatalf("Poll: %v", err)
|
|
}
|
|
if got := st.downloads["1"].State; got != tc.want {
|
|
t.Errorf("state = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Источник пропал (торрента нет в qBittorrent) — задача остаётся failed,
|
|
// воскрешать нечего (вернёт ручной retry).
|
|
func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
|
w := newTestWorker(st, &fakeQbt{torrents: nil})
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.downloads["1"].State != store.StateFailed {
|
|
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
|
|
}
|
|
}
|
|
|
|
// Конфликт идемпотентности: тот же infohash уже взяла другая активная задача —
|
|
// упавшую не воскрешаем (иначе нарушим «одна активная задача на infohash»).
|
|
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
|
st.downloads["2"] = &store.Download{
|
|
ID: "2",
|
|
State: store.StateDownloading,
|
|
SourceType: store.SourceMagnet,
|
|
Infohashes: hashesOf("2", ih),
|
|
CreatedAt: timeRecent,
|
|
}
|
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.downloads["1"].State != store.StateFailed {
|
|
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
|
|
}
|
|
}
|
|
|
|
// Тот же конфликт ключа, но торрент уже готов (recovery хочет completed):
|
|
// completed тоже нетерминален и восстановил бы idempotency_key — проверка
|
|
// конфликта обязана покрывать и эту ветку.
|
|
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
|
st.downloads["2"] = &store.Download{
|
|
ID: "2",
|
|
State: store.StateDownloading,
|
|
SourceType: store.SourceMagnet,
|
|
Infohashes: hashesOf("2", ih),
|
|
CreatedAt: timeRecent,
|
|
}
|
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
|
|
w := newTestWorker(st, qb)
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.downloads["1"].State != store.StateFailed {
|
|
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
|
|
}
|
|
}
|
|
|
|
// Повторное падение одной задачи в пределах окна дебаунса шлёт уведомление лишь
|
|
// раз (защита от спама при флаппинге stuck↔downloading).
|
|
func TestFailNotifyDebounce(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneFailed(store.StateStuck, errCodeStalled, ih, timeOld)
|
|
w := newTestWorker(st, &fakeQbt{})
|
|
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
|
w.SetNotifier(n)
|
|
d := *st.downloads["1"]
|
|
|
|
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
|
if e := waitNotify(t, n); e.ev != EventFailed {
|
|
t.Fatalf("первый пинг: ev=%v, want failed", e.ev)
|
|
}
|
|
// Второе падение при том же w.now() — в пределах дебаунса, без пинга.
|
|
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
|
select {
|
|
case e := <-n.ch:
|
|
t.Fatalf("повторный пинг в пределах дебаунса не ожидался: %+v", e)
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// Retry при живом торренте перецепляется к нему (без повторного Add) и не падает
|
|
// снова на ближайшем тике: базис таймаута берётся от added_on, а не от старого
|
|
// created_at.
|
|
func TestRetryReattachesNoReadd(t *testing.T) {
|
|
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
|
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
|
// Торрент жив, всё ещё тянет метаданные, но добавлен только что (added_on).
|
|
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
|
|
w := newTestWorker(st, qb)
|
|
|
|
if err := w.Retry(context.Background(), "1"); err != nil {
|
|
t.Fatalf("Retry: %v", err)
|
|
}
|
|
if st.downloads["1"].State != store.StateDownloading {
|
|
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
|
|
}
|
|
if len(qb.added) != 0 {
|
|
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
|
}
|
|
// Ближайший тик: metaDL свежий (added_on минуту назад) — не падает по таймауту.
|
|
if err := w.Poll(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.downloads["1"].State != store.StateDownloading {
|
|
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
|
}
|
|
}
|