Files
pad/internal/materialize/task3531_snapshot_test.go
xarmian 23c8e4f811 feat(collab): the GC compacts dormant op-logs and a covered resume merges (TASK-3531 U3-U5) (#1985)
* feat(collab): the GC tick compacts dormant op-logs and Join admits a covered resume (TASK-3531 U3-U5)

The second half of TASK-3531, behind PAD_OPLOG_COMPACT=on (default off).

U3: with a compactor set, each op-log GC tick first compacts every
dormant, flushed op-log into one Yjs snapshot (#1984's primitives): the
snapshot is built on the materializer worker WITHOUT the item lock, then
swapped in under the lock with no room open, and CompactItemOpLog
re-checks the log is the one it read. The sweep then runs as
PruneSweepKeeping and keeps compacted logs. Anything not compacted (a
failed job, a refused swap, set-aside rows, another schema) is left to
the sweep, which deletes it as before. cmd/pad builds the materializer
before the GC starts and hands it over as the compactor only when
PAD_OPLOG_COMPACT=on and the materializer is enabled.

U4: Join admits a resume cursor the snapshot covers (CompactedResumeCovers,
which requires the snapshot row to exist) instead of force_refreshing it;
the tab replays the snapshot and its own edits merge. A failed check
refreshes.

Telemetry for the default flip (lead ruling): pad_oplog_compactions_total
{outcome}, pad_oplog_compaction_snapshot_bytes_total against
pad_oplog_compaction_markdown_bytes_total, pad_collab_compacted_resumes_total.

U5: server tests (compact then keep, the no-compactor and failed-job
deletes, a room opened mid-job blocks the swap, a covered resume admitted
vs refreshed) and an opt-in e2e (a tab asleep across the sweep merges,
or with compaction off is handed its text back).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): cap compaction jobs per GC tick and keep the deferred items (TASK-3531, codex)

Compaction shares the materializer's single job slot with op-log recovery,
which carries unflushed edits, so a dormant backlog must not hold it for a
whole pass. At most 25 snapshot jobs a tick; items past the cap are kept
from that tick's sweep (deleting them would lose their chance) and
compacted by a later tick. The opt-in e2e's mode=off timeout now names
the likely cause.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(materialize): updates built on pre-compaction structs merge onto the snapshot (TASK-3531)

The merge property in CI, not only in the opt-in e2e (lead): over the
corpus, a snapshot of the first k rows followed by the rows after k
materializes to the full document (150 cases). Those later rows depend on
the prefix's structs (lone, 114 of them are pending), so this is the
dependency resolution a deleted-and-reseeded log fails.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 20:32:46 -04:00

168 lines
6.4 KiB
Go

package materialize
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// TASK-3531: compaction replaces an item's op-log with ONE frame, so the frame
// must be exactly as good as the rows. Over the parity corpus (op-logs a real
// Editor.svelte produced, with the live editor's flush as `expected`): the
// snapshot's markdown is the rows' markdown, and replaying the frame ALONE
// gives that markdown too. The bundle also checks the Yjs snapshot (state
// vector and delete set) itself before it returns a frame.
//
// Every corpus op-log mixes in a malformed sync frame on purpose (replay's
// catch path), so this also covers compacting such a row away.
func TestTASK3531_SnapshotReplaysToTheSameDocument(t *testing.T) {
r := runner(t)
compacted := 0
for _, c := range loadCorpus(t) {
job := c.job(t, r.SchemaVersion())
snap, err := r.Snapshot(context.Background(), job)
if err != nil {
t.Errorf("%s: %v", c.Name, err)
continue
}
if snap.Markdown != c.Expected {
t.Errorf("%s: snapshot markdown differs from the live editor's", c.Name)
}
again, err := r.Materialize(context.Background(), Job{Rows: [][]byte{snap.Frame}, SchemaVersion: r.SchemaVersion(), LinkIndex: c.LinkIndex, WorkspaceSlug: c.WorkspaceSlug})
if err != nil {
t.Errorf("%s: materialize the frame: %v", c.Name, err)
continue
}
if again != c.Expected {
t.Errorf("%s: the frame alone renders differently\n--- frame ---\n%s\n--- expected ---\n%s", c.Name, again, c.Expected)
}
compacted++
}
t.Logf("%d corpus op-logs compacted", compacted)
}
// A row that does not replay contributes nothing, so the snapshot with it is
// the snapshot without it: the same frame replays to the same markdown.
func TestTASK3531_ARowThatDoesNotReplayIsCompactedAwayWithoutLoss(t *testing.T) {
r := runner(t)
c := loadCorpus(t)[0]
job := c.job(t, r.SchemaVersion())
clean, err := r.Snapshot(context.Background(), job)
if err != nil {
t.Fatal(err)
}
// A sync update frame whose payload is not a Yjs update.
job.Rows = append(job.Rows, []byte{0x00, 0x02, 0x03, 0xff, 0xff, 0xff})
withBad, err := r.Snapshot(context.Background(), job)
if err != nil {
t.Fatal(err)
}
if withBad.Markdown != clean.Markdown || string(withBad.Frame) != string(clean.Frame) {
t.Fatal("a row that does not replay changed the snapshot")
}
}
// The schema check applies to snapshots as to materializations.
func TestTASK3531_SnapshotChecksTheSchemaVersion(t *testing.T) {
r := runner(t)
if _, err := r.Snapshot(context.Background(), Job{SchemaVersion: r.SchemaVersion() + "0"}); !errors.Is(err, ErrSchemaVersion) {
t.Fatalf("got %v, want ErrSchemaVersion", err)
}
}
// Structs left pending (rows referencing something no row holds) are refused:
// a frame would carry them invisibly. A later update row ON ITS OWN usually
// references structs only earlier rows hold, so snapshot each such row alone.
// (Dropping just the first row is not enough: the corpus has step2 answers
// that carry the whole state again.)
func TestTASK3531_SnapshotRefusesPendingStructs(t *testing.T) {
r := runner(t)
refused := 0
for _, c := range loadCorpus(t)[:5] {
job := c.job(t, r.SchemaVersion())
for i := 1; i < len(job.Rows); i++ {
row := job.Rows[i]
if len(row) < 2 || row[0] != 0 || row[1] != 2 { // sync update frames only
continue
}
alone := Job{Rows: [][]byte{row}, SchemaVersion: job.SchemaVersion}
if _, err := r.Snapshot(context.Background(), alone); err != nil {
if !strings.Contains(err.Error(), "pending") {
t.Fatalf("%s row %d: refused for another reason: %v", c.Name, i, err)
}
refused++
}
}
}
if refused == 0 {
t.Fatal("no lone later update was refused; the pending check is not reached")
}
t.Logf("%d lone later updates refused as pending", refused)
}
// Through the real supervisor and worker process: the same frame as in-process.
func TestTASK3531_SnapshotThroughTheWorker(t *testing.T) {
h := newHarness(t, "worker", func(c *SupervisorConfig) { c.Timeout = 30 * time.Second })
r := runner(t)
for _, tc := range loadCorpus(t)[:3] {
job := tc.job(t, r.SchemaVersion())
want, err := r.Snapshot(context.Background(), job)
if err != nil {
t.Fatal(err)
}
got, err := h.s.Snapshot(context.Background(), job)
if err != nil {
t.Fatalf("%s through the supervisor: %v", tc.Name, err)
}
if got.Markdown != want.Markdown || string(got.Frame) != string(want.Frame) {
t.Errorf("%s: the worker's snapshot differs from in-process", tc.Name)
}
}
// A plain materialization still works on the same worker.
if _, err := h.s.Materialize(context.Background(), loadCorpus(t)[0].job(t, r.SchemaVersion())); err != nil {
t.Fatal(err)
}
}
// The merge itself, in CI (lead, TASK-3531 PR 2: nothing may be covered only by
// the opt-in e2e). A tab that slept past a compaction holds structs from
// BEFORE it and sends updates built on them. Compact a corpus op-log's first k
// rows, then replay the snapshot followed by the rows after k (exactly such
// updates): the document must be the one all the rows give. On a log that was
// deleted and reseeded instead, those updates would sit pending or duplicate
// the text (BUG-3526's measurement).
func TestTASK3531_UpdatesBuiltOnTheOldStructsMergeOntoTheSnapshot(t *testing.T) {
r := runner(t)
checked := 0
for _, c := range loadCorpus(t) {
job := c.job(t, r.SchemaVersion())
if len(job.Rows) < 4 {
continue
}
for _, k := range []int{len(job.Rows) / 3, len(job.Rows) / 2, len(job.Rows) - 1} {
head := Job{Rows: job.Rows[:k], SchemaVersion: job.SchemaVersion, LinkIndex: job.LinkIndex, WorkspaceSlug: job.WorkspaceSlug}
snap, err := r.Snapshot(context.Background(), head)
if err != nil {
// A prefix can end mid-dependency (pending): not compactable, so
// not a case compaction would produce.
continue
}
rest := append([][]byte{snap.Frame}, job.Rows[k:]...)
got, err := r.Materialize(context.Background(), Job{Rows: rest, SchemaVersion: job.SchemaVersion, LinkIndex: job.LinkIndex, WorkspaceSlug: job.WorkspaceSlug})
if err != nil {
t.Fatalf("%s k=%d: %v", c.Name, k, err)
}
if got != c.Expected {
t.Fatalf("%s k=%d: the later updates did not merge onto the snapshot\n--- got ---\n%s\n--- want ---\n%s", c.Name, k, got, c.Expected)
}
checked++
}
}
if checked < 30 {
t.Fatalf("only %d snapshot+suffix cases checked", checked)
}
t.Logf("%d snapshot+suffix cases merged to the full document", checked)
}