Files
pad/internal/store/bug3253_sync_frames_test.go
T
xarmian d90ac1ebd1 fix(collab): persist a typed burst's waiting frames in one transaction (BUG-3253) (#1909)
* fix(collab): persist a typed burst's waiting frames in one transaction (BUG-3253)

The relay's readLoop persisted every inbound sync frame in its own store
call before reading the next, so a typed burst (one frame per
keystroke) drained its socket at one commit per character. An item
reopened meanwhile replayed a body missing the burst's end until the
rest arrived over the relay (the item measured the last frame landing up
to 14.6s after typing stopped, under 8 writers).

- A goroutine is now the connection's only socket reader and hands
  frames over in order. A sync frame takes every further sync frame
  already waiting (up to 64), and they persist in ONE transaction
  (store.AppendSyncFrames). Any other frame ends the run and is handled
  next, so order is kept: the applier's bracket messages, awareness,
  errors.
- AppendSyncFrames gives each frame exactly AppendSyncFrame's treatment,
  in order and inside the transaction, so the BUG-3135 duplicate check
  sees the batch's own earlier rows. All or nothing. The room publishes
  each frame with its own op-log id, as before; a failed batch publishes
  each with id 0, as a failed frame always was. The frozen / canWrite /
  evicted fences are checked once under appendMu for the run, and a
  frozen run counts every dropped frame.

Measured (store probe: K typists at 5 ms/key, 300 keys each, SQLite,
two runs). K=1: one frame per commit keeps pace either way (append p50
2-6 ms). K=8: per-frame drain lag median 20.3 s / 20.5 s; batched
0.40 s / 0.21 s, with 2400 commits down to ~250. So the lag was write
contention between writers, not per-frame cost.

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

* test(collab): a queued burst persists as a batch, stored and relayed in order (BUG-3253)

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

* fix(collab): a run carries the awareness frames between keystrokes, so bursts actually batch (BUG-3253)

The item's own probe (bug-2182's pane Back leg, 300 characters typed at
5 ms/key, x40 at 8 workers) showed the first version changed nothing:
batch size was 1 in all 12,200 appends, while frames waited a median
0.9 s (p99 7.5 s) on the server before their persist. Every keystroke
sends its sync update followed by the cursor's awareness update, and the
drain stopped at the first non-sync frame.

A run now takes every waiting sync AND awareness frame (up to 128
frames, about 64 keystrokes). Its sync frames persist in one
transaction, and then every frame publishes in arrival order. A refused
run (frozen, read-only, evicted) still relays its awareness, as single
frames always were. Control messages and errors still end a run.

Probe, same machine, x40 at 8 workers: content-bearing rows persisted
when the pane drills away 1.5 s after typing, and the reopened body once
the editor reports synced:
- main 58be45b3: median 198 of 301 persisted (min 86; all 301 in 4/40
  runs); 26/40 reopened short.
- this branch: 301 of 301 persisted in 40/40 runs; 0/40 reopened short.
No restore failed in either. Every run converged in the end, as the item
said.

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

* fix(collab): a store answering the wrong number of results is a failed batch, not an index panic (BUG-3253 codex r2)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 18:21:18 -04:00

77 lines
2.5 KiB
Go

package store
import (
"testing"
)
// BUG-3253: AppendSyncFrames persists a run of relay frames in one
// transaction. Each frame must get exactly what AppendSyncFrame alone gives
// it, in order, including the duplicate check seeing the batch's OWN earlier
// rows; and a failure stores nothing.
func TestBUG3253_AppendSyncFramesMatchesOneByOne(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
one := createTestItem(t, s, ws.ID, col.ID, "One by one", "")
batched := createTestItem(t, s, ws.ID, col.ID, "Batched", "")
a, b, c := []byte{0, 2, 10, 1}, []byte{0, 2, 10, 2}, []byte{0, 2, 10, 3}
frames := [][]byte{a, b, a, c} // the third is a byte-identical re-send of the first
var want []SyncFrameAppend
for _, f := range frames {
r, err := s.AppendSyncFrame(one.ID, f, "1")
if err != nil {
t.Fatalf("AppendSyncFrame: %v", err)
}
want = append(want, r)
}
got, err := s.AppendSyncFrames(batched.ID, frames, "1")
if err != nil {
t.Fatalf("AppendSyncFrames: %v", err)
}
if len(got) != len(want) {
t.Fatalf("got %d results, want %d", len(got), len(want))
}
// Ids differ between the items, so compare each id's offset from the
// item's first row, and the Persisted flags exactly.
for i := range want {
if got[i].Persisted != want[i].Persisted {
t.Errorf("frame %d: Persisted %v, one by one %v", i, got[i].Persisted, want[i].Persisted)
}
if got[i].ID-got[0].ID != want[i].ID-want[0].ID {
t.Errorf("frame %d: id offset %d, one by one %d", i, got[i].ID-got[0].ID, want[i].ID-want[0].ID)
}
}
if got[2].Persisted || got[2].ID != got[1].ID {
t.Errorf("the in-batch re-send = %+v, want skipped and acknowledged at the batch's max id %d", got[2], got[1].ID)
}
rows, err := s.LoadYjsUpdatesSince(batched.ID, 0)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(rows) != 3 {
t.Fatalf("batched item holds %d rows, want 3", len(rows))
}
}
func TestBUG3253_AppendSyncFramesIsAllOrNothing(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
col := createTestCollection(t, s, ws.ID, "Tasks")
item := createTestItem(t, s, ws.ID, col.ID, "Item", "")
if _, err := s.AppendSyncFrames(item.ID, [][]byte{{0, 2, 1}, {}, {0, 2, 3}}, "1"); err == nil {
t.Fatal("a batch with an empty frame was accepted")
}
rows, err := s.LoadYjsUpdatesSince(item.ID, 0)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(rows) != 0 {
t.Fatalf("a failed batch left %d rows behind", len(rows))
}
}