Files
pad/internal/server/bug3747_compaction_generation_test.go
T
xarmian 134d0a4e89 fix(collab): a compaction snapshot covers only its own generation's cursors (BUG-3747) (#2142)
* fix(collab): a compaction snapshot covers only its own generation's cursors (BUG-3747)

CompactedResumeCovers bounded a resume cursor from above only. After a
path deletes the op-log (a direct write, a restore, a dormant prune, a
schema rebuild), the next rows start a new generation with higher ids;
once that one was compacted past an old tab's cursor, Join admitted the
tab and its obsolete Y.Doc merged the deleted generation's text back in.

items.yjs_compacted_from (migration 143 / pg 113) is the lowest id the
snapshot covers: the oldest row the first snapshot of the generation
replaced, carried by later snapshots of the same generation (only when
the previous snapshot row is among the rows replaced, so a stale floor
left by a path that forgot to clear the columns never carries). A
cursor is covered only in [from, through]. A snapshot from before the
migration has no floor and covers nothing: those tabs refresh once.

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

* fix(collab): the compaction floor carries whenever the previous snapshot is replaced, not only when it is the oldest row (BUG-3747, Codex r1)

A Postgres row allocated a lower id can commit after a snapshot and sit
below it at the next compaction; the floor now carries in that case
too, as min(previous floor, oldest replaced row).

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

* chore(store): renumber the BUG-3747 migration to 145 / pg 115, after main's 144 / 114 (BUG-3744)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-13 23:30:26 -04:00

82 lines
2.6 KiB
Go

package server
import (
"encoding/json"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/collab"
)
// BUG-3747, through Join: a tab whose cursor came from a deleted op-log
// generation is refreshed, even after the NEXT generation is compacted past
// its cursor. Before the fix it was admitted onto the new snapshot.
func TestBUG3747_AnOldGenerationsCursorIsRefreshedAfterTheNextIsCompacted(t *testing.T) {
f := newRecoveryFixture(t, nil, time.Minute, materializeRecoveryConfig{})
f.srv.SetOpLogCompactor(recoveryRunner(t))
ts := httptest.NewServer(f.srv)
defer ts.Close()
// Generation 1, compacted; the old tab's cursor is inside it.
itemID, oldCursor := seedDormantCorpusLog(t, f)
f.srv.runOpLogGCTick(time.Hour)
if done, err := f.srv.store.IsCompactedLog(itemID); err != nil || !done {
t.Fatalf("premise: generation 1 compacted: %v, %v", done, err)
}
// A direct write deletes the log (PruneItemOpLogTx is its store call).
tx, err := f.srv.store.DB().Begin()
if err != nil {
t.Fatal(err)
}
if err := f.srv.store.PruneItemOpLogTx(tx, itemID); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
// Generation 2: new rows (higher ids), dormant and flushed, compacted.
raw, err := os.ReadFile("../materialize/testdata/corpus.json")
if err != nil {
t.Fatal(err)
}
var corpus []corpusCaseE2E
if err := json.Unmarshal(raw, &corpus); err != nil {
t.Fatal(err)
}
newMax := f.appendRows(t, itemID, decodeRows(t, corpus[0].Rows)...)
if _, err := f.srv.store.DB().Exec(f.srv.store.D().Rebind(`UPDATE item_yjs_updates SET created_at = '2026-01-01T00:00:00Z' WHERE item_id = ?`), itemID); err != nil {
t.Fatal(err)
}
if err := f.srv.store.SetItemContentFlushedOpLogIDForTesting(itemID, newMax); err != nil {
t.Fatal(err)
}
f.srv.runOpLogGCTick(time.Hour)
if done, err := f.srv.store.IsCompactedLog(itemID); err != nil || !done {
t.Fatalf("premise: generation 2 compacted: %v, %v", done, err)
}
if newMax <= oldCursor {
t.Fatalf("premise: generation 2 reaches past the old cursor (%d <= %d)", newMax, oldCursor)
}
for _, tc := range []struct {
name string
since int64
want string
}{
{"the old generation's cursor", oldCursor, collab.ControlMessageForceRefresh},
{"a cursor inside the new snapshot", newMax, collab.ControlMessageOpLogCursor},
} {
t.Run(tc.name, func(t *testing.T) {
c := dialAt(t, ts.URL, itemID, tc.since)
defer c.Close()
if msg := firstControl(t, c); msg.Type != tc.want {
t.Fatalf("since=%d: first control frame %q, want %q", tc.since, msg.Type, tc.want)
}
})
}
}