feat(notes): compact the operation log and purge old tombstones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s
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:
1 parent
aea4324c20
commit
f26f286fb8
17 files changed
+653
-48
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user