package storage import ( "context" "fmt" "log" "sort" "strings" "time" ) // Incremental search-index maintenance. // // # Why this exists // // The API's in-memory search index used to be rebuilt in full every 60 s: // GetSearchIndexTuples does a whole-keyspace SCAN plus batched MGETs, builds an // intermediate map of every record, then allocates a fresh []SearchTuple — all // while the previous index is still live serving reads. That cost is O(corpus), // not O(change): adding 200 records in a minute re-read every one of the other // ~620 000 field keys to notice them. // // Measured on a 19 622-record staging corpus (2026-08-30): SCAN was 57.8 % and // MGET 19.1 % of ALL Redis CPU, and ~90 % of the MGET calls plus ~48 % of the // SCAN calls were this loop — roughly 45 % of Redis's total work, spent // continuously whether or not anything changed. The Go side held 783 MB for // data Redis stores in 135 MB, largely because of the double copy. // // That was tolerable while the corpus was small and mostly static. It stops // being tolerable now that meta-core is the platform's permanent, additive // store: every search hit on every gateway is retained forever, so the rebuild // cost is a ratchet. // // # The shape // // Every write path marks its record in a dirty set; the refresh drains that set // and re-reads only those records. Cost becomes O(change). // // Marking rides along with the field-index maintenance every write path already // performs, so there is no new bookkeeping discipline to remember — if you are // adding a write path, the same line that keeps `__fields__` correct keeps this // correct. // // # Correctness // // The drain is a destructive SPOP, so a write that lands *during* a refresh is // either already in the drained batch or still in the set for the next tick — // never dropped. The cost of the race is one tick of staleness, which is well // inside the interval the index was already stale by. // // A refresh that fails after draining WOULD lose its batch, so drainDirty's // caller re-marks on error (see MarkDirty / the API's refreshIncremental). // // ⚠ **The destructive drain assumes ONE index consumer per Redis.** Two // meta-core processes sharing a keyspace would steal each other's marks, and // each would end up with an index missing whatever the other drained. That is // fine today — multi-instance leader election was deprecated and the dev/prod // stacks run a single meta-core — but it is the assumption to revisit first if // a second instance is ever pointed at the same Redis. A per-consumer dirty key // (`file:__dirty__:`) is the shape that would fix it. // // The API's periodic full reconcile (searchIndexReconcileEvery) is the backstop // for both this and for a write path that forgets to mark: a missed mark costs // staleness until the next reconcile, never permanent divergence. // dirtyField is the reserved key holding the set of record ids whose metadata // changed since the last search-index refresh. It sits under the same `file:` // namespace as `__index__` and is likewise not a record. const dirtyField = "file:__dirty__" // dirtyDrainCap bounds one incremental refresh. A burst larger than this is // drained over several ticks rather than in one long-held pass — the point of // the change is to keep any single refresh cheap. Sized well above a normal // search-persist flush (which writes at most a few hundred records). const dirtyDrainCap = 5000 // buildDirtyKey constructs the key for the dirty-record set. func (c *Client) buildDirtyKey() string { return c.buildKey(dirtyField) } // markDirtyLocked records that hashID's metadata changed. Caller must hold c.mu. // // Best-effort by design: a failure here costs one record its incremental // refresh, and the periodic full rebuild still repairs it. Failing the caller's // write instead would trade a stale index entry for a lost write, which is // strictly worse. func (c *Client) markDirtyLocked(ctx context.Context, hashID string) { if hashID == "" { return } if err := c.client.SAdd(ctx, c.buildDirtyKey(), hashID).Err(); err != nil { log.Printf("[STORAGE] dirty-mark failed for %s: %v (index repairs on the next full rebuild)", hashID, err) } } // MarkDirty records that hashID needs re-indexing. Exported for the API layer, // which re-marks a batch whose refresh failed after the drain. func (c *Client) MarkDirty(hashIDs ...string) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil || len(hashIDs) == 0 { return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() members := make([]interface{}, 0, len(hashIDs)) for _, h := range hashIDs { if h != "" { members = append(members, h) } } if len(members) == 0 { return } if err := c.client.SAdd(ctx, c.buildDirtyKey(), members...).Err(); err != nil { log.Printf("[STORAGE] dirty re-mark failed for %d records: %v", len(members), err) } } // DrainDirty removes and returns up to dirtyDrainCap record ids from the dirty // set. `more` reports whether entries remain, so the caller can schedule // another pass instead of waiting a full interval to catch up on a burst. // // Destructive on purpose: SPOP makes concurrent refreshes disjoint and stops a // record being re-read every tick forever. The caller owns the drained ids // until it has indexed them — on failure it must MarkDirty them again. func (c *Client) DrainDirty() (ids []string, more bool, err error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return nil, false, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() key := c.buildDirtyKey() ids, err = c.client.SPopN(ctx, key, dirtyDrainCap).Result() if err != nil { return nil, false, fmt.Errorf("spop dirty: %w", err) } if len(ids) < dirtyDrainCap { return ids, false, nil } remaining, err := c.client.SCard(ctx, key).Result() if err != nil { // We have the batch; not knowing the remainder just means the next // tick picks it up on schedule. return ids, false, nil } return ids, remaining > 0, nil } // GetSearchIndexTuplesFor reads the search tuples for a specific set of records // — the incremental counterpart to GetSearchIndexTuples. // // Reads go through the per-record field index (GetMetadataFlat), so this is two // round-trips per record rather than a keyspace scan. That trade is what makes // it O(change): it beats the full pass whenever the changed set is a small // fraction of the corpus, and the caller bounds the batch (dirtyDrainCap). // // A record present in the dirty set but absent from storage was deleted; it is // reported in `gone` so the caller can evict it from the index rather than // leaving a tombstone that still answers searches. func (c *Client) GetSearchIndexTuplesFor(hashIDs []string) (tuples []SearchTuple, gone []string, err error) { if len(hashIDs) == 0 { return nil, nil, nil } excluded := indexExcludePrefixes() tuples = make([]SearchTuple, 0, len(hashIDs)) for _, hashID := range hashIDs { fields, ferr := c.GetMetadataFlat(hashID) if ferr != nil { // Re-mark so the record is retried rather than silently missing // from the index until the next full rebuild. c.MarkDirty(hashID) log.Printf("[STORAGE] incremental index read failed for %s: %v", hashID, ferr) continue } if len(fields) == 0 { gone = append(gone, hashID) continue } for field := range fields { if hasAnyPrefix(field, excluded) { delete(fields, field) } } tuples = append(tuples, SearchTuple{ HashID: hashID, Haystack: buildHaystack(fields), Fields: fields, }) } return tuples, gone, nil } // buildHaystack folds a record's searchable text fields into one lowercased // blob. Shared by the full and incremental index reads so the two can never // disagree about what is searchable — a drift there would make a record's // matchability depend on which path happened to index it. // // Beyond the flat fields it folds in every `titles//` member's // name (METADATA_KEYS.md §3): the name lives in the key and the value is the // sentinel "true", so a search for an alternative title (`3 percent`) finds the // record whose `title` is `3%`. func buildHaystack(fields map[string]string) string { var sb strings.Builder for _, f := range searchIndexFields { if v, ok := fields[f]; ok && v != "" { sb.WriteString(strings.ToLower(v)) sb.WriteByte('\n') } } for _, name := range titleMemberNames(fields) { sb.WriteString(strings.ToLower(name)) sb.WriteByte('\n') } return sb.String() } // titleMemberNames returns the names of a record's `titles//` // key-set members, with the `∕` (U+2215) a writer substitutes for `/` mapped // back. Sorted: the fields map has no order, and the full and incremental // builds must produce byte-identical haystacks. func titleMemberNames(fields map[string]string) []string { var names []string for k, v := range fields { rest, ok := strings.CutPrefix(k, "titles/") if !ok || v == "" || v == "false" { continue } _, name, ok := strings.Cut(rest, "/") if !ok || name == "" { continue } names = append(names, strings.ReplaceAll(name, "\u2215", "/")) } sort.Strings(names) return names }