Идентичность на 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
+51 -39
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.vakhrushev.me/av/jellybit/internal/ident"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/magnet"
"git.vakhrushev.me/av/jellybit/internal/store"
@@ -35,15 +36,15 @@ type Ingestor interface {
// Commander исполняет команды над задачей (worker.Worker).
type Commander interface {
Cancel(ctx context.Context, id int64) error
Retry(ctx context.Context, id int64) error
Cancel(ctx context.Context, id string) error
Retry(ctx context.Context, id string) error
}
// Reader читает задачи (store.Store).
type Reader interface {
ListDownloads(ctx context.Context) ([]store.Download, error)
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
GetDownload(ctx context.Context, id int64) (*store.Download, error)
GetDownload(ctx context.Context, id string) (*store.Download, error)
}
// Deps — зависимости транспорта.
@@ -178,7 +179,7 @@ type pageLink struct {
}
type downloadView struct {
ID int64
ID string
Title string // отображаемый заголовок карточки
Infohash string // полный (для копирования)
InfohashShort string // усечённый (для показа)
@@ -269,7 +270,7 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
// Живой прогресс активных загрузок — со значениями уже в первом кадре
// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
if v.IsDownloading {
l, ok := s.deps.Live.Live(d.Infohash.String)
l, ok := s.liveFor(d)
v.Progress = buildProgress(d.ID, true, l, ok)
}
view.Downloads = append(view.Downloads, v)
@@ -394,15 +395,15 @@ func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
// --- REST API ---
type downloadDTO struct {
ID int64 `json:"id"`
SourceType string `json:"source_type"`
Infohash string `json:"infohash,omitempty"`
Context string `json:"context,omitempty"`
State string `json:"state"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"` // ULID (lowercase)
SourceType string `json:"source_type"`
Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
Context string `json:"context,omitempty"`
State string `json:"state"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type addRequest struct {
@@ -411,16 +412,16 @@ type addRequest struct {
}
type addResponse struct {
ID int64 `json:"id"`
Infohash string `json:"infohash"`
State string `json:"state"`
Deduplicated bool `json:"deduplicated"`
ID string `json:"id"` // ULID (lowercase)
Infohashes []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
State string `json:"state"`
Deduplicated bool `json:"deduplicated"`
}
func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
downloads, err := s.deps.Reader.ListDownloads(r.Context())
if err != nil {
s.apiErr(w, r, err, 0)
s.apiErr(w, r, err, "")
return
}
out := make([]downloadDTO, 0, len(downloads))
@@ -433,7 +434,8 @@ func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
return
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
@@ -448,7 +450,7 @@ func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
var req addRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", 0))
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", ""))
return
}
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
@@ -465,7 +467,7 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, status, addResponse{
ID: res.DownloadID,
Infohash: res.Infohash,
Infohashes: res.Infohashes,
State: string(res.State),
Deduplicated: res.Deduplicated,
})
@@ -479,10 +481,11 @@ func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
s.apiCommand(w, r, s.deps.Commander.Retry)
}
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, int64) error) {
func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) {
id, err := pathID(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный id", 0))
// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
return
}
if err := cmd(r.Context(), id); err != nil {
@@ -494,7 +497,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
}
d, err := s.deps.Reader.GetDownload(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusOK, map[string]int64{"id": id})
writeJSON(w, http.StatusOK, map[string]string{"id": id})
return
}
writeJSON(w, http.StatusOK, toDTO(*d))
@@ -506,7 +509,7 @@ func toDTO(d store.Download) downloadDTO {
return downloadDTO{
ID: d.ID,
SourceType: string(d.SourceType),
Infohash: d.Infohash.String,
Infohashes: d.HashList(),
Context: d.Context,
State: string(d.State),
ErrorCode: d.ErrorCode.String,
@@ -521,8 +524,8 @@ func toView(d store.Download) downloadView {
return downloadView{
ID: d.ID,
Title: downloadTitle(d),
Infohash: d.Infohash.String,
InfohashShort: shortenHash(d.Infohash.String),
Infohash: d.PrimaryInfohash(),
InfohashShort: shortenHash(d.PrimaryInfohash()),
Context: d.Context,
State: state,
Error: d.ErrorMsg.String,
@@ -586,12 +589,21 @@ func shorten(s string, n int) string {
return s[:n] + "…"
}
func pathID(r *http.Request) (int64, error) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return 0, errors.New("invalid id")
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
// трактуется вызывающими как несуществующая сущность (404).
func pathID(r *http.Request) (string, error) {
return ident.Parse(chi.URLParam(r, "id"))
}
// liveFor достаёт живую телеметрию по любому из хешей загрузки.
func (s *server) liveFor(d store.Download) (worker.Live, bool) {
for _, h := range d.HashList() {
if l, ok := s.deps.Live.Live(h); ok {
return l, true
}
}
return id, nil
return worker.Live{}, false
}
func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
@@ -627,9 +639,9 @@ func classifyErr(err error) (int, string) {
// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
// для владельца (download_id, если операция привязана к загрузке, иначе
// request_id запроса), по которому он найдёт полную ошибку в логах.
func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
body := map[string]any{"error": msg}
if downloadID > 0 {
if downloadID != "" {
body["download_id"] = downloadID
} else {
body["request_id"] = middleware.GetReqID(r.Context())
@@ -638,7 +650,7 @@ func errBody(r *http.Request, msg string, downloadID int64) map[string]any {
}
// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID int64) {
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) {
status, msg := classifyErr(err)
writeJSON(w, status, errBody(r, msg, downloadID))
}
@@ -646,10 +658,10 @@ func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downl
// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
func userErr(r *http.Request, err error, downloadID int64) string {
func userErr(r *http.Request, err error, downloadID string) string {
_, msg := classifyErr(err)
if downloadID > 0 {
return fmt.Sprintf("%s (download_id=%d)", msg, downloadID)
if downloadID != "" {
return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
}
return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
}