[core] Make stepContext a global singleton to protect against module duplication and caching issues#1591
Conversation
🦋 Changeset detectedLatest commit: c7b37f7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (60 failed)mongodb (2 failed):
redis (2 failed):
turso (56 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
When bundlers (e.g. Vercel's production bundler) create multiple copies of the context-storage module, two separate AsyncLocalStorage instances are created. The step handler sets context on one instance, but getWorkflowMetadata()/getStepMetadata() reads from the other, causing the store to appear empty. Fix by storing the AsyncLocalStorage instance on globalThis using Symbol.for(), guaranteeing a single shared instance regardless of how many times the module is loaded. This mirrors the pattern already used by the workflow VM context (WORKFLOW_CONTEXT symbol). Closes #1577 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b3d667c to
a7110de
Compare
karthikscale3
left a comment
There was a problem hiding this comment.
AI review: Approve — correct fix, follows the established Symbol.for() + globalThis singleton pattern already used in 11+ places across @workflow/core (symbols.ts, runtime/world.ts, get-workflow-metadata.ts). No regression risk — validated on all 11 Vercel preview deployments (868 passed, 0 new failures). Will let Pranay and Nate take a pass at this as well.
|
Fix seems to work to fix #1577 in realistic/production like setting (after forcing vercel not to use build cache). |
Co-authored-by: Peter Wielander <mittgfu@gmail.com> Signed-off-by: Peter Wielander <mittgfu@gmail.com>
TooTallNate
left a comment
There was a problem hiding this comment.
Clean, well-scoped fix. The Symbol.for() + globalThis singleton pattern is the standard approach for ensuring a single instance across duplicated modules, and it's implemented correctly here.
What I verified:
-
Singleton pattern is correct — The
??+ assignment expression atomically checks-and-sets the global slot.Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')guarantees the same symbol across module copies (unlikeSymbol()which creates unique symbols). -
@__PURE__correctly removed — The old/* @__PURE__ */ new AsyncLocalStorage()told tree-shakers the expression was side-effect-free. The new expression writes toglobalThis, so removing it is correct. -
All 21 callers of
contextStorageuse.run()or.getStore()— none create their own instance. The fix ensures they all reference the sameAsyncLocalStorageregardless of how many copies of the module exist. -
Extracted
StepContexttype — Good refactor. The inline type was getting unwieldy. -
Unit tests cover the three important properties: same instance across imports, stored on
globalThisviaSymbol.for(), and context propagation between separately-constructed references. -
E2e test directly reproduces the #1577 pattern — helper function at module scope calling
getWorkflowMetadata()/getStepMetadata()outside the step body. -
Changeset is correctly
patchand accurately describes the fix as defense-in-depth.
LGTM.
Summary
AsyncLocalStoragea process-wide singleton viaSymbol.for()onglobalThis, preventing dual-instance issues when bundlers create multiple copies of the moduleCloses #1577
Closes #839
Test plan
context-storage.test.ts)metadataFromHelperWorkflow) matching the exact repro pattern🤖 Generated with Claude Code