feat(notes): compact the operation log and purge old tombstones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s

- Background job at start and every 6 h: log entries older than 30 days
  (private notes: 7) are removed, documents.log_floor marks the boundary;
  clients behind it get resync and keep their queued changes
- Deleted blocks are purged 30 days after deleted_at via a "purge" log
  effect, so open editors drop them without reloading; a tombstone stays
  while a deleted child still points to it
- Text for a purged block becomes a new block at the end; inserts under a
  purged parent go to the top level (server and browser model)
- doc_op_results kept for 90 days; expired lease/session maps are swept
- Tests: storage, API, engine resync, simulation with compactions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 15:08:13 +01:00
1 parent aea4324c20
commit f26f286fb8
17 files changed
+653 -48

No files matched your search

+76 -16
View File
@@ -1,6 +1,7 @@
package storage
import (
"errors"
"fmt"
"math/rand"
"sort"
@@ -25,15 +26,18 @@ type simClient struct {
}
type simWorld struct {
t *testing.T
r *rand.Rand
store *Store
docID string
leases *collab.LeaseManager
now time.Time
clients []*simClient
sent map[string]collab.Op // every op ever sent, by ID
results map[string]collab.Result // first result received per op ID
t *testing.T
r *rand.Rand
store *Store
docID string
leases *collab.LeaseManager
now time.Time
clients []*simClient
sent map[string]collab.Op // every op ever sent, by ID
results map[string]collab.Result // first result received per op ID
fullLog []DocLogEntry // copy of the log taken before each compaction, for the final checks
logSeen int64
compactions int
}
func (w *simWorld) newOpID(c *simClient) string {
@@ -209,9 +213,17 @@ func (w *simWorld) serverSync(c *simClient, batch []collab.Op, holds []string) [
func (w *simWorld) pull(c *simClient) {
for {
entries, head, err := w.store.GetDocOpsSince(w.docID, c.lastSeq, 7)
if errors.Is(err, ErrLogCompacted) {
// Too far behind: reload the snapshot, unsent operations stay queued
w.loadSnapshot(c)
continue
}
if err != nil {
w.t.Fatalf("GetDocOpsSince: %v", err)
}
if len(entries) == 0 && c.lastSeq < head {
w.t.Fatalf("%s: stuck at seq %d, head %d, no log entries", c.actor.ClientID, c.lastSeq, head)
}
for _, e := range entries {
if e.Seq != c.lastSeq+1 {
w.t.Fatalf("%s: log gap, expected seq %d got %d", c.actor.ClientID, c.lastSeq+1, e.Seq)
@@ -225,6 +237,44 @@ func (w *simWorld) pull(c *simClient) {
}
}
func (w *simWorld) loadSnapshot(c *simClient) {
snap, err := w.store.GetDocSnapshot(w.docID)
if err != nil {
w.t.Fatal(err)
}
c.confirmed = collab.NewDoc()
c.confirmed.Title, c.confirmed.TitleVersion = snap.Title, snap.TitleVersion
for _, b := range snap.Blocks {
b := b
c.confirmed.Blocks[b.ID] = &b
}
c.lastSeq = snap.HeadSeq
}
// captureLog copies log entries the simulation has not seen yet, before compaction removes them.
func (w *simWorld) captureLog() {
entries, _, err := w.store.GetDocOpsSince(w.docID, w.logSeen, 1_000_000)
if err != nil {
w.t.Fatalf("captureLog: %v", err)
}
for _, e := range entries {
w.fullLog = append(w.fullLog, e)
w.logSeen = e.Seq
}
}
// compact ages the whole log and every tombstone past their retention and runs the compaction job.
func (w *simWorld) compact() {
w.captureLog()
old := time.Now().Add(-40 * 24 * time.Hour).UTC().Format(time.RFC3339)
w.store.db.Exec(`UPDATE doc_ops SET created_at = ? WHERE doc_id = ?`, old, w.docID)
w.store.db.Exec(`UPDATE blocks SET deleted_at = ? WHERE document_id = ? AND deleted = 1`, old, w.docID)
if _, err := w.store.CompactDocs(time.Now()); err != nil {
w.t.Fatalf("CompactDocs: %v", err)
}
w.compactions++
}
// sync runs one round trip over the unreliable (or, with reliable=true, a perfect) network.
func (w *simWorld) sync(c *simClient, reliable bool) {
if c.inflight == nil {
@@ -299,8 +349,10 @@ func runSimulation(t *testing.T, seed int64, steps int) {
}
case n < 95:
c.offline = !c.offline
default:
case n < 99:
w.now = w.now.Add(time.Duration(w.r.Intn(25)) * time.Second) // leases may expire
default:
w.compact()
}
}
@@ -339,12 +391,14 @@ func runSimulation(t *testing.T, seed int64, steps int) {
if stored != len(w.sent) {
t.Fatalf("seed %d: %d operations sent but %d results stored", seed, len(w.sent), stored)
}
log, _, _ := store.GetDocOpsSince(doc.ID, 0, 1_000_000)
w.captureLog()
log := w.fullLog
bySeq := map[int64]collab.Effect{}
inserted := map[string]string{}
for _, e := range log {
bySeq[e.Seq] = e.Effect
if e.Effect.Kind == collab.OpInsert {
// First insert only: a purged block that is edited again comes back with the same ID
if _, seen := inserted[e.Effect.BlockID]; e.Effect.Kind == collab.OpInsert && !seen {
inserted[e.Effect.BlockID] = e.Effect.Content
}
}
@@ -356,12 +410,18 @@ func runSimulation(t *testing.T, seed int64, steps int) {
}
switch op.Kind {
case collab.OpInsert, collab.OpSetContent, collab.OpSetTitle:
if res.Status == collab.StatusRejected {
// Clearing the text of a block that was purged meanwhile has nothing left to keep
emptied := op.Kind == collab.OpSetContent && *op.Content == "" && res.Reason == "missing"
if res.Status == collab.StatusRejected && !emptied {
t.Fatalf("seed %d: text-carrying op %s was rejected: %+v", seed, id, res)
}
}
if op.Kind == collab.OpSetContent && res.Status == collab.StatusApplied && res.Seq > 0 {
if e := bySeq[res.Seq]; e.Kind != collab.OpSetContent || e.Content != *op.Content {
want := collab.OpSetContent
if res.Reason == "revived" { // block was purged: the text came back as a new block
want = collab.OpInsert
}
if e := bySeq[res.Seq]; e.Kind != want || e.Content != *op.Content {
t.Fatalf("seed %d: applied text of %s not in the log", seed, id)
}
}
@@ -374,11 +434,11 @@ func runSimulation(t *testing.T, seed int64, steps int) {
text = *op.Title
}
if got, ok := inserted[res.ConflictBlockID]; !ok || got != text {
t.Fatalf("seed %d: conflict copy of %s missing or wrong", seed, id)
t.Fatalf("seed %d: conflict copy of %s missing or wrong: got %q (present %v), want %q", seed, id, got, ok, text)
}
}
}
t.Logf("seed %d: %d ops, %d log entries, %d conflict copies, %d blocks", seed, len(w.sent), len(log), conflicts, len(server.Blocks))
t.Logf("seed %d: %d ops, %d log entries, %d conflict copies, %d blocks, %d compactions", seed, len(w.sent), len(log), conflicts, len(server.Blocks), w.compactions)
}
func docsEqual(a, b *collab.Doc) bool {