etherpad-lite/src/node/handler/NewChangesPacker.ts
John McLear 816a4b379b fix(new-changes-batch): address Qodo review on #7768
Two issues raised on the first push:

1. **Rev advanced before send (Bug · Correctness).** The previous
   diff advanced sessioninfo.rev/time inside the collect loop,
   before any emit ran. A concurrent updatePadClients() could then
   see the bumped rev and skip those revisions, and if the emit
   threw later, the skipped revs were lost forever. The client
   enforces strict newRev===rev+1 and silently stops applying on
   mismatch — net effect was a possible pad desync under
   concurrent fan-outs.

   Fix: snapshot startRev/startTime once, claim the
   (startRev, headRev] range by setting sessioninfo.rev = headRev
   immediately (so a concurrent run skips it), build the pending
   list against the local startTime, then emit. If the emit
   throws, roll sessioninfo.rev back to startRev so the next
   fan-out retries. Time is only committed after a successful
   send.

2. **Test re-implemented the decision (Rule violation ·
   Reliability).** The original test re-implemented the
   NEW_CHANGES vs NEW_CHANGES_BATCH switch locally instead of
   exercising the production code. Removing the production logic
   would have left the test green.

   Fix: extract the pure wire-format decision into
   src/node/handler/NewChangesPacker.ts (no DB / pad dependency,
   so the test can import it directly under vitest), and rewrite
   the test to assert against the exported `buildNewChangesEmits`
   function from that module. PadMessageHandler now calls the
   same function; deleting it would fail the test.

9/9 tests across new-changes-batch + prom-instruments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:01:53 +01:00

41 lines
1.5 KiB
TypeScript

// Wire-format decision for NEW_CHANGES vs NEW_CHANGES_BATCH (#7756 lever 3b).
//
// Lives in its own tiny module rather than inside PadMessageHandler so the
// pure decision can be unit-tested without standing up the full pad / DB /
// socket.io stack. PadMessageHandler.updatePadClients calls this function
// once per recipient with the queued revisions for that recipient.
export type NewChangesItem = {
newRev: number;
changeset: string;
apool: unknown;
author: string;
currentTime: number;
timeDelta: number;
};
export type NewChangesEmit =
| {type: 'COLLABROOM'; data: {type: 'NEW_CHANGES'} & NewChangesItem}
| {type: 'COLLABROOM'; data: {type: 'NEW_CHANGES_BATCH'; changes: NewChangesItem[]}};
/**
* Decide what to put on the wire for one recipient.
* - No queued revisions: nothing.
* - Batching disabled, or exactly one rev: emit one NEW_CHANGES per rev
* (legacy behaviour; preserves bytes-on-wire for the steady state).
* - Batching enabled and multiple revs: emit one NEW_CHANGES_BATCH with
* the array of revisions.
*/
export const buildNewChangesEmits = (
pending: NewChangesItem[],
batchEnabled: boolean,
): NewChangesEmit[] => {
if (pending.length === 0) return [];
if (batchEnabled && pending.length > 1) {
return [{type: 'COLLABROOM', data: {type: 'NEW_CHANGES_BATCH', changes: pending}}];
}
return pending.map((change) => ({
type: 'COLLABROOM',
data: {type: 'NEW_CHANGES', ...change},
} as NewChangesEmit));
};