Идентичность на 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:
+382
-78
@@ -8,6 +8,10 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
)
|
||||
|
||||
// State — состояние загрузки в машине состояний (см. architecture.md).
|
||||
@@ -35,27 +39,26 @@ const (
|
||||
)
|
||||
|
||||
// terminalStates — единый список окончательно остановленных состояний:
|
||||
// источник истины и для IsTerminal, и для выборки «активных» задач
|
||||
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
|
||||
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
|
||||
// снимается по IsTerminal, а активность считалась бы по другому списку).
|
||||
// источник истины и для IsTerminal, и для выборки «активных» задач.
|
||||
// Активность выводится ТОЛЬКО из state (отдельного ключа идемпотентности
|
||||
// нет); любое новое терминальное состояние добавляется ТОЛЬКО сюда — иначе
|
||||
// семантика «активности» разъедется.
|
||||
//
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
|
||||
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
|
||||
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
|
||||
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
|
||||
// при оживлении источника, см. state-reconciliation) — напрямую через
|
||||
// SetDownloadState, который восстановит ключ для нетерминального целевого
|
||||
// состояния.
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted), а также failed —
|
||||
// терминальны для активности, но не «мертвы»: дальше двигает либо человек
|
||||
// (relink из target_missing, retry из failed), либо фоновая сверка (healing/
|
||||
// прогрессия desync; авто-восстановление при оживлении источника, см.
|
||||
// state-reconciliation) — через ActivateIfNoOtherActive, который атомарно
|
||||
// проверяет инвариант «не более одной активной загрузки на infohash».
|
||||
var terminalStates = []State{
|
||||
StateDone, StateCancelled, StateFailed, StateReverted,
|
||||
StateTargetMissing, StateOrphaned, StateDeleted,
|
||||
}
|
||||
|
||||
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
||||
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
||||
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
||||
// stuck терминальным не считается: задача восстановима (retry).
|
||||
// IsTerminal сообщает, завершена ли задача окончательно. Терминальная задача
|
||||
// не «владеет» своими инфохэшами — тот же infohash можно завести заново
|
||||
// новой задачей (см. architecture.md, «повторное добавление»). stuck
|
||||
// терминальным не считается: задача восстановима (retry).
|
||||
func (s State) IsTerminal() bool {
|
||||
return slices.Contains(terminalStates, s)
|
||||
}
|
||||
@@ -69,18 +72,44 @@ const (
|
||||
SourceURL SourceType = "url"
|
||||
)
|
||||
|
||||
// Виды инфохэша (download_infohash.kind).
|
||||
const (
|
||||
HashV1 = "v1" // SHA-1, 40 hex
|
||||
HashV2 = "v2" // SHA-256, 64 hex
|
||||
)
|
||||
|
||||
// HashKind — вид инфохэша по длине hex-строки: 64 — v2, иначе v1.
|
||||
func HashKind(h string) string {
|
||||
if len(h) == 64 {
|
||||
return HashV2
|
||||
}
|
||||
return HashV1
|
||||
}
|
||||
|
||||
// NormalizeHash приводит инфохэш к канонической форме хранения (нижний hex —
|
||||
// в этом же виде его отдаёт qBittorrent).
|
||||
func NormalizeHash(h string) string {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
|
||||
// Infohash — строка таблицы download_infohash: один из хешей загрузки
|
||||
// (у одной загрузки их несколько: v1/v2 гибридного торрента).
|
||||
type Infohash struct {
|
||||
DownloadID string `db:"download_id"`
|
||||
Infohash string `db:"infohash"`
|
||||
Kind string `db:"kind"`
|
||||
}
|
||||
|
||||
// Download — строка таблицы download.
|
||||
type Download struct {
|
||||
ID int64 `db:"id"`
|
||||
SourceType SourceType `db:"source_type"`
|
||||
SourceRef string `db:"source_ref"`
|
||||
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||
Context string `db:"context"`
|
||||
Infohash sql.NullString `db:"infohash"`
|
||||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||||
State State `db:"state"`
|
||||
ErrorCode sql.NullString `db:"error_code"`
|
||||
ErrorMsg sql.NullString `db:"error_msg"`
|
||||
ID string `db:"id"` // ULID (lowercase), публичный ключ домена
|
||||
SourceType SourceType `db:"source_type"`
|
||||
SourceRef string `db:"source_ref"`
|
||||
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||
Context string `db:"context"`
|
||||
State State `db:"state"`
|
||||
ErrorCode sql.NullString `db:"error_code"`
|
||||
ErrorMsg sql.NullString `db:"error_msg"`
|
||||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||||
SourceMissCount int `db:"source_miss_count"`
|
||||
@@ -91,12 +120,34 @@ type Download struct {
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
|
||||
// Infohashes — хеши загрузки (download_infohash); подгружаются вместе с
|
||||
// записью методами чтения store (v1 раньше v2 — порядок стабильный).
|
||||
Infohashes []Infohash `db:"-"`
|
||||
|
||||
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
|
||||
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
||||
// прочих выборках остаётся пустым.
|
||||
RecTitle sql.NullString `db:"rec_title"`
|
||||
}
|
||||
|
||||
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
|
||||
func (d Download) HashList() []string {
|
||||
out := make([]string, len(d.Infohashes))
|
||||
for i, h := range d.Infohashes {
|
||||
out[i] = h.Infohash
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PrimaryInfohash — первый известный хеш (v1 приоритетно) для показа и
|
||||
// scoped-логгера; пустая строка, если хешей нет.
|
||||
func (d Download) PrimaryInfohash() string {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return ""
|
||||
}
|
||||
return d.Infohashes[0].Infohash
|
||||
}
|
||||
|
||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
@@ -121,31 +172,163 @@ func NullString(s string) sql.NullString {
|
||||
return sql.NullString{String: s, Valid: s != ""}
|
||||
}
|
||||
|
||||
// CreateDownload вставляет загрузку и возвращает её id.
|
||||
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
||||
// CreateDownloadIfNoActive атомарно (одна write-транзакция, BEGIN IMMEDIATE
|
||||
// через _txlock) проверяет инвариант «не более одной активной загрузки на
|
||||
// infohash» и заводит загрузку: если активная задача с любым из hashes уже
|
||||
// есть — возвращает её (дедуп, ничего не создавая); иначе вставляет d с новым
|
||||
// ULID и его хешами и возвращает (nil, nil). d.ID и d.Infohashes заполняются.
|
||||
func (s *Store) CreateDownloadIfNoActive(ctx context.Context, d *Download, hashes []string) (*Download, error) {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return nil, fmt.Errorf("create download: no infohash")
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download: begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
existing, err := findActiveByInfohash(ctx, tx, norm, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create download: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
// Дедуп нашёл активного владельца по одному из хешей — остальные хеши
|
||||
// norm принадлежат тому же торренту (гибридный magnet): дописываем
|
||||
// недостающие, иначе второй хеш молча теряется и последующий приём по
|
||||
// нему создал бы вторую активную задачу.
|
||||
for _, h := range norm {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
existing.ID, h, HashKind(h)); err != nil {
|
||||
return nil, fmt.Errorf("create download: top up infohash: %w", err)
|
||||
}
|
||||
}
|
||||
if err := attachInfohashesOne(ctx, tx, existing); err != nil {
|
||||
return nil, fmt.Errorf("create download: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("create download: commit dedup: %w", err)
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
d.ID = ident.NewID()
|
||||
const q = `
|
||||
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
res, err := s.DB.ExecContext(ctx, q,
|
||||
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert download: %w", err)
|
||||
INSERT INTO download (id, source_type, source_ref, display_name, context, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
d.ID, d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.State); err != nil {
|
||||
return nil, fmt.Errorf("insert download: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("download last insert id: %w", err)
|
||||
d.Infohashes = d.Infohashes[:0]
|
||||
for _, h := range norm {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
d.ID, h, HashKind(h)); err != nil {
|
||||
return nil, fmt.Errorf("insert download infohash: %w", err)
|
||||
}
|
||||
d.Infohashes = append(d.Infohashes, Infohash{DownloadID: d.ID, Infohash: h, Kind: HashKind(h)})
|
||||
}
|
||||
return id, nil
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("create download: commit: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetDownload возвращает загрузку по id.
|
||||
func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||||
// ActivateIfNoOtherActive атомарно возвращает загрузку в активное состояние
|
||||
// (retry/восстановление сверкой/relink): в одной write-транзакции проверяет,
|
||||
// что никакая ДРУГАЯ активная загрузка не владеет любым из хешей этой, и
|
||||
// переводит состояние. При владении возвращает ErrInfohashTaken (обёрнутый
|
||||
// с id владельца).
|
||||
func (s *Store) ActivateIfNoOtherActive(ctx context.Context, id string, state State, errCode, errMsg string) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("activate %s: begin tx: %w", id, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var hashes []string
|
||||
if err := tx.SelectContext(ctx, &hashes,
|
||||
`SELECT infohash FROM download_infohash WHERE download_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("activate %s: read hashes: %w", id, err)
|
||||
}
|
||||
if len(hashes) > 0 {
|
||||
// Исключаем саму задачу: при retry из stuck она сама активна и без
|
||||
// исключения LIMIT 1 мог бы вернуть её, замаскировав другого владельца.
|
||||
other, err := findActiveByInfohash(ctx, tx, hashes, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("activate %s: %w", id, err)
|
||||
}
|
||||
if other != nil {
|
||||
return fmt.Errorf("activate %s: infohash owned by download %s: %w",
|
||||
id, other.ID, ErrInfohashTaken)
|
||||
}
|
||||
}
|
||||
if err := setState(ctx, tx, id, state, errCode, errMsg, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("activate %s: commit: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddInfohashes дописывает загрузке недостающие хеши (qBittorrent раскрыл
|
||||
// оба хеша гибридного торрента, а приём знал один). Это тоже мутация
|
||||
// владения хешем, поэтому она под тем же гардом, что и create/activate:
|
||||
// в одной write-транзакции каждый хеш проверяется на владение ДРУГОЙ
|
||||
// активной задачей; конфликтные хеши не дописываются, метод возвращает
|
||||
// ErrInfohashTaken (неконфликтные при этом дописаны — частичный успех).
|
||||
func (s *Store) AddInfohashes(ctx context.Context, downloadID string, hashes []string) error {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add infohashes to %s: begin tx: %w", downloadID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var taken []string
|
||||
for _, h := range norm {
|
||||
other, err := findActiveByInfohash(ctx, tx, []string{h}, downloadID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add infohashes to %s: %w", downloadID, err)
|
||||
}
|
||||
if other != nil {
|
||||
taken = append(taken, h)
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT OR IGNORE INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, ?)`,
|
||||
downloadID, h, HashKind(h)); err != nil {
|
||||
return fmt.Errorf("add infohash %s to %s: %w", h, downloadID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("add infohashes to %s: commit: %w", downloadID, err)
|
||||
}
|
||||
if len(taken) > 0 {
|
||||
return fmt.Errorf("add infohashes to %s: %v owned by another active download: %w",
|
||||
downloadID, taken, ErrInfohashTaken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDownload возвращает загрузку по id (с хешами).
|
||||
func (s *Store) GetDownload(ctx context.Context, id string) (*Download, error) {
|
||||
var d Download
|
||||
if err := s.DB.GetContext(ctx, &d, `SELECT * FROM download WHERE id = ?`, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get download %d: %w", id, ErrNotFound)
|
||||
return nil, fmt.Errorf("get download %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("get download %d: %w", id, err)
|
||||
return nil, fmt.Errorf("get download %s: %w", id, err)
|
||||
}
|
||||
if err := attachInfohashesOne(ctx, s.DB, &d); err != nil {
|
||||
return nil, fmt.Errorf("get download %s: %w", id, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
@@ -153,20 +336,24 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
||||
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
|
||||
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
|
||||
// сать значение на повторных наблюдениях: время добавления неизменно.
|
||||
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
|
||||
func (s *Store) SetSourceAddedAt(ctx context.Context, id string, t time.Time) error {
|
||||
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
|
||||
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
|
||||
return fmt.Errorf("set source added at %d: %w", id, err)
|
||||
return fmt.Errorf("set source added at %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDownloads возвращает все загрузки, новые сверху.
|
||||
// ListDownloads возвращает все загрузки, новые сверху (id — ULID, сортировка
|
||||
// по нему хронологична).
|
||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||
var out []Download
|
||||
if err := s.DB.SelectContext(ctx, &out, `SELECT * FROM download ORDER BY id DESC`); err != nil {
|
||||
return nil, fmt.Errorf("list downloads: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list downloads: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -186,6 +373,9 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do
|
||||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -210,25 +400,52 @@ func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Downloa
|
||||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||||
return nil, fmt.Errorf("list recoverable: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, fmt.Errorf("list recoverable: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FindActiveByInfohash возвращает незавершённую задачу для infohash либо
|
||||
// (nil, nil), если её нет. Основа идемпотентного приёма.
|
||||
func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) {
|
||||
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
||||
// (terminalStates), иначе семантика активности разъедется с idempotency_key.
|
||||
ph := make([]string, len(terminalStates))
|
||||
args := make([]any, 0, len(terminalStates)+1)
|
||||
args = append(args, infohash)
|
||||
for i, st := range terminalStates {
|
||||
ph[i] = "?"
|
||||
args = append(args, string(st))
|
||||
// FindActiveByInfohash возвращает незавершённую задачу, владеющую любым из
|
||||
// hashes, либо (nil, nil). Читающая основа дедупа; сам инвариант держат
|
||||
// guarded-методы (CreateDownloadIfNoActive / ActivateIfNoOtherActive).
|
||||
func (s *Store) FindActiveByInfohash(ctx context.Context, hashes ...string) (*Download, error) {
|
||||
d, err := findActiveByInfohash(ctx, s.DB, normalizeHashes(hashes), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
|
||||
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
|
||||
if d != nil {
|
||||
if err := attachInfohashesOne(ctx, s.DB, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// findActiveByInfohash — общая выборка «активная задача по любому из хешей»
|
||||
// (для guarded-методов — внутри их транзакции). hashes уже нормализованы;
|
||||
// excludeID исключает саму проверяемую задачу (она может быть активной,
|
||||
// например stuck при retry, — и не должна маскировать другого владельца);
|
||||
// пустой excludeID — без исключения. Хеши найденной загрузки НЕ подгружаются.
|
||||
func findActiveByInfohash(ctx context.Context, q sqlx.QueryerContext, hashes []string, excludeID string) (*Download, error) {
|
||||
if len(hashes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// «Активна» = не в терминальном состоянии. Список — единый с IsTerminal
|
||||
// (terminalStates), иначе семантика активности разъедется.
|
||||
var args []any
|
||||
hashPh := placeholders(&args, hashes)
|
||||
statePh := placeholders(&args, terminalStates)
|
||||
query := `SELECT download.* FROM download
|
||||
JOIN download_infohash dh ON dh.download_id = download.id
|
||||
WHERE dh.infohash IN (` + hashPh + `) AND download.state NOT IN (` + statePh + `)`
|
||||
if excludeID != "" {
|
||||
query += ` AND download.id != ?`
|
||||
args = append(args, excludeID)
|
||||
}
|
||||
query += ` ORDER BY download.id DESC LIMIT 1`
|
||||
var d Download
|
||||
err := s.DB.GetContext(ctx, &d, q, args...)
|
||||
err := sqlx.GetContext(ctx, q, &d, query, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -238,63 +455,150 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// placeholders дописывает значения in в args и возвращает строку "?,?,…"
|
||||
// той же длины — сборка IN-списков без ручного жонглирования срезами.
|
||||
func placeholders[T ~string](args *[]any, in []T) string {
|
||||
ph := make([]string, len(in))
|
||||
for i, v := range in {
|
||||
ph[i] = "?"
|
||||
*args = append(*args, string(v))
|
||||
}
|
||||
return strings.Join(ph, ",")
|
||||
}
|
||||
|
||||
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
|
||||
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
|
||||
// с любым из hashes. Discovery усыновляет раздачу только если её ещё не
|
||||
// видели — так готовые задачи не переобрабатываются на каждом тике.
|
||||
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
|
||||
func (s *Store) ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error) {
|
||||
norm := normalizeHashes(hashes)
|
||||
if len(norm) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
ph := make([]string, len(norm))
|
||||
args := make([]any, len(norm))
|
||||
for i, h := range norm {
|
||||
ph[i] = "?"
|
||||
args[i] = h
|
||||
}
|
||||
var n int
|
||||
if err := s.DB.GetContext(ctx, &n,
|
||||
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
|
||||
`SELECT COUNT(1) FROM download_infohash WHERE infohash IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...); err != nil {
|
||||
return false, fmt.Errorf("exists by infohash: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// SetDownloadState переводит загрузку в новое состояние. Ключ
|
||||
// идемпотентности пересчитывается из текущего infohash: для терминального
|
||||
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
||||
// index гарантирует не более одной активной задачи на infohash.
|
||||
func (s *Store) SetDownloadState(ctx context.Context, id int64, state State, errCode, errMsg string) error {
|
||||
const q = `
|
||||
// SetDownloadState переводит загрузку в новое состояние. Механический
|
||||
// бэкстоп инварианта «одна активная загрузка на infohash» (заменяет
|
||||
// удалённый unique-индекс): переход из терминального состояния в активное
|
||||
// этим методом отклоняется — возврат в активное идёт ТОЛЬКО через
|
||||
// ActivateIfNoOtherActive, который проверяет владение хешами.
|
||||
func (s *Store) SetDownloadState(ctx context.Context, id string, state State, errCode, errMsg string) error {
|
||||
return setState(ctx, s.DB, id, state, errCode, errMsg, false)
|
||||
}
|
||||
|
||||
// setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда
|
||||
// (ActivateIfNoOtherActive), которому переход терминал→активное разрешён;
|
||||
// иначе предикат в UPDATE не даёт молча оживить терминальную задачу.
|
||||
func setState(ctx context.Context, e sqlx.ExecerContext, id string, state State, errCode, errMsg string, reviveOK bool) error {
|
||||
q := `
|
||||
UPDATE download
|
||||
SET state = ?,
|
||||
error_code = ?,
|
||||
error_msg = ?,
|
||||
idempotency_key = CASE WHEN ? = 1 THEN NULL ELSE infohash END,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
terminal := 0
|
||||
if state.IsTerminal() {
|
||||
terminal = 1
|
||||
args := []any{string(state), nullArg(errCode), nullArg(errMsg), id}
|
||||
if !reviveOK && !state.IsTerminal() {
|
||||
q += ` AND state NOT IN (` + placeholders(&args, terminalStates) + `)`
|
||||
}
|
||||
res, err := s.DB.ExecContext(ctx, q, string(state), nullArg(errCode), nullArg(errMsg), terminal, id)
|
||||
res, err := e.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %d state %q: %w", id, state, err)
|
||||
return fmt.Errorf("set download %s state %q: %w", id, state, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %d state %q: %w", id, state, err)
|
||||
return fmt.Errorf("set download %s state %q: %w", id, state, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("set download %d state %q: not found", id, state)
|
||||
return fmt.Errorf("set download %s state %q: not found or terminal (revive requires ActivateIfNoOtherActive)", id, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
|
||||
// Состояние не трогает — это отдельная от перехода фоновая отметка.
|
||||
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error {
|
||||
func (s *Store) SetSourceMissCount(ctx context.Context, id string, n int) error {
|
||||
res, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set download %d source_miss_count: %w", id, err)
|
||||
return fmt.Errorf("set download %s source_miss_count: %w", id, err)
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
return fmt.Errorf("set download %d source_miss_count: not found", id)
|
||||
return fmt.Errorf("set download %s source_miss_count: not found", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachInfohashes подгружает хеши для набора загрузок батч-запросами
|
||||
// (порядок стабильный: v1 раньше v2). IN-список режется на чанки, чтобы
|
||||
// безлимитные выборки (ListDownloads за всю историю) не упирались в
|
||||
// SQLITE_MAX_VARIABLE_NUMBER.
|
||||
func (s *Store) attachInfohashes(ctx context.Context, ds []Download) error {
|
||||
if len(ds) == 0 {
|
||||
return nil
|
||||
}
|
||||
const chunkSize = 500
|
||||
byID := make(map[string][]Infohash, len(ds))
|
||||
for start := 0; start < len(ds); start += chunkSize {
|
||||
end := min(start+chunkSize, len(ds))
|
||||
var args []any
|
||||
ids := make([]string, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
ids = append(ids, ds[i].ID)
|
||||
}
|
||||
ph := placeholders(&args, ids)
|
||||
var rows []Infohash
|
||||
if err := s.DB.SelectContext(ctx, &rows,
|
||||
`SELECT download_id, infohash, kind FROM download_infohash
|
||||
WHERE download_id IN (`+ph+`) ORDER BY kind, infohash`,
|
||||
args...); err != nil {
|
||||
return fmt.Errorf("attach infohashes: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
byID[r.DownloadID] = append(byID[r.DownloadID], r)
|
||||
}
|
||||
}
|
||||
for i := range ds {
|
||||
ds[i].Infohashes = byID[ds[i].ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachInfohashesOne подгружает хеши одной загрузки (в т.ч. внутри tx).
|
||||
func attachInfohashesOne(ctx context.Context, q sqlx.QueryerContext, d *Download) error {
|
||||
if err := sqlx.SelectContext(ctx, q, &d.Infohashes,
|
||||
`SELECT download_id, infohash, kind FROM download_infohash
|
||||
WHERE download_id = ? ORDER BY kind, infohash`, d.ID); err != nil {
|
||||
return fmt.Errorf("attach infohashes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHashes нормализует и дедуплицирует хеши, отбрасывая пустые.
|
||||
func normalizeHashes(hashes []string) []string {
|
||||
out := make([]string, 0, len(hashes))
|
||||
for _, h := range hashes {
|
||||
h = NormalizeHash(h)
|
||||
if h == "" || slices.Contains(out, h) {
|
||||
continue
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
||||
func nullArg(s string) any {
|
||||
if s == "" {
|
||||
|
||||
+317
-41
@@ -2,6 +2,9 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -15,24 +18,39 @@ func newTestStore(t *testing.T) *Store {
|
||||
return st
|
||||
}
|
||||
|
||||
func newDownloading(infohash string) *Download {
|
||||
func newDownloading() *Download {
|
||||
return &Download{
|
||||
SourceType: SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Context: "ctx",
|
||||
Infohash: NullString(infohash),
|
||||
IdempotencyKey: NullString(infohash),
|
||||
State: StateDownloading,
|
||||
SourceType: SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:test",
|
||||
Context: "ctx",
|
||||
State: StateDownloading,
|
||||
}
|
||||
}
|
||||
|
||||
// mustCreate заводит загрузку с хешем и возвращает её id; дедуп на
|
||||
// существующую активную — ошибка теста.
|
||||
func mustCreate(t *testing.T, st *Store, infohash string) string {
|
||||
t.Helper()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(context.Background(), d, []string{infohash})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if existing != nil {
|
||||
t.Fatalf("unexpected dedup to %s", existing.ID)
|
||||
}
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func TestCreateAndGetDownload(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.CreateDownload(ctx, newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
const ih = "AABBCCDDEEFF00112233445566778899aabbccdd" // смешанный регистр — нормализуется
|
||||
id := mustCreate(t, st, ih)
|
||||
|
||||
if len(id) != 26 || id != strings.ToLower(id) {
|
||||
t.Errorf("id = %q, want lowercase ULID (26 chars)", id)
|
||||
}
|
||||
|
||||
got, err := st.GetDownload(ctx, id)
|
||||
@@ -48,6 +66,12 @@ func TestCreateAndGetDownload(t *testing.T) {
|
||||
if got.CreatedAt == "" {
|
||||
t.Error("created_at пуст")
|
||||
}
|
||||
if len(got.Infohashes) != 1 {
|
||||
t.Fatalf("infohashes = %v, want 1", got.Infohashes)
|
||||
}
|
||||
if h := got.Infohashes[0]; h.Infohash != strings.ToLower(ih) || h.Kind != HashV1 {
|
||||
t.Errorf("infohash = %+v, want lowercase v1", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindActiveByInfohash(t *testing.T) {
|
||||
@@ -59,10 +83,7 @@ func TestFindActiveByInfohash(t *testing.T) {
|
||||
t.Fatalf("ожидался (nil,nil), получили (%v,%v)", d, err)
|
||||
}
|
||||
|
||||
id, err := st.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
d, err := st.FindActiveByInfohash(ctx, ih)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -70,6 +91,9 @@ func TestFindActiveByInfohash(t *testing.T) {
|
||||
if d == nil || d.ID != id {
|
||||
t.Fatalf("активная задача не найдена: %v", d)
|
||||
}
|
||||
if len(d.Infohashes) != 1 {
|
||||
t.Fatalf("хеши не подгружены: %v", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача
|
||||
@@ -81,12 +105,9 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
t.Run(string(st), func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
ih := "33333333333333333333333333333333333333" + string(st[0:2])
|
||||
const ih = "3333333333333333333333333333333333333333"
|
||||
|
||||
id, err := store.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustCreate(t, store, ih)
|
||||
if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -97,17 +118,15 @@ func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Терминальное состояние снимает ключ идемпотентности и позволяет завести
|
||||
// тот же infohash заново (повторная закачка спустя время).
|
||||
// Терминальное состояние освобождает infohash: тот же хеш заводится заново
|
||||
// новой задачей (повторная закачка спустя время) — активность выводится
|
||||
// только из state.
|
||||
func TestTerminalReleasesInfohash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "2222222222222222222222222222222222222222"
|
||||
|
||||
id, err := st.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id, StateFailed, "qbit_add", "boom"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -116,39 +135,291 @@ func TestTerminalReleasesInfohash(t *testing.T) {
|
||||
if d, err := st.FindActiveByInfohash(ctx, ih); err != nil || d != nil {
|
||||
t.Fatalf("после failed активная задача не должна находиться: (%v,%v)", d, err)
|
||||
}
|
||||
// Ключ идемпотентности снят.
|
||||
got, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IdempotencyKey.Valid {
|
||||
t.Errorf("idempotency_key должен быть NULL, получили %q", got.IdempotencyKey.String)
|
||||
}
|
||||
if got.ErrorCode.String != "qbit_add" {
|
||||
t.Errorf("error_code = %q", got.ErrorCode.String)
|
||||
}
|
||||
|
||||
// Тот же infohash заводится заново — unique index не мешает.
|
||||
id2, err := st.CreateDownload(ctx, newDownloading(ih))
|
||||
if err != nil {
|
||||
t.Fatalf("повторное добавление после терминального должно проходить: %v", err)
|
||||
}
|
||||
// Тот же infohash заводится заново новой задачей.
|
||||
id2 := mustCreate(t, st, ih)
|
||||
if id2 == id {
|
||||
t.Error("ожидалась новая задача")
|
||||
}
|
||||
}
|
||||
|
||||
// Две активные задачи с одним ключом идемпотентности недопустимы.
|
||||
func TestActiveDuplicateRejected(t *testing.T) {
|
||||
// Повторный приём при активной задаче дедуплицируется: вторая вставка не
|
||||
// создаёт строку, а возвращает существующую активную задачу.
|
||||
func TestActiveDuplicateDeduplicated(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "3333333333333333333333333333333333333333"
|
||||
|
||||
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
|
||||
id := mustCreate(t, st, ih)
|
||||
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err == nil {
|
||||
t.Error("ожидалось нарушение уникальности idempotency_key")
|
||||
if existing == nil || existing.ID != id {
|
||||
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
|
||||
}
|
||||
if len(existing.Infohashes) != 1 {
|
||||
t.Fatalf("у существующей задачи не подгружены хеши: %v", existing.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп ловит совпадение по ЛЮБОМУ из хешей: активная задача знает v1+v2,
|
||||
// новый приём приходит только с v2.
|
||||
func TestDedupByAnyHash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const v1 = "4444444444444444444444444444444444444444"
|
||||
const v2 = "6666666666666666666666666666666666666666666666666666666666666666"
|
||||
|
||||
id := mustCreate(t, st, v1)
|
||||
if err := st.AddInfohashes(ctx, id, []string{v2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if existing == nil || existing.ID != id {
|
||||
t.Fatalf("ожидался дедуп по v2-хешу на %s, получили %v", id, existing)
|
||||
}
|
||||
|
||||
// Хеши задачи: v1 раньше v2 (стабильный порядок), повторное добавление
|
||||
// идемпотентно.
|
||||
if err := st.AddInfohashes(ctx, id, []string{v2, v1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Infohashes) != 2 || got.Infohashes[0].Kind != HashV1 || got.Infohashes[1].Kind != HashV2 {
|
||||
t.Fatalf("infohashes = %+v, want [v1, v2]", got.Infohashes)
|
||||
}
|
||||
if got.PrimaryInfohash() != v1 {
|
||||
t.Errorf("primary = %q, want v1", got.PrimaryInfohash())
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateIfNoOtherActive отказывает, когда хешем владеет другая активная
|
||||
// задача, и пропускает, когда владелец ушёл в терминал.
|
||||
func TestActivateIfNoOtherActive(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "5555555555555555555555555555555555555555"
|
||||
|
||||
id1 := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id1, StateFailed, "magnet_timeout", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Хеш перехватила новая активная задача.
|
||||
id2 := mustCreate(t, st, ih)
|
||||
|
||||
err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", "")
|
||||
if !errors.Is(err, ErrInfohashTaken) {
|
||||
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id1); d.State != StateFailed {
|
||||
t.Fatalf("задача не должна была активироваться: %s", d.State)
|
||||
}
|
||||
|
||||
// Владелец завершился → активация проходит.
|
||||
if err := st.SetDownloadState(ctx, id2, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.ActivateIfNoOtherActive(ctx, id1, StateDownloading, "", ""); err != nil {
|
||||
t.Fatalf("активация после ухода владельца: %v", err)
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id1); d.State != StateDownloading {
|
||||
t.Fatalf("state = %s, want downloading", d.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Дедуп-ветка дописывает существующей задаче недостающие хеши гибридного
|
||||
// вызова — второй хеш не теряется, и последующий приём по нему дедупится.
|
||||
func TestCreateDedupTopsUpHashes(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const v1 = "aaaa111111111111111111111111111111111111"
|
||||
const v2 = "bbbb222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
id := mustCreate(t, st, v1)
|
||||
|
||||
// Гибридный вызов с {v1, v2} дедупится на задачу и доносит ей v2.
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v1, v2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if existing == nil || existing.ID != id {
|
||||
t.Fatalf("ожидался дедуп на %s, получили %v", id, existing)
|
||||
}
|
||||
if len(existing.Infohashes) != 2 {
|
||||
t.Fatalf("хеши existing = %+v, want v1+v2 (top-up)", existing.Infohashes)
|
||||
}
|
||||
// Теперь приём только по v2 тоже дедупится, а не создаёт вторую задачу.
|
||||
byV2, err := st.CreateDownloadIfNoActive(ctx, newDownloading(), []string{v2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if byV2 == nil || byV2.ID != id {
|
||||
t.Fatalf("дедуп по донесённому v2 не сработал: %v", byV2)
|
||||
}
|
||||
}
|
||||
|
||||
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
|
||||
// не дописывается — возвращается ErrInfohashTaken.
|
||||
func TestAddInfohashesGuard(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const h1 = "cccc111111111111111111111111111111111111"
|
||||
const h2 = "dddd222222222222222222222222222222222222"
|
||||
|
||||
a := mustCreate(t, st, h1)
|
||||
_ = mustCreate(t, st, h2) // активный владелец h2
|
||||
|
||||
err := st.AddInfohashes(ctx, a, []string{h2})
|
||||
if !errors.Is(err, ErrInfohashTaken) {
|
||||
t.Fatalf("ожидался ErrInfohashTaken, получили %v", err)
|
||||
}
|
||||
got, _ := st.GetDownload(ctx, a)
|
||||
if len(got.Infohashes) != 1 || got.Infohashes[0].Infohash != h1 {
|
||||
t.Fatalf("чужой хеш не должен был дописаться: %+v", got.Infohashes)
|
||||
}
|
||||
|
||||
// Хеш терминального владельца дописывается свободно.
|
||||
if err := st.SetDownloadState(ctx, a, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := mustCreate(t, st, "eeee333333333333333333333333333333333333")
|
||||
if err := st.AddInfohashes(ctx, b, []string{h1}); err != nil {
|
||||
t.Fatalf("хеш терминальной задачи должен дописываться: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Гард активации не маскирует конфликт самой активируемой задачей: stuck
|
||||
// (нетерминальна) с более новым id не должна перекрыть старшего владельца.
|
||||
func TestActivateExcludesSelf(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const h = "ffff111111111111111111111111111111111111"
|
||||
|
||||
older := mustCreate(t, st, h) // активный владелец, id старше
|
||||
newer := mustCreate(t, st, "0000222222222222222222222222222222222222")
|
||||
if err := st.SetDownloadState(ctx, newer, StateStuck, "stalled", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Легаси/аварийное состояние: у newer тот же хеш h (мимо API — гард
|
||||
// такого не создаст, но обязан не маскировать).
|
||||
if _, err := st.DB.ExecContext(ctx,
|
||||
`INSERT INTO download_infohash (download_id, infohash, kind) VALUES (?, ?, 'v1')`,
|
||||
newer, h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := st.ActivateIfNoOtherActive(ctx, newer, StateDownloading, "", "")
|
||||
if !errors.Is(err, ErrInfohashTaken) {
|
||||
t.Fatalf("гард замаскирован self-строкой: ожидался ErrInfohashTaken, получили %v", err)
|
||||
}
|
||||
_ = older
|
||||
}
|
||||
|
||||
// Механический бэкстоп: публичный SetDownloadState не оживляет терминальную
|
||||
// задачу — возврат в активное только через ActivateIfNoOtherActive.
|
||||
func TestSetDownloadStateRejectsRevive(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id := mustCreate(t, st, "1234511111111111111111111111111111111111")
|
||||
if err := st.SetDownloadState(ctx, id, StateFailed, "x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetDownloadState(ctx, id, StateDownloading, "", ""); err == nil {
|
||||
t.Fatal("терминал→активное мимо гарда должно отклоняться")
|
||||
}
|
||||
if d, _ := st.GetDownload(ctx, id); d.State != StateFailed {
|
||||
t.Fatalf("state = %s, want failed (без изменений)", d.State)
|
||||
}
|
||||
// Терминал→терминал разрешён (например, сверка double-terminal переходов).
|
||||
if err := st.SetDownloadState(ctx, id, StateDeleted, "", ""); err != nil {
|
||||
t.Fatalf("терминал→терминал должен проходить: %v", err)
|
||||
}
|
||||
// Штатный путь оживления работает.
|
||||
if err := st.ActivateIfNoOtherActive(ctx, id, StateDownloading, "", ""); err != nil {
|
||||
t.Fatalf("оживление через гард: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Конкурентные создания одного infohash сериализуются write-транзакциями
|
||||
// (_txlock=immediate): ровно одна задача создаётся, остальные дедупятся.
|
||||
func TestConcurrentCreateDedup(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "abcde11111111111111111111111111111111111"
|
||||
const n = 8
|
||||
|
||||
ids := make(chan string, n)
|
||||
errs := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d := newDownloading()
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{ih})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
ids <- existing.ID
|
||||
} else {
|
||||
ids <- d.ID
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(ids)
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("конкурентное создание упало: %v", err)
|
||||
}
|
||||
uniq := map[string]bool{}
|
||||
for id := range ids {
|
||||
uniq[id] = true
|
||||
}
|
||||
if len(uniq) != 1 {
|
||||
t.Fatalf("создано %d разных задач на один infohash, want 1: %v", len(uniq), uniq)
|
||||
}
|
||||
all, _ := st.ListDownloads(ctx)
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("в БД %d строк, want 1", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistsByInfohash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "7777777777777777777777777777777777777777"
|
||||
|
||||
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || ok {
|
||||
t.Fatalf("ожидался (false,nil), получили (%v,%v)", ok, err)
|
||||
}
|
||||
id := mustCreate(t, st, ih)
|
||||
if err := st.SetDownloadState(ctx, id, StateDone, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Exists видит и терминальные (в отличие от FindActive).
|
||||
if ok, err := st.ExistsByInfohash(ctx, ih); err != nil || !ok {
|
||||
t.Fatalf("ожидался (true,nil), получили (%v,%v)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +427,8 @@ func TestListAndByState(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id1, _ := st.CreateDownload(ctx, newDownloading("4444444444444444444444444444444444444444"))
|
||||
id2, _ := st.CreateDownload(ctx, newDownloading("5555555555555555555555555555555555555555"))
|
||||
id1 := mustCreate(t, st, "4444444444444444444444444444444444444444")
|
||||
id2 := mustCreate(t, st, "5555555555555555555555555555555555555555")
|
||||
if err := st.SetDownloadState(ctx, id2, StateCompleted, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -169,6 +440,11 @@ func TestListAndByState(t *testing.T) {
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("ListDownloads = %d, want 2", len(all))
|
||||
}
|
||||
for _, d := range all {
|
||||
if len(d.Infohashes) != 1 {
|
||||
t.Fatalf("у %s не подгружены хеши", d.ID)
|
||||
}
|
||||
}
|
||||
|
||||
dl, err := st.ListDownloadsByState(ctx, StateDownloading)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,3 +6,9 @@ import "errors"
|
||||
// в него sql.ErrNoRows у источника, чтобы выше по коду не торчал database/sql,
|
||||
// а потребители матчили причину через errors.Is(err, store.ErrNotFound).
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrInfohashTaken — инвариант «не более одной активной загрузки на infohash»:
|
||||
// возврат задачи в активное состояние отклонён, потому что хешем уже владеет
|
||||
// другая активная задача (ActivateIfNoOtherActive). Вызывающие отличают этот
|
||||
// штатный конфликт от сбоя через errors.Is.
|
||||
var ErrInfohashTaken = errors.New("infohash owned by another active download")
|
||||
|
||||
@@ -74,7 +74,8 @@ func listWhere(f ListFilter) (string, []any) {
|
||||
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
|
||||
"OR display_name LIKE ? COLLATE NOCASE "+
|
||||
"OR context LIKE ? COLLATE NOCASE "+
|
||||
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
|
||||
"OR EXISTS (SELECT 1 FROM download_infohash dh "+
|
||||
"WHERE dh.download_id = download.id AND dh.infohash LIKE ? COLLATE NOCASE))")
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
|
||||
@@ -109,5 +110,8 @@ LIMIT ? OFFSET ?`
|
||||
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
||||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||||
}
|
||||
if err := s.attachInfohashes(ctx, out); err != nil {
|
||||
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
+15
-12
@@ -11,25 +11,28 @@ import (
|
||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||
|
||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d := newDownloading(hashN(n))
|
||||
d := newDownloading()
|
||||
d.DisplayName = display
|
||||
id, err := st.CreateDownload(ctx, d)
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", n, err)
|
||||
}
|
||||
if existing != nil {
|
||||
t.Fatalf("create #%d: unexpected dedup", n)
|
||||
}
|
||||
if state != StateDownloading {
|
||||
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
||||
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
|
||||
t.Fatalf("set state #%d: %v", n, err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func ids(ds []Download) []int64 {
|
||||
out := make([]int64, len(ds))
|
||||
func ids(ds []Download) []string {
|
||||
out := make([]string, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.ID
|
||||
}
|
||||
@@ -51,7 +54,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
||||
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
|
||||
}
|
||||
|
||||
// all: deleted скрыт по умолчанию.
|
||||
@@ -110,7 +113,7 @@ func TestListDownloadsPageSearch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
||||
if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
|
||||
t.Fatalf("search by infohash = %v", ids(page))
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,7 @@ func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||
got := ids(page)
|
||||
want := []int64{id3, id2, id1}
|
||||
want := []string{id3, id2, id1}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("порядок = %v, want %v", got, want)
|
||||
@@ -178,7 +181,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
|
||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
var idList []int64
|
||||
var idList []string
|
||||
for i := 1; i <= 3; i++ {
|
||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||
@@ -191,7 +194,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ids(page)
|
||||
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
||||
want := []string{idList[2], idList[1], idList[0]} // id DESC
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// TestUlidMigration прогоняет миграцию 0006 на фикстурной БД со старой схемой
|
||||
// (числовые id, download.infohash + idempotency_key) и проверяет: FK-связи
|
||||
// сохранены, порядок по id соответствует created_at, хеши разнесены в
|
||||
// download_infohash, старые столбцы удалены.
|
||||
func TestUlidMigration(t *testing.T) {
|
||||
dbPath := t.TempDir() + "/legacy.db"
|
||||
|
||||
// Legacy-БД: схема до 0006 + строки с числовыми id.
|
||||
legacy, err := sqlx.Connect("sqlite",
|
||||
"file:"+dbPath+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy: %v", err)
|
||||
}
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger())
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := goose.UpTo(legacy.DB, "migrations", 5); err != nil {
|
||||
t.Fatalf("migrate to v5: %v", err)
|
||||
}
|
||||
|
||||
const (
|
||||
v1hash = "aabbccddeeff00112233445566778899aabbccdd"
|
||||
v2hash = "6666666666666666666666666666666666666666666666666666666666666666"
|
||||
)
|
||||
seed := []string{
|
||||
// #1 done с v1-хешем (ключ снят терминалом), #2 активная с v2-хешем,
|
||||
// #3 без хеша; #2 и #3 в одну секунду — порядок должен сохраниться.
|
||||
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
|
||||
VALUES (1, 'magnet', 'magnet:?xt=urn:btih:` + v1hash + `', 'Old One', 'done', '` + v1hash + `', NULL,
|
||||
'2026-01-01 10:00:00', '2026-01-01 11:00:00')`,
|
||||
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
|
||||
VALUES (2, 'magnet', 'magnet:?xt=urn:btmh:1220` + v2hash + `', 'Two', 'downloading', '` + v2hash + `', '` + v2hash + `',
|
||||
'2026-02-01 10:00:00', '2026-02-01 10:00:00')`,
|
||||
`INSERT INTO download (id, source_type, source_ref, display_name, state, infohash, idempotency_key, created_at, updated_at)
|
||||
VALUES (3, 'magnet', 'magnet:?xt=urn:btih:cafe', 'Three', 'failed', NULL, NULL,
|
||||
'2026-02-01 10:00:00', '2026-02-01 10:00:00')`,
|
||||
`INSERT INTO recognition (id, download_id, attempt_no, is_current, title, provider, provider_id, plan)
|
||||
VALUES (10, 1, 1, 1, 'Fargo', 'tvdb', '269613', '{"type":"series"}')`,
|
||||
`INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, chosen)
|
||||
VALUES (20, 10, 'tvdb', '269613', 'Fargo', 1)`,
|
||||
`INSERT INTO hint (id, download_id, text) VALUES (30, 1, 'второй сезон')`,
|
||||
`INSERT INTO override (id, download_id, field, value) VALUES (40, 1, 'media_type', 'series')`,
|
||||
`INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (50, 1, 'b-1', '/d/a.mkv', '/m/A.mkv', 'video', 'linked')`,
|
||||
}
|
||||
for _, q := range seed {
|
||||
if _, err := legacy.Exec(q); err != nil {
|
||||
t.Fatalf("seed: %v\n%s", err, q)
|
||||
}
|
||||
}
|
||||
if err := legacy.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Open прогоняет оставшиеся миграции (0006).
|
||||
st, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open with migration: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
ctx := context.Background()
|
||||
|
||||
all, err := st.ListDownloads(ctx) // ORDER BY id DESC
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("downloads = %d, want 3", len(all))
|
||||
}
|
||||
// Хронология сохранена: DESC по id = [Three, Two, Old One] (при равных
|
||||
// секундах #2/#3 порядок старых id держит monotonic-энтропия).
|
||||
if all[0].DisplayName != "Three" || all[1].DisplayName != "Two" || all[2].DisplayName != "Old One" {
|
||||
t.Fatalf("порядок по id разъехался с хронологией: %s, %s, %s",
|
||||
all[0].DisplayName, all[1].DisplayName, all[2].DisplayName)
|
||||
}
|
||||
one, two, three := all[2], all[1], all[0]
|
||||
|
||||
// Хеши разнесены с верным kind; created_at/updated_at сохранены.
|
||||
if len(one.Infohashes) != 1 || one.Infohashes[0].Infohash != v1hash || one.Infohashes[0].Kind != HashV1 {
|
||||
t.Fatalf("хеши #1 = %+v", one.Infohashes)
|
||||
}
|
||||
if len(two.Infohashes) != 1 || two.Infohashes[0].Infohash != v2hash || two.Infohashes[0].Kind != HashV2 {
|
||||
t.Fatalf("хеши #2 = %+v", two.Infohashes)
|
||||
}
|
||||
if len(three.Infohashes) != 0 {
|
||||
t.Fatalf("хеши #3 = %+v, want пусто", three.Infohashes)
|
||||
}
|
||||
if one.CreatedAt != "2026-01-01 10:00:00" || one.UpdatedAt != "2026-01-01 11:00:00" {
|
||||
t.Fatalf("метки #1 = %q / %q", one.CreatedAt, one.UpdatedAt)
|
||||
}
|
||||
|
||||
// FK-связи: распознавание/кандидаты/подсказки/правки/ссылки указывают на #1.
|
||||
rec, err := st.GetCurrentRecognition(ctx, one.ID)
|
||||
if err != nil || rec == nil || rec.Title.String != "Fargo" {
|
||||
t.Fatalf("recognition #1 = %+v, %v", rec, err)
|
||||
}
|
||||
cands, err := st.ListCandidatesByRecognition(ctx, rec.ID)
|
||||
if err != nil || len(cands) != 1 || !cands[0].Chosen {
|
||||
t.Fatalf("candidates = %+v, %v", cands, err)
|
||||
}
|
||||
hints, err := st.ListHints(ctx, one.ID)
|
||||
if err != nil || len(hints) != 1 || hints[0] != "второй сезон" {
|
||||
t.Fatalf("hints = %v, %v", hints, err)
|
||||
}
|
||||
ovr, err := st.ListOverrides(ctx, one.ID)
|
||||
if err != nil || ovr["media_type"] != "series" {
|
||||
t.Fatalf("overrides = %v, %v", ovr, err)
|
||||
}
|
||||
batch, err := st.LatestBatchID(ctx, one.ID)
|
||||
if err != nil || batch != "b-1" {
|
||||
t.Fatalf("batch = %q, %v", batch, err)
|
||||
}
|
||||
|
||||
// Активность выводится из state: v2-хеш занят активной #2.
|
||||
active, err := st.FindActiveByInfohash(ctx, v2hash)
|
||||
if err != nil || active == nil || active.ID != two.ID {
|
||||
t.Fatalf("active by v2 = %+v, %v", active, err)
|
||||
}
|
||||
|
||||
// Старые столбцы удалены.
|
||||
var cols []string
|
||||
if err := st.DB.Select(&cols, `SELECT name FROM pragma_table_info('download')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range cols {
|
||||
if c == "infohash" || c == "idempotency_key" {
|
||||
t.Fatalf("столбец %q должен быть удалён", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// Package migrations содержит Go-миграции goose (SQL-миграции лежат рядом
|
||||
// *.sql-файлами и прогоняются из embed FS пакета store). Регистрация — в
|
||||
// init(); чтобы она сработала, пакет blank-импортируется из store.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upUlidIdentity, downUlidIdentity)
|
||||
}
|
||||
|
||||
// upUlidIdentity переводит все таблицы на ULID-идентификаторы (TEXT PK),
|
||||
// разносит download.infohash в download_infohash и убирает idempotency_key
|
||||
// (см. openspec/changes/ulid-identity/design.md, D6).
|
||||
//
|
||||
// Работает при включённых foreign_keys (PRAGMA внутри транзакции — no-op),
|
||||
// поэтому порядок жёсткий: новые таблицы и данные — родители первыми, DROP
|
||||
// старых — дети первыми, затем RENAME (SQLite ≥ 3.25 переписывает REFERENCES
|
||||
// в ссылающихся таблицах).
|
||||
func upUlidIdentity(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := createNewTables(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
downloadIDs, err := migrateDownloads(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recognitionIDs, err := migrateRecognitions(ctx, tx, downloadIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateHints(ctx, tx, downloadIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateOverrides(ctx, tx, downloadIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateCandidates(ctx, tx, recognitionIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateFileLinks(ctx, tx, downloadIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Старые таблицы: дети первыми, родитель последним (FK включены).
|
||||
for _, stmt := range []string{
|
||||
`DROP TABLE file_link`,
|
||||
`DROP TABLE metadata_candidate`,
|
||||
`DROP TABLE override`,
|
||||
`DROP TABLE hint`,
|
||||
`DROP TABLE recognition`,
|
||||
`DROP TABLE download`,
|
||||
`ALTER TABLE download_new RENAME TO download`,
|
||||
`ALTER TABLE recognition_new RENAME TO recognition`,
|
||||
`ALTER TABLE hint_new RENAME TO hint`,
|
||||
`ALTER TABLE override_new RENAME TO override`,
|
||||
`ALTER TABLE metadata_candidate_new RENAME TO metadata_candidate`,
|
||||
`ALTER TABLE file_link_new RENAME TO file_link`,
|
||||
// Индексы — после переименований, с каноническими именами (старые
|
||||
// одноимённые ушли вместе со старыми таблицами).
|
||||
`CREATE INDEX idx_download_state ON download (state)`,
|
||||
`CREATE INDEX idx_download_infohash_download ON download_infohash (download_id)`,
|
||||
`CREATE INDEX idx_recognition_download ON recognition (download_id)`,
|
||||
`CREATE INDEX idx_hint_download ON hint (download_id)`,
|
||||
`CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id)`,
|
||||
`CREATE INDEX idx_file_link_download ON file_link (download_id)`,
|
||||
`CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id)`,
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("ulid migration: %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
return checkForeignKeys(ctx, tx)
|
||||
}
|
||||
|
||||
// downUlidIdentity: обратной миграции нет — ULID → числовые id невосстановимы.
|
||||
// Откат — восстановление файла БД из копии (см. design.md, Migration Plan).
|
||||
func downUlidIdentity(context.Context, *sql.Tx) error {
|
||||
return fmt.Errorf("ulid identity migration is irreversible; restore the database file from a backup")
|
||||
}
|
||||
|
||||
func createNewTables(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE download_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
error_msg TEXT,
|
||||
source_miss_count INTEGER NOT NULL DEFAULT 0,
|
||||
source_added_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE download_infohash (
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
infohash TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (infohash, download_id)
|
||||
)`,
|
||||
`CREATE TABLE recognition_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
attempt_no INTEGER NOT NULL DEFAULT 1,
|
||||
is_current INTEGER NOT NULL DEFAULT 1,
|
||||
media_type TEXT,
|
||||
title TEXT,
|
||||
original_title TEXT,
|
||||
year INTEGER,
|
||||
provider TEXT,
|
||||
provider_id TEXT,
|
||||
confidence REAL,
|
||||
reasons TEXT NOT NULL DEFAULT '[]',
|
||||
raw_llm TEXT,
|
||||
plan TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE hint_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE override_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (download_id, field)
|
||||
)`,
|
||||
`CREATE TABLE metadata_candidate_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
recognition_id TEXT NOT NULL REFERENCES recognition_new (id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
chosen INTEGER NOT NULL DEFAULT 0,
|
||||
url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE file_link_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
download_id TEXT NOT NULL REFERENCES download_new (id) ON DELETE CASCADE,
|
||||
apply_batch_id TEXT NOT NULL,
|
||||
src_path TEXT NOT NULL,
|
||||
dst_path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("ulid migration: create tables: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// idMap строит маппинг «старый int id → ULID» для таблицы: строки читаются в
|
||||
// порядке старого id (хронология), timestamp-часть ULID — из created_at, так
|
||||
// что лексикографический порядок новых id сохраняет исторический. Равные
|
||||
// секунды created_at упорядочивает monotonic-энтропия по порядку обхода.
|
||||
func idMap(ctx context.Context, tx *sql.Tx, table string) (map[int64]string, error) {
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, created_at FROM `+table+` ORDER BY id`) //nolint:gosec // имена таблиц — константы этого файла
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ulid migration: read %s ids: %w", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := map[int64]string{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var createdAt string
|
||||
if err := rows.Scan(&id, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("ulid migration: scan %s id: %w", table, err)
|
||||
}
|
||||
out[id] = ident.NewIDAt(parseCreatedAt(createdAt))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// parseCreatedAt разбирает метку datetime('now') (UTC); непарсибельная метка
|
||||
// → текущее время (порядок в пределах таблицы всё равно монотонен).
|
||||
func parseCreatedAt(s string) time.Time {
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.UTC)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func migrateDownloads(ctx context.Context, tx *sql.Tx) (map[int64]string, error) {
|
||||
ids, err := idMap(ctx, tx, "download")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Сначала вычитываем всё и закрываем курсор, потом вставляем: Tx держит
|
||||
// одно соединение, Exec при открытых Rows на нём невозможен.
|
||||
type downloadRow struct {
|
||||
id int64
|
||||
sourceType, sourceRef, displayName string
|
||||
contextText, state string
|
||||
infohash, errorCode, errorMsg sql.NullString
|
||||
sourceMissCount int
|
||||
sourceAddedAt sql.NullString
|
||||
createdAt, updatedAt string
|
||||
}
|
||||
var all []downloadRow
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, source_type, source_ref, display_name, context, infohash, state,
|
||||
error_code, error_msg, source_miss_count, source_added_at,
|
||||
created_at, updated_at
|
||||
FROM download ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ulid migration: read downloads: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var r downloadRow
|
||||
if err := rows.Scan(&r.id, &r.sourceType, &r.sourceRef, &r.displayName,
|
||||
&r.contextText, &r.infohash, &r.state, &r.errorCode, &r.errorMsg,
|
||||
&r.sourceMissCount, &r.sourceAddedAt, &r.createdAt, &r.updatedAt); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("ulid migration: scan download: %w", err)
|
||||
}
|
||||
all = append(all, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, fmt.Errorf("ulid migration: iterate downloads: %w", err)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
for _, r := range all {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO download_new (id, source_type, source_ref, display_name, context,
|
||||
state, error_code, error_msg, source_miss_count,
|
||||
source_added_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
ids[r.id], r.sourceType, r.sourceRef, r.displayName, r.contextText,
|
||||
r.state, r.errorCode, r.errorMsg, r.sourceMissCount, r.sourceAddedAt,
|
||||
r.createdAt, r.updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("ulid migration: insert download %d: %w", r.id, err)
|
||||
}
|
||||
if r.infohash.Valid && r.infohash.String != "" {
|
||||
h := strings.ToLower(r.infohash.String)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO download_infohash (download_id, infohash, kind, created_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
ids[r.id], h, hashKind(h), r.createdAt); err != nil {
|
||||
return nil, fmt.Errorf("ulid migration: insert infohash for %d: %w", r.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// hashKind — вид инфохэша по длине hex: 40 — v1 (SHA-1), 64 — v2 (SHA-256).
|
||||
func hashKind(h string) string {
|
||||
if len(h) == 64 {
|
||||
return "v2"
|
||||
}
|
||||
return "v1"
|
||||
}
|
||||
|
||||
func migrateRecognitions(ctx context.Context, tx *sql.Tx, downloads map[int64]string) (map[int64]string, error) {
|
||||
ids, err := idMap(ctx, tx, "recognition")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := copyRows(ctx, tx, copySpec{
|
||||
from: "recognition", to: "recognition_new",
|
||||
cols: []string{"attempt_no", "is_current", "media_type", "title", "original_title", "year", "provider", "provider_id", "confidence", "reasons", "raw_llm", "plan", "created_at"},
|
||||
ids: ids,
|
||||
parent: parentRef{col: "download_id", ids: downloads},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func migrateHints(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
|
||||
ids, err := idMap(ctx, tx, "hint")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyRows(ctx, tx, copySpec{
|
||||
from: "hint", to: "hint_new",
|
||||
cols: []string{"text", "created_at"},
|
||||
ids: ids,
|
||||
parent: parentRef{col: "download_id", ids: downloads},
|
||||
})
|
||||
}
|
||||
|
||||
func migrateOverrides(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
|
||||
ids, err := idMap(ctx, tx, "override")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyRows(ctx, tx, copySpec{
|
||||
from: "override", to: "override_new",
|
||||
cols: []string{"field", "value", "created_at"},
|
||||
ids: ids,
|
||||
parent: parentRef{col: "download_id", ids: downloads},
|
||||
})
|
||||
}
|
||||
|
||||
func migrateCandidates(ctx context.Context, tx *sql.Tx, recognitions map[int64]string) error {
|
||||
ids, err := idMap(ctx, tx, "metadata_candidate")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyRows(ctx, tx, copySpec{
|
||||
from: "metadata_candidate", to: "metadata_candidate_new",
|
||||
cols: []string{"provider", "provider_id", "title", "year", "chosen", "url", "created_at"},
|
||||
ids: ids,
|
||||
parent: parentRef{col: "recognition_id", ids: recognitions},
|
||||
})
|
||||
}
|
||||
|
||||
func migrateFileLinks(ctx context.Context, tx *sql.Tx, downloads map[int64]string) error {
|
||||
ids, err := idMap(ctx, tx, "file_link")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyRows(ctx, tx, copySpec{
|
||||
from: "file_link", to: "file_link_new",
|
||||
cols: []string{"apply_batch_id", "src_path", "dst_path", "kind", "status", "created_at"},
|
||||
ids: ids,
|
||||
parent: parentRef{col: "download_id", ids: downloads},
|
||||
})
|
||||
}
|
||||
|
||||
// copySpec описывает перенос таблицы: собственный маппинг id, FK-родитель и
|
||||
// прочие столбцы, копируемые как есть.
|
||||
type copySpec struct {
|
||||
from, to string
|
||||
cols []string
|
||||
ids map[int64]string
|
||||
parent parentRef
|
||||
}
|
||||
|
||||
type parentRef struct {
|
||||
col string
|
||||
ids map[int64]string
|
||||
}
|
||||
|
||||
func copyRows(ctx context.Context, tx *sql.Tx, spec copySpec) error {
|
||||
colList := strings.Join(spec.cols, ", ")
|
||||
// Сначала вычитываем всё и закрываем курсор, потом вставляем (Exec при
|
||||
// открытых Rows на соединении транзакции невозможен).
|
||||
type rowData struct {
|
||||
oldID, parentID int64
|
||||
rest []any
|
||||
}
|
||||
var all []rowData
|
||||
//nolint:gosec // имена таблиц/столбцов — константы этого файла
|
||||
rows, err := tx.QueryContext(ctx, fmt.Sprintf(
|
||||
`SELECT id, %s, %s FROM %s ORDER BY id`, spec.parent.col, colList, spec.from))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ulid migration: read %s: %w", spec.from, err)
|
||||
}
|
||||
for rows.Next() {
|
||||
r := rowData{rest: make([]any, len(spec.cols))}
|
||||
dest := append([]any{&r.oldID, &r.parentID}, scanPtrs(r.rest)...)
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("ulid migration: scan %s: %w", spec.from, err)
|
||||
}
|
||||
all = append(all, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("ulid migration: iterate %s: %w", spec.from, err)
|
||||
}
|
||||
_ = rows.Close()
|
||||
|
||||
ph := strings.TrimSuffix(strings.Repeat("?, ", len(spec.cols)), ", ")
|
||||
//nolint:gosec // имена таблиц/столбцов — константы этого файла
|
||||
insert := fmt.Sprintf(`INSERT INTO %s (id, %s, %s) VALUES (?, ?, %s)`,
|
||||
spec.to, spec.parent.col, colList, ph)
|
||||
|
||||
for _, r := range all {
|
||||
newParent, ok := spec.parent.ids[r.parentID]
|
||||
if !ok {
|
||||
return fmt.Errorf("ulid migration: %s row %d references unknown %s %d",
|
||||
spec.from, r.oldID, spec.parent.col, r.parentID)
|
||||
}
|
||||
args := append([]any{spec.ids[r.oldID], newParent}, r.rest...)
|
||||
if _, err := tx.ExecContext(ctx, insert, args...); err != nil {
|
||||
return fmt.Errorf("ulid migration: insert %s row %d: %w", spec.to, r.oldID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanPtrs — указатели на элементы среза для rows.Scan (значения любого типа
|
||||
// SQLite едут через any и вставляются обратно как есть).
|
||||
func scanPtrs(vals []any) []any {
|
||||
out := make([]any, len(vals))
|
||||
for i := range vals {
|
||||
out[i] = &vals[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// checkForeignKeys — финальная самопроверка целостности после rebuild.
|
||||
func checkForeignKeys(ctx context.Context, tx *sql.Tx) error {
|
||||
rows, err := tx.QueryContext(ctx, `PRAGMA foreign_key_check`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ulid migration: foreign_key_check: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
if rows.Next() {
|
||||
var table string
|
||||
var rowid, parent, fkid any
|
||||
_ = rows.Scan(&table, &rowid, &parent, &fkid)
|
||||
return fmt.Errorf("ulid migration: foreign key violation in %s after rebuild", table)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
)
|
||||
|
||||
// Recognition — строка таблицы recognition (попытка распознавания).
|
||||
type Recognition struct {
|
||||
ID int64 `db:"id"`
|
||||
DownloadID int64 `db:"download_id"`
|
||||
ID string `db:"id"`
|
||||
DownloadID string `db:"download_id"`
|
||||
AttemptNo int `db:"attempt_no"`
|
||||
IsCurrent bool `db:"is_current"`
|
||||
MediaType sql.NullString `db:"media_type"`
|
||||
@@ -41,54 +43,50 @@ func (r Recognition) ReasonList() []string {
|
||||
// CreateRecognition вставляет новую попытку распознавания, помечая прежние
|
||||
// как неактуальные (is_current = 0) и проставляя следующий attempt_no.
|
||||
// Возвращает id новой записи. reasons сериализуется в JSON.
|
||||
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (int64, error) {
|
||||
func (s *Store) CreateRecognition(ctx context.Context, r *Recognition, reasons []string) (string, error) {
|
||||
reasonsJSON, err := json.Marshal(reasons)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal reasons: %w", err)
|
||||
return "", fmt.Errorf("marshal reasons: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin tx: %w", err)
|
||||
return "", fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE recognition SET is_current = 0 WHERE download_id = ?`, r.DownloadID); err != nil {
|
||||
return 0, fmt.Errorf("clear current recognitions: %w", err)
|
||||
return "", fmt.Errorf("clear current recognitions: %w", err)
|
||||
}
|
||||
|
||||
var nextAttempt int
|
||||
if err := tx.GetContext(ctx, &nextAttempt,
|
||||
`SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM recognition WHERE download_id = ?`,
|
||||
r.DownloadID); err != nil {
|
||||
return 0, fmt.Errorf("next attempt_no: %w", err)
|
||||
return "", fmt.Errorf("next attempt_no: %w", err)
|
||||
}
|
||||
|
||||
r.ID = ident.NewID()
|
||||
const q = `
|
||||
INSERT INTO recognition
|
||||
(download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
(id, download_id, attempt_no, is_current, media_type, title, original_title,
|
||||
year, provider, provider_id, confidence, reasons, raw_llm, plan)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
res, err := tx.ExecContext(ctx, q,
|
||||
r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert recognition: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("recognition last insert id: %w", err)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
r.ID, r.DownloadID, nextAttempt, r.MediaType, r.Title, r.OriginalTitle,
|
||||
r.Year, r.Provider, r.ProviderID, r.Confidence, string(reasonsJSON), r.RawLLM, r.Plan); err != nil {
|
||||
return "", fmt.Errorf("insert recognition: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("commit recognition: %w", err)
|
||||
return "", fmt.Errorf("commit recognition: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
// GetCurrentRecognition возвращает актуальную попытку распознавания загрузки
|
||||
// либо (nil, nil), если её ещё нет.
|
||||
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*Recognition, error) {
|
||||
func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID string) (*Recognition, error) {
|
||||
var r Recognition
|
||||
err := s.DB.GetContext(ctx, &r,
|
||||
`SELECT * FROM recognition WHERE download_id = ? AND is_current = 1
|
||||
@@ -105,16 +103,17 @@ func (s *Store) GetCurrentRecognition(ctx context.Context, downloadID int64) (*R
|
||||
// --- Подсказки (hint) ---
|
||||
|
||||
// AddHint добавляет текстовую подсказку ревьюера к загрузке.
|
||||
func (s *Store) AddHint(ctx context.Context, downloadID int64, text string) error {
|
||||
func (s *Store) AddHint(ctx context.Context, downloadID string, text string) error {
|
||||
if _, err := s.DB.ExecContext(ctx,
|
||||
`INSERT INTO hint (download_id, text) VALUES (?, ?)`, downloadID, text); err != nil {
|
||||
`INSERT INTO hint (id, download_id, text) VALUES (?, ?, ?)`,
|
||||
ident.NewID(), downloadID, text); err != nil {
|
||||
return fmt.Errorf("add hint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHints возвращает подсказки загрузки в хронологическом порядке.
|
||||
func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, error) {
|
||||
func (s *Store) ListHints(ctx context.Context, downloadID string) ([]string, error) {
|
||||
var out []string
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT text FROM hint WHERE download_id = ? ORDER BY id`, downloadID); err != nil {
|
||||
@@ -126,18 +125,18 @@ func (s *Store) ListHints(ctx context.Context, downloadID int64) ([]string, erro
|
||||
// --- Ручные правки (override) ---
|
||||
|
||||
// SetOverride пиннит значение поля (upsert по (download_id, field)).
|
||||
func (s *Store) SetOverride(ctx context.Context, downloadID int64, field, value string) error {
|
||||
func (s *Store) SetOverride(ctx context.Context, downloadID string, field, value string) error {
|
||||
const q = `
|
||||
INSERT INTO override (download_id, field, value) VALUES (?, ?, ?)
|
||||
INSERT INTO override (id, download_id, field, value) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (download_id, field) DO UPDATE SET value = excluded.value`
|
||||
if _, err := s.DB.ExecContext(ctx, q, downloadID, field, value); err != nil {
|
||||
if _, err := s.DB.ExecContext(ctx, q, ident.NewID(), downloadID, field, value); err != nil {
|
||||
return fmt.Errorf("set override %q: %w", field, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListOverrides возвращает запиненные правки загрузки как map[field]value.
|
||||
func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error) {
|
||||
func (s *Store) ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) {
|
||||
rows, err := s.DB.QueryxContext(ctx,
|
||||
`SELECT field, value FROM override WHERE download_id = ?`, downloadID)
|
||||
if err != nil {
|
||||
@@ -160,8 +159,8 @@ func (s *Store) ListOverrides(ctx context.Context, downloadID int64) (map[string
|
||||
|
||||
// FileLink — строка таблицы file_link (одна созданная/планируемая ссылка).
|
||||
type FileLink struct {
|
||||
ID int64 `db:"id"`
|
||||
DownloadID int64 `db:"download_id"`
|
||||
ID string `db:"id"`
|
||||
DownloadID string `db:"download_id"`
|
||||
ApplyBatchID string `db:"apply_batch_id"`
|
||||
SrcPath string `db:"src_path"`
|
||||
DstPath string `db:"dst_path"`
|
||||
@@ -182,11 +181,11 @@ func (s *Store) CreateFileLinks(ctx context.Context, links []FileLink) error {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO file_link (download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO file_link (id, download_id, apply_batch_id, src_path, dst_path, kind, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, l := range links {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
||||
ident.NewID(), l.DownloadID, l.ApplyBatchID, l.SrcPath, l.DstPath, l.Kind, l.Status); err != nil {
|
||||
return fmt.Errorf("insert file_link: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -202,7 +201,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
||||
// раскладка забирает владение освободившимся путём, прежние записи перестают
|
||||
// считаться целью при сверке. Затрагивает только активные статусы раскладки
|
||||
// (linked/copied/exists) и не трогает саму загрузку (download_id != ?).
|
||||
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error {
|
||||
func (s *Store) SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error {
|
||||
if len(dstPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -226,7 +225,7 @@ WHERE download_id != ?
|
||||
|
||||
// LatestBatchID возвращает apply_batch_id последнего применённого батча
|
||||
// загрузки (для undo) либо пустую строку, если ссылок нет.
|
||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID int64) (string, error) {
|
||||
func (s *Store) LatestBatchID(ctx context.Context, downloadID string) (string, error) {
|
||||
var batch string
|
||||
err := s.DB.GetContext(ctx, &batch,
|
||||
`SELECT apply_batch_id FROM file_link WHERE download_id = ?
|
||||
@@ -265,8 +264,8 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
||||
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
|
||||
// см. recognize), а не обязательно нативный id провайдера поиска.
|
||||
type MetadataCandidate struct {
|
||||
ID int64 `db:"id"`
|
||||
RecognitionID int64 `db:"recognition_id"`
|
||||
ID string `db:"id"`
|
||||
RecognitionID string `db:"recognition_id"`
|
||||
Provider string `db:"provider"`
|
||||
ProviderID string `db:"provider_id"`
|
||||
Title sql.NullString `db:"title"`
|
||||
@@ -288,11 +287,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
INSERT INTO metadata_candidate (id, recognition_id, provider, provider_id, title, year, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
for _, c := range cands {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||
ident.NewID(), c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||
return fmt.Errorf("insert candidate: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -303,7 +302,7 @@ VALUES (?, ?, ?, ?, ?, ?)`
|
||||
}
|
||||
|
||||
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
|
||||
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
|
||||
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]MetadataCandidate, error) {
|
||||
var out []MetadataCandidate
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
|
||||
@@ -313,21 +312,21 @@ func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID i
|
||||
}
|
||||
|
||||
// GetCandidate возвращает кандидата по id либо (nil, nil).
|
||||
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
|
||||
func (s *Store) GetCandidate(ctx context.Context, id string) (*MetadataCandidate, error) {
|
||||
var c MetadataCandidate
|
||||
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get candidate %d: %w", id, err)
|
||||
return nil, fmt.Errorf("get candidate %s: %w", id, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
|
||||
// той же попытке распознавания.
|
||||
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
|
||||
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
|
||||
@@ -6,14 +6,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedDownload(t *testing.T, st *Store) int64 {
|
||||
func seedDownload(t *testing.T, st *Store) string {
|
||||
t.Helper()
|
||||
id, err := st.CreateDownload(context.Background(),
|
||||
newDownloading("aabbccddeeff00112233445566778899aabbccdd"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed download: %v", err)
|
||||
}
|
||||
return id
|
||||
return mustCreate(t, st, "aabbccddeeff00112233445566778899aabbccdd")
|
||||
}
|
||||
|
||||
func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
||||
@@ -49,7 +44,7 @@ func TestCreateRecognition_AttemptsAndCurrent(t *testing.T) {
|
||||
t.Fatalf("get current: %v", err)
|
||||
}
|
||||
if cur.ID != id2 {
|
||||
t.Errorf("current id = %d, want %d", cur.ID, id2)
|
||||
t.Errorf("current id = %s, want %s", cur.ID, id2)
|
||||
}
|
||||
if cur.AttemptNo != 2 {
|
||||
t.Errorf("attempt_no = %d, want 2", cur.AttemptNo)
|
||||
@@ -163,11 +158,7 @@ func TestSupersedeForeignLinks(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
owner := seedDownload(t, st)
|
||||
foreign, err := st.CreateDownload(ctx,
|
||||
newDownloading("bbccddeeff00112233445566778899aabbccddee"))
|
||||
if err != nil {
|
||||
t.Fatalf("seed foreign: %v", err)
|
||||
}
|
||||
foreign := mustCreate(t, st, "bbccddeeff00112233445566778899aabbccddee")
|
||||
|
||||
shared := "/m/Movie (2024).mkv"
|
||||
// foreign разложена по shared (linked) и по своему пути (exists);
|
||||
@@ -244,7 +235,7 @@ func TestCandidates_Lifecycle(t *testing.T) {
|
||||
for _, c := range got {
|
||||
want := c.ID == chosenID
|
||||
if c.Chosen != want {
|
||||
t.Errorf("candidate %d chosen = %v, want %v", c.ID, c.Chosen, want)
|
||||
t.Errorf("candidate %s chosen = %v, want %v", c.ID, c.Chosen, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,33 +253,9 @@ func TestCandidates_Lifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistsByInfohash(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
const ih = "aabbccddeeff00112233445566778899aabbccdd"
|
||||
|
||||
exists, err := st.ExistsByInfohash(ctx, ih)
|
||||
if err != nil || exists {
|
||||
t.Fatalf("пусто: exists=%v err=%v", exists, err)
|
||||
}
|
||||
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exists, err = st.ExistsByInfohash(ctx, ih)
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("после вставки: exists=%v err=%v", exists, err)
|
||||
}
|
||||
// Терминальное состояние тоже считается «видели» (не реусыновляем).
|
||||
id, _ := st.CreateDownload(ctx, newDownloading("ffffffffffffffffffffffffffffffffffffffff"))
|
||||
_ = st.SetDownloadState(ctx, id, StateDone, "", "")
|
||||
if ex, _ := st.ExistsByInfohash(ctx, "ffffffffffffffffffffffffffffffffffffffff"); !ex {
|
||||
t.Error("done-задача должна считаться существующей")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCandidate_None(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
c, err := st.GetCandidate(context.Background(), 999)
|
||||
c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz")
|
||||
if err != nil || c != nil {
|
||||
t.Errorf("want nil,nil; got %+v, %v", c, err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
||||
|
||||
// Go-миграции goose (регистрируются в init(); SQL-миграции — embed ниже).
|
||||
_ "git.vakhrushev.me/av/jellybit/internal/store/migrations"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
@@ -29,8 +32,12 @@ func Open(dbPath string) (*Store, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// _txlock=immediate: каждая явная транзакция открывается как BEGIN
|
||||
// IMMEDIATE — write-транзакция с самого начала. На этом держатся
|
||||
// guarded-методы инварианта «одна активная загрузка на infohash»
|
||||
// (check-then-write без гонок: SQLite сериализует писателей).
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||
"file:%s?_txlock=immediate&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||
dbPath,
|
||||
)
|
||||
db, err := sqlx.Connect("sqlite", dsn)
|
||||
|
||||
Reference in New Issue
Block a user