Files
MindSift/internal/storage/docsync_sim_test.go
T
sandrodachandClaude Opus 5.5 f26f286fb8
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m17s
feat(notes): compact the operation log and purge old tombstones
- 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>
2026-10-28 15:08:13 +01:00

469 lines
14 KiB
Go

package storage
import (
"errors"
"fmt"
"math/rand"
"sort"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/collab"
)
// The simulation drives several clients against the real store over an unreliable network and checks
// that everyone converges to the server state without losing text or applying anything twice.
type simClient struct {
actor collab.Actor
confirmed *collab.Doc
lastSeq int64
pending []collab.Op // not sent yet; base versions are filled in when a batch is formed
inflight []collab.Op // sent, response not received; resent unchanged (same op IDs)
holds map[string]bool
offline bool
counter int
}
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
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 {
c.counter++
return fmt.Sprintf("%s-%d", c.actor.ClientID, c.counter)
}
// localView is what the client's editor shows: confirmed state plus its own unconfirmed operations.
func (c *simClient) localView() *collab.Doc {
v := c.confirmed.Clone()
for _, op := range append(append([]collab.Op{}, c.inflight...), c.pending...) {
if op.Kind == collab.OpSetContent {
if b, ok := v.Blocks[op.BlockID]; ok {
op.BaseVersion = b.Version
}
}
if op.Kind == collab.OpSetTitle {
op.BaseVersion = v.TitleVersion
}
collab.Apply(v, op, c.actor, collab.Env{})
}
return v
}
func pick[T any](r *rand.Rand, list []T) (T, bool) {
var zero T
if len(list) == 0 {
return zero, false
}
return list[r.Intn(len(list))], true
}
// edit lets a client perform one random editor action on its local view.
func (w *simWorld) edit(c *simClient) {
v := c.localView()
flat := v.Flatten()
var target *collab.Block
if fb, ok := pick(w.r, flat); ok {
target = fb.Block
}
switch n := w.r.Intn(100); {
case n < 45 && target != nil: // typing
content := target.Content + fmt.Sprintf(" %s%d", c.actor.ClientID, c.counter)
// Base = the version the user sees while typing (like the browser engine)
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetContent, BlockID: target.ID, BaseVersion: target.Version, Content: &content})
c.holds[target.ID] = true
case n < 65: // Enter: new block after target (or first block)
parent, after := "", ""
if target != nil {
parent, after = target.ParentID, target.OrderKey
}
next := ""
for _, s := range v.Children(parent) {
if s.OrderKey > after {
next = s.OrderKey
break
}
}
key, err := collab.KeyBetween(after, next)
if err != nil {
return
}
id := fmt.Sprintf("blk_%s_%d", c.actor.ClientID, c.counter)
content := "neu von " + c.actor.ClientID
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpInsert, BlockID: id, ParentID: &parent, OrderKey: key, Content: &content})
c.holds[id] = true
case n < 73 && target != nil: // indent under previous sibling
siblings := v.Children(target.ParentID)
for i, s := range siblings {
if s.ID == target.ID && i > 0 {
prev := siblings[i-1]
last := ""
if kids := v.Children(prev.ID); len(kids) > 0 {
last = kids[len(kids)-1].OrderKey
}
key, _ := collab.KeyBetween(last, "")
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpMove, BlockID: target.ID, ParentID: &prev.ID, OrderKey: key})
}
}
case n < 80 && target != nil: // outdent
if p, ok := v.Blocks[target.ParentID]; ok {
key, err := collab.KeyBetween(p.OrderKey, "")
if err == nil {
for _, s := range v.Children(p.ParentID) {
if s.OrderKey > p.OrderKey {
key, _ = collab.KeyBetween(p.OrderKey, s.OrderKey)
break
}
}
grand := p.ParentID
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpMove, BlockID: target.ID, ParentID: &grand, OrderKey: key})
}
}
case n < 87 && target != nil: // delete branch
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpDelete, BlockID: target.ID})
case n < 93 && target != nil: // tick checkbox
checked := !target.Props.Checked
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetProps, BlockID: target.ID, Patch: &collab.PropsPatch{Checked: &checked}})
case n < 96: // rename note
title := fmt.Sprintf("Titel %s%d", c.actor.ClientID, c.counter)
c.pending = append(c.pending, collab.Op{OpID: w.newOpID(c), Kind: collab.OpSetTitle, BaseVersion: v.TitleVersion, Title: &title})
default: // stop editing some block
for id := range c.holds {
delete(c.holds, id)
break
}
}
}
// formBatch takes pending operations and keeps only the last text change per block; its base version is
// the one of the first change, i.e. what the user saw when starting to type.
func (c *simClient) formBatch() []collab.Op {
lastText := map[string]int{}
firstBase := map[string]int{}
lastTitle, firstTitleBase := -1, 0
for i, op := range c.pending {
switch op.Kind {
case collab.OpSetContent:
if _, ok := lastText[op.BlockID]; !ok {
firstBase[op.BlockID] = op.BaseVersion
}
lastText[op.BlockID] = i
case collab.OpSetTitle:
if lastTitle < 0 {
firstTitleBase = op.BaseVersion
}
lastTitle = i
}
}
var batch []collab.Op
for i, op := range c.pending {
switch op.Kind {
case collab.OpSetContent:
if lastText[op.BlockID] != i {
continue
}
op.BaseVersion = firstBase[op.BlockID]
case collab.OpSetTitle:
if i != lastTitle {
continue
}
op.BaseVersion = firstTitleBase
}
batch = append(batch, op)
}
c.pending = nil
return batch
}
// serverSync is what POST /sync does, minus HTTP.
func (w *simWorld) serverSync(c *simClient, batch []collab.Op, holds []string) []collab.Result {
w.leases.Touch(w.docID, c.actor)
w.leases.ReleaseAllExcept(w.docID, c.actor.ClientID, holds)
w.leases.Hold(w.docID, c.actor, holds)
results, _, err := w.store.ApplyDocOps(w.docID, c.actor, batch, w.leases.View(w.docID))
if err != nil {
w.t.Fatalf("ApplyDocOps: %v", err)
}
var own []string
for i, res := range results {
if res.Status == collab.StatusApplied && batch[i].Kind == collab.OpInsert {
own = append(own, batch[i].BlockID)
}
if res.ConflictBlockID != "" {
own = append(own, res.ConflictBlockID)
}
}
w.leases.Hold(w.docID, c.actor, own)
return results
}
// pull fetches the log after lastSeq in small pages (to exercise paging) and replays it.
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)
}
c.confirmed.ApplyEffect(e.Effect)
c.lastSeq = e.Seq
}
if c.lastSeq >= head {
return
}
}
}
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 {
c.inflight = c.formBatch()
for _, op := range c.inflight {
w.sent[op.OpID] = op
}
}
holds := make([]string, 0, len(c.holds))
for id := range c.holds {
holds = append(holds, id)
}
sort.Strings(holds)
fate := 100
if !reliable {
fate = w.r.Intn(100)
}
switch {
case fate < 10: // request lost
return
case fate < 20: // server applied it, response lost
w.serverSync(c, c.inflight, holds)
return
case fate < 25: // request duplicated by a retrying proxy
w.serverSync(c, c.inflight, holds)
}
results := w.serverSync(c, c.inflight, holds)
for _, res := range results {
if prev, ok := w.results[res.OpID]; ok && prev != res {
w.t.Fatalf("op %s answered differently on retry: %+v vs %+v", res.OpID, prev, res)
}
w.results[res.OpID] = res
}
c.inflight = nil
w.pull(c)
}
func runSimulation(t *testing.T, seed int64, steps int) {
store, u := newSyncTestStore(t)
doc, err := store.CreateDocument(u.ID, "", "Sim", "📄", "", flatBlocks([3]interface{}{"root", 0, "Start"}), nil, false)
if err != nil {
t.Fatal(err)
}
w := &simWorld{t: t, r: rand.New(rand.NewSource(seed)), store: store, docID: doc.ID,
now: time.Unix(1_700_000_000, 0), sent: map[string]collab.Op{}, results: map[string]collab.Result{}}
w.leases = collab.NewLeaseManager(func() time.Time { return w.now })
for i := 0; i < 4; i++ {
snap, err := store.GetDocSnapshot(doc.ID)
if err != nil {
t.Fatal(err)
}
c := &simClient{actor: collab.Actor{UserID: fmt.Sprintf("u%d", i%2), ClientID: fmt.Sprintf("c%d", i), Name: fmt.Sprintf("Client %d", i)},
confirmed: collab.NewDoc(), lastSeq: snap.HeadSeq, holds: map[string]bool{}}
c.confirmed.Title, c.confirmed.TitleVersion = snap.Title, snap.TitleVersion
for _, b := range snap.Blocks {
b := b
c.confirmed.Blocks[b.ID] = &b
}
w.clients = append(w.clients, c)
}
for step := 0; step < steps; step++ {
c := w.clients[w.r.Intn(len(w.clients))]
switch n := w.r.Intn(100); {
case n < 55:
w.edit(c)
case n < 90:
if !c.offline {
w.sync(c, false)
}
case n < 95:
c.offline = !c.offline
case n < 99:
w.now = w.now.Add(time.Duration(w.r.Intn(25)) * time.Second) // leases may expire
default:
w.compact()
}
}
// Everyone comes back online and syncs until nothing is left
for round := 0; round < 10; round++ {
for _, c := range w.clients {
c.offline = false
w.sync(c, true)
}
}
for _, c := range w.clients {
if len(c.pending) > 0 || len(c.inflight) > 0 {
t.Fatalf("%s still has unsent operations", c.actor.ClientID)
}
w.pull(c)
}
server, head, err := loadCollabDoc(store.db, doc.ID)
if err != nil {
t.Fatal(err)
}
if err := server.Validate(); err != nil {
t.Fatalf("seed %d: invalid tree: %v", seed, err)
}
for _, c := range w.clients {
if c.lastSeq != head || !docsEqual(c.confirmed, server) {
t.Fatalf("seed %d: %s diverged from the server (seq %d vs %d)", seed, c.actor.ClientID, c.lastSeq, head)
}
}
// Every operation got exactly one stored result, and no text was lost
var stored int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM doc_op_results WHERE doc_id = ? AND user_id IN ('u0', 'u1')`, doc.ID).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != len(w.sent) {
t.Fatalf("seed %d: %d operations sent but %d results stored", seed, len(w.sent), stored)
}
w.captureLog()
log := w.fullLog
bySeq := map[int64]collab.Effect{}
inserted := map[string]string{}
for _, e := range log {
bySeq[e.Seq] = e.Effect
// 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
}
}
conflicts := 0
for id, op := range w.sent {
res, ok := w.results[id]
if !ok {
continue // result only seen by the server (response lost, never retried after success) is covered by the count above
}
switch op.Kind {
case collab.OpInsert, collab.OpSetContent, collab.OpSetTitle:
// 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 {
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)
}
}
if res.Status == collab.StatusConflict {
conflicts++
text := ""
if op.Content != nil {
text = *op.Content
} else if op.Title != nil {
text = *op.Title
}
if got, ok := inserted[res.ConflictBlockID]; !ok || got != text {
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, %d compactions", seed, len(w.sent), len(log), conflicts, len(server.Blocks), w.compactions)
}
func docsEqual(a, b *collab.Doc) bool {
if a.Title != b.Title || a.TitleVersion != b.TitleVersion || len(a.Blocks) != len(b.Blocks) {
return false
}
for id, x := range a.Blocks {
y, ok := b.Blocks[id]
if !ok || *x != *y {
return false
}
}
return true
}
func TestSyncSimulationUnreliableNetwork(t *testing.T) {
seeds := 25
if testing.Short() {
seeds = 3
}
for seed := int64(1); seed <= int64(seeds); seed++ {
seed := seed
t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
runSimulation(t, seed, 600)
})
}
}