replace Redis with an embedded SQLite string->string KV store (group commit, in-tx events, SQL search)
Build and Push Docker Image / build (push) Successful in 2m44s

This commit is contained in:
Worph
2026-10-19 20:51:00 +00:00
parent c53146e514
commit dc5cbdfb9b
80 changed files with 4601 additions and 6077 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ import (
// (crc32: "bafk3eaqerjhiwhy").
var cidRegex = regexp.MustCompile(`^(ba[a-z2-7]{14,}|Qm[1-9A-HJ-NP-Za-km-z]{44})$`)
// ClassifyValue returns the (primitive, hint) for a Redis value.
// ClassifyValue returns the (primitive, hint) for a stored value.
// exists=false (key missing) and JSON null literal both map to PrimUndefined.
// Empty string is a valid string.
func ClassifyValue(value string, exists bool) (Primitive, Hint) {
+89 -188
View File
@@ -6,17 +6,20 @@ import (
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/metazla/meta-core/internal/events"
"github.com/redis/go-redis/v9"
"github.com/metazla/meta-core/internal/kv"
)
var langCodeRegex = regexp.MustCompile(`^[a-z]{2,3}$`)
// fieldState is the per-field accumulator. perKey lets us undo a previous
// contribution when the same Redis key is re-SET or DELed.
// rebuildInterval is how often the schema is recomputed in the background.
// It describes the *shape* of the data (which fields exist, their types), which
// drifts slowly; a few minutes of staleness is invisible to the editor that
// reads it, and POST /api/schema/rescan recomputes on demand.
const rebuildInterval = 10 * time.Minute
// fieldState is the per-field accumulator.
type fieldState struct {
counts map[string]int
primitives map[Primitive]int
@@ -24,12 +27,6 @@ type fieldState struct {
typedCount int
undefinedCount int
languageKeyed bool
perKey map[string]labelPair
}
type labelPair struct {
prim Primitive
hint Hint
}
func newFieldState() *fieldState {
@@ -37,19 +34,25 @@ func newFieldState() *fieldState {
counts: make(map[string]int),
primitives: make(map[Primitive]int),
hintCounts: make(map[Hint]int),
perKey: make(map[string]labelPair),
}
}
// Indexer maintains a live field-schema by consuming meta:events.
// Indexer maintains the per-field schema by scanning the store.
//
// It used to follow meta:events and keep, per field, a map of EVERY record key
// it had seen (so a re-set or delete could undo that key's contribution). At
// 6.3M keys that map was ~2 GB of RAM — about half of meta-core's resident
// memory once the search copy was gone — and it only worked if every event was
// replayed into it, which is half of why meta-core republished every field on
// boot. A periodic streaming scan holds one accumulator per FIELD instead and
// is exact by construction.
type Indexer struct {
client *redis.Client
keyPrefix string
store *kv.Store
mu sync.RWMutex
fields map[string]*fieldState
mu sync.RWMutex
snap *SchemaResponse
processed uint64 // total events processed since start
rebuildMu sync.Mutex
ctx context.Context
cancel context.CancelFunc
@@ -58,37 +61,41 @@ type Indexer struct {
running bool
}
// NewIndexer creates an Indexer. keyPrefix should match storage.Client.GetPrefix()
// so the indexer can build absolute Redis keys for GET calls.
func NewIndexer(client *redis.Client, keyPrefix string) *Indexer {
// NewIndexer creates an Indexer over store.
func NewIndexer(store *kv.Store) *Indexer {
ctx, cancel := context.WithCancel(context.Background())
return &Indexer{
client: client,
keyPrefix: keyPrefix,
fields: make(map[string]*fieldState),
ctx: ctx,
cancel: cancel,
}
return &Indexer{store: store, ctx: ctx, cancel: cancel}
}
// Start begins consuming the meta:events stream from the earliest available ID.
// Start computes the schema in the background now and every rebuildInterval.
func (i *Indexer) Start() error {
i.runMu.Lock()
defer i.runMu.Unlock()
if i.running {
i.runMu.Unlock()
return nil
}
i.running = true
i.runMu.Unlock()
i.wg.Add(1)
go i.consumeLoop()
log.Println("[SchemaIndexer] Started - consuming meta:events stream")
go func() {
defer i.wg.Done()
ticker := time.NewTicker(rebuildInterval)
defer ticker.Stop()
for {
if _, err := i.Rebuild(i.ctx); err != nil && i.ctx.Err() == nil {
log.Printf("[SchemaIndexer] rebuild failed: %v", err)
}
select {
case <-i.ctx.Done():
return
case <-ticker.C:
}
}
}()
log.Println("[SchemaIndexer] Started - scanning the store")
return nil
}
// Stop terminates the consumer goroutine.
// Stop terminates the background rebuilds.
func (i *Indexer) Stop() error {
i.runMu.Lock()
if !i.running {
@@ -104,93 +111,55 @@ func (i *Indexer) Stop() error {
return nil
}
// ProcessedCount returns the total number of stream events processed.
// Used by WaitForDrain to detect when a rescan flood has been absorbed.
func (i *Indexer) ProcessedCount() uint64 {
return atomic.LoadUint64(&i.processed)
}
// Rebuild recomputes the schema from every record key and publishes it. One
// rebuild runs at a time; a concurrent caller waits and gets the fresh result.
func (i *Indexer) Rebuild(ctx context.Context) (*SchemaResponse, error) {
i.rebuildMu.Lock()
defer i.rebuildMu.Unlock()
// WaitForDrain blocks until ProcessedCount has been stable for `stableFor` or
// the timeout elapses. Suitable for use right after RepublishMetadata.
func (i *Indexer) WaitForDrain(timeout time.Duration) {
deadline := time.Now().Add(timeout)
last := i.ProcessedCount()
stableSince := time.Now()
for time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
now := i.ProcessedCount()
if now != last {
last = now
stableSince = time.Now()
continue
}
if time.Since(stableSince) >= 300*time.Millisecond {
return
}
}
}
func (i *Indexer) consumeLoop() {
defer i.wg.Done()
lastID := "0"
for {
select {
case <-i.ctx.Done():
return
default:
}
res, err := i.client.XRead(i.ctx, &redis.XReadArgs{
Streams: []string{events.MetaEventsStream, lastID},
Block: 2 * time.Second,
Count: 500,
}).Result()
if err != nil {
if err == redis.Nil || i.ctx.Err() != nil {
started := time.Now()
fields := make(map[string]*fieldState)
err := i.store.ScanPrefix(ctx, "file:", 5000, func(rows []kv.KV) error {
for _, row := range rows {
fieldPath, keyHint, ok := parseFieldPath(row.K)
if !ok {
continue
}
log.Printf("[SchemaIndexer] XRead error: %v", err)
time.Sleep(1 * time.Second)
continue
}
for _, stream := range res {
for _, msg := range stream.Messages {
lastID = msg.ID
i.processEvent(msg.Values)
atomic.AddUint64(&i.processed, 1)
fs := fields[fieldPath]
if fs == nil {
fs = newFieldState()
fields[fieldPath] = fs
}
if keyHint != "" {
fs.languageKeyed = true
}
prim, hint := ClassifyValue(row.V, true)
incContribution(fs, labelPair{prim: prim, hint: hint})
}
return ctx.Err()
})
if err != nil {
return nil, err
}
snap := &SchemaResponse{
Fields: make(map[string]*FieldSchema, len(fields)),
GeneratedAt: time.Now().UTC(),
Source: "scan",
}
for fp, fs := range fields {
snap.Fields[fp] = computeFieldSchema(fs)
}
i.mu.Lock()
i.snap = snap
i.mu.Unlock()
log.Printf("[SchemaIndexer] %d fields from a full scan in %s", len(fields), time.Since(started).Round(time.Millisecond))
return snap, nil
}
func (i *Indexer) processEvent(values map[string]any) {
op, _ := values["type"].(string)
key, _ := values["key"].(string)
if key == "" {
return
}
fieldPath, keyHint, ok := parseFieldPath(key)
if !ok {
return
}
switch op {
case "set":
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
val, err := i.client.Get(ctx, i.keyPrefix+key).Result()
cancel()
exists := err != redis.Nil
if err != nil && err != redis.Nil {
// Treat transient errors as "value disappeared" — nothing to record.
return
}
prim, hint := ClassifyValue(val, exists)
i.upsert(fieldPath, key, keyHint, prim, hint)
case "del", "expire":
i.remove(fieldPath, key)
}
type labelPair struct {
prim Primitive
hint Hint
}
// parseFieldPath turns "file:<rootID>/<propertyPath>" into the logical field
@@ -222,46 +191,6 @@ func parseFieldPath(key string) (string, KeyHint, bool) {
return property, "", true
}
func (i *Indexer) upsert(fieldPath, redisKey string, keyHint KeyHint, prim Primitive, hint Hint) {
i.mu.Lock()
defer i.mu.Unlock()
fs, ok := i.fields[fieldPath]
if !ok {
fs = newFieldState()
i.fields[fieldPath] = fs
}
if keyHint != "" {
fs.languageKeyed = true
}
if old, ok := fs.perKey[redisKey]; ok {
decContribution(fs, old)
}
fs.perKey[redisKey] = labelPair{prim: prim, hint: hint}
incContribution(fs, labelPair{prim: prim, hint: hint})
}
func (i *Indexer) remove(fieldPath, redisKey string) {
i.mu.Lock()
defer i.mu.Unlock()
fs, ok := i.fields[fieldPath]
if !ok {
return
}
old, ok := fs.perKey[redisKey]
if !ok {
return
}
delete(fs.perKey, redisKey)
decContribution(fs, old)
if len(fs.perKey) == 0 {
delete(i.fields, fieldPath)
}
}
func incContribution(fs *fieldState, lp labelPair) {
label := breakdownLabel(lp.prim, lp.hint)
fs.counts[label]++
@@ -276,43 +205,15 @@ func incContribution(fs *fieldState, lp labelPair) {
}
}
func decContribution(fs *fieldState, lp labelPair) {
label := breakdownLabel(lp.prim, lp.hint)
fs.counts[label]--
if fs.counts[label] <= 0 {
delete(fs.counts, label)
}
if lp.prim == PrimUndefined {
fs.undefinedCount--
return
}
fs.typedCount--
fs.primitives[lp.prim]--
if fs.primitives[lp.prim] <= 0 {
delete(fs.primitives, lp.prim)
}
if lp.hint != "" {
fs.hintCounts[lp.hint]--
if fs.hintCounts[lp.hint] <= 0 {
delete(fs.hintCounts, lp.hint)
}
}
}
// Snapshot returns the current schema. Safe to call concurrently with consumption.
// Snapshot returns the latest computed schema. Before the first scan finishes
// it is empty with Source "pending".
func (i *Indexer) Snapshot() *SchemaResponse {
i.mu.RLock()
defer i.mu.RUnlock()
out := &SchemaResponse{
Fields: make(map[string]*FieldSchema, len(i.fields)),
GeneratedAt: time.Now().UTC(),
Source: "live",
if i.snap == nil {
return &SchemaResponse{Fields: map[string]*FieldSchema{}, GeneratedAt: time.Now().UTC(), Source: "pending"}
}
for fp, fs := range i.fields {
out.Fields[fp] = computeFieldSchema(fs)
}
return out
return i.snap
}
func computeFieldSchema(fs *fieldState) *FieldSchema {
+56
View File
@@ -0,0 +1,56 @@
package schema
import (
"context"
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/kv"
)
func TestRebuildDerivesTypesFromTheStore(t *testing.T) {
store, err := kv.Open(filepath.Join(t.TempDir(), "kv.db"), kv.Options{})
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Update(context.Background(), func(tx *kv.Tx) error {
for k, v := range map[string]string{
"file:A/sizeByte": "100",
"file:B/sizeByte": "200",
"file:A/title/eng": "One",
"file:B/title/fre": "Deux",
"file:A/mixed": "1",
"file:B/mixed": "x",
"cid:bafknotrecord": "A",
} {
if err := tx.Put(k, v); err != nil {
return err
}
}
return nil
}); err != nil {
t.Fatal(err)
}
ix := NewIndexer(store)
if got := ix.Snapshot(); got.Source != "pending" || len(got.Fields) != 0 {
t.Fatalf("before a scan: %+v", got)
}
snap, err := ix.Rebuild(context.Background())
if err != nil {
t.Fatal(err)
}
if f := snap.Fields["sizeByte"]; f == nil || f.Type != PrimInt || f.Breakdown["int"] != 2 {
t.Fatalf("sizeByte = %+v", f)
}
if f := snap.Fields["title"]; f == nil || f.KeyHint != KeyHintLanguageCode {
t.Fatalf("title = %+v (want language-keyed)", f)
}
if f := snap.Fields["mixed"]; f == nil || f.Type != PrimMixed {
t.Fatalf("mixed = %+v", f)
}
if len(snap.Fields) != 3 {
t.Fatalf("fields = %v (non-record keys must not count)", snap.Fields)
}
}