| index 151de13..8547c58 100644 |
| --- a/internal/docs/snapshots_test.go |
| +++ b/internal/docs/snapshots_test.go |
| @@ -3,6 +3,7 @@ package docs |
| import ( |
| "context" |
| "slices" |
| + "strings" |
| "sync" |
| "testing" |
| "time" |
| @@ -624,3 +625,259 @@ func TestADocumentPastTheRetainedCapKeepsAcceptingWrites(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// seedLongLog writes total rows straight into doc_updates, bypassing |
| +// ApplyUpdate so no snapshot is ever taken along the way, and returns |
| +// the distinct update blobs it wrote, in seq order of first appearance. |
| +// |
| +// Every row is validUpdate's bytes except the seqs named in distinct, |
| +// which get the blob given for them. Duplicate Yjs updates merge |
| +// idempotently, so those distinct rows are the only ones carrying |
| +// content nothing else in the log holds — which makes the returned |
| +// slice the same document expressed in a handful of updates, short |
| +// enough to replay without approaching core.MaxRetainedUpdates at all. |
| +// That is what the equivalence test below compares against. |
| +func seedLongLog(t *testing.T, app *App, doc *Document, total int, distinct map[int][]byte) [][]byte { |
| + t.Helper() |
| + ordinary := validUpdate(t) |
| + rows := make([]DocUpdate, 0, total) |
| + unique := make([][]byte, 0, len(distinct)+1) |
| + seen := make(map[string]bool, len(distinct)+1) |
| + for seq := 1; seq <= total; seq++ { |
| + update := ordinary |
| + if d, ok := distinct[seq]; ok { |
| + update = d |
| + } |
| + rows = append(rows, DocUpdate{DocID: doc.ID, Seq: int64(seq), UpdateBin: update, CreatedAt: time.Now().UTC()}) |
| + if !seen[string(update)] { |
| + seen[string(update)] = true |
| + unique = append(unique, update) |
| + } |
| + } |
| + if err := app.Store().db.WithContext(context.Background()).CreateInBatches(rows, 500).Error; err != nil { |
| + t.Fatalf("seed a %d-row log: %v", total, err) |
| + } |
| + return unique |
| +} |
| + |
| +// pastTheCapMarker is text the concurrent fixture contributes and |
| +// validUpdate's bytes do not, so its presence in a rendered document |
| +// says the row carrying that fixture was really applied. |
| +// assertHoldsTheWholeLog checks that claim rather than trusting it. |
| +const pastTheCapMarker = "BBB" |
| + |
| +// longLogDistinctRows is the shape every test below seeds: a log past |
| +// core.MaxRetainedUpdates whose one distinct update sits PAST the cap, |
| +// among rows only a replay that carries on beyond core.ErrUpdateLogFull |
| +// ever applies. That placement is what makes "still reads" mean "reads |
| +// the whole document" rather than "reads its first 4096 rows" — a |
| +// replay that stopped at the cap and returned what it had would render |
| +// without pastTheCapMarker and fail. |
| +// |
| +// One distinct fixture, not several. The fragment fixtures are each |
| +// generated from their own yjs document, and their struct ids overlap, |
| +// so merging two of them into one document does not concatenate their |
| +// content — the equivalence test above uses exactly one for the same |
| +// reason. |
| +func longLogDistinctRows(t *testing.T) (total int, distinct map[int][]byte) { |
| + t.Helper() |
| + return core.MaxRetainedUpdates + 50, map[int][]byte{ |
| + core.MaxRetainedUpdates + 40: ReadFragmentFixture(t, "concurrent"), |
| + } |
| +} |
| + |
| +// assertHoldsTheWholeLog fails unless html holds both ends of a log |
| +// seeded by seedLongLog with longLogDistinctRows' placement: the |
| +// ordinary rows' own paragraphs, and pastTheCapMarker from the row |
| +// beyond core.MaxRetainedUpdates. |
| +// |
| +// It first renders the ordinary update on its own and requires that |
| +// NOT to contain the marker. Without that, a fixture whose content the |
| +// ordinary rows happened to carry too would make the interesting half |
| +// of this assertion vacuous. |
| +func assertHoldsTheWholeLog(t *testing.T, app *App, doc *Document, html string) { |
| + t.Helper() |
| + ordinaryOnly := renderInAFreshHandle(t, app, doc, [][]byte{validUpdate(t)}) |
| + if strings.Contains(ordinaryOnly, pastTheCapMarker) { |
| + t.Fatalf("the ordinary update already renders %q, so finding it below would prove nothing:\n%s", pastTheCapMarker, ordinaryOnly) |
| + } |
| + for _, want := range []string{"First paragraph.", pastTheCapMarker} { |
| + if !strings.Contains(html, want) { |
| + t.Errorf("the rendered document is missing %q:\n%s", want, html) |
| + } |
| + } |
| +} |
| + |
| +// A document whose log is longer than core.MaxRetainedUpdates and has |
| +// no usable snapshot must still READ. This is the failure snapshots |
| +// themselves created: before them ApplyUpdate refused once the handle |
| +// filled, so no log could ever exceed the cap; now one can, and when |
| +// its only snapshot goes away — a corrupt row, or the first ABI bump, |
| +// which newestSnapshot's exact-match filter invalidates every snapshot |
| +// with at once — replay runs the whole log into one handle, meets |
| +// ErrUpdateLogFull at row 4096 and returns an error. Text, HTML and |
| +// JSON all go through replay, so such a document is not slow, it is |
| +// unreadable. |
| +func TestALongLogWithNoUsableSnapshotStillReads(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("replays more than a full retained log; run without -short") |
| + } |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Long log, no snapshot") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + total, distinct := longLogDistinctRows(t) |
| + seedLongLog(t, app, doc, total, distinct) |
| + if snap, err := app.Store().newestSnapshot(ctx, doc.ID); err != nil || snap != nil { |
| + t.Fatalf("newestSnapshot = (%v, %v), want no snapshot at all — that is the whole premise of this test", snap, err) |
| + } |
| + |
| + html, err := app.Store().HTML(ctx, doc) |
| + if err != nil { |
| + t.Fatalf("HTML of a %d-row log with no snapshot: %v", total, err) |
| + } |
| + assertHoldsTheWholeLog(t, app, doc, string(html)) |
| +} |
| + |
| +// …and it must still accept WRITES. The read failure above takes the |
| +// self-repair down with it: ApplyUpdate returns the moment replay |
| +// fails, long before the writeSnapshot block that rescues a capped |
| +// legacy document, so nothing about such a document ever improves on |
| +// its own. |
| +func TestALongLogWithNoUsableSnapshotStillAcceptsWrites(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("replays more than a full retained log; run without -short") |
| + } |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Long log, still writable") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + total, distinct := longLogDistinctRows(t) |
| + seedLongLog(t, app, doc, total, distinct) |
| + |
| + seq, err := app.Store().ApplyUpdate(ctx, doc, validUpdate(t)) |
| + if err != nil { |
| + t.Fatalf("ApplyUpdate on a %d-row log with no snapshot: %v", total, err) |
| + } |
| + if want := int64(total + 1); seq != want { |
| + t.Errorf("ApplyUpdate wrote seq %d, want %d", seq, want) |
| + } |
| +} |
| + |
| +// The ABI-bump case, spelled out because it is the one that will |
| +// actually happen rather than the one that might: newestSnapshot |
| +// filters on an exact abi_version match, so the first bump after this |
| +// ships invalidates every snapshot in the database simultaneously and |
| +// sends every long document to a full-log replay on the same deploy. |
| +// The snapshot written here is a real one — genuine bytes for this |
| +// document, at a seq the log really reached — unusable for the single |
| +// reason that its ABI is not ours. |
| +func TestALongLogWhoseOnlySnapshotIsFromAnotherABIStillReadsAndWrites(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("replays more than a full retained log; run without -short") |
| + } |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Long log, yesterday's ABI") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + total, distinct := longLogDistinctRows(t) |
| + unique := seedLongLog(t, app, doc, total, distinct) |
| + |
| + // A real snapshot of this very document, stored under the previous |
| + // ABI — what every existing row becomes the moment core's ABI is |
| + // bumped. |
| + h, err := app.Store().core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + for i, u := range unique { |
| + if err := h.Apply(ctx, u); err != nil { |
| + t.Fatalf("apply update %d of %d: %v", i, len(unique), err) |
| + } |
| + } |
| + state, err := h.Snapshot(ctx) |
| + if err != nil { |
| + t.Fatalf("Snapshot: %v", err) |
| + } |
| + h.Close(ctx) |
| + row := &DocSnapshot{DocID: doc.ID, Seq: int64(total), State: state, ABIVersion: coreABIVersion - 1, CreatedAt: time.Now().UTC()} |
| + if err := app.Store().db.WithContext(ctx).Create(row).Error; err != nil { |
| + t.Fatalf("insert an older-ABI snapshot: %v", err) |
| + } |
| + if snap, err := app.Store().newestSnapshot(ctx, doc.ID); err != nil || snap != nil { |
| + t.Fatalf("newestSnapshot = (%v, %v), want nil — an older ABI's row must be unusable, or this test proves nothing", snap, err) |
| + } |
| + |
| + html, err := app.Store().HTML(ctx, doc) |
| + if err != nil { |
| + t.Fatalf("HTML after an ABI bump invalidated the snapshot: %v", err) |
| + } |
| + assertHoldsTheWholeLog(t, app, doc, string(html)) |
| + |
| + if _, err := app.Store().ApplyUpdate(ctx, doc, validUpdate(t)); err != nil { |
| + t.Fatalf("ApplyUpdate after an ABI bump invalidated the snapshot: %v", err) |
| + } |
| +} |
| + |
| +// Not failing is the smaller half; the property that makes re-seeding |
| +// mid-replay SAFE is that it changes nothing. A replay that crossed the |
| +// cap — and so continued from a snapshot of its own partial state, |
| +// possibly more than once — must render exactly what the same content |
| +// renders when replayed the ordinary way, in a handle that never came |
| +// near the cap at all. |
| +// |
| +// The comparison has teeth because the log carries a distinct update |
| +// past seq 4096: a re-seed that dropped the remaining rows, or one that |
| +// continued from an empty or stale snapshot, differs here rather than |
| +// merely being slower. |
| +func TestReSeedingMidReplayRendersTheSameDocument(t *testing.T) { |
| + if testing.Short() { |
| + t.Skip("replays more than a full retained log; run without -short") |
| + } |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Re-seeded, unchanged") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + total, distinct := longLogDistinctRows(t) |
| + unique := seedLongLog(t, app, doc, total, distinct) |
| + if len(unique) >= core.MaxRetainedUpdates { |
| + t.Fatalf("the comparison replay is %d updates long, which crosses the cap itself", len(unique)) |
| + } |
| + |
| + reSeeded, err := app.Store().HTML(ctx, doc) |
| + if err != nil { |
| + t.Fatalf("HTML of a %d-row log: %v", total, err) |
| + } |
| + want := renderInAFreshHandle(t, app, doc, unique) |
| + if string(reSeeded) != want { |
| + t.Errorf("re-seeding mid-replay changed the document:\n re-seeded replay: %q\n never past the cap: %q", reSeeded, want) |
| + } |
| +} |