rework domain field
Build and Push Docker Image / build (push) Failing after 13s

This commit is contained in:
Worph
2026-09-02 00:07:02 +02:00
parent 0c4e3a2227
commit 8f4e08b6f1
4 changed files with 362 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
package storage
import (
"context"
"fmt"
"time"
)
// The one-shot corpus sweep for METADATA_KEYS.md §14.17: the `film` + `tv`
// domains merged into `screen`, and the standalone-vs-serial split they were
// really carrying moved to the new `workForm` key.
//
// # Why a sweep and not a read-side alias
//
// `domain` is **stored, not derived on read** — that is what lets the
// `contentKind` vocabulary stay open (a peer that has never heard of a kind
// still routes the record correctly). The price of storing it is that a
// vocabulary change has to be paid once, in the data: every consumer's filter
// is an exact match, so a record left saying `film` is invisible to a
// `domain:screen` wall, and teaching every consumer to accept both spellings
// forever is exactly the alternation the merge exists to delete.
//
// So the values are rewritten at rest, here, and no alias vocabulary survives
// in query filters. (meta-search additionally normalises on the way into its
// index, because a record can still arrive from a peer that has not been swept
// — that is a courtesy for un-swept peers, not a second source of truth.)
//
// # Contract
//
// Idempotent: a record already carrying `screen` and a `workForm` is skipped,
// so the endpoint is safe to call repeatedly and safe to call on a fresh box.
// Returns the number of records **changed**, not the number examined.
// legacyDomainReplacements maps a retired `domain` value to its replacement.
var legacyDomainReplacements = map[string]string{
"film": "screen",
"tv": "screen",
}
// workFormByContentKind is the `contentKind` → `workForm` table from
// METADATA_KEYS.md §1.
//
// ⚠ Mirror of the Rust table in `meta-feeder-sdk::domain` (and of
// meta-search's copy). It exists here only to backfill the pre-merge corpus —
// meta-core never classifies a record it is given, and nothing else in Go reads
// it. If the tables ever disagree, the writers win: this one is a one-shot.
var workFormByContentKind = map[string]string{
// screen
"movie": "standalone",
"series": "serial",
"episode": "serial",
// music — an album is a closed work, not an ongoing run.
"track": "standalone",
"album": "standalone",
"artist": "standalone",
"musicVideo": "standalone",
"djMix": "standalone",
"liveSet": "standalone",
// literature
"book": "standalone",
"audiobook": "standalone",
"comic": "serial",
"manga": "serial",
"magazine": "serial",
// science
"paper": "standalone",
// ⚠ `pack` is deliberately absent: a season pack is `serial`, an album
// release `standalone`, and only the writer knows which. A pack that
// reaches this sweep without a `workForm` keeps none — inventing one would
// be a guess written permanently into the record.
}
// domainScreenPlan is one record's pending rewrite. Empty strings mean "leave
// this field alone".
type domainScreenPlan struct {
hashID string
domain string
workForm string
}
// MigrateDomainScreen rewrites `domain=film|tv` to `domain=screen` and
// backfills `workForm` from `contentKind` across the whole corpus.
//
// Reads in two batched MGET passes (never a per-record SCAN — a single record
// read costs a full keyspace sweep on this store), then writes only the records
// that actually change.
func (c *Client) MigrateDomainScreen(ctx context.Context) (int, error) {
plans, err := c.planDomainScreen(ctx)
if err != nil {
return 0, err
}
// Writes take the client lock themselves, so they happen after the read
// phase has released it.
fixed := 0
for _, p := range plans {
if p.domain != "" {
if err := c.SetProperty(p.hashID, "domain", p.domain); err != nil {
return fixed, fmt.Errorf("set domain on %s: %w", p.hashID, err)
}
}
if p.workForm != "" {
if err := c.SetProperty(p.hashID, "workForm", p.workForm); err != nil {
return fixed, fmt.Errorf("set workForm on %s: %w", p.hashID, err)
}
}
fixed++
}
return fixed, nil
}
// planDomainScreen is the read half: what would change, without changing it.
func (c *Client) planDomainScreen(ctx context.Context) ([]domainScreenPlan, error) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.client == nil {
return nil, fmt.Errorf("not connected")
}
hashIDs, err := c.client.SMembers(ctx, c.buildIndexKey()).Result()
if err != nil {
return nil, fmt.Errorf("smembers index: %w", err)
}
if len(hashIDs) == 0 {
return nil, nil
}
// Three keys per record, in a fixed order so the MGET response decodes by
// position: keys[i*3+n] belongs to hashIDs[i].
const fieldsPerFile = 3
fields := [fieldsPerFile]string{"domain", "workForm", "contentKind"}
keys := make([]string, 0, len(hashIDs)*fieldsPerFile)
for _, h := range hashIDs {
prefix := c.buildKeyPrefix(h)
for _, f := range fields {
keys = append(keys, prefix+f)
}
}
// Chunked so one MGET never carries the whole corpus.
const chunk = 3000
values := make([]interface{}, 0, len(keys))
for start := 0; start < len(keys); start += chunk {
end := start + chunk
if end > len(keys) {
end = len(keys)
}
got, err := c.client.MGet(ctx, keys[start:end]...).Result()
if err != nil {
return nil, fmt.Errorf("mget: %w", err)
}
values = append(values, got...)
}
str := func(i int) string {
if i >= len(values) {
return ""
}
s, _ := values[i].(string)
return s
}
plans := make([]domainScreenPlan, 0)
for i, h := range hashIDs {
base := i * fieldsPerFile
domain, workForm, contentKind := str(base), str(base+1), str(base+2)
plan := domainScreenPlan{hashID: h}
if replacement, ok := legacyDomainReplacements[domain]; ok {
plan.domain = replacement
}
// A stored `workForm` is authoritative — a `pack` writer knew something
// the table cannot. Only an absent one is filled in.
if workForm == "" {
if form, ok := workFormByContentKind[contentKind]; ok {
plan.workForm = form
}
}
if plan.domain != "" || plan.workForm != "" {
plans = append(plans, plan)
}
}
return plans, nil
}
// MigrateDomainScreenWithTimeout is the convenience wrapper used by the API
// handler. Generous timeout: the read phase is two batched passes, but the
// write phase is one round-trip per changed field.
func (c *Client) MigrateDomainScreenWithTimeout() (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
return c.MigrateDomainScreen(ctx)
}
@@ -0,0 +1,140 @@
package storage
import (
"context"
"testing"
)
// seedRecord writes one record's classification fields straight through
// SetProperty, which is what a real writer uses (it maintains the field index
// and the root index the sweep reads from).
func seedRecord(t *testing.T, c *Client, hashID string, fields map[string]string) {
t.Helper()
for k, v := range fields {
if err := c.SetProperty(hashID, k, v); err != nil {
t.Fatalf("seed %s/%s: %v", hashID, k, err)
}
}
}
// The merge (METADATA_KEYS.md §14.17) is paid in the data: `domain` is stored,
// not derived on read, so a record left saying `film` is invisible to a
// `domain:screen` wall. This is the sweep that pays it.
func TestMigrateDomainScreen_RewritesLegacyDomainsAndBackfillsWorkForm(t *testing.T) {
c, _ := newTestClient(t, "")
seedRecord(t, c, "rec-movie", map[string]string{
"domain": "film",
"contentKind": "movie",
})
seedRecord(t, c, "rec-episode", map[string]string{
"domain": "tv",
"contentKind": "episode",
})
// Already current, and already carries both keys: must be left alone.
seedRecord(t, c, "rec-current", map[string]string{
"domain": "screen",
"contentKind": "series",
"workForm": "serial",
})
// A different domain entirely — the sweep must not touch it, but it is
// still owed the `workForm` backfill.
seedRecord(t, c, "rec-book", map[string]string{
"domain": "literature",
"contentKind": "book",
})
fixed, err := c.MigrateDomainScreen(context.Background())
if err != nil {
t.Fatalf("migrate: %v", err)
}
if fixed != 3 {
t.Errorf("fixed = %d, want 3 (the two legacy rows + the book's backfill)", fixed)
}
for _, tc := range []struct {
hashID string
domain string
workForm string
}{
{"rec-movie", "screen", "standalone"},
{"rec-episode", "screen", "serial"},
{"rec-current", "screen", "serial"},
{"rec-book", "literature", "standalone"},
} {
m, err := c.GetMetadataFlat(tc.hashID)
if err != nil {
t.Fatalf("read %s: %v", tc.hashID, err)
}
if m["domain"] != tc.domain {
t.Errorf("%s domain = %q, want %q", tc.hashID, m["domain"], tc.domain)
}
if m["workForm"] != tc.workForm {
t.Errorf("%s workForm = %q, want %q", tc.hashID, m["workForm"], tc.workForm)
}
}
}
// A second pass must find nothing to do — the endpoint is called by hand on
// each box, and "run it twice" must not mean "write the whole corpus twice".
func TestMigrateDomainScreen_IsIdempotent(t *testing.T) {
c, _ := newTestClient(t, "mm:")
seedRecord(t, c, "rec-1", map[string]string{
"domain": "tv",
"contentKind": "episode",
})
if _, err := c.MigrateDomainScreen(context.Background()); err != nil {
t.Fatalf("first pass: %v", err)
}
fixed, err := c.MigrateDomainScreen(context.Background())
if err != nil {
t.Fatalf("second pass: %v", err)
}
if fixed != 0 {
t.Errorf("second pass fixed = %d, want 0", fixed)
}
}
// `pack` is the one kind neither routing table can decide — a season pack is
// `serial`, an album release `standalone`. A pack that reaches the sweep
// without a `workForm` must keep none: inventing one writes a guess into the
// record permanently.
func TestMigrateDomainScreen_NeverGuessesAPacksWorkForm(t *testing.T) {
c, _ := newTestClient(t, "")
seedRecord(t, c, "rec-pack", map[string]string{
"domain": "tv",
"contentKind": "pack",
})
// A writer that DID know keeps what it wrote.
seedRecord(t, c, "rec-album", map[string]string{
"domain": "music",
"contentKind": "pack",
"workForm": "standalone",
})
if _, err := c.MigrateDomainScreen(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
m, err := c.GetMetadataFlat("rec-pack")
if err != nil {
t.Fatalf("read: %v", err)
}
if m["domain"] != "screen" {
t.Errorf("domain = %q, want screen (the domain WAS knowable)", m["domain"])
}
if _, ok := m["workForm"]; ok {
t.Errorf("workForm = %q, want absent", m["workForm"])
}
a, err := c.GetMetadataFlat("rec-album")
if err != nil {
t.Fatalf("read: %v", err)
}
if a["workForm"] != "standalone" {
t.Errorf("workForm = %q, want standalone (writer-supplied, never overwritten)", a["workForm"])
}
}