Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-buttons-swim.md
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
85 changes: 84 additions & 1 deletion packages/core/src/events-consumer.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { withResolvers } from '@workflow/utils';
import type { Event } from '@workflow/world';
import { describe, expect, it, vi } from 'vitest';
import { EventConsumerResult, EventsConsumer } from './events-consumer.js';
import {
DEFERRED_CHECK_DELAY_MS,
EventConsumerResult,
EventsConsumer,
} from './events-consumer.js';

// Helper function to create mock events
function createMockEvent(overrides: Partial<Event> = {}): Event {
Expand Down Expand Up @@ -476,4 +480,83 @@ describe('EventsConsumer', () => {
expect(onUnconsumedEvent).not.toHaveBeenCalled();
});
});

describe('delivery-idle gate', () => {
// An event nobody claims is only evidence of divergence once the workflow
// VM has stopped reacting. While a delivery is in flight the walk is
// simply ahead of the code that would register the consumer, so the check
// has to wait rather than time out. See `isDeliveryIdle` in private.ts.
it('should not fire the unconsumed check while a delivery is in flight', async () => {
const event = createMockEvent();
const onUnconsumedEvent = vi.fn();
let idle = false;
const consumer = new EventsConsumer([event], {
onUnconsumedEvent,
getPromiseQueue: () => Promise.resolve(),
isDeliveryIdle: () => idle,
});

consumer.subscribe(
vi.fn().mockReturnValue(EventConsumerResult.NotConsumed)
);

// Several times the window the check would otherwise have fired in.
await new Promise((resolve) =>
setTimeout(resolve, DEFERRED_CHECK_DELAY_MS * 5)
);
expect(onUnconsumedEvent).not.toHaveBeenCalled();

idle = true;
await vi.waitFor(() => {
expect(onUnconsumedEvent).toHaveBeenCalledWith(event);
});
});

it('should let a consumer registered during the wait claim the event', async () => {
const event = createMockEvent();
const onUnconsumedEvent = vi.fn();
let idle = false;
const consumer = new EventsConsumer([event], {
onUnconsumedEvent,
getPromiseQueue: () => Promise.resolve(),
isDeliveryIdle: () => idle,
});

consumer.subscribe(
vi.fn().mockReturnValue(EventConsumerResult.NotConsumed)
);
await new Promise((resolve) =>
setTimeout(resolve, DEFERRED_CHECK_DELAY_MS * 2)
);

// What the in-flight delivery was on its way to doing: resume workflow
// code that subscribes the consumer this event belongs to.
consumer.subscribe(vi.fn().mockReturnValue(EventConsumerResult.Finished));
idle = true;

await vi.waitFor(() => {
expect(consumer.eventIndex).toBe(1);
});
await new Promise((resolve) =>
setTimeout(resolve, DEFERRED_CHECK_DELAY_MS * 2)
);
expect(onUnconsumedEvent).not.toHaveBeenCalled();
});

it('should fire without delay for an event no delivery is waiting on', async () => {
const event = createMockEvent();
const unconsumedReceived = withResolvers<Event>();
const consumer = new EventsConsumer([event], {
onUnconsumedEvent: unconsumedReceived.resolve,
getPromiseQueue: () => Promise.resolve(),
isDeliveryIdle: () => true,
});

consumer.subscribe(
vi.fn().mockReturnValue(EventConsumerResult.NotConsumed)
);

expect(await unconsumedReceived.promise).toEqual(event);
});
});
});
91 changes: 76 additions & 15 deletions packages/core/src/events-consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member Author

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 () => true explicitly costs a few lines and removes that failure mode.

}

export class EventsConsumer {
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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, () => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file just wrap the existing code block for this.pendingUnconsumedTimeout in this.whenDeliveryIdle(checkVersion ,() => {

// 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 WORKFLOW_DEFERRED_CHECK_DELAY_MS later will read this table as proof the default is marginal.

*
* 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 isDeliveryIdle edge, with different post-idle timing: scheduleWhenIdle fires on the first timer tick after idle, this check waits a further DEFERRED_CHECK_DELAY_MS. So the suspension always wins, and a pending sleep() arms one on every replay. onWorkflowError's 'suspended' branch then discards what arrives second (state = { type: 'replay' }, nothing surfaced, the interruption was already rejected with the suspension). For a genuinely diverged log with an armed delivery in flight the outcome is therefore "suspend, then demote a later resume to cold replay", not ReplayDivergenceError.

Measured on one context driving both decisions with one armed registerDeliveryBarrier, delay floored to 10ms:

before delivery lands after
gate off [divergence] [divergence, suspension]
gate on [] [suspension, divergence]

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 fn on the first poll" is only the no-delivery case. Worth a sentence saying that when a delivery is in flight, the suspension gets there first and the divergence this eventually reports is dropped, so nothing should treat the check as the mechanism that catches a diverged log.

*/
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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

pendingUnconsumedTimeout is now written by two state machines, and poll() nulls it before checking its version, so a stale poll can clear the live chain's handle and leave its timer unclearable by subscribe().

I tested this rather than guessing: stacked append() calls while a check is parked on the gate, then a late subscribe(). No double-fire, and no report for the claimed event. The version guard covers it, so this is cosmetic. Noting only because the comment says the poll is held here so subscribe() cancels it "exactly as it cancels the check itself", and the two are not quite equivalent: for the poll the clearTimeout is redundant and the version bump is what does the work.

});
};
poll();
}
}
32 changes: 24 additions & 8 deletions packages/core/src/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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.
Expand Down
Loading
Loading