oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "slices"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/docs/internal/core"
12)
13
14func TestNewestSnapshotPrefersHighestSeqThenHighestID(t *testing.T) {
15 app, err := NewForTest(t)
16 if err != nil {
17 t.Fatalf("NewForTest: %v", err)
18 }
19 ctx := context.Background()
20 owner := app.TestPerson(t, "owner@example.com")
21 doc, _ := app.Store().Create(ctx, owner, "Snapshots")
22
23 write := func(seq int64, state string, abi uint32) {
24 row := &DocSnapshot{DocID: doc.ID, Seq: seq, State: []byte(state), ABIVersion: abi, CreatedAt: time.Now().UTC()}
25 if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
26 t.Fatalf("insert snapshot: %v", err)
27 }
28 }
29 write(10, "older", coreABIVersion)
30 write(20, "first-at-20", coreABIVersion)
31 write(20, "second-at-20", coreABIVersion)
32
33 got, err := app.Store().newestSnapshot(ctx, doc.ID)
34 if err != nil {
35 t.Fatalf("newestSnapshot: %v", err)
36 }
37 if got == nil || string(got.State) != "second-at-20" {
38 t.Fatalf("newestSnapshot returned %+v, want the second row at seq 20", got)
39 }
40}
41
42// TestNewestSnapshotPrefersHigherSeqOverHigherID pins seq as the
43// primary sort key, not just id. TestNewestSnapshotPrefersHighestSeqThenHighestID
44// above writes rows in seq order, so its highest-seq row is also its
45// highest-id row -- an implementation that ordered by id DESC alone,
46// ignoring seq entirely, would pass it too. Here the higher-seq row
47// is written first and so gets the lower id, decoupling the two: only
48// an implementation that actually orders by seq first returns it.
49func TestNewestSnapshotPrefersHigherSeqOverHigherID(t *testing.T) {
50 app, err := NewForTest(t)
51 if err != nil {
52 t.Fatalf("NewForTest: %v", err)
53 }
54 ctx := context.Background()
55 owner := app.TestPerson(t, "owner@example.com")
56 doc, _ := app.Store().Create(ctx, owner, "Seq before id")
57
58 write := func(seq int64, state string) {
59 row := &DocSnapshot{DocID: doc.ID, Seq: seq, State: []byte(state), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()}
60 if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
61 t.Fatalf("insert snapshot: %v", err)
62 }
63 }
64 write(30, "high-seq-low-id")
65 write(10, "low-seq-high-id")
66
67 got, err := app.Store().newestSnapshot(ctx, doc.ID)
68 if err != nil {
69 t.Fatalf("newestSnapshot: %v", err)
70 }
71 if got == nil || string(got.State) != "high-seq-low-id" {
72 t.Fatalf("newestSnapshot returned %+v, want the seq 30 row despite its lower id", got)
73 }
74}
75
76func TestNewestSnapshotIgnoresAnotherABIsRows(t *testing.T) {
77 app, _ := NewForTest(t)
78 ctx := context.Background()
79 owner := app.TestPerson(t, "owner@example.com")
80 doc, _ := app.Store().Create(ctx, owner, "Old ABI")
81
82 row := &DocSnapshot{DocID: doc.ID, Seq: 5, State: []byte("from an older core"), ABIVersion: coreABIVersion - 1, CreatedAt: time.Now().UTC()}
83 if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
84 t.Fatalf("insert: %v", err)
85 }
86
87 got, err := app.Store().newestSnapshot(ctx, doc.ID)
88 if err != nil {
89 t.Fatalf("newestSnapshot: %v", err)
90 }
91 if got != nil {
92 t.Error("a snapshot from another ABI was treated as usable")
93 }
94}
95
96func TestNewestSnapshotIsNilWhenThereAreNone(t *testing.T) {
97 app, _ := NewForTest(t)
98 ctx := context.Background()
99 owner := app.TestPerson(t, "owner@example.com")
100 doc, _ := app.Store().Create(ctx, owner, "None")
101
102 got, err := app.Store().newestSnapshot(ctx, doc.ID)
103 if err != nil || got != nil {
104 t.Errorf("newestSnapshot = (%v, %v), want (nil, nil)", got, err)
105 }
106}
107
108// The central property (the snapshots design, D6 and its §8): a
109// document replayed through snapshot-then-tail must render identically
110// to one replayed from the whole log. Asserted against the rendered
111// document rather than against the snapshot row, because the row is
112// exactly what a stale snapshot makes look right.
113//
114// Property-shaped rather than one fixture at one cadence, as §8 asks:
115// three documents — one snapshot apart, two snapshots apart, and one
116// whose distinct row sits exactly at the tail's first seq — and each
117// carries one update whose content nothing else in its log holds.
118//
119// That last part is what gives the comparison teeth, and leaving it out
120// makes this test near-vacuous. Every other update here is the same
121// simple.bin bytes, which Yjs merges idempotently, so a log of nothing
122// but those reconstructs the whole document from the tail alone: an
123// empty snapshot, or one stranding any row at all, would render
124// identically and this test would pass. The first two cases put the
125// distinct row in the range only the snapshot can carry — everything at
126// or below its seq — so if the snapshot does not hold it, nothing does.
127//
128// The third case inverts that on purpose: its distinct row sits one
129// past the snapshot, at the tail's first seq, so it is the tail's
130// content that must carry it — a snapshot dropping the first tail row
131// would render identically to a full replay here on every existing
132// case, which is exactly the shape of tail query that `seq >
133// snapshot.Seq + 1` would produce without a single test noticing.
134func TestSnapshotThenTailRendersIdenticallyToAFullReplay(t *testing.T) {
135 // distinctAt is a 0-based index into the run of updates; the row it
136 // lands in gets seq distinctAt+1.
137 cases := []struct {
138 name string
139 updates int
140 distinctAt int
141 // inTail is true when the distinct row must land in the tail
142 // (after the snapshot) rather than inside the snapshot itself
143 // — the vacuity guard below is inverted for such a case.
144 inTail bool
145 }{
146 // One snapshot at seq snapshotCadence and a five-row tail. The
147 // distinct row is deep inside the snapshot's range.
148 {"one cadence, the distinct row deep inside the snapshot", snapshotCadence + 5, 3, false},
149 // Two cadences: the snapshot at seq snapshotCadence is
150 // superseded by one at 2*snapshotCadence, and the distinct row
151 // is the very last row that surviving snapshot must contain —
152 // the boundary itself.
153 {"two cadences, the distinct row on the boundary", 2*snapshotCadence + 3, 2*snapshotCadence - 1, false},
154 // One snapshot at seq snapshotCadence, and the distinct row is
155 // the very first row of the tail (seq snapshotCadence+1) rather
156 // than anything the snapshot covers. Only the tail query can
157 // carry this content, which is what makes it the case that
158 // catches a tail query narrowed to `seq > snapshot.Seq + 1`.
159 {"one cadence, the distinct row is the first tail row", snapshotCadence + 3, snapshotCadence, true},
160 }
161
162 for _, tc := range cases {
163 t.Run(tc.name, func(t *testing.T) {
164 app, err := NewForTest(t)
165 if err != nil {
166 t.Fatalf("NewForTest: %v", err)
167 }
168 ctx := context.Background()
169 owner := app.TestPerson(t, "owner@example.com")
170 doc, err := app.Store().Create(ctx, owner, "Equivalence")
171 if err != nil {
172 t.Fatalf("Create: %v", err)
173 }
174
175 ordinary := validUpdate(t)
176 distinct := ReadFragmentFixture(t, "concurrent")
177 for i := 0; i < tc.updates; i++ {
178 update := ordinary
179 if i == tc.distinctAt {
180 update = distinct
181 }
182 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err != nil {
183 t.Fatalf("ApplyUpdate %d: %v", i, err)
184 }
185 }
186
187 snap, err := app.Store().newestSnapshot(ctx, doc.ID)
188 if err != nil {
189 t.Fatalf("newestSnapshot: %v", err)
190 }
191 if snap == nil {
192 t.Fatal("no snapshot exists past the cadence, so nothing below exercises snapshot-then-tail")
193 }
194 want := int64(tc.distinctAt + 1)
195 if tc.inTail {
196 if snap.Seq >= want {
197 t.Fatalf("newest snapshot is at seq %d, at or past the distinct row at seq %d — the snapshot would carry that content on its own and this test would prove nothing", snap.Seq, want)
198 }
199 } else if snap.Seq < want {
200 t.Fatalf("newest snapshot is at seq %d, below the distinct row at seq %d — the tail would carry that content on its own and this test would prove nothing", snap.Seq, want)
201 }
202
203 withSnapshot, err := app.Store().HTML(ctx, doc)
204 if err != nil {
205 t.Fatalf("HTML: %v", err)
206 }
207
208 // Delete every snapshot and read again: same document, full replay.
209 if err := app.Store().db.WithContext(ctx).
210 Where("doc_id = ?", doc.ID).Delete(&DocSnapshot{}).Error; err != nil {
211 t.Fatalf("delete snapshots: %v", err)
212 }
213 fullReplay, err := app.Store().HTML(ctx, doc)
214 if err != nil {
215 t.Fatalf("HTML after deleting snapshots: %v", err)
216 }
217 if withSnapshot != fullReplay {
218 t.Errorf("snapshot-then-tail and full replay disagree about the document:\n through the snapshot: %q\n full replay: %q", withSnapshot, fullReplay)
219 }
220 })
221 }
222}
223
224// The snapshots design's D3, first defect: a snapshot must never claim
225// a seq whose content it does not contain. Asserted against the
226// rendered document, never against the snapshot row — the row is
227// exactly what a stale snapshot makes look right.
228//
229// Two things this test needs that are easy to leave out, and without
230// either of which it passes no matter what the implementation does.
231//
232// It warms up to exactly snapshotCadence rows, not one short of it.
233// Replay runs outside ApplyUpdate's transaction, so with 255 rows in
234// hand all eight racers can (and in practice do) replay before any of
235// them commits, every one of them measures a distance of 255, and not
236// one snapshot is written — the boundary the test is named for is
237// never crossed.
238//
239// And exactly one racer posts different content. The rest post the
240// same bytes as the warmup, which Yjs merges idempotently: if every
241// row said the same thing, a row a stale snapshot dropped would be
242// invisible in the render, and this comparison could not fail. The one
243// odd update is the content a snapshot taken after a successful Apply
244// would strand — its writer's handle never saw that row, but its seq
245// column would cover it, so the tail after the snapshot skips it too.
246func TestConcurrentPostsAtTheCadenceBoundaryDoNotStrandContent(t *testing.T) {
247 app, err := NewForTest(t)
248 if err != nil {
249 t.Fatalf("NewForTest: %v", err)
250 }
251 ctx := context.Background()
252 owner := app.TestPerson(t, "owner@example.com")
253 doc, err := app.Store().Create(ctx, owner, "Boundary")
254 if err != nil {
255 t.Fatalf("Create: %v", err)
256 }
257
258 for i := 0; i < snapshotCadence; i++ {
259 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil {
260 t.Fatalf("warmup %d: %v", i, err)
261 }
262 }
263
264 if err := raceEightPosts(t, app, doc); err != nil {
265 t.Fatalf("the racer carrying the distinct content failed, so this test has nothing to detect a stranding with: %v", err)
266 }
267
268 // Every racer measured a distance of at least snapshotCadence, so
269 // a snapshot must exist — otherwise the comparison below is
270 // between two identical full replays and proves nothing.
271 snap, err := app.Store().newestSnapshot(ctx, doc.ID)
272 if err != nil || snap == nil {
273 t.Fatalf("newestSnapshot = (%v, %v), want a snapshot written at the boundary", snap, err)
274 }
275
276 withSnapshot, err := app.Store().HTML(ctx, doc)
277 if err != nil {
278 t.Fatalf("HTML: %v", err)
279 }
280 if err := app.Store().db.WithContext(ctx).
281 Where("doc_id = ?", doc.ID).Delete(&DocSnapshot{}).Error; err != nil {
282 t.Fatalf("delete snapshots: %v", err)
283 }
284 fullReplay, err := app.Store().HTML(ctx, doc)
285 if err != nil {
286 t.Fatalf("HTML: %v", err)
287 }
288 if withSnapshot != fullReplay {
289 t.Errorf("a snapshot written during concurrent writes stranded content the log still holds:\n through the snapshot: %q\n full replay: %q", withSnapshot, fullReplay)
290 }
291}
292
293// The same property as above, stated directly rather than through a
294// render that happens to differ: the seq a snapshot claims must be
295// truthful about the rows it contains. Replaying doc_updates through
296// snap.Seq and applying snap alone must produce the same document.
297//
298// This is the assertion D5's in-core verification structurally cannot
299// make — it compares a candidate against the very handle that produced
300// it, so a faithful copy of a stale state passes it perfectly. Only a
301// comparison against the log, which is the authority, can catch that.
302func TestASnapshotContainsEveryRowItsSeqClaims(t *testing.T) {
303 app, err := NewForTest(t)
304 if err != nil {
305 t.Fatalf("NewForTest: %v", err)
306 }
307 ctx := context.Background()
308 owner := app.TestPerson(t, "owner@example.com")
309 doc, err := app.Store().Create(ctx, owner, "Truthful seq")
310 if err != nil {
311 t.Fatalf("Create: %v", err)
312 }
313
314 for i := 0; i < snapshotCadence; i++ {
315 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil {
316 t.Fatalf("warmup %d: %v", i, err)
317 }
318 }
319 if err := raceEightPosts(t, app, doc); err != nil {
320 t.Fatalf("the racer carrying the distinct content failed, so this test has nothing to detect a stranding with: %v", err)
321 }
322
323 var snaps []DocSnapshot
324 if err := app.Store().db.WithContext(ctx).
325 Where("doc_id = ?", doc.ID).Order("seq ASC, id ASC").Find(&snaps).Error; err != nil {
326 t.Fatalf("load snapshots: %v", err)
327 }
328 if len(snaps) == 0 {
329 t.Fatal("no snapshot was written at the boundary")
330 }
331
332 for _, snap := range snaps {
333 fromSnapshot := renderInAFreshHandle(t, app, doc, [][]byte{snap.State})
334 fromLog := renderInAFreshHandle(t, app, doc, updateBinsThrough(t, app, doc, snap.Seq))
335 if fromSnapshot != fromLog {
336 t.Errorf("snapshot at seq %d does not contain everything the log holds through that seq:\n snapshot: %q\n log: %q", snap.Seq, fromSnapshot, fromLog)
337 }
338 }
339}
340
341// Supersession, which nothing else here pins. The delete predicate is
342// narrow on purpose — it removes the rows the new snapshot supersedes
343// by newestSnapshot's own (seq, id) ordering, and nothing else — and
344// there are two obvious simplifications of it that both do damage.
345//
346// "Delete every other row for this document" throws away a FRESHER
347// snapshot. Two writers cross the boundary together; the faster one
348// commits at seq 30, and the slower one — working from a replay it
349// began earlier — commits at seq 25 afterwards and takes the seq 30
350// row with it. The document then replays five updates it did not need
351// to, silently, and on a long document that gap is the difference
352// between a snapshot and the cliff.
353//
354// "Delete every row below this seq" without scoping to the document
355// is not a performance question at all: it deletes other documents'
356// snapshots on every write.
357func TestWritingASnapshotDeletesOnlyWhatItSupersedes(t *testing.T) {
358 app, err := NewForTest(t)
359 if err != nil {
360 t.Fatalf("NewForTest: %v", err)
361 }
362 ctx := context.Background()
363 owner := app.TestPerson(t, "owner@example.com")
364 doc, err := app.Store().Create(ctx, owner, "Superseded")
365 if err != nil {
366 t.Fatalf("Create: %v", err)
367 }
368 other, err := app.Store().Create(ctx, owner, "A different document")
369 if err != nil {
370 t.Fatalf("Create: %v", err)
371 }
372
373 seed := func(d *Document, seq int64) {
374 t.Helper()
375 row := &DocSnapshot{DocID: d.ID, Seq: seq, State: []byte("an older snapshot"), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()}
376 if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
377 t.Fatalf("seed snapshot at seq %d: %v", seq, err)
378 }
379 }
380 seed(doc, 10)
381 seed(doc, 20)
382 seed(other, 10)
383 seed(other, 20)
384
385 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil {
386 t.Fatalf("ApplyUpdate: %v", err)
387 }
388 h, _, _, err := app.Store().replay(ctx, doc)
389 if err != nil {
390 t.Fatalf("replay: %v", err)
391 }
392 defer h.Close(ctx)
393
394 // A real snapshot at seq 30 supersedes both hand-written rows for
395 // this document, and neither of the other document's.
396 if err := app.Store().writeSnapshot(ctx, doc, h, 30); err != nil {
397 t.Fatalf("writeSnapshot at seq 30: %v", err)
398 }
399 assertSnapshotSeqs(t, app, doc, []int64{30})
400 assertSnapshotSeqs(t, app, other, []int64{10, 20})
401
402 // Now the slower of two boundary writers, committing afterwards
403 // from a prefix that stopped earlier. Its own row goes in; the
404 // fresher seq 30 row stays, and newestSnapshot still prefers it.
405 //
406 // The seq passed here is one no caller would ever pass — ApplyUpdate
407 // only ever hands over the seq its own replay actually reached —
408 // but the delete predicate is what is under test, not the caller.
409 if err := app.Store().writeSnapshot(ctx, doc, h, 25); err != nil {
410 t.Fatalf("writeSnapshot at seq 25: %v", err)
411 }
412 assertSnapshotSeqs(t, app, doc, []int64{25, 30})
413 assertSnapshotSeqs(t, app, other, []int64{10, 20})
414
415 newest, err := app.Store().newestSnapshot(ctx, doc.ID)
416 if err != nil || newest == nil {
417 t.Fatalf("newestSnapshot = (%v, %v), want the seq 30 row", newest, err)
418 }
419 if newest.Seq != 30 {
420 t.Errorf("newestSnapshot returned seq %d, want the fresher seq 30 row the slower writer must not have deleted", newest.Seq)
421 }
422}
423
424// assertSnapshotSeqs pins exactly which snapshot rows a document has
425// left, in seq order.
426func assertSnapshotSeqs(t *testing.T, app *App, doc *Document, want []int64) {
427 t.Helper()
428 var rows []DocSnapshot
429 if err := app.Store().db.WithContext(context.Background()).
430 Where("doc_id = ?", doc.ID).Order("seq ASC, id ASC").Find(&rows).Error; err != nil {
431 t.Fatalf("load snapshots for %q: %v", doc.Title, err)
432 }
433 got := make([]int64, 0, len(rows))
434 for _, r := range rows {
435 got = append(got, r.Seq)
436 }
437 if !slices.Equal(got, want) {
438 t.Errorf("%q has snapshots at seqs %v, want %v", doc.Title, got, want)
439 }
440}
441
442// raceEightPosts fires eight concurrent ApplyUpdates at doc, exactly
443// one of which carries content nothing else in the log holds, and
444// returns that one's error.
445//
446// Both fixtures are read here, on the test goroutine, before any racer
447// starts. validUpdate and ReadFragmentFixture call t.Fatalf if the
448// file cannot be read, and t.Fatalf from a non-test goroutine does not
449// do what the call site looks like it is asking for: it marks the test
450// failed and calls runtime.Goexit on that one goroutine, so the
451// remaining racers, the WaitGroup and every assertion after it carry
452// on running against a document nobody meant to build.
453//
454// The distinct racer's error comes back rather than being dropped on
455// the floor. If that single POST ever failed, its content would never
456// reach the log, every remaining row would say exactly what every
457// other row says, and the caller's comparison could no longer fail no
458// matter what the implementation did — the test would pass by having
459// nothing left to detect.
460func raceEightPosts(t *testing.T, app *App, doc *Document) error {
461 t.Helper()
462 ctx := context.Background()
463 ordinary := validUpdate(t)
464 distinct := ReadFragmentFixture(t, "concurrent")
465
466 const carriesTheDistinctContent = 3
467 var distinctErr error
468 var wg sync.WaitGroup
469 for i := 0; i < 8; i++ {
470 wg.Add(1)
471 go func(i int) {
472 defer wg.Done()
473 if i == carriesTheDistinctContent {
474 // Written by this one goroutine and read only after
475 // wg.Wait below, which is the happens-before edge.
476 _, distinctErr = app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), distinct)
477 return
478 }
479 _, _ = app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), ordinary)
480 }(i)
481 }
482 wg.Wait()
483 return distinctErr
484}
485
486// updateBinsThrough returns doc's update_bin blobs with seq <= through,
487// in seq order — the log's own answer to "what is this document at
488// that seq", which is the only authority a snapshot can be checked
489// against.
490func updateBinsThrough(t *testing.T, app *App, doc *Document, through int64) [][]byte {
491 t.Helper()
492 var rows []DocUpdate
493 if err := app.Store().db.WithContext(context.Background()).
494 Where("doc_id = ? AND seq <= ?", doc.ID, through).
495 Order("seq ASC").Find(&rows).Error; err != nil {
496 t.Fatalf("load updates through seq %d: %v", through, err)
497 }
498 bins := make([][]byte, 0, len(rows))
499 for _, r := range rows {
500 bins = append(bins, r.UpdateBin)
501 }
502 return bins
503}
504
505// renderInAFreshHandle applies updates, in order, to a brand new core
506// handle for doc and returns its HTML. Deliberately not Store.HTML:
507// that reads whatever is in the database, and these two callers need
508// to render an explicit list of bytes instead.
509func renderInAFreshHandle(t *testing.T, app *App, doc *Document, updates [][]byte) string {
510 t.Helper()
511 ctx := context.Background()
512 h, err := app.Store().core.NewDoc(ctx, doc.GUID, doc.ClientID)
513 if err != nil {
514 t.Fatalf("NewDoc: %v", err)
515 }
516 defer h.Close(ctx)
517 for i, u := range updates {
518 if err := h.Apply(ctx, u); err != nil {
519 t.Fatalf("apply update %d of %d: %v", i, len(updates), err)
520 }
521 }
522 html, err := h.HTML(ctx)
523 if err != nil {
524 t.Fatalf("HTML: %v", err)
525 }
526 return html
527}
528
529// The snapshots design's D3, second defect, and the case its §1 opens
530// with: a document that is ALREADY at the cap when this ships. Driven
531// through the new path a document never reaches the cap, so this
532// builds the production shape directly — a full log and no snapshot —
533// and asserts it recovers.
534func TestADocumentAlreadyAtTheCapRecovers(t *testing.T) {
535 if testing.Short() {
536 t.Skip("replays a full retained log; run without -short")
537 }
538 app, err := NewForTest(t)
539 if err != nil {
540 t.Fatalf("NewForTest: %v", err)
541 }
542 ctx := context.Background()
543 owner := app.TestPerson(t, "owner@example.com")
544 doc, err := app.Store().Create(ctx, owner, "At the cap")
545 if err != nil {
546 t.Fatalf("Create: %v", err)
547 }
548
549 // Write rows straight into the log, bypassing ApplyUpdate, so no
550 // snapshot is ever taken — exactly the state a document edited
551 // before this feature shipped is in.
552 update := validUpdate(t)
553 rows := make([]DocUpdate, 0, core.MaxRetainedUpdates)
554 for i := 1; i <= core.MaxRetainedUpdates; i++ {
555 rows = append(rows, DocUpdate{DocID: doc.ID, Seq: int64(i), UpdateBin: update, CreatedAt: time.Now().UTC()})
556 }
557 if err := app.Store().db.WithContext(ctx).CreateInBatches(rows, 500).Error; err != nil {
558 t.Fatalf("seed a full log: %v", err)
559 }
560
561 // The first write still fails — the handle is full before the
562 // snapshot can help — but it must leave a snapshot behind.
563 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err == nil {
564 t.Log("first write succeeded; acceptable if the cap was not reached")
565 }
566 snap, err := app.Store().newestSnapshot(ctx, doc.ID)
567 if err != nil {
568 t.Fatalf("newestSnapshot: %v", err)
569 }
570 if snap == nil {
571 t.Fatal("a capped document got no snapshot, so it can never recover")
572 }
573
574 // The retry replays snapshot + empty tail and succeeds.
575 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err != nil {
576 t.Errorf("retry after a snapshot existed still failed: %v", err)
577 }
578}
579
580// A corrupted snapshot row must not corrupt the document: the log is
581// authoritative, so an unreadable snapshot is discarded and the whole
582// log replayed instead. Correct, and merely slower.
583func TestACorruptSnapshotFallsBackToTheLog(t *testing.T) {
584 app, err := NewForTest(t)
585 if err != nil {
586 t.Fatalf("NewForTest: %v", err)
587 }
588 ctx := context.Background()
589 owner := app.TestPerson(t, "owner@example.com")
590 doc, err := app.Store().Create(ctx, owner, "Corrupt")
591 if err != nil {
592 t.Fatalf("Create: %v", err)
593 }
594 for i := 0; i < snapshotCadence+2; i++ {
595 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil {
596 t.Fatalf("ApplyUpdate %d: %v", i, err)
597 }
598 }
599 want, err := app.Store().HTML(ctx, doc)
600 if err != nil {
601 t.Fatalf("HTML: %v", err)
602 }
603 // Without this, the corruption below would update zero rows and
604 // the whole test would pass vacuously.
605 if snap, err := app.Store().newestSnapshot(ctx, doc.ID); err != nil || snap == nil {
606 t.Fatalf("newestSnapshot = (%v, %v), want a row to corrupt", snap, err)
607 }
608
609 if err := app.Store().db.WithContext(ctx).Model(&DocSnapshot{}).
610 Where("doc_id = ?", doc.ID).
611 Update("state", []byte{0xff, 0x00, 0x13, 0x37}).Error; err != nil {
612 t.Fatalf("corrupt the snapshot: %v", err)
613 }
614
615 got, err := app.Store().HTML(ctx, doc)
616 if err != nil {
617 t.Fatalf("HTML with a corrupt snapshot: %v", err)
618 }
619 if got != want {
620 t.Error("a corrupt snapshot changed the document instead of being discarded")
621 }
622}
623
624// The cliff itself, driven the long way round: more updates than
625// core.MaxRetainedUpdates through the real path, which must keep
626// accepting them. Slow — thousands of wasm applies — so it is skipped
627// under -short, but it is the one test that proves the headline claim
628// rather than a proxy for it.
629func TestADocumentPastTheRetainedCapKeepsAcceptingWrites(t *testing.T) {
630 if testing.Short() {
631 t.Skip("thousands of updates; run without -short")
632 }
633 app, err := NewForTest(t)
634 if err != nil {
635 t.Fatalf("NewForTest: %v", err)
636 }
637 ctx := context.Background()
638 owner := app.TestPerson(t, "owner@example.com")
639 doc, err := app.Store().Create(ctx, owner, "Past the cap")
640 if err != nil {
641 t.Fatalf("Create: %v", err)
642 }
643
644 update := validUpdate(t)
645 for i := 0; i < core.MaxRetainedUpdates+50; i++ {
646 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), update); err != nil {
647 t.Fatalf("ApplyUpdate %d of %d: %v", i, core.MaxRetainedUpdates+50, err)
648 }
649 }
650}
651
652// seedLongLog writes total rows straight into doc_updates, bypassing
653// ApplyUpdate so no snapshot is ever taken along the way, and returns
654// the distinct update blobs it wrote, in seq order of first appearance.
655//
656// Every row is validUpdate's bytes except the seqs named in distinct,
657// which get the blob given for them. Duplicate Yjs updates merge
658// idempotently, so those distinct rows are the only ones carrying
659// content nothing else in the log holds — which makes the returned
660// slice the same document expressed in a handful of updates, short
661// enough to replay without approaching core.MaxRetainedUpdates at all.
662// That is what the equivalence test below compares against.
663func seedLongLog(t *testing.T, app *App, doc *Document, total int, distinct map[int][]byte) [][]byte {
664 t.Helper()
665 ordinary := validUpdate(t)
666 rows := make([]DocUpdate, 0, total)
667 unique := make([][]byte, 0, len(distinct)+1)
668 seen := make(map[string]bool, len(distinct)+1)
669 for seq := 1; seq <= total; seq++ {
670 update := ordinary
671 if d, ok := distinct[seq]; ok {
672 update = d
673 }
674 rows = append(rows, DocUpdate{DocID: doc.ID, Seq: int64(seq), UpdateBin: update, CreatedAt: time.Now().UTC()})
675 if !seen[string(update)] {
676 seen[string(update)] = true
677 unique = append(unique, update)
678 }
679 }
680 if err := app.Store().db.WithContext(context.Background()).CreateInBatches(rows, 500).Error; err != nil {
681 t.Fatalf("seed a %d-row log: %v", total, err)
682 }
683 return unique
684}
685
686// pastTheCapMarker is text the concurrent fixture contributes and
687// validUpdate's bytes do not, so its presence in a rendered document
688// says the row carrying that fixture was really applied.
689// assertHoldsTheWholeLog checks that claim rather than trusting it.
690const pastTheCapMarker = "BBB"
691
692// longLogDistinctRows is the shape every test below seeds: a log past
693// core.MaxRetainedUpdates whose one distinct update sits at seq
694// core.MaxRetainedUpdates+1 — the row a replay's first handle REFUSES
695// with core.ErrUpdateLogFull, having already retained the 4096 before
696// it. That seq, and not a later one, is the whole point of the
697// placement, because it pins two properties with one assertion:
698//
699// - the replay carried on past the cap at all, rather than stopping
700// there and returning what it had; and
701// - the refused row was re-applied into the re-seeded handle rather
702// than dropped.
703//
704// The second needs this exact seq. Every other row here is validUpdate's
705// bytes, and Yjs applies a duplicate update idempotently, so dropping
706// any ordinary row changes nothing anyone can observe: a re-seed that
707// discarded the refused row and continued would render identically, and
708// a fixture parked at, say, +40 would let that variant pass. With the
709// fixture ON the refused row, dropping it loses content nothing else in
710// the log holds, and every assertion below notices.
711//
712// One distinct fixture, not several. The fragment fixtures are each
713// generated from their own yjs document, and their struct ids overlap,
714// so merging two of them into one document does not concatenate their
715// content — the equivalence test above uses exactly one for the same
716// reason.
717func longLogDistinctRows(t *testing.T) (total int, distinct map[int][]byte) {
718 t.Helper()
719 return core.MaxRetainedUpdates + 50, map[int][]byte{
720 core.MaxRetainedUpdates + 1: ReadFragmentFixture(t, "concurrent"),
721 }
722}
723
724// assertHoldsTheWholeLog fails unless html holds both ends of a log
725// seeded by seedLongLog with longLogDistinctRows' placement: the
726// ordinary rows' own paragraphs, and pastTheCapMarker from the row the
727// first handle refused with core.ErrUpdateLogFull.
728//
729// It first renders the ordinary update on its own and requires that
730// NOT to contain the marker. Without that, a fixture whose content the
731// ordinary rows happened to carry too would make the interesting half
732// of this assertion vacuous.
733func assertHoldsTheWholeLog(t *testing.T, app *App, doc *Document, html string) {
734 t.Helper()
735 ordinaryOnly := renderInAFreshHandle(t, app, doc, [][]byte{validUpdate(t)})
736 if strings.Contains(ordinaryOnly, pastTheCapMarker) {
737 t.Fatalf("the ordinary update already renders %q, so finding it below would prove nothing:\n%s", pastTheCapMarker, ordinaryOnly)
738 }
739 for _, want := range []string{"First paragraph.", pastTheCapMarker} {
740 if !strings.Contains(html, want) {
741 t.Errorf("the rendered document is missing %q:\n%s", want, html)
742 }
743 }
744}
745
746// A document whose log is longer than core.MaxRetainedUpdates and has
747// no usable snapshot must still READ. This is the failure snapshots
748// themselves created: before them ApplyUpdate refused once the handle
749// filled, so no log could ever exceed the cap; now one can, and when
750// its only snapshot goes away — a corrupt row, or the first ABI bump,
751// which newestSnapshot's exact-match filter invalidates every snapshot
752// with at once — replay runs the whole log into one handle, meets
753// ErrUpdateLogFull at row 4097 and returns an error. Text, HTML and
754// JSON all go through replay, so such a document is not slow, it is
755// unreadable.
756func TestALongLogWithNoUsableSnapshotStillReads(t *testing.T) {
757 if testing.Short() {
758 t.Skip("replays more than a full retained log; run without -short")
759 }
760 app, err := NewForTest(t)
761 if err != nil {
762 t.Fatalf("NewForTest: %v", err)
763 }
764 ctx := context.Background()
765 owner := app.TestPerson(t, "owner@example.com")
766 doc, err := app.Store().Create(ctx, owner, "Long log, no snapshot")
767 if err != nil {
768 t.Fatalf("Create: %v", err)
769 }
770
771 total, distinct := longLogDistinctRows(t)
772 seedLongLog(t, app, doc, total, distinct)
773 if snap, err := app.Store().newestSnapshot(ctx, doc.ID); err != nil || snap != nil {
774 t.Fatalf("newestSnapshot = (%v, %v), want no snapshot at all — that is the whole premise of this test", snap, err)
775 }
776
777 html, err := app.Store().HTML(ctx, doc)
778 if err != nil {
779 t.Fatalf("HTML of a %d-row log with no snapshot: %v", total, err)
780 }
781 assertHoldsTheWholeLog(t, app, doc, string(html))
782}
783
784// …and it must still accept WRITES. The read failure above takes the
785// self-repair down with it: ApplyUpdate returns the moment replay
786// fails, long before the writeSnapshot block that rescues a capped
787// legacy document, so nothing about such a document ever improves on
788// its own.
789func TestALongLogWithNoUsableSnapshotStillAcceptsWrites(t *testing.T) {
790 if testing.Short() {
791 t.Skip("replays more than a full retained log; run without -short")
792 }
793 app, err := NewForTest(t)
794 if err != nil {
795 t.Fatalf("NewForTest: %v", err)
796 }
797 ctx := context.Background()
798 owner := app.TestPerson(t, "owner@example.com")
799 doc, err := app.Store().Create(ctx, owner, "Long log, still writable")
800 if err != nil {
801 t.Fatalf("Create: %v", err)
802 }
803
804 total, distinct := longLogDistinctRows(t)
805 seedLongLog(t, app, doc, total, distinct)
806
807 seq, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t))
808 if err != nil {
809 t.Fatalf("ApplyUpdate on a %d-row log with no snapshot: %v", total, err)
810 }
811 if want := int64(total + 1); seq != want {
812 t.Errorf("ApplyUpdate wrote seq %d, want %d", seq, want)
813 }
814
815 // Accepting a write is not enough on its own: a write that went
816 // through on top of a replay that had quietly lost the row the cap
817 // refused would persist that loss into the snapshot ApplyUpdate
818 // takes above. So read the document back and require the whole log
819 // to still be in it.
820 html, err := app.Store().HTML(ctx, doc)
821 if err != nil {
822 t.Fatalf("HTML after the write: %v", err)
823 }
824 assertHoldsTheWholeLog(t, app, doc, string(html))
825}
826
827// The ABI-bump case, spelled out because it is the one that will
828// actually happen rather than the one that might: newestSnapshot
829// filters on an exact abi_version match, so the first bump after this
830// ships invalidates every snapshot in the database simultaneously and
831// sends every long document to a full-log replay on the same deploy.
832// The snapshot written here is a real one — genuine bytes for this
833// document, at a seq the log really reached — unusable for the single
834// reason that its ABI is not ours.
835func TestALongLogWhoseOnlySnapshotIsFromAnotherABIStillReadsAndWrites(t *testing.T) {
836 if testing.Short() {
837 t.Skip("replays more than a full retained log; run without -short")
838 }
839 app, err := NewForTest(t)
840 if err != nil {
841 t.Fatalf("NewForTest: %v", err)
842 }
843 ctx := context.Background()
844 owner := app.TestPerson(t, "owner@example.com")
845 doc, err := app.Store().Create(ctx, owner, "Long log, yesterday's ABI")
846 if err != nil {
847 t.Fatalf("Create: %v", err)
848 }
849
850 total, distinct := longLogDistinctRows(t)
851 unique := seedLongLog(t, app, doc, total, distinct)
852
853 // A real snapshot of this very document, stored under the previous
854 // ABI — what every existing row becomes the moment core's ABI is
855 // bumped.
856 h, err := app.Store().core.NewDoc(ctx, doc.GUID, doc.ClientID)
857 if err != nil {
858 t.Fatalf("NewDoc: %v", err)
859 }
860 for i, u := range unique {
861 if err := h.Apply(ctx, u); err != nil {
862 t.Fatalf("apply update %d of %d: %v", i, len(unique), err)
863 }
864 }
865 state, err := h.Snapshot(ctx)
866 if err != nil {
867 t.Fatalf("Snapshot: %v", err)
868 }
869 h.Close(ctx)
870 row := &DocSnapshot{DocID: doc.ID, Seq: int64(total), State: state, ABIVersion: coreABIVersion - 1, CreatedAt: time.Now().UTC()}
871 if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil {
872 t.Fatalf("insert an older-ABI snapshot: %v", err)
873 }
874 if snap, err := app.Store().newestSnapshot(ctx, doc.ID); err != nil || snap != nil {
875 t.Fatalf("newestSnapshot = (%v, %v), want nil — an older ABI's row must be unusable, or this test proves nothing", snap, err)
876 }
877
878 html, err := app.Store().HTML(ctx, doc)
879 if err != nil {
880 t.Fatalf("HTML after an ABI bump invalidated the snapshot: %v", err)
881 }
882 assertHoldsTheWholeLog(t, app, doc, string(html))
883
884 if _, err := app.Store().ApplyUpdate(ctx, doc, app.TestActorFor(t, doc), validUpdate(t)); err != nil {
885 t.Fatalf("ApplyUpdate after an ABI bump invalidated the snapshot: %v", err)
886 }
887}
888
889// Not failing is the smaller half; the property that makes re-seeding
890// mid-replay SAFE is that it changes nothing. A replay that crossed the
891// cap — and so continued from a snapshot of its own partial state,
892// possibly more than once — must render exactly what the same content
893// renders when replayed the ordinary way, in a handle that never came
894// near the cap at all.
895//
896// The comparison has teeth because the log carries a distinct update
897// past seq 4096: a re-seed that dropped the remaining rows, or one that
898// continued from an empty or stale snapshot, differs here rather than
899// merely being slower.
900func TestReSeedingMidReplayRendersTheSameDocument(t *testing.T) {
901 if testing.Short() {
902 t.Skip("replays more than a full retained log; run without -short")
903 }
904 app, err := NewForTest(t)
905 if err != nil {
906 t.Fatalf("NewForTest: %v", err)
907 }
908 ctx := context.Background()
909 owner := app.TestPerson(t, "owner@example.com")
910 doc, err := app.Store().Create(ctx, owner, "Re-seeded, unchanged")
911 if err != nil {
912 t.Fatalf("Create: %v", err)
913 }
914
915 total, distinct := longLogDistinctRows(t)
916 unique := seedLongLog(t, app, doc, total, distinct)
917 if len(unique) >= core.MaxRetainedUpdates {
918 t.Fatalf("the comparison replay is %d updates long, which crosses the cap itself", len(unique))
919 }
920
921 reSeeded, err := app.Store().HTML(ctx, doc)
922 if err != nil {
923 t.Fatalf("HTML of a %d-row log: %v", total, err)
924 }
925 want := renderInAFreshHandle(t, app, doc, unique)
926 if string(reSeeded) != want {
927 t.Errorf("re-seeding mid-replay changed the document:\n re-seeded replay: %q\n never past the cap: %q", reSeeded, want)
928 }
929}
930