Skip to content
16 changes: 16 additions & 0 deletions .changeset/t13256-idle-no-pingpong.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions packages/core/src/cloud/__tests__/nexus-vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
Expand Down
Loading