Идентичность на 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:
+15
-12
@@ -11,25 +11,28 @@ import (
|
||||
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||
|
||||
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
||||
func mkDownload(t *testing.T, st *Store, n int, state State, display string) string {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d := newDownloading(hashN(n))
|
||||
d := newDownloading()
|
||||
d.DisplayName = display
|
||||
id, err := st.CreateDownload(ctx, d)
|
||||
existing, err := st.CreateDownloadIfNoActive(ctx, d, []string{hashN(n)})
|
||||
if err != nil {
|
||||
t.Fatalf("create #%d: %v", n, err)
|
||||
}
|
||||
if existing != nil {
|
||||
t.Fatalf("create #%d: unexpected dedup", n)
|
||||
}
|
||||
if state != StateDownloading {
|
||||
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
||||
if err := st.SetDownloadState(ctx, d.ID, state, "", ""); err != nil {
|
||||
t.Fatalf("set state #%d: %v", n, err)
|
||||
}
|
||||
}
|
||||
return id
|
||||
return d.ID
|
||||
}
|
||||
|
||||
func ids(ds []Download) []int64 {
|
||||
out := make([]int64, len(ds))
|
||||
func ids(ds []Download) []string {
|
||||
out := make([]string, len(ds))
|
||||
for i, d := range ds {
|
||||
out[i] = d.ID
|
||||
}
|
||||
@@ -51,7 +54,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
||||
t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review)
|
||||
}
|
||||
|
||||
// all: deleted скрыт по умолчанию.
|
||||
@@ -110,7 +113,7 @@ func TestListDownloadsPageSearch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
||||
if len(page) != 1 || page[0].PrimaryInfohash() != hashN(2) {
|
||||
t.Fatalf("search by infohash = %v", ids(page))
|
||||
}
|
||||
}
|
||||
@@ -144,7 +147,7 @@ func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
||||
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||
got := ids(page)
|
||||
want := []int64{id3, id2, id1}
|
||||
want := []string{id3, id2, id1}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("порядок = %v, want %v", got, want)
|
||||
@@ -178,7 +181,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
|
||||
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
var idList []int64
|
||||
var idList []string
|
||||
for i := 1; i <= 3; i++ {
|
||||
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||
@@ -191,7 +194,7 @@ func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ids(page)
|
||||
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
||||
want := []string{idList[2], idList[1], idList[0]} // id DESC
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||
|
||||
Reference in New Issue
Block a user