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
Build and Push Docker Image / build (push) Successful in 2m44s
This commit is contained in:
+105
-91
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user