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