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) }