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
+105 -91
View File
@@ -1,111 +1,122 @@
package storage
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/metazla/meta-core/internal/kv"
)
// newTestClient spins an in-memory Redis and returns a connected Client with
// the given namespace prefix. The server is torn down with the test.
func newTestClient(t *testing.T, prefix string) (*Client, *miniredis.Miniredis) {
// newTestClient opens a fresh database in the test's temp dir.
func newTestClient(t *testing.T) *Client {
t.Helper()
s := miniredis.RunT(t)
c := NewClient(prefix)
if err := c.Connect("redis://" + s.Addr()); err != nil {
t.Fatalf("connect to miniredis: %v", err)
c := NewClient()
if err := c.Connect(filepath.Join(t.TempDir(), "meta-core.db")); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = c.Close() })
return c, s
return c
}
// allKeys lists every row (test helper; full scan).
func allKeys(t *testing.T, c *Client) []string {
t.Helper()
var keys []string
if err := c.Store().ScanPrefix(context.Background(), "", 1000, func(rows []kv.KV) error {
for _, r := range rows {
keys = append(keys, r.K)
}
return nil
}); err != nil {
t.Fatalf("scan: %v", err)
}
return keys
}
// TestClearAllMetadata_PurgesCIDReverseIndex is the regression test for the
// leak: ClearAllMetadata used to delete every file:<root>/* key and the
// file:__index__ set, but left the cid:<cid> → root reverse-index entries
// behind. Those stale aliases are not cosmetic — ResolveRoot consults them, so
// a surviving entry silently redirects a later write into a deleted root.
// leak: ClearAllMetadata used to delete every record key and the root index,
// but left the cid:<cid> → root reverse-index entries behind. Those stale
// aliases are not cosmetic — ResolveRoot consults them, so a surviving entry
// silently redirects a later write into a deleted root.
func TestClearAllMetadata_PurgesCIDReverseIndex(t *testing.T) {
for _, prefix := range []string{"", "mm:"} {
name := "no-prefix"
if prefix != "" {
name = "prefixed"
c := newTestClient(t)
// Two records, each with several CID aliases (a midhash plus siblings) —
// exactly what the watcher produces.
aliases := map[string][]string{}
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
if err != nil {
t.Fatalf("mint: %v", err)
}
t.Run(name, func(t *testing.T) {
c, s := newTestClient(t, prefix)
cids := []string{"bafkmid" + uuid, "bafkfull" + uuid}
for _, cidStr := range cids {
if err := c.AddAlias(uuid, cidStr); err != nil {
t.Fatalf("add alias %s: %v", cidStr, err)
}
}
if _, err := c.AddDuplicatePath(uuid, "watch/copy-"+f); err != nil {
t.Fatalf("add duplicate: %v", err)
}
aliases[uuid] = cids
}
// Two records, each with several CID aliases (a midhash plus
// siblings) — exactly what the watcher produces.
aliases := map[string][]string{}
for i, f := range []string{"watch/a.mkv", "watch/b.mkv"} {
uuid, err := c.Mint(f, int64(100+i), int64(1000+i))
if err != nil {
t.Fatalf("mint: %v", err)
}
cids := []string{
"bafkmid" + uuid,
"bafkfull" + uuid,
}
for _, cidStr := range cids {
if err := c.AddAlias(uuid, cidStr); err != nil {
t.Fatalf("add alias %s: %v", cidStr, err)
}
}
aliases[uuid] = cids
}
// An entry already orphaned by a previous (buggy) clear: a reverse-index
// key whose root no longer exists. The sweep is expected to collect it too.
if err := c.SetRawValue(CIDIndexPrefix+"bafkorphan", "01OLDDEADROOT"); err != nil {
t.Fatalf("seed orphan: %v", err)
}
// An entry already orphaned by a previous (buggy) clear: a
// reverse-index key whose root no longer exists. The sweep is
// expected to collect it too.
if err := s.Set(c.buildCIDIndexKey("bafkorphan"), "01OLDDEADROOT"); err != nil {
t.Fatalf("seed orphan: %v", err)
for uuid, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != uuid {
t.Fatalf("pre-clear ResolveRoot(%s) = %q, want %q", cidStr, got, uuid)
}
}
}
// Sanity: the aliases resolve before the clear.
for uuid, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != uuid {
t.Fatalf("pre-clear ResolveRoot(%s) = %q, want %q", cidStr, got, uuid)
}
}
}
deleted, err := c.ClearAllMetadata()
if err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
if deleted != 2 {
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
}
deleted, err := c.ClearAllMetadata()
if err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
// deletedCount counts files, not keys.
if deleted != 2 {
t.Errorf("deletedCount = %d, want 2 (files, not keys)", deleted)
// Nothing record-shaped may survive: not the records, not their aliases,
// duplicates or search rows, not the pre-existing orphan. (The event
// streams and the schema sentinel are not metadata and stay.)
for _, k := range allKeys(t, c) {
for _, ns := range []string{recordNS, CIDIndexPrefix, rootsNS, ftsNS, dupNS} {
if strings.HasPrefix(k, ns) {
t.Errorf("key survived the clear: %q", k)
}
}
}
var ftsRows int
if err := c.Store().DB().QueryRow(`SELECT count(*) FROM fts`).Scan(&ftsRows); err != nil || ftsRows != 0 {
t.Errorf("fts rows after clear = %d (err %v), want 0", ftsRows, err)
}
// No reverse-index key may survive — not for the cleared records,
// not the pre-existing orphan.
if leaked := s.Keys(); len(leaked) != 0 {
t.Errorf("keys survived the clear: %v", leaked)
for _, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != cidStr {
t.Errorf("post-clear ResolveRoot(%s) = %q, want the hash unchanged (stale alias)", cidStr, got)
}
// And the aliases must no longer resolve: ResolveRoot returns the
// hash unchanged when there is no index entry.
for _, cids := range aliases {
for _, cidStr := range cids {
if got := c.ResolveRoot(cidStr); got != cidStr {
t.Errorf("post-clear ResolveRoot(%s) = %q, want the hash unchanged (stale alias)", cidStr, got)
}
}
}
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
}
})
}
}
if got, err := c.GetByCID("bafkorphan"); err != nil || got != "" {
t.Errorf("post-clear GetByCID(bafkorphan) = %q (err %v), want \"\"", got, err)
}
}
// TestClearAllMetadata_LeavesForeignKeysAlone guards the sweep's blast radius:
// it must key off buildCIDIndexKey (namespace-aware), not a hardcoded "cid:",
// so a clear on a prefixed client cannot reach another namespace's index.
// keys outside the metadata namespaces (magnet cache, coverage markers, a key
// that merely CONTAINS "cid:") must survive a metadata wipe.
func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
c, s := newTestClient(t, "mm:")
c := newTestClient(t)
uuid, err := c.Mint("watch/a.mkv", 100, 1000)
if err != nil {
@@ -114,25 +125,28 @@ func TestClearAllMetadata_LeavesForeignKeysAlone(t *testing.T) {
if err := c.AddAlias(uuid, "bafkmine"); err != nil {
t.Fatalf("add alias: %v", err)
}
// Another namespace's reverse-index entry, plus an unrelated key.
if err := s.Set("other:cid:bafktheirs", "01THEIRROOT"); err != nil {
t.Fatalf("seed foreign cid: %v", err)
}
if err := s.Set("unrelated", "keep-me"); err != nil {
t.Fatalf("seed unrelated: %v", err)
for k, v := range map[string]string{"other:cid:bafktheirs": "01THEIRROOT", "btmagnet:abc": "magnet:?xt=urn:btih:abc", "unrelated": "keep-me"} {
if err := c.SetRawValue(k, v); err != nil {
t.Fatalf("seed %s: %v", k, err)
}
}
if _, err := c.ClearAllMetadata(); err != nil {
t.Fatalf("ClearAllMetadata: %v", err)
}
for _, k := range []string{"other:cid:bafktheirs", "unrelated"} {
if !s.Exists(k) {
t.Errorf("clear deleted foreign key %q — the cid sweep is not namespace-aware", k)
for _, k := range []string{"other:cid:bafktheirs", "btmagnet:abc", "unrelated"} {
if _, _, ok, _ := c.GetKeyInfo(k); !ok {
t.Errorf("clear deleted foreign key %q", k)
}
}
if s.Exists(c.buildCIDIndexKey("bafkmine")) {
if _, _, ok, _ := c.GetKeyInfo(CIDIndexPrefix + "bafkmine"); ok {
t.Errorf("own reverse-index entry survived the clear")
}
}
// withClient runs fn against a fresh client.
func withClient(t *testing.T, fn func(t *testing.T, c *Client)) {
t.Helper()
fn(t, newTestClient(t))
}