Идентичность на 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:
+165
-47
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -20,12 +21,12 @@ const (
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
downloads map[int64]*store.Download
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
id int64
|
||||
id string
|
||||
state store.State
|
||||
}
|
||||
|
||||
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("download %d not found", id)
|
||||
return nil, fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
|
||||
func hasAnyHash(d *store.Download, hashes []string) bool {
|
||||
for _, own := range d.Infohashes {
|
||||
for _, h := range hashes {
|
||||
if own.Infohash == store.NormalizeHash(h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, hashes) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(f.downloads) + 1)
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
id := fmt.Sprintf("%d", len(f.downloads)+1)
|
||||
cp := *d
|
||||
cp.ID = id
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
f.downloads[id] = &cp
|
||||
return id, nil
|
||||
d.ID = id
|
||||
d.Infohashes = cp.Infohashes
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
for _, other := range f.downloads {
|
||||
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
|
||||
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
|
||||
}
|
||||
}
|
||||
return f.SetDownloadState(ctx, id, st, code, msg)
|
||||
}
|
||||
|
||||
func hashList(d *store.Download) []string {
|
||||
out := make([]string, len(d.Infohashes))
|
||||
for i, h := range d.Infohashes {
|
||||
out[i] = h.Infohash
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
if !hasAnyHash(d, []string{h}) {
|
||||
d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.SourceMissCount = n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
|
||||
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
||||
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
||||
@@ -128,23 +185,23 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) e
|
||||
|
||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
||||
return 0, nil
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) {
|
||||
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
|
||||
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
|
||||
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
|
||||
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
|
||||
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
|
||||
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
|
||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
|
||||
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
|
||||
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
|
||||
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
|
||||
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
type fakeQbt struct {
|
||||
torrents []qbt.Torrent
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
files []qbt.File
|
||||
}
|
||||
|
||||
@@ -184,6 +242,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
f.added = append(f.added, ar)
|
||||
return nil
|
||||
}
|
||||
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
||||
}
|
||||
|
||||
func oneDownloading(infohash, createdAt string) *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {
|
||||
ID: 1,
|
||||
return &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {
|
||||
ID: "1",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Infohash: store.NullString(infohash),
|
||||
Infohashes: hashesOf("1", infohash),
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// hashesOf — срез хешей загрузки для литералов фикстур.
|
||||
func hashesOf(id string, hashes ...string) []store.Infohash {
|
||||
out := make([]store.Infohash, 0, len(hashes))
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPollTransitions(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
tests := []struct {
|
||||
@@ -242,7 +313,7 @@ func TestPollTransitions(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := st.downloads[1].State; got != tc.want {
|
||||
if got := st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -257,8 +328,8 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateCompleted {
|
||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCompleted {
|
||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,46 +340,93 @@ func TestPollIgnoresMissingTorrent(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancel(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
if err := w.Cancel(context.Background(), 1); err != nil {
|
||||
if err := w.Cancel(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Cancel: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
|
||||
}
|
||||
// Повторная отмена терминальной задачи — ошибка.
|
||||
if err := w.Cancel(context.Background(), 1); err == nil {
|
||||
if err := w.Cancel(context.Background(), "1"); err == nil {
|
||||
t.Error("ожидалась ошибка при отмене терминальной задачи")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetry(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
st.downloads[1].State = store.StateStuck
|
||||
st.downloads["1"].State = store.StateStuck
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
if err := w.Retry(context.Background(), 1); err != nil {
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
if len(qb.added) != 1 {
|
||||
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
|
||||
}
|
||||
}
|
||||
|
||||
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
|
||||
// добавляется в qBittorrent повторно.
|
||||
func TestRetryConflictNoAdd(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
||||
// Хешем владеет другая активная задача.
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("ожидался ErrConflict, получили %v", err)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Если после активации повторный Add в qBittorrent упал — задача
|
||||
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
|
||||
func TestRetryRollsBackOnAddFailure(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
||||
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидалась ошибка Add")
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
|
||||
}
|
||||
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
|
||||
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryRejectsActive(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
if err := w.Retry(context.Background(), 1); err == nil {
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Error("retry активной (downloading) задачи должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user