-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(scaling): serialize per-socket fan-out + NEW_CHANGES_BATCH #7881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
deepshekhardas
wants to merge
1
commit into
ether:develop
from
deepshekhardas:fix/pr-7768-socket-fanout-batch
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // 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)); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,7 @@ const hooks = require('../../static/js/pluginfw/hooks'); | |
| const stats = require('../stats') | ||
| const assert = require('assert').strict; | ||
| import {recordChangesetApply, recordSocketEmit} from '../prom-instruments'; | ||
| import {buildNewChangesEmits, type NewChangesItem} from './NewChangesPacker'; | ||
| import {RateLimiterMemory} from 'rate-limiter-flexible'; | ||
| import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest"; | ||
| import {APool, AText, PadAuthor, PadType} from "../types/PadType"; | ||
|
|
@@ -1011,45 +1012,76 @@ exports.updatePadClients = async (pad: PadType) => { | |
| // but benefit of reusing cached revision object is HUGE | ||
| const revCache:MapArrayType<any> = {}; | ||
|
|
||
| // When `settings.newChangesBatch` is true and a recipient is more than one | ||
| // revision behind, pack the queued revisions into a single NEW_CHANGES_BATCH | ||
| // emit per recipient. The engine.io WebSocket transport sends one frame per | ||
| // packet (the polling transport already batches at the HTTP-response layer), | ||
| // so reducing the packet count translates directly into fewer system calls | ||
| // on the server and fewer onmessage callbacks on the client. | ||
| const batchEnabled = settings.newChangesBatch === true; | ||
|
|
||
| await Promise.all(roomSockets.map(async (socket) => { | ||
| const sessioninfo = sessioninfos[socket.id]; | ||
| // The user might have disconnected since _getRoomSockets() was called. | ||
| if (sessioninfo == null) return; | ||
|
|
||
| while (sessioninfo.rev < pad.getHeadRevisionNumber()) { | ||
| const r = sessioninfo.rev + 1; | ||
| let revision = revCache[r]; | ||
| if (!revision) { | ||
| revision = await pad.getRevision(r); | ||
| revCache[r] = revision; | ||
| } | ||
|
|
||
| const author = revision.meta.author; | ||
| const revChangeset = revision.changeset; | ||
| const currentTime = revision.meta.timestamp; | ||
| // Snapshot the local state so a concurrent updatePadClients() can't make | ||
| // us double-emit. We hold the "I'm responsible for revs (startRev, | ||
| // headRev]" claim by reading sessioninfo.rev once and overwriting it | ||
| // before any await. A second invocation arriving mid-loop will see the | ||
| // bumped rev and skip those revisions; if our emit fails the catch | ||
| // below rolls sessioninfo.rev back so they aren't lost. | ||
| const startRev = sessioninfo.rev; | ||
| const headRev = pad.getHeadRevisionNumber(); | ||
| if (startRev >= headRev) return; | ||
| const startTime = sessioninfo.time; | ||
| // Claim the range immediately so concurrent runs skip it. | ||
| sessioninfo.rev = headRev; | ||
|
|
||
|
Comment on lines
+1034
to
+1040
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Premature rev claim updatePadClients() sets sessioninfo.rev=headRev before emitting any NEW_CHANGES/NEW_CHANGES_BATCH, so other server logic can believe a client is caught up when it is not. This can cause ACCEPT_COMMIT (or subsequent NEW_CHANGES) to arrive with newRev > clientRev+1, which the client treats as a bad revision and stops processing messages. Agent Prompt
|
||
| // Collect all queued revisions for this socket. | ||
| const pending: Array<{ | ||
| newRev: number; | ||
| changeset: string; | ||
| apool: unknown; | ||
| author: string; | ||
| currentTime: number; | ||
| timeDelta: number; | ||
| }> = []; | ||
|
|
||
| const forWire = prepareForWire(revChangeset, pad.pool); | ||
| const msg = { | ||
| type: 'COLLABROOM', | ||
| data: { | ||
| type: 'NEW_CHANGES', | ||
| try { | ||
| let previousTime = startTime; | ||
| for (let r = startRev + 1; r <= headRev; r++) { | ||
| let revision = revCache[r]; | ||
| if (!revision) { | ||
| revision = await pad.getRevision(r); | ||
| revCache[r] = revision; | ||
| } | ||
| const author = revision.meta.author; | ||
| const revChangeset = revision.changeset; | ||
| const currentTime = revision.meta.timestamp; | ||
| const forWire = prepareForWire(revChangeset, pad.pool); | ||
| pending.push({ | ||
| newRev: r, | ||
| changeset: forWire.translated, | ||
| apool: forWire.pool, | ||
| author, | ||
| currentTime, | ||
| timeDelta: currentTime - sessioninfo.time, | ||
| }, | ||
| }; | ||
| try { | ||
| socket.emit('message', msg); | ||
| recordSocketEmit('NEW_CHANGES'); | ||
| } catch (err:any) { | ||
| messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`); | ||
| return; | ||
| timeDelta: currentTime - previousTime, | ||
| }); | ||
| previousTime = currentTime; | ||
| } | ||
|
|
||
| for (const emit of buildNewChangesEmits(pending, batchEnabled)) { | ||
| socket.emit('message', emit); | ||
| recordSocketEmit(emit.data.type); | ||
| } | ||
| sessioninfo.time = currentTime; | ||
| sessioninfo.rev = r; | ||
| // Only after the wire send succeeds do we commit the new time. | ||
| sessioninfo.time = previousTime; | ||
| } catch (err: any) { | ||
| // Roll back the claim so the next updatePadClients retries these revs. | ||
| // Only set rev back if no one else has advanced past us in the meantime. | ||
| if (sessioninfo.rev === headRev) sessioninfo.rev = startRev; | ||
| messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`); | ||
| } | ||
| })); | ||
| }; | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // Regression test for the NEW_CHANGES_BATCH wire-format decision | ||
| // (#7756 lever 3b). Imports the real implementation from | ||
| // PadMessageHandler so removing or breaking the production batching | ||
| // logic fails this test. | ||
|
|
||
| import {describe, it, expect, beforeEach, afterEach} from 'vitest'; | ||
| import settings from '../../../node/utils/Settings'; | ||
| import {buildNewChangesEmits, type NewChangesItem} from '../../../node/handler/NewChangesPacker'; | ||
|
|
||
| const ORIGINAL_FLAG = settings.newChangesBatch; | ||
|
|
||
| beforeEach(() => { settings.newChangesBatch = false; }); | ||
| afterEach(() => { settings.newChangesBatch = ORIGINAL_FLAG; }); | ||
|
|
||
| const fakePending = (n: number): NewChangesItem[] => | ||
| Array.from({length: n}, (_, i) => ({ | ||
| newRev: i + 1, changeset: `=${i}`, apool: {}, author: 'a.1', | ||
| currentTime: 1_000 * (i + 1), timeDelta: 1_000, | ||
| })); | ||
|
|
||
| describe('buildNewChangesEmits', () => { | ||
| it('flag OFF: one NEW_CHANGES per rev regardless of count', () => { | ||
| const emits = buildNewChangesEmits(fakePending(5), false); | ||
| expect(emits).toHaveLength(5); | ||
| expect(emits.every((e) => e.data.type === 'NEW_CHANGES')).toBe(true); | ||
| }); | ||
|
|
||
| it('flag ON, single rev: still NEW_CHANGES (no batch overhead for the steady state)', () => { | ||
| const emits = buildNewChangesEmits(fakePending(1), true); | ||
| expect(emits).toHaveLength(1); | ||
| expect(emits[0]!.data.type).toBe('NEW_CHANGES'); | ||
| }); | ||
|
|
||
| it('flag ON, multiple revs: a single NEW_CHANGES_BATCH carrying all of them', () => { | ||
| const emits = buildNewChangesEmits(fakePending(5), true); | ||
| expect(emits).toHaveLength(1); | ||
| expect(emits[0]!.data.type).toBe('NEW_CHANGES_BATCH'); | ||
| const batch = emits[0]!.data as {type: 'NEW_CHANGES_BATCH'; changes: NewChangesItem[]}; | ||
| expect(batch.changes).toHaveLength(5); | ||
| expect(batch.changes[0]!.newRev).toBe(1); | ||
| expect(batch.changes[4]!.newRev).toBe(5); | ||
| }); | ||
|
|
||
| it('empty pending list emits nothing', () => { | ||
| expect(buildNewChangesEmits([], true)).toEqual([]); | ||
| expect(buildNewChangesEmits([], false)).toEqual([]); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Lost revisions on race
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools