Идентичность на 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:
+51
-39
@@ -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()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user