From a4faa7249af7179ebbccdf500bfc2fb8f87a0920 Mon Sep 17 00:00:00 2001 From: Audrey Tang Date: Wed, 12 Aug 2026 09:33:45 +0800 Subject: [PATCH] fix(store): record an oplog entry for every auto-pruned insight Auto-prune soft-deletes insights and hard-deletes their edges, but was the only mutation path that wrote nothing to the oplog. Every other mutation (remember, forget, link, diff-replace, diff-skip, embed) records one, so the oplog reads as a complete history when it is not: a store that has shed insights offers no way to discover which ones, when, or why. This is easy to hit. AutoPrune runs on every remember once the active count exceeds MaxInsights (default 1000), pruning up to PruneBatchSize per call. A store that has been over capacity for a while can quietly lose thousands of insights, and the loss is only visible as a count that stopped growing. Log one entry per pruned id, with the active count and the cap that triggered it, so the oplog can be used as a recovery index. No behavior change beyond the added log line. --- internal/memory/store/node.go | 6 ++++ internal/memory/store/store_test.go | 52 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/internal/memory/store/node.go b/internal/memory/store/node.go index 69e67539..a7b9b0cb 100644 --- a/internal/memory/store/node.go +++ b/internal/memory/store/node.go @@ -477,6 +477,12 @@ func (db *DB) autoPrune(maxInsights int, excludeIDs []string) (int, error) { if err := db.DeleteEdgesByNode(id); err != nil { return pruned, fmt.Errorf("delete edges for pruned %s: %w", id, err) } + // Auto-prune is the only destructive path that leaves no trace: + // every other write the CLI makes -- remember, forget, link, + // import, embed -- records an oplog entry. Without this, a store + // can silently lose thousands of insights with no way to find out + // which, when, or why. + db.LogOp("prune", id, fmt.Sprintf("auto-prune: over capacity (active=%d, max=%d)", total, maxInsights)) pruned++ } } diff --git a/internal/memory/store/store_test.go b/internal/memory/store/store_test.go index 21067abf..f2b19761 100644 --- a/internal/memory/store/store_test.go +++ b/internal/memory/store/store_test.go @@ -478,6 +478,58 @@ func TestAutoPrune_PrunesLowestEI(t *testing.T) { } } +// Auto-prune is destructive and, before this test, was the only mutation that +// left no oplog entry — a store could silently shed thousands of insights with +// no record of which ones. Every pruned id must be recoverable from the oplog. +func TestAutoPrune_RecordsOplogEntryPerPrunedInsight(t *testing.T) { + db := testDB(t) + + for i := range 5 { + db.InsertInsight(makeInsight("audit-"+string(rune('a'+i)), "content", 2)) + } + + pruned, err := db.AutoPrune(3, nil) + if err != nil { + t.Fatalf("auto prune: %v", err) + } + if pruned != 2 { + t.Fatalf("want 2 pruned, got %d", pruned) + } + + entries, err := db.GetOplog(50) + if err != nil { + t.Fatalf("get oplog: %v", err) + } + logged := map[string]bool{} + for _, e := range entries { + if e.Operation == "prune" { + logged[e.InsightID] = true + if e.Detail == "" { + t.Errorf("prune entry for %s has empty detail", e.InsightID) + } + } + } + if len(logged) != pruned { + t.Errorf("want %d prune oplog entries, got %d", pruned, len(logged)) + } + + // Every id recorded as pruned must name a row that still exists and is + // soft-deleted, so the oplog can be trusted as the recovery index. Asking + // only whether the id is inactive is not the same claim: a lookup that + // excludes deleted rows also returns nothing for an id that was never in + // the store, so a fabricated entry would satisfy it. + for id := range logged { + ins, err := db.GetInsightByIDIncludeDeleted(id) + if err != nil { + t.Errorf("oplog names %s, but no such insight exists: %v", id, err) + continue + } + if ins.DeletedAt == nil { + t.Errorf("oplog claims %s pruned but it is still active", id) + } + } +} + func TestAutoPrune_RespectsImmune(t *testing.T) { db := testDB(t)