Идентичность на 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:
av
2026-07-02 21:25:00 +03:00
co-authored by Claude Fable 5
parent b808ceff25
commit 37f2f6481a
53 changed files with 3640 additions and 1035 deletions
+382 -78
View File
@@ -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 == "" {