diff --git a/.changeset/t13275-page-clock.md b/.changeset/t13275-page-clock.md new file mode 100644 index 0000000000..d535a84df4 --- /dev/null +++ b/.changeset/t13275-page-clock.md @@ -0,0 +1,10 @@ +--- +id: t13275-page-clock +tasks: [T13275] +kind: fix +summary: A sync apply page's time budget counts applying, not its rewind +--- + +The page time bound (`REBASE_PAGE_MS`) used to be measured from before the page's rewind. When the local scope was large, the rewind +alone could use up the budget. Every page then took a single transaction, and each page rewound and replayed the same scope again, +all under the write lock. The bound now starts once the rewind is done. diff --git a/packages/core/src/store/sync/apply/__tests__/rebase.test.ts b/packages/core/src/store/sync/apply/__tests__/rebase.test.ts index 9d8613b1bc..693ef2ee47 100644 --- a/packages/core/src/store/sync/apply/__tests__/rebase.test.ts +++ b/packages/core/src/store/sync/apply/__tests__/rebase.test.ts @@ -919,6 +919,23 @@ describe('pages (ยง3.5 Rule 3)', () => { expect(last2[0]?.f).not.toBe(last2[1]?.f); }); + it("the time bound counts applying, not the page's rewind (T13275)", async () => { + const [a, b] = await threeReplicas(); + write(a, "UPDATE tasks_tasks SET priority = 'high' WHERE uid = 'x'"); + for (const t of ['t1', 't2', 't3']) { + publish(b, write(b, `UPDATE tasks_tasks SET title = '${t}' WHERE uid = 'x'`)); + } + // Opening the page (its rewind) takes 100 ms; applying takes no time. + const base = Date.now(); + const clock = () => (a.db.isTransaction ? base + 100 : base); + expect(sync(a, published.length, { pageMs: 50, now: clock })).toMatchObject({ + applied: 3, + rebased: 3, + }); + const last3 = frames(a).slice(-3); + expect(new Set(last3.map((x) => x.f)).size, 'the rewind used up the page budget').toBe(1); + }); + it('a page breaks on an actor change and keeps a larger transaction whole', async () => { const [a, b] = await threeReplicas(); publish( diff --git a/packages/core/src/store/sync/apply/applier.ts b/packages/core/src/store/sync/apply/applier.ts index 47aaf3d80a..9db9fec0f6 100644 --- a/packages/core/src/store/sync/apply/applier.ts +++ b/packages/core/src/store/sync/apply/applier.ts @@ -1426,10 +1426,13 @@ export function applyStagedTxns(db: DatabaseSync, opts: ApplyStagedOptions): App const rw: Rewound = rebase ? rewindTxns(c0, rebase) : { kept: new Map(), after: new Map() }; const rewound = new Set(rebase?.rewind.map((l) => l.txn) ?? []); const decided = new Set(); // rewound locals whose echo this page applied + // The time bound covers applying, not the rewind: a large scope must + // not shrink every page to one transaction that rewinds it again. + const applyStart = now(); let taken = 0; for (const { st } of page) { // The time bound cuts a page between transactions (never inside one). - if (taken > 0 && now() - pageStart > (opts.pageMs ?? REBASE_PAGE_MS)) break; + if (taken > 0 && now() - applyStart > (opts.pageMs ?? REBASE_PAGE_MS)) break; taken += 1; const nowMs = now(); const nowIso = new Date(nowMs).toISOString();