diff --git a/.changeset/t13256-idle-no-pingpong.md b/.changeset/t13256-idle-no-pingpong.md new file mode 100644 index 0000000000..8f1703180f --- /dev/null +++ b/.changeset/t13256-idle-no-pingpong.md @@ -0,0 +1,16 @@ +--- +id: t13256-idle-no-pingpong +tasks: [T13256] +kind: test +summary: "Two idle replicas syncing in a loop exchange no segments after the first round (journal spec §3.5 R7-7)" +--- + +A new two-device test runs on the fake server. Device B joins the journal device A started, and each device makes one real +write. One round of `cleo cloud sync` carries both writes across. Then four more rounds with no writes run on each device. + +- No device sends a segment in those rounds, and the stream's segment count does not move. +- Applying the other device's segment never makes a device seal or send one back: the apply's echo is subtracted (§3.3). +- Both stores end with the same rows. + +This test guards the R7-7 rule for when receive watermarks land: a watermark rides on an ordinary segment and is never sent +alone after a pull. diff --git a/packages/core/src/cloud/__tests__/nexus-vault.test.ts b/packages/core/src/cloud/__tests__/nexus-vault.test.ts index 8b8d067d9c..9bdb6e1a8d 100644 --- a/packages/core/src/cloud/__tests__/nexus-vault.test.ts +++ b/packages/core/src/cloud/__tests__/nexus-vault.test.ts @@ -5787,6 +5787,57 @@ describe('sync enable push: the genesis checkpoint (T12343 S4-1b)', () => { }); }); + it('two idle replicas syncing in a loop exchange no segments after the first round (T13256, §3.5 R7-7)', async () => { + const { m: a, dbPath: aDb } = await journalMachine(); + await on(a, () => enableSyncPush(vopts(a, { allowUnreleased: true }))); + await on(a, async () => { + setSyncFlag(await storeOf(aDb), 'sync.pull', true, { allowUnreleased: true }); + }); + const { b, bDb } = await restoredJoiner(); + expect((await on(b, () => enableSyncPush(vopts(b, { allowUnreleased: true })))).status).toBe( + 'joined', + ); + const sync = async (m: Machine) => { + const r = await on(m, () => cloudSync(vopts(m, { scope: 'project', allowUnreleased: true }))); + expect(r.streams[0]?.status, `${m.name}: ${r.streams[0]?.refused}`).toBe('synced'); + return r.streams[0]; + }; + // One real write each, then a first round carries them both ways. + await on(a, async () => { + (await storeOf(aDb)).exec("UPDATE tasks_tasks SET title = 'from A' WHERE id = 'T1'"); + }); + await on(b, async () => { + (await storeOf(bDb)).exec("UPDATE tasks_tasks SET title = 'from B' WHERE id = 'T2'"); + }); + await sync(a); + await sync(b); + await sync(a); + const settled = fake.stream(STREAM).segments.length; + expect(settled).toBe(2); + // Idle from here on: applying the other's segment never makes either side send one back. + for (let round = 0; round < 4; round++) { + const ra = await sync(a); + const rb = await sync(b); + // Nothing sealed (no echo re-sealed and netted away), sent or received. + for (const [who, r] of [ + ['A', ra], + ['B', rb], + ] as const) { + expect(r?.sealed, `round ${round}: ${who} sealed`).toBe(0); + expect(r?.sent, `round ${round}: ${who} sent`).toBe(0); + expect(r?.received, `round ${round}: ${who} received`).toBe(0); + } + } + expect(fake.stream(STREAM).segments).toHaveLength(settled); + const titles = "SELECT id, title FROM tasks_tasks WHERE id IN ('T1', 'T2') ORDER BY id"; + const onA = await on(a, async () => (await storeOf(aDb)).prepare(titles).all()); + expect(await on(b, async () => (await storeOf(bDb)).prepare(titles).all())).toEqual(onA); + expect(onA).toEqual([ + { id: 'T1', title: 'from A' }, + { id: 'T2', title: 'from B' }, + ]); + }); + it('a joined store with push off syncs pull-only: synced, the push leg skipped (T13312)', async () => { const { m: a, dbPath: aDb } = await journalMachine(); await on(a, () => enableSyncPush(vopts(a, { allowUnreleased: true })));