Идентичность на 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
+59 -40
View File
@@ -60,52 +60,55 @@ func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Res
type fakeReviewer struct {
data *worker.ReviewData
applied []int64
refined map[int64]string
typed map[int64]string
deferred []int64
canceled []int64
retried []int64
applied []string
refined map[string]string
typed map[string]string
deferred []string
canceled []string
retried []string
}
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
f.applied = append(f.applied, id)
return nil
}
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
if f.refined == nil {
f.refined = map[int64]string{}
f.refined = map[string]string{}
}
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
func (f *fakeReviewer) SetType(_ context.Context, id string, t string) error {
if f.typed == nil {
f.typed = map[int64]string{}
f.typed = map[string]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
f.deferred = append(f.deferred, id)
return nil
}
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
f.canceled = append(f.canceled, id)
return nil
}
func (f *fakeReviewer) Retry(_ context.Context, id int64) error {
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
f.retried = append(f.retried, id)
return nil
}
// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"
func reviewData(state store.State) *worker.ReviewData {
s, e := 2, 1
return &worker.ReviewData{
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Download: store.Download{ID: tid, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Recognition: &store.Recognition{
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
Reasons: `["неполный пак"]`,
@@ -123,7 +126,7 @@ func reviewData(state store.State) *worker.ReviewData {
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
t.Helper()
api := &fakeAPI{}
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
rev := &fakeReviewer{data: reviewData(store.StateReview)}
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -146,7 +149,7 @@ func TestBot_IngestFromMagnet(t *testing.T) {
if ing.lastReq.Context != "крутой сериал" {
t.Errorf("context = %q", ing.lastReq.Context)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+tid) {
t.Errorf("sent = %+v", api.sent)
}
}
@@ -174,10 +177,10 @@ func TestBot_NoMagnet(t *testing.T) {
func TestBot_RefineViaReply(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
b.setPending(7, 5)
b.setPending(7, tid)
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
if rev.refined[5] != "это второй сезон" {
if rev.refined[tid] != "это второй сезон" {
t.Errorf("refine = %v", rev.refined)
}
}
@@ -191,9 +194,9 @@ func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
func TestBot_CallbackApply(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))
if len(rev.applied) != 1 || rev.applied[0] != 5 {
if len(rev.applied) != 1 || rev.applied[0] != tid {
t.Errorf("applied = %v", rev.applied)
}
if len(api.answers) != 1 {
@@ -206,18 +209,18 @@ func TestBot_CallbackApply(t *testing.T) {
func TestBot_CallbackType(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
if rev.typed[5] != "movie" {
b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
if rev.typed[tid] != "movie" {
t.Errorf("typed = %v", rev.typed)
}
}
func TestBot_CallbackRefineSetsPending(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))
if id, ok := b.takePending(7); !ok || id != 5 {
t.Errorf("pending = %d,%v", id, ok)
if id, ok := b.takePending(7); !ok || id != tid {
t.Errorf("pending = %s,%v", id, ok)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
t.Errorf("sent = %+v", api.sent)
@@ -226,7 +229,7 @@ func TestBot_CallbackRefineSetsPending(t *testing.T) {
func TestBot_CallbackDeniesUnknown(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
if len(rev.applied) != 0 {
t.Error("чужой колбэк не должен исполняться")
}
@@ -234,12 +237,12 @@ func TestBot_CallbackDeniesUnknown(t *testing.T) {
func TestBot_NotifyReview(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7, 8})
b.Notify(context.Background(), 5, worker.EventReview)
b.Notify(context.Background(), tid, worker.EventReview)
if len(api.sent) != 2 { // обоим доверенным
t.Fatalf("sent to %d chats, want 2", len(api.sent))
}
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+tid) {
t.Errorf("card text = %q", api.sent[0].text)
}
if !api.sent[0].hasKB {
@@ -250,7 +253,7 @@ func TestBot_NotifyReview(t *testing.T) {
func TestBot_NotifyDone(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateDone)
b.Notify(context.Background(), 5, worker.EventDone)
b.Notify(context.Background(), tid, worker.EventDone)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
t.Errorf("sent = %+v", api.sent)
@@ -260,7 +263,7 @@ func TestBot_NotifyDone(t *testing.T) {
func TestBot_NotifyFailed(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.Notify(context.Background(), 5, worker.EventFailed)
b.Notify(context.Background(), tid, worker.EventFailed)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") {
t.Errorf("sent = %+v", api.sent)
@@ -273,20 +276,36 @@ func TestBot_NotifyFailed(t *testing.T) {
func TestBot_CallbackRetry(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateFailed)
b.handleCallback(context.Background(), cbFrom(7, "retry:5"))
b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))
if len(rev.retried) != 1 || rev.retried[0] != 5 {
if len(rev.retried) != 1 || rev.retried[0] != tid {
t.Errorf("retried = %v", rev.retried)
}
}
func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:5:series")
if a != "type" || id != 5 || v != "series" {
t.Errorf("got %q %d %q", a, id, v)
a, id, v := parseCallback("type:" + tid + ":series")
if a != "type" || id != tid || v != "series" {
t.Errorf("got %q %q %q", a, id, v)
}
a, id, v = parseCallback("apply:9")
if a != "apply" || id != 9 || v != "" {
t.Errorf("got %q %d %q", a, id, v)
a, id, v = parseCallback("apply:" + tid)
if a != "apply" || id != tid || v != "" {
t.Errorf("got %q %q %q", a, id, v)
}
// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
if _, id, _ := parseCallback("apply:5"); id != "" {
t.Errorf("legacy numeric id must be rejected, got %q", id)
}
}
// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
func TestBot_CallbackStaleButton(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
if len(rev.applied) != 0 {
t.Error("устаревшая кнопка не должна исполняться")
}
if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
t.Errorf("answers = %v, want понятный ответ", api.answers)
}
}