Skip to content

Commit d8aaf27

Browse files
[core] Enforce single ALS context to protect against duplicate module and caching issues (#1591)
1 parent 7c996a7 commit d8aaf27

5 files changed

Lines changed: 136 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Fix step `contextStorage` global _potentially_ seeing dual-instance issues when bundlers create multiple copies of the module.

packages/core/e2e/e2e.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,4 +2156,20 @@ describe('e2e', () => {
21562156
});
21572157
}
21582158
);
2159+
2160+
test(
2161+
'metadataFromHelperWorkflow - getWorkflowMetadata/getStepMetadata work from module-level helper (#1577)',
2162+
{ timeout: 60_000 },
2163+
async () => {
2164+
const run = await start(await e2e('metadataFromHelperWorkflow'), [
2165+
'smoke-test',
2166+
]);
2167+
const returnValue = await run.returnValue;
2168+
2169+
expect(returnValue.label).toBe('smoke-test');
2170+
expect(typeof returnValue.workflowRunId).toBe('string');
2171+
expect(typeof returnValue.stepId).toBe('string');
2172+
expect(returnValue.attempt).toBeGreaterThanOrEqual(1);
2173+
}
2174+
);
21592175
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
2+
import { describe, expect, it } from 'vitest';
3+
4+
const CONTEXT_STORAGE_SYMBOL = Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE');
5+
6+
describe('contextStorage singleton', () => {
7+
it('returns the same AsyncLocalStorage instance across multiple imports', async () => {
8+
// Import the module twice (simulating what bundlers might do)
9+
const mod1 = await import('./context-storage.js');
10+
const mod2 = await import('./context-storage.js');
11+
12+
expect(mod1.contextStorage).toBe(mod2.contextStorage);
13+
});
14+
15+
it('shares the same instance stored on globalThis via Symbol.for()', async () => {
16+
const { contextStorage } = await import('./context-storage.js');
17+
const globalInstance = (globalThis as any)[CONTEXT_STORAGE_SYMBOL];
18+
19+
expect(globalInstance).toBe(contextStorage);
20+
expect(globalInstance).toBeInstanceOf(AsyncLocalStorage);
21+
});
22+
23+
it('preserves context from run() when getStore() is called from a separately-constructed reference', async () => {
24+
// This simulates the dual-module-instance problem:
25+
// step-handler sets context via one reference, user code reads via another.
26+
// With the Symbol.for() singleton fix, both references point to the same instance.
27+
const { contextStorage } = await import('./context-storage.js');
28+
const globalInstance = (globalThis as any)[
29+
CONTEXT_STORAGE_SYMBOL
30+
] as AsyncLocalStorage<{ value: string }>;
31+
32+
let storeFromGlobal: { value: string } | undefined;
33+
34+
globalInstance.run({ value: 'test-context' }, () => {
35+
storeFromGlobal = contextStorage.getStore() as any;
36+
});
37+
38+
expect(storeFromGlobal).toEqual({ value: 'test-context' });
39+
});
40+
});

packages/core/src/step/context-storage.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,35 @@ import type { CryptoKey } from '../encryption.js';
33
import type { WorkflowMetadata } from '../workflow/get-workflow-metadata.js';
44
import type { StepMetadata } from './get-step-metadata.js';
55

6-
export const contextStorage = /* @__PURE__ */ new AsyncLocalStorage<{
6+
export type StepContext = {
77
stepMetadata: StepMetadata;
88
workflowMetadata: WorkflowMetadata;
99
ops: Promise<void>[];
1010
closureVars?: Record<string, any>;
1111
encryptionKey?: CryptoKey;
12-
}>();
12+
};
13+
14+
/**
15+
* Process-wide singleton AsyncLocalStorage for step execution context.
16+
*
17+
* Uses `Symbol.for()` on globalThis to guarantee a single instance even when
18+
* bundlers (e.g. Vercel's production bundler) create multiple copies of this
19+
* module. Without this, `contextStorage.run()` in the step handler and
20+
* `contextStorage.getStore()` in user code (via getWorkflowMetadata /
21+
* getStepMetadata) can reference different AsyncLocalStorage instances,
22+
* causing the store to appear empty.
23+
*
24+
* Note that we were unable to reproduce this issue. This is a fix for the only synthetic way
25+
* way in which we could get the builder to break with the reported error message, and
26+
* serves as defense-in-depth, since the change is otherwise safe.
27+
*
28+
* See: https://github.com/vercel/workflow/issues/1577
29+
*/
30+
const CONTEXT_STORAGE_SYMBOL = Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE');
31+
32+
export const contextStorage: AsyncLocalStorage<StepContext> =
33+
((globalThis as any)[CONTEXT_STORAGE_SYMBOL] as
34+
| AsyncLocalStorage<StepContext>
35+
| undefined) ??
36+
((globalThis as any)[CONTEXT_STORAGE_SYMBOL] =
37+
new AsyncLocalStorage<StepContext>());

workbench/example/workflows/99_e2e.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,3 +1502,51 @@ export async function importMetaUrlWorkflow() {
15021502
'use workflow';
15031503
return await checkImportMetaUrl();
15041504
}
1505+
1506+
//////////////////////////////////////////////////////////
1507+
// Regression test for #1577:
1508+
// getWorkflowMetadata()/getStepMetadata() called from a module-level helper
1509+
// function (not directly inside the step body) must still have access to the
1510+
// AsyncLocalStorage context.
1511+
1512+
const withStrictMetadataCheck = async <T>(fn: () => Promise<T>) => {
1513+
const workflowMetadata = getWorkflowMetadata();
1514+
const stepMetadata = getStepMetadata();
1515+
1516+
return await fn().then((result) => ({
1517+
result,
1518+
workflowMetadata,
1519+
stepMetadata,
1520+
}));
1521+
};
1522+
1523+
async function metadataHelperStep(label: string): Promise<{
1524+
label: string;
1525+
workflowRunId: string;
1526+
stepId: string;
1527+
attempt: number;
1528+
}> {
1529+
'use step';
1530+
1531+
const { workflowMetadata, stepMetadata } = await withStrictMetadataCheck(
1532+
async () => label
1533+
);
1534+
1535+
return {
1536+
label,
1537+
workflowRunId: workflowMetadata.workflowRunId,
1538+
stepId: stepMetadata.stepId,
1539+
attempt: stepMetadata.attempt,
1540+
};
1541+
}
1542+
1543+
export async function metadataFromHelperWorkflow(label: string): Promise<{
1544+
label: string;
1545+
workflowRunId: string;
1546+
stepId: string;
1547+
attempt: number;
1548+
}> {
1549+
'use workflow';
1550+
1551+
return await metadataHelperStep(label);
1552+
}

0 commit comments

Comments
 (0)