-
Notifications
You must be signed in to change notification settings - Fork 331
Fix requestSnapshot publish ordering #4282
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@electric-sql/client': patch | ||
| --- | ||
|
|
||
| Fix `requestSnapshot()` so it resolves only after the injected snapshot batch has been delivered to subscribers, including async and reentrant subscriber paths. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -609,6 +609,12 @@ export class ShapeStream<T extends Row<unknown> = Row> | |
| #tickPromiseResolver?: () => void | ||
| #tickPromiseRejecter?: (reason?: unknown) => void | ||
| #messageChain = Promise.resolve<void[]>([]) // promise chain for incoming messages | ||
| // Tracks when subscriber callbacks are actively being delivered from | ||
| // #messageChain. requestSnapshot can inject a nested batch from inside a | ||
| // subscriber; in that reentrant case #publish uses this as an intentional | ||
| // escape hatch to deliver the nested snapshot batch immediately rather than | ||
| // queueing it behind the subscriber that is awaiting it. | ||
| #isPublishing = false | ||
| #snapshotTracker = new SnapshotTracker() | ||
| #pauseLock: PauseLock | ||
| #currentFetchUrl?: URL // Current fetch URL for computing shape key | ||
|
|
@@ -1719,11 +1725,7 @@ export class ShapeStream<T extends Row<unknown> = Row> | |
| } | ||
|
|
||
| async #publish(messages: Message<T>[]): Promise<void[]> { | ||
| // We process messages asynchronously | ||
| // but SSE's `onmessage` handler is synchronous. | ||
| // We use a promise chain to ensure that the handlers | ||
| // execute sequentially in the order the messages were received. | ||
| this.#messageChain = this.#messageChain.then(() => | ||
| const deliver = () => | ||
| Promise.all( | ||
| Array.from(this.#subscribers.values()).map(async ([callback, __]) => { | ||
| try { | ||
|
|
@@ -1735,7 +1737,25 @@ export class ShapeStream<T extends Row<unknown> = Row> | |
| } | ||
| }) | ||
| ) | ||
| ) | ||
|
|
||
| // We process messages asynchronously but SSE's `onmessage` handler is | ||
| // synchronous. Use a promise chain to ensure handlers execute sequentially | ||
| // in the order messages were received. If a subscriber reentrantly requests | ||
| // a snapshot, deliver that nested batch immediately instead of appending it | ||
| // behind the currently-running subscriber callback, which would deadlock | ||
| // when requestSnapshot awaits publication. | ||
| if (this.#isPublishing) { | ||
| return deliver() | ||
|
Contributor
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. Since we call
So the per-subscriber invariant "my callback won't be re-entered while a previous invocation is in flight" is broken for any bystander subscriber whose M1 callback is still awaiting something when the reentrant publish fires. The messageChain-level ordering is preserved (next queued batch still waits), but B can observe In practice this may be fine — if subscribers are effectively stateless across awaits, the window is invisible. But a subscriber that mutates |
||
| } | ||
|
Comment on lines
+1747
to
+1749
Contributor
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. This feels a bit odd - we have a message chain promise to serialize message processing, which gets bypassed if you publish messages while a batch of messages is being processed. Effectively, anyone calling I'd much prefer if we could explicitly specify an API parameter to skip the strict ordering such that the
Contributor
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. Just seeing this, turns out we have the same concern :) |
||
|
|
||
| this.#messageChain = this.#messageChain.then(async () => { | ||
| this.#isPublishing = true | ||
| try { | ||
| return await deliver() | ||
| } finally { | ||
| this.#isPublishing = false | ||
| } | ||
| }) | ||
|
|
||
| return this.#messageChain | ||
| } | ||
|
|
@@ -1901,7 +1921,7 @@ export class ShapeStream<T extends Row<unknown> = Row> | |
| metadata, | ||
| new Set(data.map((message) => message.key)) | ||
| ) | ||
| this.#onMessages(dataWithEndBoundary, false) | ||
| await this.#onMessages(dataWithEndBoundary, false) | ||
|
|
||
| // On cold start the stream's offset is still at "now". Advance it | ||
| // to the snapshot's position so no updates are missed in between. | ||
|
|
||
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.
I think we should try to avoid agent comment-creep - the flag's function is fairly clear with the comment left when used in code further down