-
Notifications
You must be signed in to change notification settings - Fork 331
[core] Gate the unconsumed-event check on delivery idleness #3439
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
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 @@ | ||
| --- | ||
| '@workflow/core': patch | ||
| --- | ||
|
|
||
| Stop reporting replay divergence for an event the workflow is still on its way to consuming, by waiting for in-flight step and hook deliveries instead of a fixed delay |
| Original file line number | Diff line number | Diff line change | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -65,6 +65,16 @@ export interface EventsConsumerOptions { | ||||||||||
| * deserialization delays the resolve() that triggers the next subscribe(). | |||||||||||
| */ | |||||||||||
| getPromiseQueue: () => Promise<void>; | |||||||||||
| /** | |||||||||||
| * Whether no data delivery is in flight (`isDeliveryIdle` in private.ts). | |||||||||||
| * The unconsumed-event check waits for this before it fires: a delivery in | |||||||||||
| * flight means the workflow VM is mid-reaction, and an event it has not | |||||||||||
| * claimed yet is an event it has not reached yet. | |||||||||||
| * | |||||||||||
| * Defaults to always-idle so the tests that drive a consumer with no | |||||||||||
| * orchestrator context keep the pre-existing timing. | |||||||||||
| */ | |||||||||||
| isDeliveryIdle?: () => boolean; | |||||||||||
| } | |||||||||||
|
|
|||||||||||
| export class EventsConsumer { | |||||||||||
|
|
@@ -74,6 +84,7 @@ export class EventsConsumer { | ||||||||||
| private onConsumedEvent?: (event: Event) => void; | |||||||||||
| private onUnconsumedEvent: (event: Event) => void; | |||||||||||
| private getPromiseQueue: () => Promise<void>; | |||||||||||
| private isDeliveryIdle: () => boolean; | |||||||||||
| private pendingUnconsumedCheck: Promise<void> | null = null; | |||||||||||
| private pendingUnconsumedTimeout: ReturnType<typeof setTimeout> | null = null; | |||||||||||
| private unconsumedCheckVersion = 0; | |||||||||||
|
|
@@ -87,6 +98,7 @@ export class EventsConsumer { | ||||||||||
| this.onConsumedEvent = options.onConsumedEvent; | |||||||||||
| this.onUnconsumedEvent = options.onUnconsumedEvent; | |||||||||||
| this.getPromiseQueue = options.getPromiseQueue; | |||||||||||
| this.isDeliveryIdle = options.isDeliveryIdle ?? (() => true); | |||||||||||
| } | |||||||||||
|
|
|||||||||||
| append(events: Event[]): void { | |||||||||||
|
|
@@ -218,22 +230,71 @@ export class EventsConsumer { | ||||||||||
| ) | |||||||||||
| .then(() => this.getPromiseQueue()) | |||||||||||
| .then(() => { | |||||||||||
| // Use a delayed setTimeout after the queue drains. The delay must be | |||||||||||
| // long enough for promise chains to propagate across the VM boundary | |||||||||||
| // (from resolve() in the host context through to the workflow code | |||||||||||
| // calling subscribe() in the VM context). Node.js does not guarantee | |||||||||||
| // that setTimeout(0) fires after all cross-context microtasks settle, | |||||||||||
| // so we use a small but non-zero delay. Any subscribe() call that | |||||||||||
| // arrives during this window will cancel the check via version | |||||||||||
| // invalidation + clearTimeout. | |||||||||||
| this.pendingUnconsumedTimeout = setTimeout(() => { | |||||||||||
| this.pendingUnconsumedTimeout = null; | |||||||||||
| if (this.unconsumedCheckVersion === checkVersion) { | |||||||||||
| this.pendingUnconsumedCheck = null; | |||||||||||
| this.onUnconsumedEvent(currentEvent); | |||||||||||
| } | |||||||||||
| }, getDeferredCheckDelayMs()); | |||||||||||
| // Wait out any delivery still in flight before starting the timer. | |||||||||||
| // The queue draining says the host has no hydration work left; it | |||||||||||
| // does not say the VM has finished reacting to what was hydrated. | |||||||||||
| this.whenDeliveryIdle(checkVersion, () => { | |||||||||||
|
Member
Author
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. Changes in this file just wrap the existing code block for |
|||||||||||
| // Use a delayed setTimeout once deliveries are idle. The delay must | |||||||||||
| // be long enough for promise chains to propagate across the VM | |||||||||||
| // boundary (from resolve() in the host context through to the | |||||||||||
| // workflow code calling subscribe() in the VM context). Node.js | |||||||||||
| // does not guarantee that setTimeout(0) fires after all | |||||||||||
| // cross-context microtasks settle, so we use a small but non-zero | |||||||||||
| // delay. Any subscribe() call that arrives during this window will | |||||||||||
| // cancel the check via version invalidation + clearTimeout. | |||||||||||
| this.pendingUnconsumedTimeout = setTimeout(() => { | |||||||||||
| this.pendingUnconsumedTimeout = null; | |||||||||||
| if (this.unconsumedCheckVersion === checkVersion) { | |||||||||||
| this.pendingUnconsumedCheck = null; | |||||||||||
| this.onUnconsumedEvent(currentEvent); | |||||||||||
| } | |||||||||||
| }, getDeferredCheckDelayMs()); | |||||||||||
| }); | |||||||||||
| }); | |||||||||||
| } | |||||||||||
| } | |||||||||||
|
|
|||||||||||
| /** | |||||||||||
| * Run `fn` once no data delivery is in flight, polling the way | |||||||||||
| * `scheduleWhenIdle` does: let the promise queue drain, re-check a timer | |||||||||||
| * tick later, repeat. | |||||||||||
| * | |||||||||||
| * Without this the check is a bet that every delivery the walk is running | |||||||||||
| * ahead of lands inside a fixed window. Consumption is synchronous while the | |||||||||||
| * resolution it triggers is not: a step result hydrates in the host, resolves | |||||||||||
| * from a detached continuation behind `awaitEarlierDeliveries`, and only then | |||||||||||
| * does VM code run far enough to subscribe the next consumer. Replaying a | |||||||||||
| * batch of N parallel step results leaves N-1 of them on that detached path | |||||||||||
| * with the queue already drained, so the walk sits on the ordered event the | |||||||||||
| * VM is about to draw and the window is the only thing standing between a | |||||||||||
| * healthy run and `ReplayDivergenceError`. On a backend whose deliveries take | |||||||||||
| * longer than the window, that bet loses: the local race repro corrupts 34 of | |||||||||||
| * 42 runs at a 10ms window and 0 of 114 at 100ms, on the same event logs. | |||||||||||
|
Member
Author
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. AI Review: Note These numbers establish the mechanism, not the ship decision. 0 of 114 at the default 100ms and 34 of 42 at 10ms says a too-short window manufactures divergence; it does not show the default window being lost. The claim actually carrying the change is that world-vercel deliveries outrun 100ms, and neither the comment nor the PR body has data behind it. Either cite the world-vercel evidence, or drop the implication and describe this as hardening the check against a window it is not currently observed to lose. As written, someone tuning |
|||||||||||
| * | |||||||||||
| * Termination is `hasParkedCommittedDelivery`'s: it counts only deliveries | |||||||||||
| * that resolve on their own, so nothing here can gate its own retirement. A | |||||||||||
| * genuinely orphaned event has no delivery to wait on and reaches `fn` on the | |||||||||||
| * first poll. | |||||||||||
|
Member
Author
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. AI Review: Note Termination holds, but the check stops being a divergence backstop whenever a delivery is in flight, and this paragraph reads as though it still is. Both decisions now wake from the same Measured on one context driving both decisions with one armed
Not a regression to fix here: pre-PR the suspension already won whenever the delivery landed inside the 100ms window, so this determinizes an outcome that was timing-dependent. But "a genuinely orphaned event has no delivery to wait on and reaches |
|||||||||||
| */ | |||||||||||
| private whenDeliveryIdle(checkVersion: number, fn: () => void): void { | |||||||||||
| const poll = () => { | |||||||||||
| this.pendingUnconsumedTimeout = null; | |||||||||||
| if (this.unconsumedCheckVersion !== checkVersion) { | |||||||||||
| return; | |||||||||||
| } | |||||||||||
| if (this.isDeliveryIdle()) { | |||||||||||
| fn(); | |||||||||||
| return; | |||||||||||
| } | |||||||||||
| this.getPromiseQueue().then(() => { | |||||||||||
| if (this.unconsumedCheckVersion !== checkVersion) { | |||||||||||
| return; | |||||||||||
| } | |||||||||||
| // Held in the same field the fired check uses so subscribe() cancels a | |||||||||||
| // poll in progress exactly as it cancels the check itself. | |||||||||||
| this.pendingUnconsumedTimeout = setTimeout(poll, 0); | |||||||||||
|
Member
Author
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. AI Review: Nit
I tested this rather than guessing: stacked |
|||||||||||
| }); | |||||||||||
| }; | |||||||||||
| poll(); | |||||||||||
| } | |||||||||||
| } | |||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -583,7 +583,9 @@ export function registerDeliveryBarrier( | |
| * {@link registerDeliveryBarrier}) and never need that net, so waiting on | ||
| * them is deadlock-free. | ||
| */ | ||
| function hasParkedCommittedDelivery(ctx: WorkflowOrchestratorContext): boolean { | ||
| export function hasParkedCommittedDelivery( | ||
| ctx: WorkflowOrchestratorContext | ||
| ): boolean { | ||
| const barriers = ctx.pendingDeliveryBarriers; | ||
| if (!barriers || barriers.size === 0) { | ||
| return false; | ||
|
|
@@ -599,19 +601,33 @@ function hasParkedCommittedDelivery(ctx: WorkflowOrchestratorContext): boolean { | |
| } | ||
|
|
||
| /** | ||
| * Schedule a callback to fire only after all pending data deliveries | ||
| * (step results, hook payloads) and async deserialization have completed. | ||
| * Uses a polling loop: setTimeout(0) → check pendingDeliveries and the | ||
| * barrier registry → if anything is still in flight, wait for promiseQueue → | ||
| * repeat. This handles the multi-round delivery pattern where each hook | ||
| * payload delivery cycle appends new async work to the promiseQueue. | ||
| * Whether no data delivery (step result, hook payload) is in flight right now. | ||
| * | ||
| * "In flight" is two distinct windows, each with its own guard: | ||
| * `pendingDeliveries > 0` covers hydration inside the serial queue slots, and | ||
| * {@link hasParkedCommittedDelivery} covers the detached gap between a slot | ||
| * releasing that counter and the delivery's `resolve()` actually running — | ||
| * deliberately outside `pendingDeliveries` (see step.ts), and invisible to it. | ||
| * | ||
| * Anything that decides a replay is over, or that a replay went wrong, has to | ||
| * consult this first: while it is false the workflow VM is mid-reaction, so | ||
| * what it has and has not done yet says nothing about the run. Two callers | ||
| * read it, for the two such decisions: {@link scheduleWhenIdle} for the | ||
| * suspension, and the events consumer's unconsumed-event check for divergence. | ||
| */ | ||
| export function isDeliveryIdle(ctx: WorkflowOrchestratorContext): boolean { | ||
|
Member
Author
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 file is a no-op except for exporting this helper |
||
| return ctx.pendingDeliveries === 0 && !hasParkedCommittedDelivery(ctx); | ||
| } | ||
|
|
||
| /** | ||
| * Schedule a callback to fire only after all pending data deliveries | ||
| * (step results, hook payloads) and async deserialization have completed. | ||
| * Uses a polling loop: setTimeout(0) → check pendingDeliveries and the | ||
| * barrier registry → if anything is still in flight, wait for promiseQueue → | ||
| * repeat. This handles the multi-round delivery pattern where each hook | ||
| * payload delivery cycle appends new async work to the promiseQueue. What | ||
| * counts as in flight is {@link isDeliveryIdle}. | ||
| * | ||
| * The initial `setTimeout(0)` macrotask is load-bearing and must NOT be | ||
| * downgraded to a microtask (`queueMicrotask`/`Promise.resolve().then`). | ||
| * `pendingDeliveries` only guards the host-side hydration window; between a | ||
|
|
@@ -629,7 +645,7 @@ export function scheduleWhenIdle( | |
| fn: () => void | ||
| ): void { | ||
| const check = () => { | ||
| if (ctx.pendingDeliveries > 0 || hasParkedCommittedDelivery(ctx)) { | ||
| if (!isDeliveryIdle(ctx)) { | ||
| // A delivery is still hydrating, or is committed but parked behind its | ||
| // deferral (whose resolve runs on a detached timer, not this queue). | ||
| // Either way: let the queue drain, then re-check a timer tick 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.
AI Review: Nit
The optional-with-always-idle default means a future second construction site silently opts out of the fix, and silently, because always-idle is exactly the pre-PR behavior. There is one production site today (
workflow.ts:396), so making this required and having the unit tests pass() => trueexplicitly costs a few lines and removes that failure mode.