Skip to content

Commit 82e2678

Browse files
[core] Retain workflow VMs across attributes (#3609)
* Retain workflow VMs across attributes * Restore runtime logger spy automatically * Explain retained attribute race ordering * Update retained attribute telemetry expectation * Clarify retained attribute replay coverage * Clarify retained attribute execution comment
1 parent 556f3f0 commit 82e2678

7 files changed

Lines changed: 81 additions & 54 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/core': patch
3+
---
4+
5+
Retain workflow VMs across workflow attribute writes.

‎packages/core/src/private.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,11 @@ export interface WorkflowOrchestratorContext {
137137
globalThis: typeof globalThis;
138138
/**
139139
* Increments when a suspension is accepted and on every retained-session
140-
* resume. Step and hook suspension signals capture it when scheduled and
141-
* no-op if it moved, which drops same-boundary sibling signals and timers
142-
* queued at boundary N that would fire after the session resumed into
143-
* boundary N+1. Sleep and attribute signals are intentionally unguarded:
144-
* their presence makes the boundary unretainable, so a late signal correctly
140+
* resume. Step, hook, and attribute suspension signals capture it when
141+
* scheduled and no-op if it moved, which drops same-boundary sibling signals
142+
* and timers queued at boundary N that would fire after the session resumed
143+
* into boundary N+1. Sleep signals are intentionally unguarded: their
144+
* presence makes the boundary unretainable, so a late signal correctly
145145
* demotes the session (workflow.ts `onWorkflowError`).
146146
*/
147147
suspensionGeneration: number;

‎packages/core/src/retained-vm-loop.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ const concurrentHookWakeWorkflow = `const createHook = globalThis[Symbol.for("WO
144144
}
145145
globalThis.__private_workflows = new Map([["workflow", workflow]]);`;
146146

147+
const attributeThenStepWorkflow = `const setAttributes = globalThis[Symbol.for("WORKFLOW_SET_ATTRIBUTES")];
148+
const s1 = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("r_s1");
149+
async function workflow() {
150+
await setAttributes([{ key: "phase", value: "ready" }]);
151+
return await s1();
152+
}
153+
globalThis.__private_workflows = new Map([["workflow", workflow]]);`;
154+
147155
// Hook metadata is serialized at the same suspension boundary as step input.
148156
// A getter mutating workflow state must demote the retained VM just like an
149157
// unsafe step argument, because a cold replay skips serialization after the
@@ -781,6 +789,24 @@ describe('retained VM through the inline replay loop', () => {
781789
expect(on.durableLog).toEqual(off.durableLog);
782790
});
783791

792+
it('retains one VM across an attribute write and the following step', async () => {
793+
process.env.WORKFLOW_RETAINED_VM = '0';
794+
const off = await drive(
795+
'wrun_retained_attribute_then_step',
796+
attributeThenStepWorkflow
797+
);
798+
createContextSpy.mockClear();
799+
delete process.env.WORKFLOW_RETAINED_VM;
800+
801+
const on = await drive(
802+
'wrun_retained_attribute_then_step',
803+
attributeThenStepWorkflow
804+
);
805+
expect(on.result).toBe(10);
806+
expect(on.vmBuilds).toBe(1);
807+
expect(on.durableLog).toEqual(off.durableLog);
808+
});
809+
784810
it('matches cold replay when hook metadata serialization mutates workflow state', async () => {
785811
process.env.WORKFLOW_RETAINED_VM = '0';
786812
const off = await drive(

‎packages/core/src/runtime.test.ts‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,8 +1060,8 @@ describe('workflowEntrypoint replay guards', () => {
10601060
).toEqual([]);
10611061
});
10621062

1063-
it('replays attribute events before executing a step that loses the same race', async () => {
1064-
const debug = vi
1063+
it('retains when an attribute write beats a step that signals first', async () => {
1064+
using debug = vi
10651065
.spyOn(runtimeLogger, 'debug')
10661066
.mockImplementation(() => undefined);
10671067
const ops: Promise<any>[] = [];
@@ -1080,14 +1080,17 @@ describe('workflowEntrypoint replay guards', () => {
10801080
startedAt: new Date('2024-01-01T00:00:00.000Z'),
10811081
deploymentId: 'test-deployment',
10821082
};
1083+
// Register the step consumer first so it schedules the accepted
1084+
// suspension. The later attribute signal must be dropped by its new
1085+
// generation guard after the retained session resumes.
10831086
const workflowCode = `
10841087
const setAttributes = globalThis[Symbol.for("WORKFLOW_SET_ATTRIBUTES")];
10851088
const useStep = globalThis[Symbol.for("WORKFLOW_USE_STEP")];
10861089
const slowStep = useStep("slowStep");
10871090
async function workflow() {
10881091
await Promise.race([
1089-
setAttributes([{ key: "winner", value: "attribute" }]),
10901092
slowStep(),
1093+
setAttributes([{ key: "winner", value: "attribute" }]),
10911094
]);
10921095
return "attribute won";
10931096
}${getWorkflowTransformCode('workflow')}`;
@@ -1103,19 +1106,17 @@ describe('workflowEntrypoint replay guards', () => {
11031106
expect(createdEvents).toContainEqual(
11041107
expect.objectContaining({ eventType: 'attr_set' })
11051108
);
1106-
// The attr_set suspension skips step processing and resolves through an
1107-
// in-process replay, where the durable attribute event wins the race —
1108-
// so the run completes within this same delivery, with no queue
1109-
// interaction.
1109+
// The durable attribute event wins the retained resume. The later
1110+
// attribute suspension signal must not demote the session.
11101111
expect(createdEvents).toContainEqual(
11111112
expect.objectContaining({ eventType: 'run_completed' })
11121113
);
11131114
expect(queueCalls).toEqual([]);
11141115
// Under lazy inline start the step that loses the attribute race is NOT
11151116
// eagerly created: its step_created is deferred for a lazy step_started
1116-
// that never fires, because the attribute-resolving replay decides the
1117-
// race before any step executes. So the losing step leaves no events at
1118-
// all — strictly less event-log garbage than the eager model.
1117+
// that never fires, because the attribute-resolving retained execution
1118+
// decides the race before any step executes. So the losing step leaves no
1119+
// events at all — strictly less event-log garbage than the eager model.
11191120
expect(createdEvents).not.toContainEqual(
11201121
expect.objectContaining({ eventType: 'step_created' })
11211122
);
@@ -1125,8 +1126,7 @@ describe('workflowEntrypoint replay guards', () => {
11251126
const executionModes = debug.mock.calls
11261127
.filter(([message]) => message === 'Starting workflow execution')
11271128
.map(([, context]) => context?.executionMode);
1128-
expect(executionModes).toEqual(['replay', 'replay']);
1129-
debug.mockRestore();
1129+
expect(executionModes).toEqual(['replay', 'retained']);
11301130
});
11311131

11321132
it('fails the run when the World rejects an attr_set event as invalid', async () => {
@@ -3088,11 +3088,11 @@ describe('workflowEntrypoint latency telemetry (ttfs / stso)', () => {
30883088
}${latXform('workflow')}`;
30893089

30903090
it('reports ttfs across a pre-step setAttributes, resolved in-process without a re-invoke', async () => {
3091-
// A workflow-body setAttributes before the first step resolves through
3092-
// an in-process replay: the same delivery commits attr_set, replays, and
3093-
// runs the step — no queue interaction. TTFS ends at the attr write, so
3094-
// the setAttributes call's duration is subtracted; here that detour is
3095-
// milliseconds, keeping ttfs ≈ the run's ULID backdate.
3091+
// A workflow-body setAttributes before the first step resolves in-process:
3092+
// the same delivery commits attr_set, reloads it, and advances the workflow
3093+
// by resuming or replaying — no queue interaction. TTFS ends at the attr
3094+
// write, so the setAttributes call's duration is subtracted. That detour is
3095+
// only milliseconds here, keeping ttfs ≈ the run's ULID backdate.
30963096
const backdateMs = 10_000;
30973097
const before = Date.now();
30983098
const first = await driveLatency({
@@ -3115,11 +3115,13 @@ describe('workflowEntrypoint latency telemetry (ttfs / stso)', () => {
31153115
expect(ttfs).toBeLessThanOrEqual(backdateMs + elapsed);
31163116
expect(stso).toBeUndefined();
31173117
// The attr detour does not end turbo: no resume invocation source
3118-
// exists, so the forced-optimistic fast path stays engaged.
3118+
// exists, so the forced-optimistic fast path stays engaged. The live VM
3119+
// also resumes after the attr write instead of replaying in a fresh VM.
31193120
expect(optimizations).toEqual([
31203121
'turbo',
31213122
'lazyStepStart',
31223123
'optimisticStart',
3124+
'retained',
31233125
]);
31243126
});
31253127

‎packages/core/src/runtime.ts‎

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ type RetentionDecision =
524524
reason:
525525
| 'disabled'
526526
| 'serialization_executed_workflow_code'
527-
| 'no_step_driver'
527+
| 'no_replay_driver'
528528
| 'unsupported_suspension_item'
529529
| 'open_wait';
530530
};
@@ -534,9 +534,9 @@ type RetentionDecision =
534534
*
535535
* Every item type accepted here must use the suspension-generation guard when
536536
* signaling. Otherwise a signal scheduled at boundary N could suspend the VM
537-
* after it has already resumed into boundary N+1. Waits and attributes are not
538-
* guarded, so they remain unretainable. A step is required to drive the next
539-
* inline iteration; hook-only suspensions park normally.
537+
* after it has already resumed into boundary N+1. Waits are not guarded, so
538+
* they remain unretainable. A step or attribute write is required to drive the
539+
* next inline iteration; hook-only suspensions park normally.
540540
*
541541
* Retaining across an open hook also permits a hook-woken cold replay to race
542542
* this invocation. That is safe only because each loaded log is a monotone,
@@ -575,17 +575,17 @@ function getRetentionDecision({
575575
reason: 'serialization_executed_workflow_code',
576576
};
577577
}
578-
if (suspension.stepCount === 0) {
579-
return { retain: false, reason: 'no_step_driver' };
578+
if (suspension.stepCount === 0 && suspension.attributeCount === 0) {
579+
return { retain: false, reason: 'no_replay_driver' };
580580
}
581581
if (
582582
!suspension.items.every((item) => {
583583
switch (item.type) {
584584
case 'step':
585585
case 'hook':
586+
case 'attribute':
586587
return true;
587588
case 'wait':
588-
case 'attribute':
589589
return false;
590590
default:
591591
item satisfies never;
@@ -3423,15 +3423,16 @@ export function workflowEntrypoint(
34233423
return await reinvoke(0);
34243424
}
34253425

3426-
// Native workflow attribute events are resolved
3427-
// through replay: the next loop iteration reloads the
3428-
// log (now holding the just-committed attr_set) and
3429-
// replays, resolving the setAttributes promise. Skip
3430-
// step processing for this pass so that replay decides
3431-
// races first: in Promise.race([setAttributes(),
3432-
// step()]), the durable attribute event must be able
3433-
// to win without executing the losing step. The replay
3434-
// happens in-process rather than via a queue
3426+
// Native workflow attribute events are resolved by the
3427+
// next execution pass: it reloads the log (now holding
3428+
// the just-committed attr_set), then either resumes the
3429+
// retained VM or performs a cold replay to resolve the
3430+
// setAttributes promise. Skip step processing for this
3431+
// pass so that the durable event decides races first:
3432+
// in Promise.race([setAttributes(), step()]), the
3433+
// attribute must be able to win without executing the
3434+
// losing step. The next pass happens in-process rather
3435+
// than via a queue
34353436
// re-invocation: unlike hooks and waits, an attr_set
34363437
// introduces no out-of-band invocation source that the
34373438
// handler would need to yield the message for, so

‎packages/core/src/workflow.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -367,17 +367,17 @@ async function createWorkflowSession({
367367
state = WorkflowSuspension.is(error)
368368
? { type: 'suspended', suspension: error }
369369
: { type: 'replay' };
370-
// Step and hook consumers can schedule the same suspension. The first
371-
// signal advances the generation so the rest no-op.
370+
// Step, hook, and attribute consumers can schedule the same suspension.
371+
// The first signal advances the generation so the rest no-op.
372372
workflowContext.suspensionGeneration++;
373373
interruption.reject(error);
374374
return;
375375
}
376376
case 'suspended':
377377
// Same-boundary duplicates were staled by the generation bump above,
378-
// so anything landing here is out-of-band — an unguarded sleep/
379-
// attribute signal or a divergence. Those boundaries are unretainable
380-
// (the runtime demotes them too), so fall back to replay.
378+
// so anything landing here is out-of-band — an unguarded sleep signal
379+
// or a divergence. Those boundaries are unretainable (the runtime
380+
// demotes them too), so fall back to replay.
381381
state = { type: 'replay' };
382382
return;
383383
case 'replay':

‎packages/core/src/workflow/attribute-dispatcher.ts‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@ import { ReplayDivergenceError } from '@workflow/errors';
22
import { withResolvers } from '@workflow/utils';
33
import type { AttributeChange } from '@workflow/world';
44
import { EventConsumerResult } from '../events-consumer.js';
5+
import type { AttributeInvocationQueueItem } from '../global.js';
56
import {
6-
type AttributeInvocationQueueItem,
7-
WorkflowSuspension,
8-
} from '../global.js';
9-
import {
10-
scheduleWhenIdle,
7+
scheduleWorkflowSuspension,
118
type WorkflowOrchestratorContext,
129
} from '../private.js';
1310

@@ -30,11 +27,7 @@ export function createSetAttributes(ctx: WorkflowOrchestratorContext) {
3027

3128
ctx.eventsConsumer.subscribe((event) => {
3229
if (!event) {
33-
scheduleWhenIdle(ctx, () => {
34-
ctx.onWorkflowError(
35-
new WorkflowSuspension(ctx.invocationsQueue, ctx.globalThis)
36-
);
37-
});
30+
scheduleWorkflowSuspension(ctx);
3831
return EventConsumerResult.NotConsumed;
3932
}
4033

0 commit comments

Comments
 (0)