Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/memory/store/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
}
}
Expand Down
52 changes: 52 additions & 0 deletions internal/memory/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading