package storage import ( "context" "fmt" "log" "sort" "strings" "time" "github.com/redis/go-redis/v9" ) // The per-record field-name index. // // A record is stored as one flat Redis STRING per leaf // (`file:/`), and that shape is deliberate — it is what lets // keyspace notifications fire per *field*, so meta-fuse / meta-editor / // meta-search can react to one field changing instead of diffing a whole hash // (see docs/project-architecture/persistent-storage.md, and // dev/test/suites/flat-keys.bats which asserts records are never hashes). // // The cost of that shape was that reading one record meant asking Redis "which // keys start with `file:/`?", and Redis has no prefix index — `SCAN // MATCH` walks the ENTIRE keyspace. On a real store (445k keys, ~36 fields per // record) a single record read cost ~670 SCAN round-trips and ~0.5s of Redis // CPU; SCAN accounted for ~97.5% of all Redis CPU time on the box. That sat on // the byte path for every poster meta-share served, which is how it surfaced. // // So we keep the flat keys and index their *names*: // // file:/__fields__ SET{ "filePath", "sizeByte", "cids/bafk…", … } // // A read becomes SMEMBERS + a chunked MGET — two round-trips instead of ~670. // This mirrors `file:__index__` (the set of every root) one level down, and the // bulk reads already worked this way: `GetTuplesForAllFiles` composes its keys // from the index rather than scanning per root. // // **The set is derived, never authoritative.** The flat keys remain the source // of truth. A record whose set is missing (anything written before this landed) // falls back to the old SCAN and populates the set on the way out, so existing // deployments heal themselves on first touch with no migration and no // SchemaVersion bump — which matters, because a bump is a flag day that makes // operators wipe Redis (see schema_version.go). // // The consistency argument that makes a derived index safe here is meta-core's // single-writer rule: leader election publishes an empty `redisUrl` on purpose // (internal/leader/election.go), so nothing outside this process can write a // `file:` key behind the index's back. // FieldsField is the reserved field name holding a record's field-name index. // // It is a Redis SET living at `file:/__fields__`, i.e. inside the same // prefix as the record's own fields. That keeps every existing sweep correct // for free — `DeleteMetadata`, `DeleteRoot` and `ClearAllMetadata` all // SCAN+DEL `file:/*` and therefore drop the index along with the record. // The flip side is that every reader which enumerates that prefix has to skip // it, exactly as they already skip `file:__index__`. const FieldsField = "__fields__" // mgetBatch caps how many keys go into one MGET. Redis limits arguments per // command; 1000 is the batch size `GetTuplesForAllFiles` already uses. const mgetBatch = 1000 // buildFieldsKey constructs the key for a record's field-name index set. func (c *Client) buildFieldsKey(hashID string) string { return c.buildKeyPrefix(hashID) + FieldsField } // isReservedFieldName reports whether a field name is bookkeeping rather than // metadata, and so must never be returned to an API caller, exported into a // snapshot, or folded into the search haystack. func isReservedFieldName(field string) bool { return field == FieldsField } // readMetadataLocked returns a record's full flat metadata. Caller must hold // c.mu (either mode). // // Fast path: SMEMBERS the field index, then MGET those keys. Fallback: the // legacy full-keyspace SCAN, whose result is used to populate the index so the // next read takes the fast path. // // Returns (nil, nil) for a record with no fields — callers treat that as 404. func (c *Client) readMetadataLocked(ctx context.Context, hashID string) (map[string]string, error) { fields, err := c.client.SMembers(ctx, c.buildFieldsKey(hashID)).Result() if err != nil && err != redis.Nil { return nil, fmt.Errorf("smembers fields: %w", err) } if len(fields) > 0 { return c.mgetFieldsLocked(ctx, hashID, fields) } // No index yet: a record written before the index existed, or one that // genuinely has no fields. Fall back to the scan, then backfill. result, scanned, err := c.scanMetadataLocked(ctx, hashID) if err != nil { return nil, err } if len(scanned) > 0 { // Best-effort: a failed backfill costs the next read a scan, it does // not make the read wrong. Never fail the caller's read for it. if err := c.client.SAdd(ctx, c.buildFieldsKey(hashID), toAny(scanned)...).Err(); err != nil { logFieldIndexBackfillFailure(hashID, err) } } if len(result) == 0 { return nil, nil } return result, nil } // mgetFieldsLocked reads the named fields of a record in chunked MGETs. // // A field named in the index whose key is gone reads back as nil and is simply // skipped: a *stale* index entry is harmless. (A *missing* one would silently // drop data, which is why every write path maintains the set.) func (c *Client) mgetFieldsLocked(ctx context.Context, hashID string, fields []string) (map[string]string, error) { prefix := c.buildKeyPrefix(hashID) // Keep the reserved name out of the key list AND out of the result — it is // a SET, so MGET would error on it in real Redis rather than return nil. wanted := make([]string, 0, len(fields)) for _, f := range fields { if !isReservedFieldName(f) { wanted = append(wanted, f) } } if len(wanted) == 0 { return nil, nil } result := make(map[string]string, len(wanted)) for start := 0; start < len(wanted); start += mgetBatch { end := start + mgetBatch if end > len(wanted) { end = len(wanted) } chunk := wanted[start:end] keys := make([]string, len(chunk)) for i, f := range chunk { keys[i] = prefix + f } values, err := c.client.MGet(ctx, keys...).Result() if err != nil { return nil, fmt.Errorf("mget failed: %w", err) } for i, v := range values { if s, ok := v.(string); ok { result[chunk[i]] = s } } } if len(result) == 0 { return nil, nil } return result, nil } // scanMetadataLocked is the pre-index read: SCAN the record's key prefix, then // MGET. Retained as the fallback for un-indexed records and as the source for // the backfill. Returns the metadata plus the field names it saw (which // includes fields whose value came back nil, so the index stays complete). func (c *Client) scanMetadataLocked(ctx context.Context, hashID string) (map[string]string, []string, error) { prefix := c.buildKeyPrefix(hashID) result := make(map[string]string) var fields []string var cursor uint64 for { keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result() if err != nil { return nil, nil, fmt.Errorf("scan failed: %w", err) } if len(keys) > 0 { wanted := make([]string, 0, len(keys)) for _, key := range keys { field := strings.TrimPrefix(key, prefix) if isReservedFieldName(field) { continue } fields = append(fields, field) wanted = append(wanted, key) } if len(wanted) > 0 { values, err := c.client.MGet(ctx, wanted...).Result() if err != nil { return nil, nil, fmt.Errorf("mget failed: %w", err) } for i, key := range wanted { if s, ok := values[i].(string); ok { result[strings.TrimPrefix(key, prefix)] = s } } } } cursor = nextCursor if cursor == 0 { break } } return result, fields, nil } // addFieldsToPipe records field names in the index as part of an existing // pipeline, so a multi-field write stays one round-trip. func (c *Client) addFieldsToPipe(ctx context.Context, pipe redis.Pipeliner, hashID string, fields []string) { names := filterReserved(fields) if len(names) == 0 { return } pipe.SAdd(ctx, c.buildFieldsKey(hashID), toAny(names)...) } // addFieldLocked records a single field name in the index. Caller must hold // c.mu. Best-effort by the same argument as the backfill: the flat key is the // source of truth, and a lost index entry is repaired by the next scan // fallback — so it must not fail the write it accompanies. func (c *Client) addFieldLocked(ctx context.Context, hashID, field string) { if isReservedFieldName(field) { return } if err := c.client.SAdd(ctx, c.buildFieldsKey(hashID), field).Err(); err != nil { logFieldIndexWriteFailure(hashID, field, err) } } // removeFieldLocked drops a field name from the index. Caller must hold c.mu. // // A stale entry left behind by a failure is harmless (the MGET reads nil and // skips it), so this is best-effort too. func (c *Client) removeFieldLocked(ctx context.Context, hashID, field string) { if isReservedFieldName(field) { return } if err := c.client.SRem(ctx, c.buildFieldsKey(hashID), field).Err(); err != nil { logFieldIndexWriteFailure(hashID, field, err) } } // fieldsWithPrefixLocked returns the record's indexed field names under a // field-name prefix (e.g. "cids/"), falling back to a SCAN when the record has // no index yet. Caller must hold c.mu. func (c *Client) fieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix string) ([]string, error) { fields, err := c.client.SMembers(ctx, c.buildFieldsKey(hashID)).Result() if err != nil && err != redis.Nil { return nil, fmt.Errorf("smembers fields: %w", err) } if len(fields) == 0 { // Un-indexed record: scan the narrower `*` glob // rather than the whole record, and do not backfill from it — a // partial view must never be mistaken for a complete index. return c.scanFieldsWithPrefixLocked(ctx, hashID, fieldPrefix) } var out []string for _, f := range fields { if isReservedFieldName(f) { continue } if strings.HasPrefix(f, fieldPrefix) { out = append(out, strings.TrimPrefix(f, fieldPrefix)) } } // SMEMBERS order is arbitrary and SCAN order was too, so the CID array in // GetMetadataDocument used to shuffle between calls. Sorting costs nothing // at these sizes and makes the response byte-stable, which matters for // anything diffing or caching it. sort.Strings(out) return out, nil } func (c *Client) scanFieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix string) ([]string, error) { prefix := c.buildKeyPrefix(hashID) + fieldPrefix var out []string var cursor uint64 for { keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result() if err != nil { return nil, fmt.Errorf("scan fields: %w", err) } for _, k := range keys { out = append(out, strings.TrimPrefix(k, prefix)) } cursor = next if cursor == 0 { break } } sort.Strings(out) return out, nil } // WarmFieldIndexes builds every missing field index in a SINGLE keyspace pass. // // Without it the indexes still appear — each record's first read falls back to // a scan and backfills — but that transition is O(records x keyspace) and it // runs again from scratch after any wipe. Measured on the real store (12,345 // records / 445k keys) the lazy path warmed at ~1.3 records/s: a ~2.6 HOUR tail // during which meta-core stays pinned exactly as it was before the fix. Worse, // `RepublishMetadata` walks every record at startup, so that tail is paid on // every restart. // // One bulk `file:*` scan grouped by record collapses it to a few seconds. This // is the same shape `GetSearchIndexTuples` already uses for the bulk read, and // for the same reason. // // Idempotent, and safe to run against a partially-indexed store: SADD of a // member that is already there is a no-op. Returns the number of records it // wrote an index for. func (c *Client) WarmFieldIndexes() (int, error) { c.mu.RLock() defer c.mu.RUnlock() if c.client == nil { return 0, fmt.Errorf("not connected") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() filePrefix := c.buildKey("file:") // {filePrefix}{hashID}/{field} indexKey := c.buildIndexKey() byRecord := make(map[string][]string) var cursor uint64 for { keys, next, err := c.client.Scan(ctx, cursor, filePrefix+"*", 1000).Result() if err != nil { return 0, fmt.Errorf("scan: %w", err) } for _, k := range keys { if k == indexKey { continue } // hashID has no '/'; field may contain them. rest := strings.TrimPrefix(k, filePrefix) slash := strings.IndexByte(rest, '/') if slash < 0 { continue } hashID, field := rest[:slash], rest[slash+1:] if isReservedFieldName(field) { // Already indexed — remember the record so we don't treat it // as missing, but there is nothing to add for this key. if _, seen := byRecord[hashID]; !seen { byRecord[hashID] = nil } continue } byRecord[hashID] = append(byRecord[hashID], field) } cursor = next if cursor == 0 { break } } written := 0 pipe := c.client.Pipeline() pending := 0 for hashID, fields := range byRecord { if len(fields) == 0 { continue } pipe.SAdd(ctx, c.buildFieldsKey(hashID), toAny(fields)...) written++ pending++ // Bound the pipeline so a large store doesn't buffer every command. if pending >= 500 { if _, err := pipe.Exec(ctx); err != nil { return written, fmt.Errorf("sadd pipeline: %w", err) } pipe = c.client.Pipeline() pending = 0 } } if pending > 0 { if _, err := pipe.Exec(ctx); err != nil { return written, fmt.Errorf("sadd pipeline: %w", err) } } return written, nil } // recordHasFieldsLocked reports whether a record still has at least one // property key. Caller must hold c.mu. // // `__fields__` must not count — it lives under the record's own prefix, so a // naive "does any `file:/*` key exist?" probe would answer yes for a record // whose every property has been deleted, and the root could never be // de-indexed. func (c *Client) recordHasFieldsLocked(ctx context.Context, hashID string) (bool, error) { n, err := c.client.SCard(ctx, c.buildFieldsKey(hashID)).Result() if err != nil && err != redis.Nil { return false, fmt.Errorf("scard fields: %w", err) } if n > 0 { return true, nil } // Either genuinely empty, or an un-indexed record from before the index // existed — the scan is the only way to tell those apart. prefix := c.buildKeyPrefix(hashID) var cursor uint64 for { keys, next, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result() if err != nil { return false, fmt.Errorf("scan failed: %w", err) } for _, k := range keys { if !isReservedFieldName(strings.TrimPrefix(k, prefix)) { return true, nil } } cursor = next if cursor == 0 { return false, nil } } } // splitRecordKey decomposes a full Redis key into (hashID, field) when it is a // record property key. Reports ok=false for anything else — the index set // itself, `file:__index__`, and any key outside the `file:` namespace. // // This is what lets the raw KV escape hatch (SetRawValue / DeleteRawKey) keep // the index honest without its callers knowing the layout. func (c *Client) splitRecordKey(key string) (hashID, field string, ok bool) { rest := key if c.prefix != "" { var found bool rest, found = strings.CutPrefix(rest, c.prefix) if !found { return "", "", false } } rest, found := strings.CutPrefix(rest, "file:") if !found { return "", "", false } hashID, field, found = strings.Cut(rest, "/") if !found || hashID == "" || field == "" { // `file:__index__` has no slash and lands here. return "", "", false } if isReservedFieldName(field) { return "", "", false } return hashID, field, true } // The index is derived and self-healing, so a write failure degrades // performance (the next read falls back to a scan and re-populates) rather // than correctness. Log it rather than failing the caller's write — but log it // loudly, because a *persistent* failure means every read is scanning again. func logFieldIndexWriteFailure(hashID, field string, err error) { log.Printf("[Storage] field index update failed for %s/%s: %v (reads fall back to SCAN)", hashID, field, err) } func logFieldIndexBackfillFailure(hashID string, err error) { log.Printf("[Storage] field index backfill failed for %s: %v (reads keep falling back to SCAN)", hashID, err) } func filterReserved(fields []string) []string { out := make([]string, 0, len(fields)) for _, f := range fields { if !isReservedFieldName(f) { out = append(out, f) } } return out } func toAny(ss []string) []interface{} { out := make([]interface{}, len(ss)) for i, s := range ss { out[i] = s } return out }