Долгий metaDL больше не убивается агрессивным таймаутом: дефолт magnet_timeout 30m → 24h (страховочный предохранитель), базис отсчёта — added_on из qBittorrent, а не created_at (переживает retry/усыновление). Авто-восстановление: фоновая сверка возвращает в поток задачи, упавшие по нашей нетерпеливости (magnet_timeout/stalled), когда источник ожил и продвинулся за условие падения (downloading/completed по статусу торрента); qbit_error не воскрешается. Конфликт idempotency (infohash занят другой активной задачей) — оставляем в failed. Уведомления: любой переход в failed/stuck пингует автора (включая приёмный qbit_add через ingest), с дебаунсом против спама при флаппинге stalled. Ручной retry добавлен в веб-UI и Telegram; Retry перецепляется к живому торренту вместо слепого Add. Дельта state-reconciliation влита в живые спеки; обновлён workflow.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
276 lines
12 KiB
Go
276 lines
12 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// State — состояние загрузки в машине состояний (см. architecture.md).
|
||
// В Ф1 используется подмножество: downloading → completed, плюс stuck,
|
||
// failed, cancelled. Остальные состояния заведены под будущие фазы.
|
||
type State string
|
||
|
||
const (
|
||
StateDownloading State = "downloading"
|
||
StateCompleted State = "completed"
|
||
StateRecognizing State = "recognizing" // Ф2
|
||
StateReview State = "review" // Ф3
|
||
StateLinking State = "linking" // Ф3
|
||
StateDone State = "done" // Ф3
|
||
StateDeferred State = "deferred" // Ф3
|
||
StateStuck State = "stuck"
|
||
StateFailed State = "failed"
|
||
StateCancelled State = "cancelled"
|
||
StateReverted State = "reverted" // Ф3
|
||
|
||
// Состояния рассинхрона с реальностью (см. state-reconciliation).
|
||
StateTargetMissing State = "target_missing" // источник есть, цель удалена → relink
|
||
StateOrphaned State = "orphaned" // источник пропал, цель (последняя копия) есть
|
||
StateDeleted State = "deleted" // нет ни источника, ни цели
|
||
)
|
||
|
||
// terminalStates — единый список окончательно остановленных состояний:
|
||
// источник истины и для IsTerminal, и для выборки «активных» задач
|
||
// (FindActiveByInfohash). Любое новое терминальное состояние добавляется
|
||
// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key
|
||
// снимается по IsTerminal, а активность считалась бы по другому списку).
|
||
//
|
||
// Состояния рассинхрона (target_missing/orphaned/deleted), а также
|
||
// failed/stuck — терминальны для idempotency_key, но не «мертвы»: дальше
|
||
// двигает либо человек (relink из target_missing, retry из failed/stuck), либо
|
||
// фоновая сверка (healing/прогрессия desync; авто-восстановление failed/stuck
|
||
// при оживлении источника, см. state-reconciliation) — напрямую через
|
||
// SetDownloadState, который восстановит ключ для нетерминального целевого
|
||
// состояния.
|
||
var terminalStates = []State{
|
||
StateDone, StateCancelled, StateFailed, StateReverted,
|
||
StateTargetMissing, StateOrphaned, StateDeleted,
|
||
}
|
||
|
||
// IsTerminal сообщает, завершена ли задача окончательно. Для терминальных
|
||
// состояний снимается ключ идемпотентности — тот же infohash можно завести
|
||
// заново новой задачей (см. architecture.md, «повторное добавление»).
|
||
// stuck терминальным не считается: задача восстановима (retry).
|
||
func (s State) IsTerminal() bool {
|
||
return slices.Contains(terminalStates, s)
|
||
}
|
||
|
||
// SourceType — вид источника загрузки.
|
||
type SourceType string
|
||
|
||
const (
|
||
SourceMagnet SourceType = "magnet"
|
||
SourceTorrent SourceType = "torrent"
|
||
SourceURL SourceType = "url"
|
||
)
|
||
|
||
// Download — строка таблицы download.
|
||
type Download struct {
|
||
ID int64 `db:"id"`
|
||
SourceType SourceType `db:"source_type"`
|
||
SourceRef string `db:"source_ref"`
|
||
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"`
|
||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||
SourceMissCount int `db:"source_miss_count"`
|
||
CreatedAt string `db:"created_at"`
|
||
UpdatedAt string `db:"updated_at"`
|
||
}
|
||
|
||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||
const sqliteTimeLayout = "2006-01-02 15:04:05"
|
||
|
||
// ParseTime разбирает временную метку SQLite (datetime('now'), всегда UTC).
|
||
func ParseTime(s string) (time.Time, error) {
|
||
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
||
}
|
||
|
||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||
|
||
// NullString строит sql.NullString: пустая строка → NULL.
|
||
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) {
|
||
const q = `
|
||
INSERT INTO download (source_type, source_ref, context, infohash, idempotency_key, state)
|
||
VALUES (?, ?, ?, ?, ?, ?)`
|
||
res, err := s.DB.ExecContext(ctx, q,
|
||
d.SourceType, d.SourceRef, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("insert download: %w", err)
|
||
}
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("download last insert id: %w", err)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// GetDownload возвращает загрузку по id.
|
||
func (s *Store) GetDownload(ctx context.Context, id int64) (*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 %d: %w", id, err)
|
||
}
|
||
return &d, nil
|
||
}
|
||
|
||
// ListDownloads возвращает все загрузки, новые сверху.
|
||
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)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ListDownloadsByState возвращает загрузки в одном из указанных состояний.
|
||
func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Download, error) {
|
||
if len(states) == 0 {
|
||
return nil, nil
|
||
}
|
||
ph := make([]string, len(states))
|
||
args := make([]any, len(states))
|
||
for i, st := range states {
|
||
ph[i] = "?"
|
||
args[i] = string(st)
|
||
}
|
||
q := `SELECT * FROM download WHERE state IN (` + strings.Join(ph, ",") + `) ORDER BY id DESC`
|
||
var out []Download
|
||
if err := s.DB.SelectContext(ctx, &out, q, args...); err != nil {
|
||
return nil, fmt.Errorf("list downloads by state: %w", err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ListRecoverable возвращает задачи в failed/stuck с одним из переданных
|
||
// error_code — кандидатов на авто-восстановление (см. state-reconciliation).
|
||
// Фильтр по коду в SQL, чтобы не вычитывать на каждом тике поллинга все
|
||
// накопленные провалы (qbit_error и пр.), которые восстановлению не подлежат.
|
||
func (s *Store) ListRecoverable(ctx context.Context, codes ...string) ([]Download, error) {
|
||
if len(codes) == 0 {
|
||
return nil, nil
|
||
}
|
||
ph := make([]string, len(codes))
|
||
args := make([]any, 0, len(codes)+2)
|
||
for i, c := range codes {
|
||
ph[i] = "?"
|
||
args = append(args, c)
|
||
}
|
||
args = append(args, string(StateFailed), string(StateStuck))
|
||
q := `SELECT * FROM download WHERE error_code IN (` + strings.Join(ph, ",") +
|
||
`) AND state IN (?, ?) ORDER BY id DESC`
|
||
var out []Download
|
||
if err := s.DB.SelectContext(ctx, &out, q, args...); 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))
|
||
}
|
||
q := `SELECT * FROM download WHERE infohash = ? AND state NOT IN (` +
|
||
strings.Join(ph, ",") + `) ORDER BY id DESC LIMIT 1`
|
||
var d Download
|
||
err := s.DB.GetContext(ctx, &d, q, args...)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("find active by infohash: %w", err)
|
||
}
|
||
return &d, nil
|
||
}
|
||
|
||
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
|
||
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
|
||
// видели — так готовые задачи не переобрабатываются на каждом тике.
|
||
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
|
||
var n int
|
||
if err := s.DB.GetContext(ctx, &n,
|
||
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); 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 = `
|
||
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
|
||
}
|
||
res, err := s.DB.ExecContext(ctx, q, string(state), nullArg(errCode), nullArg(errMsg), terminal, id)
|
||
if err != nil {
|
||
return fmt.Errorf("set download %d 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)
|
||
}
|
||
if n == 0 {
|
||
return fmt.Errorf("set download %d state %q: not found", id, state)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки).
|
||
// Состояние не трогает — это отдельная от перехода фоновая отметка.
|
||
func (s *Store) SetSourceMissCount(ctx context.Context, id int64, 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)
|
||
}
|
||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||
return fmt.Errorf("set download %d source_miss_count: not found", id)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// nullArg возвращает nil для пустой строки (чтобы писать NULL, не "").
|
||
func nullArg(s string) any {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return s
|
||
}
|