Skip to content

[world-local] Reduce sequential replay I/O#2152

Open
pranaygp wants to merge 1 commit into
mainfrom
pranaygp/codex/world-local-sequential-perf
Open

[world-local] Reduce sequential replay I/O#2152
pranaygp wants to merge 1 commit into
mainfrom
pranaygp/codex/world-local-sequential-perf

Conversation

@pranaygp
Copy link
Copy Markdown
Contributor

@pranaygp pranaygp commented May 29, 2026

Summary

  • cache a bounded recent window of append-only local events so immediate replay pagination avoids rereading JSON files
  • cache storage directories created by this process so sequential event writes avoid repeated recursive mkdir syscalls
  • preserve correctness under mutation, cleanup, and long-lived active runs with focused regression coverage and a patch changeset

Root cause

The existing sequentialStepsWorkflow(count, 0) benchmark reproduces the zero-work sequential-step shape. On current main, its local-world storage work is dominated by three persisted lifecycle events per step and the incremental events.list() call used for replay. The listing path rereads append-only event files that the same storage instance just wrote, while the write path repeatedly calls mkdir(..., { recursive: true }) for fixed directories.

This workload does not exercise streams; the previously landed stream metadata optimization is separate from this path.

Correctness and memory safeguards

Reviewing the initial caching implementation exposed two reproducible correctness issues:

  • events.create() returned an event object that was also retained in cache; mutating the returned payload changed a subsequent cached events.list() response.
  • caching created directories without recovery caused a later event write to fail with ENOENT when a live dev server's data directory was externally removed.

This version addresses those issues and bounds retention:

  • cached event entries are decoded from the serialized snapshot through EventSchema, so they are detached from caller mutations and have the same normalized shape as disk reads
  • the recent-event cache is capped at 4 MiB and 1000 entries across active runs; oversized events are read from disk instead of retained
  • cached events are released for terminal runs and when world.clear() or world.close() is called
  • atomic and exclusive writes retry once after recreating a cached directory removed externally

Measurement

I modeled the event/replay lifecycle for a no-delay sequential workflow directly through @workflow/world-local storage:

Sequential steps Before After bounded cache Improvement
50 289.57 ms 217.85 ms 24.8% faster
200 977.72 ms 740.59 ms 24.3% faster

For 200 steps, incremental events.list() time fell from 240.60 ms to 115.13 ms (52.1% lower).

A 50-step filesystem-operation trace demonstrates the removed work:

Operation Before calls After calls
readFile 454 252
mkdir 404 4

An end-to-end workbench probe also showed that most remaining no-delay sequential-workflow latency occurs above this storage path: a 200-step run reported 22.8 s inside /.well-known/workflow/v1/flow.

Validation

  • reproduced the pre-fix cached-object alias: a mutated events.create() result surfaced in events.list(); verified the amended implementation returns the persisted value
  • reproduced the pre-fix external-directory cleanup failure (ENOENT on the next event write); verified the amended implementation recreates the directory
  • pnpm --filter @workflow/world-local typecheck
  • pnpm --filter @workflow/world-local test (375 tests passed)
  • pnpm --filter '@workflow/world-local...' build
  • pnpm changeset status --since=origin-https/main
  • git diff --check

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: d9a284c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@workflow/world-local Patch
@workflow/cli Patch
@workflow/core Patch
@workflow/vitest Patch
@workflow/world-postgres Patch
workflow Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment May 29, 2026 2:13am
example-nextjs-workflow-webpack Ready Ready Preview, Comment May 29, 2026 2:13am
example-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-astro-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-express-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-fastify-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-hono-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-nitro-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-nuxt-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-sveltekit-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-tanstack-start-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workbench-vite-workflow Ready Ready Preview, Comment May 29, 2026 2:13am
workflow-docs Ready Ready Preview, Comment, Open in v0 May 29, 2026 2:13am
workflow-swc-playground Ready Ready Preview, Comment May 29, 2026 2:13am
workflow-tarballs Ready Ready Preview, Comment May 29, 2026 2:13am
workflow-web Ready Ready Preview, Comment May 29, 2026 2:13am

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1254 1 219 1474
✅ 💻 Local Development 1657 0 219 1876
✅ 📦 Local Production 1657 0 219 1876
✅ 🐘 Local Postgres 1657 0 219 1876
✅ 🪟 Windows 134 0 0 134
✅ 📋 Other 762 0 176 938
Total 7121 1 1052 8174

❌ Failed Tests

▲ Vercel Production (1 failed)

vite (1 failed):

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 108 0 26
✅ example 108 0 26
✅ express 108 0 26
✅ fastify 108 0 26
✅ hono 108 0 26
✅ nextjs-turbopack 132 0 2
✅ nextjs-webpack 132 0 2
✅ nitro 108 0 26
✅ nuxt 108 0 26
✅ sveltekit 127 0 7
❌ vite 107 1 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 109 0 25
✅ express-stable 109 0 25
✅ fastify-stable 109 0 25
✅ hono-stable 109 0 25
✅ nextjs-turbopack-canary 115 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 134 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 134 0 0
✅ nextjs-webpack-canary 115 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 134 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 134 0 0
✅ nitro-stable 109 0 25
✅ nuxt-stable 109 0 25
✅ sveltekit-stable 128 0 6
✅ vite-stable 109 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 109 0 25
✅ express-stable 109 0 25
✅ fastify-stable 109 0 25
✅ hono-stable 109 0 25
✅ nextjs-turbopack-canary 115 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 134 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 134 0 0
✅ nextjs-webpack-canary 115 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 134 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 134 0 0
✅ nitro-stable 109 0 25
✅ nuxt-stable 109 0 25
✅ sveltekit-stable 128 0 6
✅ vite-stable 109 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 109 0 25
✅ express-stable 109 0 25
✅ fastify-stable 109 0 25
✅ hono-stable 109 0 25
✅ nextjs-turbopack-canary 115 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 134 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 134 0 0
✅ nextjs-webpack-canary 115 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 134 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 134 0 0
✅ nitro-stable 109 0 25
✅ nuxt-stable 109 0 25
✅ sveltekit-stable 128 0 6
✅ vite-stable 109 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 134 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 109 0 25
✅ e2e-local-dev-tanstack-start- 109 0 25
✅ e2e-local-postgres-nest-stable 109 0 25
✅ e2e-local-postgres-tanstack-start- 109 0 25
✅ e2e-local-prod-nest-stable 109 0 25
✅ e2e-local-prod-tanstack-start- 109 0 25
✅ e2e-vercel-prod-tanstack-start 108 0 26

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.042s (-5.9% 🟢) 1.007s (~) 0.965s 10 1.00x
💻 Local Nitro 0.043s (-1.4%) 1.007s (~) 0.965s 10 1.02x
🐘 Postgres Express 0.058s (~) 1.011s (~) 0.954s 10 1.39x
💻 Local Next.js (Turbopack) 0.062s 1.007s 0.945s 10 1.49x
🐘 Postgres Next.js (Turbopack) 0.068s 1.011s 0.943s 10 1.63x
🐘 Postgres Nitro 0.084s (-11.4% 🟢) 1.048s (~) 0.964s 10 2.02x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 0.309s (+22.9% 🔺) 2.318s (-0.6%) 2.009s 10 1.00x
▲ Vercel Express 0.312s (+32.5% 🔺) 2.174s (+1.8%) 1.863s 10 1.01x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.091s (-3.6%) 2.005s (~) 0.914s 10 1.00x
💻 Local Express 1.092s (-3.0%) 2.006s (~) 0.915s 10 1.00x
🐘 Postgres Express 1.108s (-3.4%) 2.009s (~) 0.901s 10 1.02x
💻 Local Next.js (Turbopack) 1.133s 2.006s 0.873s 10 1.04x
🐘 Postgres Next.js (Turbopack) 1.144s 2.009s 0.865s 10 1.05x
🐘 Postgres Nitro 1.173s (+2.9%) 2.046s (+1.8%) 0.873s 10 1.08x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.796s (-4.2%) 3.703s (-2.8%) 1.907s 10 1.00x
▲ Vercel Next.js (Turbopack) 1.825s (-10.3% 🟢) 3.868s (+1.0%) 2.043s 10 1.02x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.489s (-4.0%) 11.021s (~) 0.532s 3 1.00x
💻 Local Nitro 10.516s (-3.9%) 11.023s (~) 0.507s 3 1.00x
🐘 Postgres Express 10.570s (-3.6%) 11.020s (~) 0.450s 3 1.01x
🐘 Postgres Next.js (Turbopack) 10.792s 11.016s 0.224s 3 1.03x
💻 Local Next.js (Turbopack) 10.797s 11.022s 0.225s 3 1.03x
🐘 Postgres Nitro 10.961s (+0.8%) 11.747s (+6.5% 🔺) 0.786s 3 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 14.198s (-18.0% 🟢) 16.159s (-16.7% 🟢) 1.960s 2 1.00x
▲ Vercel Express 14.594s (-14.1% 🟢) 16.152s (-19.3% 🟢) 1.558s 2 1.03x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 13.631s (-6.6% 🟢) 14.012s (-6.8% 🟢) 0.381s 5 1.00x
💻 Local Express 13.638s (-8.9% 🟢) 14.028s (-6.7% 🟢) 0.389s 5 1.00x
💻 Local Nitro 13.742s (-8.8% 🟢) 14.026s (-12.5% 🟢) 0.284s 5 1.01x
🐘 Postgres Express 13.766s (-5.6% 🟢) 14.021s (-6.7% 🟢) 0.255s 5 1.01x
💻 Local Next.js (Turbopack) 14.408s 15.030s 0.622s 4 1.06x
🐘 Postgres Next.js (Turbopack) 14.456s 15.013s 0.557s 4 1.06x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 23.026s (-54.2% 🟢) 26.220s (-50.1% 🟢) 3.194s 3 1.00x
▲ Vercel Next.js (Turbopack) 25.681s (-51.1% 🟢) 27.948s (-48.8% 🟢) 2.267s 3 1.12x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 12.183s (-12.8% 🟢) 12.639s (-11.7% 🟢) 0.456s 8 1.00x
💻 Local Express 12.306s (-25.9% 🟢) 13.025s (-23.5% 🟢) 0.719s 7 1.01x
🐘 Postgres Express 12.355s (-11.8% 🟢) 13.019s (-10.8% 🟢) 0.663s 7 1.01x
💻 Local Nitro 12.423s (-26.0% 🟢) 13.024s (-23.5% 🟢) 0.601s 7 1.02x
💻 Local Next.js (Turbopack) 13.567s 14.028s 0.460s 7 1.11x
🐘 Postgres Next.js (Turbopack) 13.739s 14.017s 0.277s 7 1.13x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 36.135s (-90.8% 🟢) 38.497s (-90.3% 🟢) 2.362s 3 1.00x
▲ Vercel Express 39.198s (-67.7% 🟢) 41.433s (-66.5% 🟢) 2.235s 3 1.08x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.159s (-9.1% 🟢) 2.006s (~) 0.847s 15 1.00x
🐘 Postgres Express 1.175s (-6.8% 🟢) 2.007s (~) 0.832s 15 1.01x
💻 Local Nitro 1.210s (-25.9% 🟢) 2.005s (-3.3%) 0.796s 15 1.04x
💻 Local Express 1.220s (-18.0% 🟢) 2.006s (~) 0.785s 15 1.05x
🐘 Postgres Next.js (Turbopack) 1.242s 2.007s 0.766s 15 1.07x
💻 Local Next.js (Turbopack) 1.310s 2.006s 0.696s 15 1.13x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.554s (-10.7% 🟢) 4.050s (-12.4% 🟢) 1.496s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.694s (-20.7% 🟢) 4.294s (-13.0% 🟢) 1.600s 8 1.05x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.249s (-47.1% 🟢) 2.007s (-33.3% 🟢) 0.759s 15 1.00x
🐘 Postgres Next.js (Turbopack) 1.396s 2.007s 0.610s 15 1.12x
💻 Local Nitro 1.547s (-50.8% 🟢) 2.005s (-48.4% 🟢) 0.458s 15 1.24x
🐘 Postgres Nitro 1.602s (-31.9% 🟢) 2.511s (-16.5% 🟢) 0.909s 12 1.28x
💻 Local Express 1.718s (-41.8% 🟢) 2.072s (-40.0% 🟢) 0.354s 15 1.38x
💻 Local Next.js (Turbopack) 1.848s 2.221s 0.373s 14 1.48x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.429s (-5.3% 🟢) 5.170s (+1.2%) 1.741s 6 1.00x
▲ Vercel Next.js (Turbopack) 3.559s (-49.9% 🟢) 5.010s (-43.7% 🟢) 1.452s 6 1.04x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.379s (-60.5% 🟢) 2.007s (-50.0% 🟢) 0.628s 15 1.00x
🐘 Postgres Nitro 1.571s (-54.9% 🟢) 2.753s (-31.3% 🟢) 1.182s 11 1.14x
🐘 Postgres Next.js (Turbopack) 1.658s 2.075s 0.417s 15 1.20x
💻 Local Nitro 4.345s (-48.0% 🟢) 4.868s (-46.0% 🟢) 0.523s 7 3.15x
💻 Local Express 5.056s (-39.4% 🟢) 5.848s (-35.2% 🟢) 0.792s 6 3.67x
💻 Local Next.js (Turbopack) 5.350s 6.015s 0.665s 5 3.88x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 7.021s (-21.2% 🟢) 8.561s (-21.9% 🟢) 1.540s 4 1.00x
▲ Vercel Express 309.834s (+7207.4% 🔺) 311.952s (+4991.4% 🔺) 2.118s 1 44.13x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.187s (-5.6% 🟢) 2.008s (~) 0.821s 15 1.00x
🐘 Postgres Nitro 1.209s (-3.9%) 2.007s (~) 0.799s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.256s 2.009s 0.752s 15 1.06x
💻 Local Next.js (Turbopack) 1.344s 2.006s 0.662s 15 1.13x
💻 Local Express 1.524s (-19.5% 🟢) 2.006s (-15.1% 🟢) 0.481s 15 1.28x
💻 Local Nitro 1.554s (-16.7% 🟢) 2.006s (-14.3% 🟢) 0.452s 15 1.31x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.550s (-13.0% 🟢) 4.357s (-6.1% 🟢) 1.808s 7 1.00x
▲ Vercel Express 2.780s (+7.7% 🔺) 4.482s (+3.0%) 1.702s 7 1.09x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.256s (-46.3% 🟢) 2.008s (-33.3% 🟢) 0.752s 15 1.00x
🐘 Postgres Nitro 1.279s (-45.3% 🟢) 2.007s (-33.3% 🟢) 0.728s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.365s 2.008s 0.643s 15 1.09x
💻 Local Express 1.923s (-38.6% 🟢) 2.392s (-36.4% 🟢) 0.469s 13 1.53x
💻 Local Nitro 2.002s (-34.7% 🟢) 2.392s (-38.4% 🟢) 0.390s 13 1.59x
💻 Local Next.js (Turbopack) 2.051s 2.674s 0.623s 12 1.63x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.743s (+17.3% 🔺) 5.636s (+17.6% 🔺) 1.893s 6 1.00x
▲ Vercel Next.js (Turbopack) 4.007s (+27.5% 🔺) 5.748s (+27.1% 🔺) 1.741s 6 1.07x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.352s (-61.2% 🟢) 2.015s (-49.7% 🟢) 0.663s 15 1.00x
🐘 Postgres Express 1.400s (-60.0% 🟢) 2.009s (-49.9% 🟢) 0.609s 15 1.04x
🐘 Postgres Next.js (Turbopack) 1.736s 2.224s 0.488s 14 1.28x
💻 Local Nitro 4.886s (-46.6% 🟢) 5.346s (-46.7% 🟢) 0.460s 6 3.61x
💻 Local Express 5.035s (-42.8% 🟢) 5.515s (-40.5% 🟢) 0.480s 6 3.72x
💻 Local Next.js (Turbopack) 5.527s 6.015s 0.488s 5 4.09x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 5.254s (-22.2% 🟢) 7.006s (-18.0% 🟢) 1.751s 5 1.00x
▲ Vercel Express 5.769s (-10.1% 🟢) 7.682s (-6.1% 🟢) 1.913s 5 1.10x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.483s (-41.2% 🟢) 1.022s (+1.6%) 0.540s 59 1.00x
🐘 Postgres Express 0.562s (-33.0% 🟢) 1.024s (~) 0.462s 59 1.17x
💻 Local Express 0.569s (-42.2% 🟢) 1.004s (-6.7% 🟢) 0.435s 60 1.18x
💻 Local Nitro 0.587s (-40.2% 🟢) 1.004s (-8.2% 🟢) 0.418s 60 1.22x
🐘 Postgres Next.js (Turbopack) 0.837s 1.023s 0.185s 59 1.74x
💻 Local Next.js (Turbopack) 0.846s 1.004s 0.158s 60 1.75x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.797s (-69.5% 🟢) 7.763s (-63.6% 🟢) 1.966s 8 1.00x
▲ Vercel Next.js (Turbopack) 6.073s (-58.1% 🟢) 8.128s (-49.5% 🟢) 2.055s 8 1.05x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.245s (-35.4% 🟢) 1.924s (-8.4% 🟢) 0.679s 47 1.00x
🐘 Postgres Express 1.339s (-32.3% 🟢) 2.008s (-11.1% 🟢) 0.669s 45 1.08x
💻 Local Nitro 1.462s (-51.8% 🟢) 2.006s (-46.6% 🟢) 0.543s 45 1.17x
💻 Local Express 1.569s (-48.0% 🟢) 2.122s (-40.8% 🟢) 0.553s 43 1.26x
🐘 Postgres Next.js (Turbopack) 1.920s 2.101s 0.181s 43 1.54x
💻 Local Next.js (Turbopack) 2.043s 2.713s 0.670s 34 1.64x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 14.294s (-71.3% 🟢) 16.158s (-68.8% 🟢) 1.863s 6 1.00x
▲ Vercel Express 14.966s (-56.7% 🟢) 17.252s (-53.1% 🟢) 2.286s 6 1.05x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Express

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 2.711s (-32.1% 🟢) 3.111s (-28.8% 🟢) 0.400s 39 1.00x
🐘 Postgres Nitro 2.889s (-29.6% 🟢) 3.429s (-25.5% 🟢) 0.541s 35 1.07x
💻 Local Nitro 3.143s (-66.2% 🟢) 3.736s (-62.7% 🟢) 0.592s 33 1.16x
💻 Local Express 3.155s (-65.7% 🟢) 3.880s (-61.3% 🟢) 0.725s 31 1.16x
🐘 Postgres Next.js (Turbopack) 3.802s 4.010s 0.208s 30 1.40x
💻 Local Next.js (Turbopack) 4.247s 5.010s 0.764s 24 1.57x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 24.978s (-80.8% 🟢) 27.460s (-79.2% 🟢) 2.481s 5 1.00x
▲ Vercel Next.js (Turbopack) 33.880s (-68.4% 🟢) 36.358s (-66.6% 🟢) 2.479s 4 1.36x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.208s (-26.4% 🟢) 1.033s (+2.6%) 0.825s 59 1.00x
🐘 Postgres Express 0.215s (-24.0% 🟢) 1.006s (~) 0.792s 60 1.03x
🐘 Postgres Next.js (Turbopack) 0.271s 1.006s 0.735s 60 1.30x
💻 Local Express 0.490s (-12.6% 🟢) 1.004s (~) 0.514s 60 2.35x
💻 Local Nitro 0.552s (-8.8% 🟢) 1.004s (-1.7%) 0.452s 60 2.65x
💻 Local Next.js (Turbopack) 0.657s 1.137s 0.480s 53 3.15x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.420s (+23.8% 🔺) 4.253s (+16.9% 🔺) 1.834s 15 1.00x
▲ Vercel Next.js (Turbopack) 2.660s (+31.5% 🔺) 4.641s (+22.3% 🔺) 1.981s 13 1.10x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.341s (-33.2% 🟢) 1.006s (~) 0.666s 90 1.00x
🐘 Postgres Nitro 0.361s (-27.3% 🟢) 1.032s (+2.5%) 0.671s 88 1.06x
🐘 Postgres Next.js (Turbopack) 0.466s 1.005s 0.539s 90 1.37x
💻 Local Nitro 1.976s (-22.1% 🟢) 2.401s (-20.2% 🟢) 0.425s 38 5.80x
💻 Local Express 2.048s (-18.5% 🟢) 2.537s (-15.7% 🟢) 0.489s 36 6.01x
💻 Local Next.js (Turbopack) 2.409s 3.077s 0.668s 30 7.07x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.337s (+75.2% 🔺) 7.010s (+45.8% 🔺) 1.673s 13 1.00x
▲ Vercel Next.js (Turbopack) 5.949s (+68.3% 🔺) 7.989s (+53.8% 🔺) 2.041s 12 1.11x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.674s (-17.7% 🟢) 1.006s (-1.1%) 0.332s 120 1.00x
🐘 Postgres Nitro 0.792s (~) 1.228s (+21.9% 🔺) 0.437s 98 1.17x
🐘 Postgres Next.js (Turbopack) 0.938s 1.419s 0.482s 85 1.39x
💻 Local Nitro 8.851s (-20.9% 🟢) 9.411s (-19.3% 🟢) 0.559s 13 13.13x
💻 Local Express 9.300s (-16.9% 🟢) 9.950s (-16.7% 🟢) 0.650s 13 13.80x
💻 Local Next.js (Turbopack) 10.370s 10.945s 0.575s 12 15.38x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 12.868s (+73.4% 🔺) 15.255s (+65.0% 🔺) 2.387s 8 1.00x
▲ Vercel Next.js (Turbopack) 13.701s (+32.7% 🔺) 15.708s (+27.9% 🔺) 2.007s 8 1.06x
▲ Vercel Nitro ⚠️ missing - - - -

🔍 Observability: Express | Next.js (Turbopack)

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.125s (+448.9% 🔺) 2.002s (+100.2% 🔺) 0.001s (-53.3% 🟢) 2.009s (+98.6% 🔺) 0.883s 10 1.00x
💻 Local Nitro 1.147s (+436.7% 🔺) 2.004s (+99.5% 🔺) 0.010s (-20.8% 🟢) 2.016s (+97.9% 🔺) 0.869s 10 1.02x
💻 Local Express 1.157s (+481.3% 🔺) 2.005s (+99.6% 🔺) 0.010s (-15.7% 🟢) 2.018s (+98.2% 🔺) 0.860s 10 1.03x
🐘 Postgres Express 1.166s (+468.7% 🔺) 2.001s (+100.3% 🔺) 0.001s (-25.0% 🟢) 2.011s (+98.8% 🔺) 0.845s 10 1.04x
💻 Local Next.js (Turbopack) 1.206s 2.004s 0.012s 2.020s 0.814s 10 1.07x
🐘 Postgres Next.js (Turbopack) 1.220s 2.001s 0.001s 2.010s 0.790s 10 1.08x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.224s (-67.6% 🟢) 3.547s (-59.0% 🟢) 1.536s (+143.1% 🔺) 5.570s (-43.1% 🟢) 3.346s 10 1.00x
▲ Vercel Express 2.463s (-1.7%) 3.371s (-17.6% 🟢) 1.662s (+73.0% 🔺) 5.754s (+2.9%) 3.291s 10 1.11x
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Next.js (Turbopack) | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 1.553s (+85.2% 🔺) 2.009s (+98.5% 🔺) 0.010s (+11.3% 🔺) 2.022s (+81.2% 🔺) 0.468s 30 1.00x
🐘 Postgres Express 1.584s (+151.5% 🔺) 2.007s (+99.4% 🔺) 0.004s (+6.2% 🔺) 2.026s (+98.1% 🔺) 0.442s 30 1.02x
💻 Local Next.js (Turbopack) 1.709s 2.008s 0.011s 2.022s 0.314s 30 1.10x
🐘 Postgres Next.js (Turbopack) 1.720s 2.009s 0.004s 2.024s 0.303s 30 1.11x
🐘 Postgres Nitro 1.767s (+183.0% 🔺) 2.257s (+124.2% 🔺) 0.003s (-24.8% 🟢) 2.282s (+123.1% 🔺) 0.515s 27 1.14x
💻 Local Express 2.211s (+192.1% 🔺) 2.009s (+95.3% 🔺) 0.009s (-9.5% 🟢) 2.672s (+157.0% 🔺) 0.461s 23 1.42x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.814s (-10.6% 🟢) 7.460s (-6.9% 🟢) 0.170s (-58.4% 🟢) 8.233s (-6.8% 🟢) 2.419s 8 1.00x
▲ Vercel Next.js (Turbopack) 5.886s (-65.2% 🟢) 7.543s (-58.6% 🟢) 0.273s (+29.2% 🔺) 8.405s (-55.6% 🟢) 2.519s 8 1.01x
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Express | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.719s (-25.2% 🟢) 1.070s (-16.3% 🟢) 0.000s (+23.2% 🔺) 1.082s (-17.2% 🟢) 0.363s 56 1.00x
🐘 Postgres Next.js (Turbopack) 0.775s 1.053s 0.000s 1.065s 0.290s 57 1.08x
🐘 Postgres Nitro 0.967s (~) 1.365s (+9.4% 🔺) 0.000s (-100.0% 🟢) 1.382s (+9.9% 🔺) 0.415s 46 1.34x
💻 Local Next.js (Turbopack) 1.479s 2.012s 0.000s 2.015s 0.536s 30 2.06x
💻 Local Express 1.535s (+25.3% 🔺) 2.012s (~) 0.001s (+82.1% 🔺) 2.194s (+8.5% 🔺) 0.659s 28 2.13x
💻 Local Nitro 1.550s (+26.7% 🔺) 2.012s (~) 0.000s (+348.3% 🔺) 2.187s (+8.2% 🔺) 0.637s 29 2.16x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 3.636s (-64.3% 🟢) 4.915s (-57.3% 🟢) 0.001s (+Infinity% 🔺) 5.570s (-53.8% 🟢) 1.934s 11 1.00x
▲ Vercel Express 4.208s (+12.5% 🔺) 5.883s (+15.3% 🔺) 0.000s (-100.0% 🟢) 6.507s (+17.7% 🔺) 2.299s 10 1.16x
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Next.js (Turbopack) | Express

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.408s (-20.5% 🟢) 2.137s (-1.8%) 0.000s (NaN%) 2.150s (-2.2%) 0.742s 29 1.00x
🐘 Postgres Nitro 1.652s (-7.8% 🟢) 2.100s (-1.9%) 0.000s (-3.4%) 2.121s (-2.5%) 0.469s 29 1.17x
🐘 Postgres Next.js (Turbopack) 1.753s 2.388s 0.000s 2.396s 0.643s 26 1.24x
💻 Local Next.js (Turbopack) 2.916s 3.555s 0.001s 3.559s 0.644s 17 2.07x
💻 Local Nitro 2.979s (-12.1% 🟢) 3.540s (-12.2% 🟢) 0.000s (-11.8% 🟢) 3.557s (-11.9% 🟢) 0.579s 17 2.12x
💻 Local Express 3.319s (-4.3%) 3.584s (-11.1% 🟢) 0.000s (-84.4% 🟢) 3.906s (-3.2%) 0.587s 16 2.36x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.344s (+16.5% 🔺) 7.182s (+19.3% 🔺) 0.000s (NaN%) 7.877s (+22.0% 🔺) 2.533s 8 1.00x
▲ Vercel Next.js (Turbopack) 5.777s (+2.9%) 7.684s (+10.1% 🔺) 0.001s (+700.0% 🔺) 8.236s (+9.2% 🔺) 2.459s 8 1.08x
▲ Vercel Nitro ⚠️ missing - - - - -

🔍 Observability: Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Nitro 11/21
🐘 Postgres Express 13/21
▲ Vercel Express 12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 15/21
Next.js (Turbopack) 🐘 Postgres 15/21
Nitro 🐘 Postgres 16/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

// Terminal events clear their run's entries so a long-lived dev server does
// not retain completed-run payloads. Externally written event paths are
// absent from this cache and still load from disk normally.
const eventCache = new Map<string, Event>();
Copy link
Copy Markdown
Contributor

@vercel vercel Bot May 29, 2026

Choose a reason for hiding this comment

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

Event cache is silently a no-op when basedir is a relative path because cache keys (absolute) never match lookup keys (relative).

Fix on Vercel

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR targets the local filesystem-backed world (@workflow/world-local) to reduce sequential-step replay overhead by avoiding repeated disk reads of just-written event files and repeated recursive mkdir calls for stable directories.

Changes:

  • Add a bounded in-memory cache for recently appended events and plumb it into paginatedFileSystemQuery() via an optional cachedItems map.
  • Add a process-local directory creation cache + retry-on-ENOENT behavior for atomic/exclusive writes to tolerate external data-dir deletion.
  • Add regression tests and a patch changeset covering cache isolation, oversize handling, normalization, and cache clearing on world.clear() / terminal runs.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/world-local/src/storage/events-storage.ts Introduces bounded recent-event caching and uses it to short-circuit replay pagination disk reads.
packages/world-local/src/fs.ts Adds directory caching + retry-on-missing-dir writes; extends paginatedFileSystemQuery to optionally serve results from an in-memory cache.
packages/world-local/src/storage/index.ts Extends local storage shape with clearCache() and wires it to the events cache.
packages/world-local/src/index.ts Clears storage cache on world.clear()/world.close() and ensures write-path caches are cleared when removing the whole data directory.
packages/world-local/src/storage.test.ts Adds tests to verify cached events don’t alias returned instances, oversize events bypass cache, normalization matches disk reads, and cache can be released.
packages/world-local/src/fs.test.ts Adds tests for directory caching and for recreating cached directories after external removal for atomic/exclusive writes.
packages/world-local/src/tag.test.ts Adds regression test ensuring untagged world.clear() doesn’t leave cached directories that break subsequent writes.
.changeset/quick-local-replay.md Adds a patch changeset describing the optimization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -544,7 +582,11 @@ export async function paginatedFileSystemQuery<T extends { createdAt: Date }>(
const filePath = path.join(directory, `${fileId}.json`);
expect(result.data[3].eventType).toBe('hook_disposed');
});
});

Copy link
Copy Markdown
Contributor

@karthikscale3 karthikscale3 left a comment

Choose a reason for hiding this comment

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

ai review: A few notes from a regression/coverage pass. One blocking correctness issue on the read-cache key, plus some test-coverage gaps (several marked optional).

let item: T | null = null;
try {
item = await readJSON(filePath, schema);
const cachedItem = cachedItems?.get(filePath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ai review (critical): this lookup key never matches the cache key when dataDir is relative, so the event read-cache is silently a no-op in the default config.

storeEvent keys the cache via taggedPath -> resolveWithinBase -> path.resolve(...) (absolute), but here filePath = path.join(directory, ...) where directory = path.join(basedir, 'events') stays relative when basedir is relative. Verified:

cacheKey : /…/workflow/.next/workflow-data/events/wrun_ABC-evt_123.json
lookupKey: .next/workflow-data/events/wrun_ABC-evt_123.json
match    : false

This is the default path: world-local defaults to '.workflow-data' and the Next.js adapter sets WORKFLOW_LOCAL_DATA_DIR = '.next/workflow-data' (both relative). So in a Next.js dev server — the long-lived-server scenario this PR targets — events.list() re-reads every event from disk and the headline 240ms -> 115ms win does not apply. The mkdir cache is unaffected (it path.resolves on both sides).

Suggested fix: normalize both sides, e.g. key the lookup by path.resolve(directory, ${fileId}.json) here (defensive for any relative-directory caller), or build the cache key in storeEvent with the same path.join(basedir, 'events', ...) the query uses.

});
});

it('reuses locally appended events without exposing cached instances', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ai review (test gap): every suite uses fs.mkdtemp(os.tmpdir(), …) (absolute), which is exactly why the relative-dataDir cache-key mismatch above isn't caught. Please add a regression test that builds storage with a relative basedir, writes an event, lists, and asserts eventFileReads is empty — that would have flagged the no-op.

Also, this test models a single run_created event, but the optimization targets sequential multi-step replay. A test that appends N events and asserts the incremental list() does zero event-file reads would actually exercise the benchmarked path.

const result = await paginatedFileSystemQuery({
directory: path.join(basedir, 'events'),
schema: EventSchema,
cachedItems: eventCache,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ai review (test gap, optional): listByCorrelationId now passes cachedItems: eventCache but there's no test asserting a correlation-id list actually hits the cache (the create/list paths are covered). Low priority since it shares the same code path, but worth a small case.

expect(second.data[0]?.eventType).toBe('run_created');
});

it('reads oversized event payloads from disk instead of retaining them', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ai review (test gap, optional): the > maxCachedEventBytes skip is covered here, but the FIFO eviction loop (maxCachedEventEntries / totalCachedEventBytes accounting) has no direct test. Optional — a test that writes >1000 small events and asserts bounded cache size / no byte-counter drift would lock in the eviction logic, but it's not blocking.

Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

Choose a reason for hiding this comment

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

Request changes — critical bug confirmed: cache silently a no-op with relative dataDir

The structure of this PR is great — bounded LRU-ish cache, careful mutation isolation via structuredClone, proper terminal-run cleanup, sensible 4 MiB / 1000-entry caps, ENOENT retry for the createdDirectoriesCache. The test coverage for the in-cache scenarios is genuinely thorough (5 new storage tests + 3 new fs tests covering hit/miss/oversized/normalization/clearCache/terminal-release).

But there's a critical correctness bug that @karthikscale3 and Vade already flagged, and I've now reproduced it locally — see inline. The cache is silently a no-op in the default dataDir: '.workflow-data' config, which means the perf numbers in the PR description don't hold for production.

Verified locally

  • 375/375 tests pass (the existing absolute-path tests don't exercise the bug)
  • Reproduced the cache-key mismatch in a 6-line Node script — path.resolve vs path.join produce different strings when basedir is relative

Other points from the prior reviews

I agree with @karthikscale3 on the remaining items:

  1. Test gap — relative dataDir (high): Every existing suite uses mkdtempSync(os.tmpdir(), ...) which gives absolute paths. Add a regression test that builds the world with a relative dataDir — inline comment includes a snippet.
  2. Test gap — listByCorrelationId cache (optional): The listByCorrelationId path also passes cachedItems: eventCache now, but no test asserts it hits the cache. Easy to add.
  3. Test gap — FIFO eviction (optional): The > maxCachedEventBytes skip is tested, but the while (...) eviction loop has no direct coverage. Worth adding a test that writes >1000 events and asserts the oldest gets evicted.
  4. Test mocks not restored (Copilot): The new vi.spyOn(fs, 'readFile') calls should be wrapped in try { ... } finally { fs.readFile.mockRestore() } (or rely on a global afterEach(() => vi.restoreAllMocks()) if the suite has one).

What's good

The non-cache parts of the PR are solid:

  • createdDirectoriesCache with withEnsuredDirectory ENOENT retry is the right pattern — covers the dev-server-survives-rm scenario explicitly.
  • Cache release on terminal runs (run_completed/run_failed/run_cancelled) is the right boundary — bounds memory growth for long-lived dev servers.
  • structuredClone on cache hit detaches caller-owned mutations from cached state — the test that mutates first.data[0].eventType = 'run_failed' and asserts the next list returns the original is exactly right.
  • The world.clear() + world.close() integration is correct — both clear caches, including createdFilesCache so newly-created files after clear() work.

Request changes

Pending the relative-path bug fix + regression test. Once those are in, this is a clean perf improvement.

@@ -544,7 +582,11 @@ export async function paginatedFileSystemQuery<T extends { createdAt: Date }>(
const filePath = path.join(directory, `${fileId}.json`);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirming the critical bug @karthikscale3 and Vade flagged: the event cache is silently a no-op when dataDir is relative.

I traced it through and reproduced the mismatch locally:

// taggedPath() (cache write key) uses path.resolve():
resolveWithinBase('.workflow-data', 'events', 'wrun_X-evt_Y.json')
// → '/Users/me/repo/.workflow-data/events/wrun_X-evt_Y.json'

// paginatedFileSystemQuery's filePath lookup uses path.join():
path.join(path.join('.workflow-data', 'events'), 'wrun_X-evt_Y.json')
// → '.workflow-data/events/wrun_X-evt_Y.json'

The two never match. cachedItems?.get(filePath) always returns undefined, the code falls through to readJSON(filePath, schema), and the cache contributes zero perf benefit in the default config.

The perf numbers in the PR description (24% faster, 52% fewer events.list ms) were measured against a setup that probably used os.tmpdir() (absolute), which is exactly why the bug isn't caught by any existing test — every suite uses mkdtempSync(os.tmpdir(), ...).

Fix

Normalize the cache key on both sides. Simplest: have storeEvent use the same path.join(directory, ...) construction the lookup uses, OR have paginatedFileSystemQuery resolve filePath before consulting the cache:

// In paginatedFileSystemQuery:
const filePath = path.join(directory, `${fileId}.json`);
const cacheKey = path.resolve(filePath); // <- add this
const cachedItem = cachedItems?.get(cacheKey);

And in storeEvent (already using taggedPath which resolves, so just keep that and don't change the read side). The cleaner version is to standardize on path.resolve(...) for cache keys everywhere.

Regression test

Karthik's suggestion to add a regression test that builds the world with a relative dataDir is exactly right. Suggested:

it('event cache works when dataDir is a relative path', async () => {
  const cwd = process.cwd();
  const relativeDir = path.relative(cwd, testDir); // make it relative
  const localStorage = createStorage(relativeDir);
  const run = await createRun(localStorage, {
    deploymentId: 'dep',
    workflowName: 'wf',
    input: new Uint8Array([1]),
  });
  const readFileSpy = vi.spyOn(fs, 'readFile');
  await localStorage.events.list({ runId: run.runId });
  const eventFileReads = readFileSpy.mock.calls.filter(([fp]) =>
    String(fp).includes(`${path.sep}events${path.sep}`)
  );
  expect(eventFileReads).toHaveLength(0); // cache hit
});

Blocking on this — the perf claim doesn't hold for the default config until the cache keys align.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants