bugfix indexer field
This commit is contained in:
@@ -351,6 +351,21 @@ func (s *Server) Start() error {
|
|||||||
|
|
||||||
// Republish events for all existing metadata
|
// Republish events for all existing metadata
|
||||||
go func() {
|
go func() {
|
||||||
|
// Build any missing per-record field indexes first, in ONE
|
||||||
|
// keyspace pass. The republish below reads every record, and
|
||||||
|
// an un-indexed record read costs a full-keyspace SCAN — on a
|
||||||
|
// real store that walk warmed at ~1.3 records/s (a ~2.6h tail,
|
||||||
|
// paid again on every restart). Warming first turns the whole
|
||||||
|
// republish into index-served reads. Idempotent, so a
|
||||||
|
// fully-indexed store just pays the single scan.
|
||||||
|
// See storage/field_index.go.
|
||||||
|
started := time.Now()
|
||||||
|
if n, err := s.storage.WarmFieldIndexes(); err != nil {
|
||||||
|
log.Printf("[API] Warning: field index warm failed: %v (reads fall back to SCAN)", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
|
||||||
count, err := s.RepublishMetadata()
|
count, err := s.RepublishMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[API] Warning: failed to republish metadata on startup: %v", err)
|
log.Printf("[API] Warning: failed to republish metadata on startup: %v", err)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
@@ -265,6 +267,11 @@ func (c *Client) fieldsWithPrefixLocked(ctx context.Context, hashID, fieldPrefix
|
|||||||
out = append(out, strings.TrimPrefix(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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +292,102 @@ func (c *Client) scanFieldsWithPrefixLocked(ctx context.Context, hashID, fieldPr
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sort.Strings(out)
|
||||||
return out, nil
|
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
|
// recordHasFieldsLocked reports whether a record still has at least one
|
||||||
// property key. Caller must hold c.mu.
|
// property key. Caller must hold c.mu.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -369,6 +369,68 @@ func TestFieldIndex_StaleEntryIsHarmless(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WarmFieldIndexes is what keeps an upgrade from spending hours reading
|
||||||
|
// un-indexed records one full-keyspace scan at a time. It must index every
|
||||||
|
// record in one pass, be idempotent, and leave already-indexed records alone.
|
||||||
|
func TestFieldIndex_WarmBuildsEveryMissingIndex(t *testing.T) {
|
||||||
|
forEachPrefix(t, func(t *testing.T, c *Client) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Two legacy records seeded straight into Redis (no index), plus one
|
||||||
|
// written through the normal path (already indexed).
|
||||||
|
legacy := map[string]map[string]string{
|
||||||
|
"old1": {"filePath": "/a.mkv", "cids/bafkreiaaa": "true"},
|
||||||
|
"old2": {"filePath": "/b.mkv", "titles/jpn": "ナルト", "deep/a/b": "x"},
|
||||||
|
}
|
||||||
|
for root, fields := range legacy {
|
||||||
|
for f, v := range fields {
|
||||||
|
if err := c.client.Set(ctx, c.buildKeyPrefix(root)+f, v, 0).Err(); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.IndexAdd(root); err != nil {
|
||||||
|
t.Fatalf("IndexAdd: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.SetMetadataFlat("fresh", map[string]string{"title": "x"}); err != nil {
|
||||||
|
t.Fatalf("SetMetadataFlat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := c.WarmFieldIndexes()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WarmFieldIndexes: %v", err)
|
||||||
|
}
|
||||||
|
if n < 2 {
|
||||||
|
t.Errorf("warmed %d records, want at least the 2 legacy ones", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fieldNames(t, c, "old1"); !reflect.DeepEqual(got, []string{"cids/bafkreiaaa", "filePath"}) {
|
||||||
|
t.Errorf("old1 index = %v", got)
|
||||||
|
}
|
||||||
|
if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn"}) {
|
||||||
|
t.Errorf("old2 index = %v", got)
|
||||||
|
}
|
||||||
|
if got := fieldNames(t, c, "fresh"); !reflect.DeepEqual(got, []string{"title"}) {
|
||||||
|
t.Errorf("fresh index disturbed = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads must now agree with what the records actually hold.
|
||||||
|
for root, want := range legacy {
|
||||||
|
if got := mustGetMetadata(t, c, root); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("%s read = %v, want %v", root, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent: a second pass changes nothing.
|
||||||
|
if _, err := c.WarmFieldIndexes(); err != nil {
|
||||||
|
t.Fatalf("second WarmFieldIndexes: %v", err)
|
||||||
|
}
|
||||||
|
if got := fieldNames(t, c, "old2"); !reflect.DeepEqual(got, []string{"deep/a/b", "filePath", "titles/jpn"}) {
|
||||||
|
t.Errorf("old2 index after second warm = %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// The bulk search read enumerates the whole file: keyspace and MGETs what it
|
// The bulk search read enumerates the whole file: keyspace and MGETs what it
|
||||||
// finds. The index keys are SETs living in that same namespace, so it has to
|
// finds. The index keys are SETs living in that same namespace, so it has to
|
||||||
// skip them or the MGET fails with WRONGTYPE and search dies wholesale.
|
// skip them or the MGET fails with WRONGTYPE and search dies wholesale.
|
||||||
|
|||||||
Reference in New Issue
Block a user