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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [core] Overlap workflow compilation with replay loading by NathanColosimo · Pull Request #3798 · vercel/workflow · GitHub
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/overlap-workflow-compile.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Compile Node.js workflow scripts while loading the replay event log.
78 changes: 75 additions & 3 deletions packages/core/src/runtime-trace-mode.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,10 @@ const simpleWorkflow = `async function workflow() {
return 'done';
}${getWorkflowTransformCode('workflow')}`;

async function makeRunningRun(runId: string): Promise<WorkflowRun> {
async function makeRunningRun(
runId: string,
executionContext?: WorkflowRun['executionContext']
): Promise<WorkflowRun> {
return {
runId,
workflowName: 'workflow',
Expand All@@ -91,6 +94,7 @@ async function makeRunningRun(runId: string): Promise<WorkflowRun> {
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
executionContext,
};
}

Expand All@@ -105,12 +109,16 @@ async function driveHandler(opts: {
workflowCode: string;
traceCarrier?: Record<string, string>;
routeModuleBodyStartedAt?: number;
includeRunInput?: boolean;
executionContext?: WorkflowRun['executionContext'];
whileRunStartedPending?: () => Promise<void>;
}) {
const workflowRun = await makeRunningRun(opts.runId);
const workflowRun = await makeRunningRun(opts.runId, opts.executionContext);
const queuedMessages: any[] = [];

const eventsCreate = vi.fn(async (_runId: string, data: any) => {
if (data.eventType === 'run_started') {
await opts.whileRunStartedPending?.();
return { run: workflowRun, events: [] as Event[] };
}
return {
Expand All@@ -136,10 +144,23 @@ async function driveHandler(opts: {
runId: workflowRun.runId,
requestedAt: new Date('2024-01-01T00:00:00.000Z'),
traceCarrier: opts.traceCarrier,
...(opts.includeRunInput
? {
runInput: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
specVersion: SPEC_VERSION_CURRENT,
executionContext: workflowRun.executionContext,
},
}
: {}),
},
{
requestId: 'req_test',
attempt: 1,
// Keep this trace harness on the awaited run_started path even
// when a test supplies runInput for pre-response VM selection.
attempt: opts.includeRunInput ? 2 : 1,
queueName: '__wkf_workflow_workflow',
messageId: 'msg_test',
}
Expand DownExpand Up@@ -246,6 +267,57 @@ describe('getWorkflowTraceMode', () => {
});

describe('workflowEntrypoint trace modes', () => {
it('compiles while run_started is loading, without evaluating early', async () => {
let observedOverlap = false;
await driveHandler({
runId: 'wrun_trace_compile_overlap',
workflowCode: simpleWorkflow,
includeRunInput: true,
whileRunStartedPending: async () => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
await vi.waitFor(() => {
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeDefined();
});
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeUndefined();
observedOverlap = true;
},
});

expect(observedOverlap).toBe(true);
expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.evaluate')
).toBeDefined();
});

it('does not compile a Node bundle for a known QuickJS run', async () => {
await driveHandler({
runId: 'wrun_trace_quickjs_compile',
workflowCode: simpleWorkflow,
includeRunInput: true,
executionContext: { workflowVm: 'quickjs' },
});

expect(
exporter
.getFinishedSpans()
.find((span) => span.name === 'workflow.bundle.compile')
).toBeUndefined();
});

it('linked (default): nests under the flow route context with a link to the run-origin context', async () => {
const {
workflowSpan,
Expand Down
48 changes: 44 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -134,6 +134,7 @@ import {
import { getErrorName, getErrorStack, normalizeUnknownError } from './types.js';
import { buildWorkflowSuspensionMessage } from './util.js';
import {
compileWorkflowBundle,
replayWorkflow,
resumeWorkflow,
type WorkflowResumeResult,
Expand DownExpand Up@@ -948,6 +949,26 @@ export function workflowEntrypoint(
const replayRecoveryReporter = replayDivergence
? new ReplayRecoveryReporter(replayDivergence.count)
: ReplayRecoveryReporter.inert();
// Compilation is useful only for the Node VM. Wait until the
// run's engine selection is known so QuickJS deliveries never
// parse and cache an unused node:vm Script. The promise is
// invocation-scoped and reused by every cold replay;
// evaluation still waits for a fresh VM context.
let compiledWorkflowScripts:
| ReturnType<typeof compileWorkflowBundle>
| undefined;
const startWorkflowCompile = (
workflow?: Pick<WorkflowRun, 'executionContext'>
) => {
if (!workflow || useQuickJSVm(workflow)) return;
compiledWorkflowScripts ??= compileWorkflowBundle(
workflowCode,
workflowName
);
// Terminal runs can return without awaiting compilation.
void compiledWorkflowScripts.catch(() => {});
return compiledWorkflowScripts;
};
// Every write this loop makes carries the cursor of the log
// it was computed against, and folds a complete returned
// delta into that log.
Expand DownExpand Up@@ -1892,6 +1913,11 @@ export function workflowEntrypoint(

// All steps done: fall through to the main replay loop.
// Set up shared state so the loop can continue.
// The step body itself needs no workflow VM. Start Node
// compilation only now, once this delivery is known to
// continue into a workflow replay rather than return for
// a still-pending sibling.
startWorkflowCompile(bgRun);
runtimeLogger.debug(
'All parallel steps done, replaying inline after background step',
{ workflowRunId: runId }
Expand DownExpand Up@@ -2052,7 +2078,7 @@ export function workflowEntrypoint(
span?.addEvent('workflow.hook_received.create.start', {
'workflow.hook_received.preload_events': true,
});
const result = await traceReplayLoad('hook_preload', () =>
const replayLoad = traceReplayLoad('hook_preload', () =>
createEvent(
{
eventType: 'hook_received',
Expand All@@ -2072,6 +2098,7 @@ export function workflowEntrypoint(
}
)
);
const result = await replayLoad;
hookEnsured = true;
// Note: unlike the re-ensure below, this hoisted write
// does NOT set HookResilientResumeMaterialized: it
Expand DownExpand Up@@ -2161,6 +2188,7 @@ export function workflowEntrypoint(
return;
}
workflowRun = result.run;
startWorkflowCompile(workflowRun);
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above. This
// response plays run_started's role on this path.
Expand DownExpand Up@@ -2276,6 +2304,7 @@ export function workflowEntrypoint(
// wasted list+resolve it would otherwise compute.
{ requestId, skipPreload: true }
);
startWorkflowCompile(runInput);
runReadyBarrier = startedPromise;
// Turbo backgrounds run_started, so the non-turbo
// assignment below never runs. Thread the per-run event
Expand DownExpand Up@@ -2344,10 +2373,14 @@ export function workflowEntrypoint(
span?.addEvent('workflow.run_started.create.start', {
'workflow.run_started.skip_preload': false,
});
const result = await traceReplayLoad(
'run_started',
() => createEvent(runStartedEvent, { requestId })
const replayLoad = traceReplayLoad('run_started', () =>
createEvent(runStartedEvent, { requestId })
);
// Initial deliveries carry runInput, so Node compilation
// can overlap this load without guessing the VM engine.
// Continuations learn the engine from result.run below.
startWorkflowCompile(runInput);
const result = await replayLoad;
workflowRun = result.run;
maxEventsLimit = clampMaxEvents(result.maxEvents);
// Anchors RSFS, see the declaration above.
Expand DownExpand Up@@ -2392,6 +2425,7 @@ export function workflowEntrypoint(

return;
}
startWorkflowCompile(workflowRun);
} catch (err) {
// Run was concurrently completed/failed/canceled
if (
Expand DownExpand Up@@ -3084,12 +3118,18 @@ export function workflowEntrypoint(

if (workflowResult.type === 'replay') {
retainedSession = null;
const compiled = startWorkflowCompile(workflowRun);
assert(
compiled,
'Node workflow replay requires compiled scripts'
);
workflowResult = await replayWorkflow({
workflowCode,
workflowRun,
events: eventLog.events,
encryptionKey,
replayPayloadCache,
compiledWorkflowScripts: await compiled,
// Turbo: the end-of-run drain inside workflow
// execution commits fire-and-forget `*_created`
// events before the terminal `awaitRunReady()` below.
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/vm-mode.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,9 @@ export function getWorkflowVmFromEnv(
* Throws if `WORKFLOW_VM` or `executionContext.workflowVm` is set to an
* unknown value.
*/
export function useQuickJSVm(workflowRun: WorkflowRun): boolean {
export function useQuickJSVm(
workflowRun: Pick<WorkflowRun, 'executionContext'>
): boolean {
const vmFromRun = (
workflowRun.executionContext as { workflowVm?: string } | undefined
)?.workflowVm;
Expand Down
53 changes: 22 additions & 31 deletions packages/core/src/vm/script-cache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,27 +39,21 @@ import { globalSingleton } from '@workflow/utils';
* function bodies the duplicated work is the (cheap) top-level parse, not full
* per-workflow codegen.
*
* We use a nested Map (code -> filename -> Script) so that evicting a bundle
* (e.g. a new deployment/hot-reload producing a different `code`) drops the old
* code string and all of its per-filename scripts together.
* We use a nested Map (code -> filename -> Script) so that evicting a source
* string drops all of its per-filename scripts together. Most entries are full
* workflow bundles; `compileWorkflowBundle` also caches its tiny workflow-name
* lookup snippets here.
*
* Bounding
* --------
* The top-level (`code`-keyed) map is an insertion-ordered LRU capped at
* `MAX_BUNDLES` entries. In production this bound is never reached: a
* deployment is its own process serving exactly one build-time bundle literal
* (skew protection runs old versions as separate processes), so there is a
* single `code` key for the process lifetime. The bound exists for dev/watch
* mode, where the dev route re-reads `workflowCode` from disk and re-invokes
* the entrypoint on every edit: each edit produces a NEW bundle string, which
* without a bound would pin every historical version forever (~0.8MB per edit,
* growing monotonically with edit count). The dev path only ever needs the
* latest bundle, so an LRU that keeps the few most-recent bundles and evicts
* the rest preserves the pre-cache GC behaviour while still serving the
* steady-state single-bundle case for free. The per-`filename` inner map is not
* separately bounded: it is naturally bounded by the (small) number of workflow
* source files in a bundle and is dropped wholesale when its parent `code`
* entry is evicted.
* `MAX_SCRIPT_SOURCES` entries. A production deployment has one large bundle
* source plus small lookup sources; the bundle is touched immediately before
* its lookup on every compilation, so lookup churn cannot evict the expensive
* entry in normal use. The bound primarily protects dev/watch mode, where every
* edit produces a new bundle string that would otherwise pin all historical
* versions. The per-`filename` inner map is naturally bounded by the workflows
* compiled from that source and is dropped wholesale with its parent entry.
*/
// On `globalThis` (see `globalSingleton`): compiling a bundle is the expensive
// part this cache exists to skip, and per-copy caches would pay it once per
Expand All@@ -69,21 +63,18 @@ const scripts = globalSingleton('@workflow/core//vmScriptCache', 1, () => ({
}));

/**
* Max number of distinct bundle (`code`) versions to retain. One is enough for
* production; a handful covers pathological dev hot-reload / repeated-rebuild
* churn within a single long-lived process (e.g. a watch session or a test
* file) without unbounded growth. Kept deliberately small: there is no value
* in retaining stale bundles, only a memory cost.
* Maximum number of distinct script source strings to retain. Kept deliberately
* small because stale bundles and one-off lookup snippets have no lasting value.
*/
const MAX_BUNDLES = 8;
const MAX_SCRIPT_SOURCES = 8;

/**
* Looks up the per-filename map for `code`, marking it most-recently-used.
* Relies on `Map` preserving insertion order: deleting and re-inserting an
* existing key moves it to the end (newest), so the first key is always the
* least-recently-used eviction candidate.
*/
function touchBundle(code: string): Map<string, Script> | undefined {
function touchScriptSource(code: string): Map<string, Script> | undefined {
const byFilename = scripts.byCode.get(code);
if (byFilename === undefined) {
return undefined;
Expand All@@ -95,9 +86,9 @@ function touchBundle(code: string): Map<string, Script> | undefined {
}

/**
* Returns a compiled `vm.Script` for the given workflow bundle code and
* filename, compiling and caching it on first use. Subsequent calls with the
* same `(code, filename)` return the cached `Script`.
* Returns a compiled `vm.Script` for the given source code and filename,
* compiling and caching it on first use. Subsequent calls with the same
* `(code, filename)` return the cached `Script`.
*
* The returned `Script` is not yet bound to any context; the caller runs it
* against a specific VM context via `script.runInContext(context)`. This is
Expand All@@ -108,13 +99,13 @@ export function getCachedWorkflowScript(
code: string,
filename: string
): { script: Script; cacheHit: boolean } {
let byFilename = touchBundle(code);
let byFilename = touchScriptSource(code);
if (byFilename === undefined) {
byFilename = new Map<string, Script>();
scripts.byCode.set(code, byFilename);
// Evict the least-recently-used bundle(s) when over the cap. New bundles
// Evict the least-recently-used source(s) when over the cap. New sources
// are appended at the end, so the oldest live at the front.
while (scripts.byCode.size > MAX_BUNDLES) {
while (scripts.byCode.size > MAX_SCRIPT_SOURCES) {
const oldest = scripts.byCode.keys().next().value;
if (oldest === undefined) {
break;
Expand All@@ -140,7 +131,7 @@ export function clearWorkflowScriptCache(): void {
}

/**
* Number of distinct bundle (`code`) versions currently retained. Exposed for
* Number of distinct script source strings currently retained. Exposed for
* tests asserting the LRU bound; not used on the hot path.
*/
export function workflowScriptCacheSize(): number {
Expand Down
Loading
Loading