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
-131
View File
@@ -1,131 +0,0 @@
package api
import (
"net/http"
"strconv"
)
// handleMigrateDualRoots triggers the one-shot sweep that re-unifies
// stranded midhash-rooted entries into their UUID roots. See
// storage.MigrateDualRoots for the data-flow detail and
// docs/api-mediated-access.md for the bug background.
//
// Returns {fixed: N} on success. Safe to call repeatedly — entries already
// migrated are skipped on the next pass.
func (s *Server) handleMigrateDualRoots(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
fixed, err := s.storage.MigrateDualRootsWithTimeout()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"fixed": fixed,
})
}
// handleMigrateDomainScreen triggers the one-shot sweep that rewrites the
// retired `domain=film|tv` values to `screen` and backfills `workForm` from
// `contentKind` (METADATA_KEYS.md §14.17). See
// storage.MigrateDomainScreen for why the vocabulary change is paid in the
// data rather than with a read-side alias.
//
// Returns {fixed: N} — records changed, not records examined. Safe to call
// repeatedly: a record already on the current vocabulary is skipped.
func (s *Server) handleMigrateDomainScreen(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
fixed, err := s.storage.MigrateDomainScreenWithTimeout()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"fixed": fixed,
})
}
// handleSweepLiteratureEchoes removes query echoes stored on literature cards:
// `anilistid` on a site card, and `workcid` / position keys on any card
// (meta-read docs/reading-model.md §7). See storage.SweepLiteratureEchoes for
// why chapters and anchor cards' ids are deliberately left alone.
//
// Dry-run unless `?apply=true`: unlike the migrations above this one deletes
// keys, so a bare call only reports what it would delete. An unparseable
// `apply` is a 400 rather than a silent dry run, so a typo cannot pass for a
// sweep that ran.
//
// Returns {success, dryRun, planned, fixed, byKey} — planned/fixed count
// records, byKey counts fields per key. Safe to call repeatedly: a swept
// record plans nothing on the next pass.
func (s *Server) handleSweepLiteratureEchoes(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
apply := false
if v := r.URL.Query().Get("apply"); v != "" {
parsed, err := strconv.ParseBool(v)
if err != nil {
writeError(w, http.StatusBadRequest, "apply must be true or false")
return
}
apply = parsed
}
res, err := s.storage.SweepLiteratureEchoesWithTimeout(apply)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"dryRun": !apply,
"planned": res.Planned,
"fixed": res.Fixed,
"byKey": res.ByKey,
})
}
// handleMigrateUserCids triggers the one-shot sweep that builds
// `udl:idx:user:<uid>:cids` for User Data Layer records written before that
// index existed. See storage.UDLBackfillUserCids.
//
// The index is what makes whole-profile export a set read instead of a
// keyspace scan, and it is maintained by the upsert script from now on — so
// this only ever matters once, for data already on disk. Until it has run, a
// profile that predates the index exports as **empty**, which means a
// cross-instance recovery reports success having restored nothing.
//
// Returns {fixed: N} — cids added, not cids examined. Safe to call repeatedly:
// a second pass adds nothing.
func (s *Server) handleMigrateUserCids(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
fixed, err := s.storage.UDLBackfillUserCids()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"fixed": fixed,
})
}
-93
View File
@@ -1,93 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/storage"
)
// newSweepTestServer is a Server over miniredis with one literature card
// carrying a copied `workcid`.
func newSweepTestServer(t *testing.T) *Server {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
for k, v := range map[string]string{
"domain": "literature",
"fileType": "card",
"workcid": "bagdsaaaua5qw42lmnfzxi3lbnztwcorrga2tonzy",
} {
if err := c.SetProperty("card-1", k, v); err != nil {
t.Fatalf("seed %s: %v", k, err)
}
}
return &Server{storage: c}
}
type sweepResponse struct {
Success bool `json:"success"`
DryRun bool `json:"dryRun"`
Planned int `json:"planned"`
Fixed int `json:"fixed"`
ByKey map[string]int `json:"byKey"`
}
func postSweep(t *testing.T, s *Server, query string) (*httptest.ResponseRecorder, sweepResponse) {
t.Helper()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/admin/sweep-literature-echoes"+query, nil)
s.handleSweepLiteratureEchoes(rr, req)
var body sweepResponse
if rr.Code == http.StatusOK {
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %s: %v", rr.Body.String(), err)
}
}
return rr, body
}
// A bare call must never delete: the default is the dry run.
func TestSweepLiteratureEchoes_DefaultsToDryRun(t *testing.T) {
s := newSweepTestServer(t)
rr, body := postSweep(t, s, "")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
}
if !body.Success || !body.DryRun || body.Planned != 1 || body.Fixed != 0 || body.ByKey["workcid"] != 1 {
t.Errorf("dry run = %+v", body)
}
if v, _ := s.storage.GetProperty("card-1", "workcid"); v == "" {
t.Errorf("dry run deleted workcid")
}
rr, body = postSweep(t, s, "?apply=true")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rr.Code, rr.Body.String())
}
if body.DryRun || body.Planned != 1 || body.Fixed != 1 || body.ByKey["workcid"] != 1 {
t.Errorf("apply = %+v", body)
}
if v, _ := s.storage.GetProperty("card-1", "workcid"); v != "" {
t.Errorf("apply left workcid = %q", v)
}
}
// A typo must not pass for a sweep that ran.
func TestSweepLiteratureEchoes_RejectsUnparseableApply(t *testing.T) {
s := newSweepTestServer(t)
rr, _ := postSweep(t, s, "?apply=yes-please")
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
+16 -25
View File
@@ -9,24 +9,16 @@ import (
"strings"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/storage"
)
// newRegisterTestServer wires handleRegisterFile to an in-memory Redis and a
// temp FILES_PATH. Built directly, not through NewServer, so no watcher runs —
// newRegisterTestServer wires handleRegisterFile to a temp store and a temp
// FILES_PATH. Built directly, not through NewServer, so no watcher runs —
// which is exactly the configuration this route exists for.
func newRegisterTestServer(t *testing.T) (*Server, string, *miniredis.Miniredis) {
func newRegisterTestServer(t *testing.T) (*Server, string) {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
files := t.TempDir()
return &Server{storage: c, config: &config.Config{FilesPath: files}}, files, mr
return &Server{storage: newTestStorage(t), config: &config.Config{FilesPath: files}}, files
}
func writeFixture(t *testing.T, files, rel, body string) string {
@@ -52,20 +44,20 @@ func postRegister(t *testing.T, s *Server, path string) (int, registerFileRespon
return rr.Code, resp
}
func streamLen(t *testing.T, mr *miniredis.Miniredis) int {
func streamLen(t *testing.T, s *Server) int {
t.Helper()
entries, err := mr.Stream("file:events")
n, err := s.storage.GetStreamLength("file:events")
if err != nil {
return 0
t.Fatalf("stream length: %v", err)
}
return len(entries)
return int(n)
}
// The whole point of the route: with the watcher off, a file written under
// plugin/ becomes a record resolvable by its midhash, and meta-share hears
// about it on file:events.
func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
s, files, mr := newRegisterTestServer(t)
s, files := newRegisterTestServer(t)
rel := "plugin/subtitles/ab12cd34.eng.srt"
full := writeFixture(t, files, rel, "1\n00:00:01,000 --> 00:00:02,000\nHello\n")
@@ -85,20 +77,19 @@ func TestRegisterFile_MintsAliasAndPublishesEvent(t *testing.T) {
t.Fatalf("filePath = %q, want %q", fp, full)
}
entries, err := mr.Stream("file:events")
entries, err := s.storage.XRevRange("file:events", "+", "-", 0)
if err != nil || len(entries) != 1 {
t.Fatalf("want exactly one file:events entry, got %d err=%v", len(entries), err)
}
vals := strings.Join(entries[0].Values, "|")
if !strings.Contains(vals, "midhash256|"+resp.CID) || !strings.Contains(vals, "path|"+rel) {
t.Fatalf("event must carry the midhash and the relative path, got %q", vals)
if vals := entries[0].Values; vals["midhash256"] != resp.CID || vals["path"] != rel {
t.Fatalf("event must carry the midhash and the relative path, got %v", vals)
}
}
// Re-uploading the same subtitle is a no-op: same root, no second event (which
// would make meta-share re-chunk and re-provide the file).
func TestRegisterFile_IsIdempotent(t *testing.T) {
s, files, mr := newRegisterTestServer(t)
s, files := newRegisterTestServer(t)
rel := "plugin/subtitles/same.eng.srt"
writeFixture(t, files, rel, "same bytes")
@@ -110,7 +101,7 @@ func TestRegisterFile_IsIdempotent(t *testing.T) {
if second.Root != first.Root || second.CID != first.CID {
t.Fatalf("second register must return the same record: %+v vs %+v", second, first)
}
if n := streamLen(t, mr); n != 1 {
if n := streamLen(t, s); n != 1 {
t.Fatalf("an idempotent re-register must not publish again, stream has %d", n)
}
}
@@ -118,7 +109,7 @@ func TestRegisterFile_IsIdempotent(t *testing.T) {
// Only plugin output may be registered here. /files/watch is the user's
// library and belongs to the watcher; traversal must not escape either.
func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
s, files, _ := newRegisterTestServer(t)
s, files := newRegisterTestServer(t)
writeFixture(t, files, "watch/movie.mkv", "x")
for _, p := range []string{
"", "plugin", "plugin/", "watch/movie.mkv", "/watch/movie.mkv",
@@ -131,7 +122,7 @@ func TestRegisterFile_RejectsPathsOutsidePlugin(t *testing.T) {
}
func TestRegisterFile_MissingFileAndDirectory(t *testing.T) {
s, files, _ := newRegisterTestServer(t)
s, files := newRegisterTestServer(t)
if code, _ := postRegister(t, s, "plugin/subtitles/nope.srt"); code != http.StatusNotFound {
t.Errorf("missing file: want 404, got %d", code)
}
+2 -8
View File
@@ -7,18 +7,12 @@ import (
"sort"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/storage"
)
func newFileTuplesTestServer(t *testing.T) (*Server, *storage.Client) {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
c := newTestStorage(t)
return &Server{storage: c}, c
}
@@ -105,7 +99,7 @@ func TestFileTuples_EmptyLibraryKeepsFilesKey(t *testing.T) {
}
func TestFileTuples_StorageNotConnected(t *testing.T) {
s := &Server{storage: storage.NewClient("")}
s := &Server{storage: storage.NewClient()}
code, _ := getFileTuples(t, s, "")
if code != http.StatusServiceUnavailable {
+2 -9
View File
@@ -1,6 +1,6 @@
package api
// End-to-end exercise of the account gate against a live router + miniredis:
// End-to-end exercise of the account gate against a live router + a temp store:
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
// both the key and every row are gone. Deliberately drives the real HTTP
// handlers rather than the packages beneath them, because the bugs that matter
@@ -16,21 +16,14 @@ import (
"net/http/httptest"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/gorilla/mux"
"github.com/metazla/meta-core/internal/config"
"github.com/metazla/meta-core/internal/identity"
"github.com/metazla/meta-core/internal/storage"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
mr := miniredis.RunT(t)
stor := storage.NewClient("")
if err := stor.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = stor.Close() })
stor := newTestStorage(t)
s := &Server{
config: &config.Config{MetaCorePath: t.TempDir()},
+3 -3
View File
@@ -206,10 +206,10 @@ type KVSearchResponse struct {
// KVFindResponse is the response for GET /api/kv/find
type KVFindResponse struct {
Contains string `json:"contains"`
Fields []string `json:"fields"`
Contains string `json:"contains"`
Fields []string `json:"fields"`
Matches []storage.FindMatch `json:"matches"`
Truncated bool `json:"truncated"`
Truncated bool `json:"truncated"`
}
// KVValueResponse is the response for GET /api/kv/value
+5 -6
View File
@@ -40,9 +40,9 @@ type MetadataSearchRequest struct {
Limit int `json:"limit,omitempty"`
// Filters is the structured form: AND across entries, OR within an
// entry's Values. Evaluated in memory against the search index alongside
// the free-text Query, so an id-anchored lookup costs a map probe per
// record rather than a Redis round-trip per record.
// entry's Values. Narrowed in SQL through the field index alongside the
// free-text Query, then verified per record (see serveIndexedSearch), so an
// id-anchored lookup costs an index probe rather than a read per record.
//
// Property/PropertyValue is the single-filter legacy shape and folds into
// this list (see normalizedFilters) with Exact=false, preserving its
@@ -445,9 +445,8 @@ func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
}
// Everything else — free text, structured filters, both, or neither
// (which returns the first `limit`) — is served from the in-memory search
// index (search_index.go). Matching and the returned metadata both come
// from memory, so a query costs zero Redis round-trips.
// (which returns the first `limit`) — is served from the store's derived
// search indexes (search_index.go → storage.Search).
//
// There is deliberately no second path. The property branch used to fall
// through to a `GetAllHashIDs` + per-record `GetMetadataFlat` scan, which
+10 -13
View File
@@ -3,10 +3,10 @@ package api
import (
"encoding/json"
"net/http"
"time"
)
// handleSchemaGet returns the live per-field schema derived from meta:events.
// handleSchemaGet returns the per-field schema (recomputed from the store every
// few minutes; see schema.Indexer).
func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) {
if s.schemaIndexer == nil {
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
@@ -17,29 +17,26 @@ func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(snap)
}
// handleSchemaRescan re-emits a set event for every existing property via the
// meta publisher, waits for the indexer to drain, and returns the refreshed
// schema. Equivalent to a full SCAN-based rebuild without locking Redis.
// handleSchemaRescan recomputes the schema from a full scan of the store and
// returns it. It no longer republishes every property into meta:events — the
// indexer reads the store itself — so a rescan floods no consumer.
func (s *Server) handleSchemaRescan(w http.ResponseWriter, r *http.Request) {
if s.schemaIndexer == nil {
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
return
}
count, err := s.RepublishMetadata()
snap, err := s.schemaIndexer.Rebuild(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.schemaIndexer.WaitForDrain(10 * time.Second)
snap := s.schemaIndexer.Snapshot()
snap.Source = "rescan"
out := *snap
out.Source = "rescan"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"published_events": count,
"schema": snap,
"published_events": 0,
"schema": &out,
})
}
+33 -195
View File
@@ -1,221 +1,59 @@
package api
import (
"log"
"net/http"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/metazla/meta-core/internal/storage"
)
// searchIndexRefreshInterval is how often the in-memory search index is rebuilt
// from Redis in the background. Records ingest continuously, so a newly stored
// record becomes searchable within one interval; the rebuild is a single
// batched bulk read (storage.GetSearchIndexTuples), off the request path.
const searchIndexRefreshInterval = 60 * time.Second
// searchIndexReconcileEvery is how many refresh ticks pass between full
// rebuilds. Every other tick is incremental. 30 ticks × 60 s = a full
// reconcile every 30 minutes — see runRefreshLoop for why one is kept at all.
const searchIndexReconcileEvery = 30
// searchIndex is an in-memory snapshot of every record's search haystack
// (hashID + lowercased searchable fields), kept current from Redis. It exists
// so POST /api/metadata/search matches in memory (string ops over a slice)
// instead of doing a SCAN+MGET Redis round-trip per record — the latter took
// 40s+ over ~72k records and made a live parallel search path unusable.
// serveIndexedSearch answers a search from the database's derived indexes
// (storage.Search): SQL narrows to a superset of candidates through the field
// expression index and the FTS5 trigram table, then every candidate is checked
// here with the exact matchers below — a token-AND match against the record's
// haystack (or a whole-query hashID substring, preserving the legacy
// behaviour) AND every structured filter.
//
// # Two update paths, and why
//
// `refresh` rebuilds everything: a whole-keyspace SCAN, batched MGETs, and a
// complete fresh copy. That is O(corpus) regardless of how much changed, and it
// used to run every 60 s forever. Measured on a 19 622-record staging corpus
// (2026-08-30): the loop accounted for ~45 % of ALL Redis CPU, and the Go side
// held 783 MB for data Redis stores in 135 MB — largely the double copy.
//
// `refreshIncremental` re-reads only the records that changed, tracked by
// storage's dirty set (search_dirty.go). Cost is O(change). It is the steady
// state; the full rebuild runs once at startup (there is nothing to be
// incremental against) and then only as an explicit repair.
//
// The distinction matters because meta-core is the platform's permanent,
// additive store — every search hit on every gateway is retained — so a rebuild
// priced against the whole corpus is a cost that only ever grows.
type searchIndex struct {
mu sync.RWMutex
// byID is the authoritative content; tuples is a stable-order view of it
// rebuilt on each update. Map iteration order in Go is randomised, so
// serving a limited query straight off the map would return a different
// subset each call — the slice is what keeps results stable.
byID map[string]storage.SearchTuple
tuples []storage.SearchTuple
ready atomic.Bool
}
// snapshot returns the current tuples slice and whether the index has built at
// least once. Every update swaps in a fresh slice under the write lock (never
// mutates in place), so the returned slice is safe to read lock-free.
func (idx *searchIndex) snapshot() ([]storage.SearchTuple, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.tuples, idx.ready.Load()
}
// rebuildViewLocked regenerates the stable-order slice from byID. Caller holds
// the write lock.
func (idx *searchIndex) rebuildViewLocked() {
view := make([]storage.SearchTuple, 0, len(idx.byID))
for _, t := range idx.byID {
view = append(view, t)
}
// Stable across rebuilds so a limited query returns a consistent page
// rather than a fresh random subset every interval.
sort.Slice(view, func(i, j int) bool { return view[i].HashID < view[j].HashID })
idx.tuples = view
}
// refresh rebuilds the whole index from Redis (a batched bulk read). Used for
// the initial build and as an explicit repair — not the steady state.
func (idx *searchIndex) refresh(stor *storage.Client) {
if stor == nil || !stor.IsConnected() {
return
}
tuples, err := stor.GetSearchIndexTuples()
if err != nil {
log.Printf("[API] search index refresh failed: %v", err)
return
}
byID := make(map[string]storage.SearchTuple, len(tuples))
for _, t := range tuples {
byID[t.HashID] = t
}
idx.mu.Lock()
idx.byID = byID
idx.rebuildViewLocked()
idx.mu.Unlock()
idx.ready.Store(true)
}
// refreshIncremental drains the dirty set and patches only those records.
// Returns whether more dirty entries remain, so a burst is caught up over
// consecutive passes rather than one per interval.
//
// A record in the dirty set but absent from storage was deleted and is evicted
// here — otherwise it would keep answering searches until the next full
// rebuild. A batch that fails after the (destructive) drain is re-marked by
// storage so it is retried rather than silently lost.
func (idx *searchIndex) refreshIncremental(stor *storage.Client) (more bool) {
if stor == nil || !stor.IsConnected() {
return false
}
ids, more, err := stor.DrainDirty()
if err != nil {
log.Printf("[API] search index drain failed: %v", err)
return false
}
if len(ids) == 0 {
return false
}
tuples, gone, err := stor.GetSearchIndexTuplesFor(ids)
if err != nil {
stor.MarkDirty(ids...)
log.Printf("[API] incremental search index refresh failed: %v", err)
return false
}
idx.mu.Lock()
if idx.byID == nil {
idx.byID = make(map[string]storage.SearchTuple, len(tuples))
}
for _, t := range tuples {
idx.byID[t.HashID] = t
}
for _, id := range gone {
delete(idx.byID, id)
}
idx.rebuildViewLocked()
idx.mu.Unlock()
log.Printf("[API] search index: %d updated, %d evicted (%d records)", len(tuples), len(gone), len(idx.tuples))
return more
}
// runRefreshLoop builds the index once in full, then keeps it current from the
// dirty set, with a periodic full reconcile as a safety net.
//
// The reconcile exists because a *missed* dirty mark is silent: no error, just
// a record whose indexed copy quietly diverges from storage until something
// forces a rebuild. Marking is spread across eight write paths plus anything
// added later, and the cost of getting it wrong is invisible — so the index
// should not depend on that coverage being perfect forever. Every
// searchIndexReconcileEvery-th tick pays the old O(corpus) pass and repairs
// whatever drifted.
//
// That still drops the full rebuild from every 60 s to every 30 min — a ~30×
// reduction in the steady-state cost that measured at ~45 % of all Redis CPU —
// while keeping the self-healing property that made the naive loop safe.
func (idx *searchIndex) runRefreshLoop(stor *storage.Client) {
idx.refresh(stor)
ticker := time.NewTicker(searchIndexRefreshInterval)
defer ticker.Stop()
ticks := 0
for range ticker.C {
ticks++
if ticks%searchIndexReconcileEvery == 0 {
idx.refresh(stor)
continue
}
// Drain until caught up, so a burst larger than one batch does not take
// one interval per batch to land.
for idx.refreshIncremental(stor) {
}
}
}
// serveIndexedSearch answers a search from the in-memory index: a token-AND
// match against each record's haystack (or a whole-query hashID substring,
// preserving the legacy behaviour) AND every structured filter, then returns
// the full metadata for the <=limit survivors. No Redis round-trip per scanned
// record — matching and results both come from memory.
// Until 2026-10 this ran against an in-memory copy of every record, rebuilt
// from Redis; that copy cost ~30 KB of RAM per record (5.1 GB on watch.nsl.sh)
// and is gone. The matchers did not change, so neither did the semantics.
//
// This is the ONLY search path. Free text, filters, both, or neither all land
// here; only the `hashId` exact-match branch in handleSearchMetadata bypasses it.
func (s *Server) serveIndexedSearch(w http.ResponseWriter, req MetadataSearchRequest) {
tuples, ready := s.search.snapshot()
if !ready {
// First call before the background build finished: warm inline with the
// efficient batched read (seconds), never the per-record SCAN fallback.
s.search.refresh(s.storage)
tuples, _ = s.search.snapshot()
}
tokens := strings.Fields(strings.ToLower(req.Query))
queryLower := strings.ToLower(req.Query)
filters := normalizedFilters(req)
results := make([]MetadataSearchResult, 0, req.Limit)
for _, t := range tuples {
if len(results) >= req.Limit {
break
}
narrow := make([]storage.SearchFilter, 0, len(filters))
for _, f := range filters {
narrow = append(narrow, storage.SearchFilter{Property: f.Property, Values: f.Values})
}
hits, err := s.storage.Search(req.Query, narrow, req.Limit, func(hashID string, fields map[string]string) bool {
if req.Query != "" &&
!matchAllTokens(t.Haystack, tokens) &&
!strings.Contains(strings.ToLower(t.HashID), queryLower) {
continue
!matchAllTokens(storage.Haystack(fields), tokens) &&
!strings.Contains(strings.ToLower(hashID), queryLower) {
return false
}
if !recordMatchesFilters(t.Fields, filters) {
continue
}
// Full metadata is cached in the index — return it straight from memory,
// no Redis round-trip per result.
results = append(results, MetadataSearchResult{HashID: t.HashID, Metadata: t.Fields})
return recordMatchesFilters(fields, filters)
})
if err != nil {
writeError(w, http.StatusInternalServerError, "search failed: "+err.Error())
return
}
total, err := s.storage.CountRoots()
if err != nil {
total = 0
}
results := make([]MetadataSearchResult, 0, len(hits))
for _, h := range hits {
results = append(results, MetadataSearchResult{HashID: h.HashID, Metadata: h.Fields})
}
writeJSON(w, http.StatusOK, MetadataSearchResponse{
Results: results,
Count: len(results),
Total: len(tuples),
Total: int(total),
})
}
+20 -47
View File
@@ -39,7 +39,6 @@ type Server struct {
metaPublisher *events.MetaPublisher
schemaIndexer *schema.Indexer
webdavHandler *webdav.Handler
search *searchIndex
// challenges backs proof-of-possession on reveal/delete. In-process, so a
// restart invalidates the ones in flight — see identity/challenge.go.
challenges *identity.ChallengeStore
@@ -51,7 +50,7 @@ type Server struct {
router *mux.Router
server *http.Server
// stats caches the keyspace-walk half of GET /api/stats so a polling
// dashboard never puts a multi-second Redis walk on the request path.
// dashboard never puts a multi-second store walk on the request path.
stats *statsCache
}
@@ -71,7 +70,6 @@ func NewServer(
config: cfg,
leaderProvider: leaderProvider,
storage: stor,
search: &searchIndex{},
router: mux.NewRouter(),
stats: &statsCache{},
}
@@ -80,7 +78,7 @@ func NewServer(
var scanFunc mounts.ScanFunc
if cfg.EnableFileWatcher {
s.watcherDispatcher = watcher.NewDispatcher()
// Wire storage client for Redis Stream publishing
// Wire storage client for file:events publishing
if stor != nil {
s.watcherDispatcher.SetStorageClient(stor)
}
@@ -90,7 +88,7 @@ func NewServer(
} else {
s.fileWatcher = fileWatcher
// Wire storage so the watcher can persist (path,size,mtime,cid)
// tuples to Redis as it computes midhashes.
// tuples to the store as it computes midhashes.
if stor != nil {
fileWatcher.SetStorageClient(stor)
}
@@ -112,7 +110,7 @@ func NewServer(
// Pass republish callback to handlers (handles nil metaPublisher case)
s.watchersHandlers = watchers.NewHandlers(watchersManager, s.watchersPoller, s.RepublishMetadata)
// Hydrate the in-memory state registry from Redis tuples so
// Hydrate the in-memory state registry from stored tuples so
// the first scan tick after boot can skip ComputeMidHash256
// for files whose (size, mtime) match.
configs := watchersManager.List()
@@ -123,7 +121,7 @@ func NewServer(
if loaded, skipped, err := s.fileWatcher.HydrateStateFromStorage(roots); err != nil {
log.Printf("[API] Warning: failed to hydrate watcher state: %v", err)
} else {
log.Printf("[API] Hydrated watcher state from Redis: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped)
log.Printf("[API] Hydrated watcher state from the store: %d files loaded, %d skipped (no matching watcher root)", loaded, skipped)
}
}
}
@@ -272,20 +270,8 @@ func (s *Server) setupRoutes() {
// without going through Authelia.
s.router.HandleFunc("/api/meta/{cid}", s.handleGetMetaByCID).Methods("GET")
// Admin: reunify stranded midhash-rooted entries into their UUID roots
// (the dual-root pattern caused by historical writes that bypassed CID
// resolution).
s.router.HandleFunc("/api/admin/migrate-dual-roots", s.handleMigrateDualRoots).Methods("POST")
// One-shot vocabulary sweep: domain film|tv → screen, workForm backfill
// (METADATA_KEYS.md §14.17). Idempotent.
s.router.HandleFunc("/api/admin/migrate-domain-screen", s.handleMigrateDomainScreen).Methods("POST")
s.router.HandleFunc("/api/admin/migrate-user-cids", s.handleMigrateUserCids).Methods("POST")
// One-shot sweep of query echoes on literature cards (meta-read
// reading-model.md §7). Dry-run unless ?apply=true. Idempotent.
s.router.HandleFunc("/api/admin/sweep-literature-echoes", s.handleSweepLiteratureEchoes).Methods("POST")
// SSE event streams. Mediated mirror of the Redis Streams; consumers
// reach them over HTTP instead of touching Redis directly. Auth-bypass
// SSE event streams. The only way consumers see meta-core's change
// streams — they never touch the database. Auth-bypass
// applies but exposure should be inside-only (do NOT proxy through
// Caddy publicly). See docs/api-mediated-access.md "Auth".
s.router.HandleFunc("/api/events/files", s.handleEventsFiles).Methods("GET")
@@ -363,44 +349,31 @@ func (s *Server) Start() error {
s.mountStatsPoller.Start()
}
// Build + periodically refresh the in-memory search index so
// POST /api/metadata/search matches in memory instead of a Redis
// round-trip per record (see search_index.go).
if s.storage != nil {
go s.search.runRefreshLoop(s.storage)
}
// Start meta publisher (if storage is connected)
// Publishes keyspace notifications to meta:events stream
// Start meta publisher (if storage is connected). Live change events are
// written by the storage engine itself; the publisher owns the bulk
// republish below.
if s.storage != nil && s.storage.IsConnected() {
s.metaPublisher = events.NewMetaPublisher(s.storage.GetRedisClient())
s.metaPublisher = events.NewMetaPublisher(s.storage)
if err := s.metaPublisher.Start(); err != nil {
log.Printf("[API] Warning: failed to start meta publisher: %v", err)
} else {
// Schema indexer consumes the same stream to derive a live
// per-field schema (type/hint/breakdown). Start it before the
// republish flood so it captures the bootstrap events.
s.schemaIndexer = schema.NewIndexer(s.storage.GetRedisClient(), s.storage.GetPrefix())
// Schema indexer: per-field schema (type/hint/breakdown) from a
// periodic scan of the store.
s.schemaIndexer = schema.NewIndexer(s.storage.Store())
if err := s.schemaIndexer.Start(); err != nil {
log.Printf("[API] Warning: failed to start schema indexer: %v", err)
s.schemaIndexer = nil
}
// Republish events for all existing metadata
// Republish events for all existing metadata. Consumers that
// rebuild by replaying meta:events from the beginning (meta-fuse)
// depend on it.
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)
if n, err := s.storage.RebuildSearchIfEmpty(); err != nil {
log.Printf("[API] Warning: search row rebuild failed: %v", err)
} else if n > 0 {
log.Printf("[API] Warmed %d field indexes in %s", n, time.Since(started).Round(time.Millisecond))
log.Printf("[API] Rebuilt search rows for %d records in %s", n, time.Since(started).Round(time.Millisecond))
}
count, err := s.RepublishMetadata()
+1 -1
View File
@@ -103,7 +103,7 @@ func (s *Server) handleSnapshotImport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
// handleSnapshotWipe clears Redis metadata.
// handleSnapshotWipe clears all stored metadata.
// POST /api/snapshot/wipe?scope=metadata
func (s *Server) handleSnapshotWipe(w http.ResponseWriter, r *http.Request) {
if !s.storage.IsConnected() {
+72 -87
View File
@@ -3,7 +3,6 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -11,15 +10,17 @@ import (
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/metazla/meta-core/internal/kv"
)
// SSE event stream endpoints for the file:events and meta:events Redis Streams.
// SSE event stream endpoints for the file:events and meta:events streams.
//
// Wire contract (see docs/api-mediated-access.md):
// - One SSE event per Redis Stream entry.
// - `id:` is the opaque Redis Stream entry ID (`<ms>-<seq>`); consumers
// MUST treat it as a black-box string they echo back via Last-Event-ID.
// - One SSE event per stream entry.
// - `id:` is the opaque stream entry ID (`<ms>-<seq>`, the Redis Streams
// form, kept across the move to SQLite so cursors consumers persisted to
// disk still compare correctly); consumers MUST treat it as a black-box
// string they echo back via Last-Event-ID.
// - `event:` is the `type` field from the underlying entry.
// - `data:` is the JSON-encoded rest of the entry (all fields except `type`).
// - Heartbeats are SSE comment lines (`:keep-alive\n\n`) every 30s of silence.
@@ -30,30 +31,30 @@ import (
const (
sseHeartbeatInterval = 30 * time.Second
// sseReadBlock controls how long each XREAD blocks before returning.
// sseReadBlock controls how long each read waits for new entries.
// Much shorter than sseHeartbeatInterval so the handler stays responsive
// to client disconnects and the heartbeat check runs frequently enough
// that the keep-alive fires within a few seconds of its nominal cadence.
sseReadBlock = 2 * time.Second
// sseReadCount caps how many entries one XREAD returns. Small enough
// sseReadCount caps how many entries one read returns. Small enough
// to keep per-flush memory low, large enough to drain bursts cheaply.
sseReadCount = 100
)
// handleEventsFiles streams the file:events Redis Stream as SSE.
// handleEventsFiles streams the file:events stream as SSE.
func (s *Server) handleEventsFiles(w http.ResponseWriter, r *http.Request) {
s.streamSSE(w, r, "file:events")
}
// handleEventsMeta streams the meta:events Redis Stream as SSE.
// handleEventsMeta streams the meta:events stream as SSE.
func (s *Server) handleEventsMeta(w http.ResponseWriter, r *http.Request) {
s.streamSSE(w, r, "meta:events")
}
// streamSSE is the shared SSE pump shared by both endpoints. One handler per
// stream — there is no fan-out; each subscriber holds its own XREAD loop.
// That's fine at our scale (≤20 subscribers; one per consumer service).
// streamSSE is the SSE pump shared by both endpoints. Each subscriber reads
// the stream itself and sleeps on the store's commit broadcast between reads —
// fine at our scale (≤20 subscribers; one per consumer service).
func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string) {
if !s.storage.IsConnected() {
w.Header().Set("Retry-After", "5")
@@ -89,14 +90,14 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
w.WriteHeader(http.StatusOK)
flusher.Flush()
client := s.storage.GetRedisClient()
if client == nil {
store := s.storage.Store()
if store == nil {
// Storage was just disconnected — close cleanly.
return
}
ctx := r.Context()
cursor := resolveStartCursor(ctx, client, stream, r.Header.Get("Last-Event-ID"), w, flusher)
cursor := resolveStartCursor(ctx, store, stream, r.Header.Get("Last-Event-ID"), w, flusher)
lastWrite := time.Now()
for {
@@ -104,37 +105,34 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
return
}
// Bounded BLOCK lets us re-check ctx and emit heartbeats. The
// context's deadline isn't propagated into XREAD's BLOCK arg —
// that's a poll-style timeout on Redis itself — so we keep BLOCK
// short.
readCtx, cancel := context.WithTimeout(ctx, sseReadBlock+2*time.Second)
result, err := client.XRead(readCtx, &redis.XReadArgs{
Streams: []string{stream, cursor},
Block: sseReadBlock,
Count: sseReadCount,
}).Result()
cancel()
// Take the wake-up channel BEFORE reading, so an append landing
// between the read and the wait still wakes us.
wake := store.Wait(stream)
entries, err := store.ReadAfter(ctx, stream, cursor, sseReadCount)
if err != nil {
// redis.Nil = BLOCK timeout with no entries. Anything else
// while the client is still connected is a real failure.
if errors.Is(err, redis.Nil) || isContextTimeout(err) {
// fall through to heartbeat check
} else if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return
} else {
log.Printf("[SSE %s] XREAD error: %v", stream, err)
if ctx.Err() != nil {
return
}
log.Printf("[SSE %s] read error: %v", stream, err)
return
}
for _, str := range result {
for _, msg := range str.Messages {
writeSSEEntry(w, flusher, msg)
cursor = msg.ID
lastWrite = time.Now()
for _, e := range entries {
writeSSEEntry(w, flusher, e)
cursor = e.ID
lastWrite = time.Now()
}
if len(entries) == 0 {
timer := time.NewTimer(sseReadBlock)
select {
case <-ctx.Done():
timer.Stop()
return
case <-wake:
case <-timer.C:
}
timer.Stop()
}
// Heartbeat if quiet for >= the interval. Comment line; SSE
@@ -150,38 +148,44 @@ func (s *Server) streamSSE(w http.ResponseWriter, r *http.Request, stream string
}
// resolveStartCursor decides where to start reading.
// - No Last-Event-ID → start at "$" (only new entries).
// - No Last-Event-ID → the stream's last issued ID, resolved ONCE here (only
// entries appended from now on). Redis' "$" was re-resolved on every
// XREAD, which dropped anything appended between two reads.
// - Last-Event-ID provided AND still in stream → resume from it.
// - Last-Event-ID provided BUT trimmed → emit one synthetic `gap` event
// and resume from the oldest available entry's predecessor "0", so the
// next XREAD picks up everything still in the stream.
// next read picks up everything still in the stream.
//
// The "still in stream" check is cheap: one XRANGE for the first entry.
func resolveStartCursor(ctx context.Context, client *redis.Client, stream, lastID string, w http.ResponseWriter, flusher http.Flusher) string {
if lastID == "" {
return "$"
}
// One-entry XRANGE to find the oldest ID currently in the stream.
// The "still in stream" check is cheap: one read of the first entry.
func resolveStartCursor(ctx context.Context, store *kv.Store, stream, lastID string, w http.ResponseWriter, flusher http.Flusher) string {
xCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
entries, err := client.XRangeN(xCtx, stream, "-", "+", 1).Result()
if lastID == "" {
head, err := store.LastID(xCtx, stream)
if err != nil {
log.Printf("[SSE %s] head lookup failed: %v (starting from the beginning)", stream, err)
return "0"
}
return head
}
oldestEntry, ok, err := store.Oldest(xCtx, stream)
if err != nil {
// Treat any error as "stream not available right now" and resume
// from the cursor — XREAD will either find it or block until new
// entries arrive.
log.Printf("[SSE %s] XRANGE probe failed: %v (resuming from cursor)", stream, err)
// from the cursor — the read either finds it or waits for new entries.
log.Printf("[SSE %s] oldest-entry probe failed: %v (resuming from cursor)", stream, err)
return lastID
}
if len(entries) == 0 {
if !ok {
// Stream is empty. Cursor may be from a previous incarnation; safest
// is to resume from the cursor — XREAD against an empty stream
// returns nothing and blocks until new entries arrive.
// is to resume from the cursor — reading an empty stream returns
// nothing and waits until new entries arrive.
return lastID
}
oldest := entries[0].ID
oldest := oldestEntry.ID
if compareStreamIDs(lastID, oldest) >= 0 {
// Cursor is at or past the oldest — fine to resume.
return lastID
@@ -197,39 +201,35 @@ func resolveStartCursor(ctx context.Context, client *redis.Client, stream, lastI
fmt.Fprintf(w, "event: gap\ndata: %s\n\n", string(payload))
flusher.Flush()
// Return "0" so the next XREAD picks up entries starting from the
// Return "0" so the next read picks up entries starting from the
// stream's true minimum — including `oldest` itself.
return "0"
}
// writeSSEEntry serialises one Redis Stream entry as an SSE event.
// writeSSEEntry serialises one stream entry as an SSE event.
//
// `event:` comes from the entry's `type` field (no prefix munging). `data:`
// is a JSON object containing all OTHER fields. Field values from the Redis
// stream are interface{} (typically strings); we encode whatever the stream
// holds.
func writeSSEEntry(w http.ResponseWriter, flusher http.Flusher, msg redis.XMessage) {
// is a JSON object containing all OTHER fields.
func writeSSEEntry(w http.ResponseWriter, flusher http.Flusher, e kv.Entry) {
eventType := "message"
data := make(map[string]interface{}, len(msg.Values))
for k, v := range msg.Values {
data := make(map[string]string, len(e.Values))
for k, v := range e.Values {
if k == "type" {
if s, ok := v.(string); ok {
eventType = s
continue
}
eventType = v
continue
}
data[k] = v
}
payload, err := json.Marshal(data)
if err != nil {
log.Printf("[SSE] failed to marshal entry %s: %v", msg.ID, err)
log.Printf("[SSE] failed to marshal entry %s: %v", e.ID, err)
return
}
// Spec: each event ends with a blank line. id/event/data lines are
// terminated with single \n.
fmt.Fprintf(w, "id: %s\n", msg.ID)
fmt.Fprintf(w, "id: %s\n", e.ID)
fmt.Fprintf(w, "event: %s\n", eventType)
fmt.Fprintf(w, "data: %s\n\n", string(payload))
flusher.Flush()
@@ -263,18 +263,3 @@ func parseStreamID(id string) (int64, int64) {
}
return ms, seq
}
// isContextTimeout returns true for the context deadline error wrapped by
// go-redis when XREAD's read context fires. We treat it the same as
// redis.Nil — i.e. an empty poll cycle, not a fatal error.
func isContextTimeout(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
// go-redis sometimes wraps this in its own error type. String match is
// a safety net.
return strings.Contains(err.Error(), "context deadline exceeded")
}
+6 -4
View File
@@ -27,8 +27,10 @@ type fileStatsJSON struct {
// dashboard shows, in one request.
//
// Deliberately split by cost. Records/RedisKeys/RedisMemory/Identities are
// O(1) commands (SCARD, DBSIZE, INFO) plus a directory listing, recomputed on
// every call. Files and UDLUsers need a keyspace walk — ~0.5s for the file
// cheap (an index range count, a cached row count, a file stat) plus a
// directory listing, recomputed on every call. The `redis*` JSON names
// predate the move to SQLite and are kept for dashboard compatibility:
// redisKeys is the store's row count, redisMemory its on-disk size. Files and UDLUsers need a keyspace walk — ~0.5s for the file
// tuples and ~3s for the UDL SCAN on an 88k-record box — so they are served
// from a background-refreshed cache and are null until the first sweep lands.
type StatsResponse struct {
@@ -47,7 +49,7 @@ type StatsResponse struct {
// statsCache holds the expensive half of /api/stats.
//
// Why a cache and not just "compute it": the dashboard polls. A 3s Redis walk
// Why a cache and not just "compute it": the dashboard polls. A multi-second store walk
// per tick, per open tab, is a self-inflicted outage on a box that is also
// serving playback — the same shape as meta-sort's /api/stats full sweep. So
// the handler NEVER waits on a walk: it returns whatever the last sweep
@@ -86,7 +88,7 @@ func (c *statsCache) refreshIfStale(sweep func() (*fileStatsJSON, *int)) bool {
c.running = false
// A failed sweep leaves the previous numbers in place rather than
// blanking the tiles; sweptAt still advances so a hard-failing walk
// (Redis down) does not spin once per poll.
// (store down) does not spin once per poll.
c.sweptAt = time.Now()
if files != nil {
c.files = files
+19
View File
@@ -0,0 +1,19 @@
package api
import (
"path/filepath"
"testing"
"github.com/metazla/meta-core/internal/storage"
)
// newTestStorage opens a fresh metadata store in the test's temp dir.
func newTestStorage(t *testing.T) *storage.Client {
t.Helper()
c := storage.NewClient()
if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c
}
+3 -3
View File
@@ -7,7 +7,7 @@ package api
// meta-watch's local op-log. Records arrive already signed (and, for
// private-tier keys, already encrypted) as opaque base64 CBOR blobs; meta-core
// version-gates and indexes them but never inspects the crypto. See
// internal/storage/udl.go for the Redis model.
// internal/storage/udl.go for the key model.
import (
"encoding/json"
@@ -65,7 +65,7 @@ type UDLPutBatchResponse struct {
Results []UDLPutBatchResult `json:"results"`
}
// maxUDLBatch bounds one batch. Each record is a Redis round trip while the
// maxUDLBatch bounds one batch. Each record is a store write while the
// request is held open; sized to match the sign batch on the other side of the
// same operation (see maxSignBatch).
const maxUDLBatch = 1000
@@ -126,7 +126,7 @@ func (s *Server) handleUDLRecordGet(w http.ResponseWriter, r *http.Request) {
Exists: exists,
}
// json.Valid guards the encoder: a malformed `value` hash field (hand-edited
// Redis, a future writer bug) would otherwise fail mid-encode and emit a
// the store, a future writer bug) would otherwise fail mid-encode and emit a
// truncated body instead of a readable error.
if exists && cell.HasValue && json.Valid([]byte(cell.Value)) {
raw := json.RawMessage(cell.Value)
+1 -11
View File
@@ -6,9 +6,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/storage"
)
// newUDLTestServer returns a Server wired to an in-memory Redis.
@@ -18,14 +15,7 @@ import (
// which the UDL handlers touch — they use s.storage and nothing else.
func newUDLTestServer(t *testing.T) *Server {
t.Helper()
mr := miniredis.RunT(t)
c := storage.NewClient("")
if err := c.Connect("redis://" + mr.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return &Server{storage: c}
return &Server{storage: newTestStorage(t)}
}
// getRecord drives handleUDLRecordGet and decodes the body into raw JSON