Идентичность на 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
+31 -28
View File
@@ -4,12 +4,12 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
@@ -30,13 +30,13 @@ type Ingestor interface {
// Reviewer — операции ревью (worker.Worker).
type Reviewer interface {
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
Apply(ctx context.Context, id int64) error
Refine(ctx context.Context, id int64, hint string) error
SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
Apply(ctx context.Context, id string) error
Refine(ctx context.Context, id string, hint string) error
SetType(ctx context.Context, id string, mediaType string) error
Defer(ctx context.Context, id string) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
}
// Config — параметры бота.
@@ -54,8 +54,8 @@ type Bot struct {
webBase string
log *slog.Logger
mu sync.Mutex // защищает pending
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
mu sync.Mutex // защищает pending
pending map[int64]string // chatID → downloadID, ждущий подсказку
}
// New собирает бота поверх клиента Telegram.
@@ -71,7 +71,7 @@ func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logge
allowed: allowed,
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log,
pending: map[int64]int64{},
pending: map[int64]string{},
}
}
@@ -129,7 +129,7 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось обработать подсказку", id), nil)
return
}
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+id+"…", nil)
return
}
@@ -151,9 +151,9 @@ func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
b.send(m.Chat.ID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
return
}
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
msg := fmt.Sprintf("Принято #%s — %s.", res.DownloadID, res.State)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
msg = fmt.Sprintf("Уже в работе #%s — %s.", res.DownloadID, res.State)
}
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
}
@@ -173,8 +173,10 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
}
action, id, val := parseCallback(cq.Data)
if id == 0 {
b.answer(cq.ID, "")
if id == "" {
// Пустой/невалидный id — в т.ч. старые числовые кнопки, оставшиеся в
// истории чата до перехода на ULID: отвечаем понятно, а не молчим.
b.answer(cq.ID, "Кнопка устарела — откройте задачу в вебе")
return
}
chatID := cq.Message.Chat.ID
@@ -201,7 +203,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
case "refine":
b.setPending(chatID, id)
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
b.send(chatID, "Ответьте сообщением с подсказкой для #"+id+".", nil)
return
default:
b.answer(cq.ID, "")
@@ -218,7 +220,7 @@ func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
}
// refreshCard перечитывает задачу и обновляет карточку на месте.
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id string) {
rd, err := b.reviewer.ReviewData(ctx, id)
if err != nil {
b.log.Warn("telegram refresh card failed", "download_id", id, "error", err)
@@ -239,7 +241,7 @@ func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64
// --- Notifier (worker.Notifier) ---
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
func (b *Bot) Notify(ctx context.Context, downloadID string, event worker.NotifyEvent) {
rd, err := b.reviewer.ReviewData(ctx, downloadID)
if err != nil {
b.log.Warn("telegram notify review data", "download_id", downloadID, "error", err)
@@ -281,13 +283,13 @@ func (b *Bot) answer(callbackID, text string) {
}
}
func (b *Bot) setPending(chatID, id int64) {
func (b *Bot) setPending(chatID int64, id string) {
b.mu.Lock()
b.pending[chatID] = id
b.mu.Unlock()
}
func (b *Bot) takePending(chatID int64) (int64, bool) {
func (b *Bot) takePending(chatID int64) (string, bool) {
b.mu.Lock()
defer b.mu.Unlock()
id, ok := b.pending[chatID]
@@ -300,20 +302,21 @@ func (b *Bot) takePending(chatID int64) (int64, bool) {
// opErr — сообщение публичного канала Telegram по доменной ошибке: нейтральный
// текст + download_id для корреляции с логами (полная ошибка уже там, на
// доменной границе). Сырой err.Error() пользователю не показываем. Если id
// операции ещё нет (downloadID == 0) — дружелюбный текст без ключа.
func opErr(msg string, downloadID int64) string {
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d).", msg, downloadID)
// операции ещё нет (downloadID == "") — дружелюбный текст без ключа.
func opErr(msg string, downloadID string) string {
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s).", msg, downloadID)
}
return msg + "."
}
// parseCallback разбирает "action[:id[:value]]".
func parseCallback(data string) (action string, id int64, value string) {
// parseCallback разбирает "action[:id[:value]]". id валидируется как ULID
// (входная граница); невалидный/устаревший (числовой) → пустая строка.
func parseCallback(data string) (action string, id string, value string) {
parts := strings.Split(data, ":")
action = parts[0]
if len(parts) > 1 {
id, _ = strconv.ParseInt(parts[1], 10, 64)
id, _ = ident.Parse(parts[1])
}
if len(parts) > 2 {
value = parts[2]