Skip to content

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

Merged
pranaygp merged 4 commits into
mainfrom
pranaygp/codex/world-local-sequential-perf
Jun 30, 2026
Merged

[world-local] Reduce sequential replay I/O#2152
pranaygp merged 4 commits into
mainfrom
pranaygp/codex/world-local-sequential-perf

Conversation

@pranaygp

@pranaygppranaygp commented May 29, 2026

Copy link
Copy Markdown
Contributor

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 relative data directories, 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 the PR merge base, 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 caching implementation exposed three correctness issues that are covered here:

  • cached event lookup now resolves relative data directories before using the absolute-path cache key, so the ordinary local-world configuration receives the optimization
  • 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
  • atomic and exclusive writes retry once after recreating a cached directory removed externally while a dev server is still running

Retention is explicitly bounded:

  • 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
  • tests exercise eviction after exceeding the byte limit and correlation-id cache queries

Measurement

I modeled the event/replay lifecycle for a no-delay sequential workflow directly through @workflow/world-local storage with a relative dataDir, matching normal local-world configuration. Both revisions ran the same probe with one warmup and five measured trials per size; medians are reported. The control is the PR merge base (ae37315cb).

Sequential stepsMerge baseThis branchImprovement
50178.49 ms124.97 ms30.0% faster
200742.24 ms609.04 ms17.9% faster

For 200 steps, incremental events.list() time fell from 193.35 ms to 104.86 ms (45.8% lower).

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

OperationMerge-base callsThis branch calls
readFile457252
mkdir4044

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.

Note: the figures above were captured against the original merge base ae37315cb, before the merge with main below. They characterize the optimization relative to its baseline and have not been re-measured against current main HEAD.

Merge with main

This branch has been merged up to the latest main. main reworked the local event-write path in the interim (atomic publish via writeExclusive temp-file + hard-link, per-instance stepLocks/hookLocks, and hook_created dedup-recovery). Conflicts were resolved so the recent-event cache layers on top of those write semantics rather than replacing them:

  • writeExclusive keeps main's atomic temp-file + hard-link publish (and Windows retry), now wrapped in the branch's withEnsuredDirectory ENOENT-retry for externally-removed data dirs
  • event caching moved into a rememberStoredEvent() helper invoked after each successful publish (both the main event path and the hook_conflict path), preserving the pre-await serialized-snapshot detachment guarantee

Validation

  • reproduced and fixed relative-dataDir cache misses; a five-step sequential lifecycle regression test now verifies repeated event lists make zero event-file reads
  • reproduced the pre-fix cached-object alias and verified the amended implementation returns the persisted value
  • reproduced the pre-fix external-directory cleanup failure (ENOENT on the next event write) and verified directory recovery
  • added cache-hit coverage for listByCorrelationId() and FIFO byte-limit eviction without a Windows-hostile high-file-count test
  • pnpm --filter @workflow/world-local typecheck
  • pnpm --filter @workflow/world-local test (427 tests passed, post-merge)
  • pnpm --filter '@workflow/world-local...' build
  • pnpm changeset status --since=origin-https/main
  • git diff --check

@changeset-bot

changeset-botBot commented May 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0a0264f

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

This PR includes changesets to release 18 packages
NameType
@workflow/world-localPatch
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/world-postgresPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
@workflow/webPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

vercelBot commented May 29, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented May 29, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
❌ ▲ Vercel Production144112301672
✅ 💻 Local Development160502191824
✅ 📦 Local Production160502191824
✅ 🐘 Local Postgres159302311824
✅ 🪟 Windows15200152
✅ 📋 Other88501791064
Total7281110788360

❌ Failed Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

  • hookSupersedeOwnerWorkflow - duplicate cancels the owner and claims the released token | wrun_01KWAM2C6SS2EHJ226Z88KA764 | 🔍 observability

Details by Category

❌ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro125027
✅ example125027
✅ express125027
❌ fastify124127
✅ hono125027
✅ nextjs-turbopack14903
✅ nextjs-webpack14903
✅ nitro125027
✅ nuxt125027
✅ sveltekit14408
✅ vite125027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable127025
✅ express-stable127025
✅ fastify-stable127025
✅ hono-stable127025
✅ nextjs-turbopack-canary133019
✅ nextjs-turbopack-stable15200
✅ nextjs-webpack-canary133019
✅ nextjs-webpack-stable15200
✅ nitro-stable127025
✅ nuxt-stable127025
✅ sveltekit-stable14606
✅ vite-stable127025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable127025
✅ express-stable127025
✅ fastify-stable127025
✅ hono-stable127025
✅ nextjs-turbopack-canary133019
✅ nextjs-turbopack-stable15200
✅ nextjs-webpack-canary133019
✅ nextjs-webpack-stable15200
✅ nitro-stable127025
✅ nuxt-stable127025
✅ sveltekit-stable14606
✅ vite-stable127025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable126026
✅ express-stable126026
✅ fastify-stable126026
✅ hono-stable126026
✅ nextjs-turbopack-canary132020
✅ nextjs-turbopack-stable15101
✅ nextjs-webpack-canary132020
✅ nextjs-webpack-stable15101
✅ nitro-stable126026
✅ nuxt-stable126026
✅ sveltekit-stable14507
✅ vite-stable126026
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15200
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable127025
✅ e2e-local-dev-tanstack-start-127025
✅ e2e-local-postgres-nest-stable126026
✅ e2e-local-postgres-tanstack-start-126026
✅ e2e-local-prod-nest-stable127025
✅ e2e-local-prod-tanstack-start-127025
✅ e2e-vercel-prod-tanstack-start125027

📋 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

github-actionsBot commented May 29, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

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

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.047s (-28.6% 🟢)1.011s (~)0.964s101.00x
💻 LocalNitro0.047s (+7.7% 🔺)1.007s (~)0.960s101.01x
💻 LocalNext.js (Turbopack)0.048s (-15.0% 🟢)1.006s (~)0.958s101.01x
💻 LocalExpress0.048s (~)1.007s (~)0.959s101.03x
🐘 PostgresNext.js (Turbopack)0.060s (-1.0%)1.014s (~)0.953s101.28x
🐘 PostgresExpress0.072s (+13.0% 🔺)1.012s (~)0.940s101.54x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro0.220s (+4.3%)1.906s (-5.6% 🟢)1.686s101.00x
▲ VercelExpress0.301s (+16.2% 🔺)2.187s (+4.1%)1.886s101.37x
▲ VercelNext.js (Turbopack)0.742s (+202.2% 🔺)2.602s (+9.6% 🔺)1.861s103.38x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.083s (-1.0%)2.006s (~)0.923s101.00x
💻 LocalNitro1.084s (~)2.007s (~)0.923s101.00x
💻 LocalExpress1.085s (~)2.006s (~)0.922s101.00x
🐘 PostgresNitro1.092s (~)2.018s (~)0.925s101.01x
🐘 PostgresNext.js (Turbopack)1.104s (+0.9%)2.010s (~)0.906s101.02x
🐘 PostgresExpress1.104s (+1.2%)2.010s (~)0.906s101.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro1.367s (-1.2%)2.688s (-6.4% 🟢)1.321s101.00x
▲ VercelExpress1.457s (+4.1%)3.192s (+3.8%)1.735s101.07x
▲ VercelNext.js (Turbopack)2.368s (+0.5%)4.063s (+10.3% 🔺)1.695s101.73x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro10.307s (-1.7%)11.016s (~)0.709s31.00x
💻 LocalExpress10.454s (~)11.023s (~)0.569s31.01x
🐘 PostgresExpress10.470s (~)11.012s (~)0.542s31.02x
💻 LocalNitro10.471s (~)11.023s (~)0.552s31.02x
💻 LocalNext.js (Turbopack)10.484s (~)11.021s (~)0.538s31.02x
🐘 PostgresNext.js (Turbopack)10.558s (~)11.022s (~)0.464s31.02x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express12.089s (+3.6%)14.019s (+5.7% 🔺)1.930s31.00x
▲ VercelNitro12.266s (+4.6%)13.894s (+1.5%)1.629s31.01x
▲ VercelNext.js (Turbopack)12.655s (-3.8%)14.022s (-5.7% 🟢)1.367s31.05x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro13.292s (-2.1%)14.018s (~)0.725s51.00x
💻 LocalNext.js (Turbopack)13.559s (-1.3%)14.026s (~)0.467s51.02x
🐘 PostgresExpress13.614s (~)14.019s (~)0.405s51.02x
💻 LocalExpress13.651s (~)14.027s (~)0.376s51.03x
💻 LocalNitro13.680s (+0.7%)14.030s (~)0.349s51.03x
🐘 PostgresNext.js (Turbopack)13.704s (+0.6%)14.023s (~)0.319s51.03x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro16.596s (+2.0%)18.301s (-1.8%)1.705s41.00x
▲ VercelExpress16.768s (+2.1%)18.892s (+4.7%)2.124s41.01x
▲ VercelNext.js (Turbopack)17.950s (-4.2%)19.419s (-5.5% 🟢)1.469s41.08x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro11.372s (-7.0% 🟢)12.013s (-7.7% 🟢)0.640s81.00x
💻 LocalNext.js (Turbopack)12.180s (-2.0%)13.027s (~)0.847s71.07x
💻 LocalExpress12.210s (~)13.026s (~)0.817s71.07x
💻 LocalNitro12.271s (~)13.027s (~)0.756s71.08x
🐘 PostgresExpress12.297s (+1.0%)13.018s (~)0.721s71.08x
🐘 PostgresNext.js (Turbopack)12.315s (-2.2%)13.015s (-1.1%)0.700s71.08x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express19.810s (+12.0% 🔺)21.683s (+13.1% 🔺)1.873s51.00x
▲ VercelNitro20.970s (+17.1% 🔺)22.334s (+13.1% 🔺)1.364s51.06x
▲ VercelNext.js (Turbopack)21.732s (+6.5% 🔺)23.672s (+4.9%)1.940s41.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.183s (~)2.007s (~)0.824s151.00x
🐘 PostgresNext.js (Turbopack)1.189s (-0.9%)2.008s (~)0.819s151.01x
🐘 PostgresNitro1.214s (+0.5%)2.006s (~)0.793s151.03x
💻 LocalNext.js (Turbopack)1.381s (-9.2% 🟢)2.006s (~)0.625s151.17x
💻 LocalNitro1.408s (+2.1%)2.007s (~)0.599s151.19x
💻 LocalExpress1.441s (+0.7%)2.007s (~)0.566s151.22x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.359s (+3.4%)4.210s (+9.4% 🔺)1.850s81.00x
▲ VercelExpress2.449s (+11.3% 🔺)4.077s (+16.6% 🔺)1.628s81.04x
▲ VercelNext.js (Turbopack)4.180s (+31.1% 🔺)6.023s (+22.9% 🔺)1.843s51.77x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express1.331s (-2.3%)2.592s (+11.9% 🔺)1.260s121.00x
🐘 PostgresNext.js (Turbopack)1.333s (+1.1%)3.109s (+6.5% 🔺)1.776s101.00x
🐘 PostgresNitro1.558s (+17.2% 🔺)2.625s (+9.7% 🔺)1.067s121.17x
💻 LocalNext.js (Turbopack)2.136s (-16.8% 🟢)2.826s (-9.1% 🟢)0.690s111.60x
💻 LocalNitro2.387s (-4.1%)3.010s (+3.2%)0.623s101.79x
💻 LocalExpress2.397s (-12.5% 🟢)3.009s (-6.3% 🟢)0.612s101.80x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.634s (+7.6% 🔺)4.366s (+22.4% 🔺)1.732s71.00x
▲ VercelNitro3.921s (+55.7% 🔺)5.340s (+39.6% 🔺)1.420s61.49x
▲ VercelNext.js (Turbopack)4.358s (+21.8% 🔺)5.901s (+11.1% 🔺)1.543s61.65x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.439s (-10.3% 🟢)3.208s (-22.5% 🟢)1.769s101.00x
🐘 PostgresExpress1.617s (+0.8%)4.135s (~)2.519s81.12x
🐘 PostgresNext.js (Turbopack)2.659s (-10.2% 🟢)5.684s (-5.5% 🟢)3.025s61.85x
💻 LocalNitro4.490s (-21.7% 🟢)5.157s (-22.1% 🟢)0.667s73.12x
💻 LocalNext.js (Turbopack)4.593s (-32.4% 🟢)5.011s (-33.3% 🟢)0.418s63.19x
💻 LocalExpress4.749s (-33.2% 🟢)5.347s (-29.8% 🟢)0.598s63.30x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.933s (+46.7% 🔺)5.766s (+27.7% 🔺)1.833s61.00x
▲ VercelNitro4.339s (+61.0% 🔺)5.701s (+26.1% 🔺)1.361s61.10x
▲ VercelNext.js (Turbopack)5.709s (+40.6% 🔺)7.363s (+23.4% 🔺)1.655s51.45x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.131s (-5.3% 🟢)2.008s (~)0.877s151.00x
🐘 PostgresExpress1.184s (-0.7%)2.007s (~)0.823s151.05x
🐘 PostgresNext.js (Turbopack)1.201s (~)2.008s (~)0.807s151.06x
💻 LocalNext.js (Turbopack)1.400s (-5.6% 🟢)2.006s (~)0.607s151.24x
💻 LocalExpress1.413s (-1.7%)2.007s (~)0.594s151.25x
💻 LocalNitro1.461s (+8.3% 🔺)2.007s (~)0.546s151.29x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.718s (+30.6% 🔺)4.449s (+33.4% 🔺)1.731s71.00x
▲ VercelNitro2.871s (+46.3% 🔺)4.453s (+19.0% 🔺)1.581s71.06x
▲ VercelNext.js (Turbopack)3.885s (+26.3% 🔺)5.514s (+15.7% 🔺)1.629s61.43x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.237s (-5.2% 🟢)2.006s (-20.0% 🟢)0.769s151.00x
🐘 PostgresExpress1.322s (~)2.507s (+4.7%)1.185s121.07x
🐘 PostgresNext.js (Turbopack)1.402s (+6.6% 🔺)3.010s (~)1.608s101.13x
💻 LocalNext.js (Turbopack)2.437s (-9.4% 🟢)3.008s (-10.0% 🟢)0.571s101.97x
💻 LocalNitro2.510s (+1.5%)3.009s (~)0.499s102.03x
💻 LocalExpress2.550s (+0.6%)3.009s (-6.3% 🟢)0.459s102.06x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.700s (+12.3% 🔺)4.572s (+25.3% 🔺)1.872s71.00x
▲ VercelNitro2.881s (+8.2% 🔺)4.423s (+6.1% 🔺)1.542s71.07x
▲ VercelNext.js (Turbopack)3.955s (+17.9% 🔺)5.521s (+10.0% 🔺)1.566s61.46x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.462s (-9.0% 🟢)3.209s (-22.4% 🟢)1.747s101.00x
🐘 PostgresExpress1.616s (-0.8%)4.136s (~)2.521s81.11x
🐘 PostgresNext.js (Turbopack)3.006s (+17.1% 🔺)6.218s (+3.3%)3.212s52.06x
💻 LocalExpress5.398s (-22.5% 🟢)6.013s (-22.6% 🟢)0.615s53.69x
💻 LocalNitro5.540s (-8.9% 🟢)6.216s (-8.8% 🟢)0.676s53.79x
💻 LocalNext.js (Turbopack)5.568s (-22.7% 🟢)6.015s (-22.6% 🟢)0.448s63.81x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.426s (+13.0% 🔺)5.237s (+11.9% 🔺)1.812s61.00x
▲ VercelNitro4.057s (+35.2% 🔺)5.515s (+12.1% 🔺)1.458s61.18x
▲ VercelNext.js (Turbopack)5.030s (+12.9% 🔺)6.347s (+3.3%)1.317s51.47x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.381s (-29.5% 🟢)1.006s (-1.6%)0.626s601.00x
🐘 PostgresNext.js (Turbopack)0.572s (+3.8%)1.007s (~)0.435s601.50x
🐘 PostgresExpress0.574s (+4.8%)1.041s (+1.7%)0.466s581.51x
💻 LocalNext.js (Turbopack)0.599s (-3.9%)1.005s (~)0.406s601.57x
💻 LocalExpress0.601s (+1.6%)1.005s (~)0.404s601.58x
💻 LocalNitro0.636s (+7.9% 🔺)1.005s (-1.6%)0.370s601.67x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express3.016s (+20.1% 🔺)4.926s (+22.8% 🔺)1.910s131.00x
▲ VercelNitro3.055s (+14.7% 🔺)4.620s (+7.9% 🔺)1.565s131.01x
▲ VercelNext.js (Turbopack)4.222s (+8.2% 🔺)6.142s (+9.5% 🔺)1.921s111.40x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.977s (-24.0% 🟢)1.312s (-34.6% 🟢)0.335s701.00x
🐘 PostgresExpress1.336s (+3.1%)2.029s (+1.1%)0.693s451.37x
🐘 PostgresNext.js (Turbopack)1.375s (+5.1% 🔺)2.008s (~)0.633s451.41x
💻 LocalNext.js (Turbopack)1.476s (-4.1%)2.006s (~)0.529s451.51x
💻 LocalExpress1.512s (+2.7%)2.006s (~)0.495s451.55x
💻 LocalNitro1.538s (+8.0% 🔺)2.007s (~)0.469s451.57x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro7.970s (+32.3% 🔺)9.654s (+26.2% 🔺)1.685s101.00x
▲ VercelExpress8.444s (+41.5% 🔺)10.453s (+44.1% 🔺)2.009s91.06x
▲ VercelNext.js (Turbopack)9.911s (+19.9% 🔺)11.632s (+19.2% 🔺)1.721s81.24x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.935s (-23.8% 🟢)2.356s (-23.0% 🟢)0.421s521.00x
🐘 PostgresExpress2.615s (+1.5%)3.058s (-0.9%)0.444s401.35x
🐘 PostgresNext.js (Turbopack)2.804s (+3.6%)3.112s (+0.8%)0.308s391.45x
💻 LocalExpress3.242s (+0.9%)4.009s (~)0.767s301.68x
💻 LocalNext.js (Turbopack)3.245s (-4.7%)3.977s (-1.6%)0.732s311.68x
💻 LocalNitro3.354s (+5.7% 🔺)4.010s (+5.8% 🔺)0.656s301.73x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro14.987s (+24.3% 🔺)16.561s (+20.5% 🔺)1.574s81.00x
▲ VercelExpress15.440s (+32.3% 🔺)17.571s (+33.0% 🔺)2.131s71.03x
▲ VercelNext.js (Turbopack)20.372s (+24.7% 🔺)21.652s (+18.5% 🔺)1.280s61.36x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.160s (-25.6% 🟢)1.005s (~)0.846s601.00x
🐘 PostgresNext.js (Turbopack)0.195s (+10.7% 🔺)1.007s (~)0.812s601.22x
🐘 PostgresExpress0.211s (-1.5%)1.006s (~)0.795s601.32x
💻 LocalExpress0.543s (+23.9% 🔺)1.022s (+1.7%)0.479s593.40x
💻 LocalNitro0.560s (+25.9% 🔺)1.022s (+1.8%)0.463s593.50x
💻 LocalNext.js (Turbopack)0.618s (-5.8% 🟢)1.021s (~)0.403s593.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.267s (+23.5% 🔺)2.940s (+25.4% 🔺)1.673s211.00x
▲ VercelNitro1.275s (+22.8% 🔺)2.561s (-2.4%)1.286s241.01x
▲ VercelNext.js (Turbopack)2.526s (+21.0% 🔺)3.927s (+4.0%)1.401s161.99x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.282s (-12.8% 🟢)1.030s (+2.4%)0.749s881.00x
🐘 PostgresNext.js (Turbopack)0.309s (+9.5% 🔺)1.053s (+3.5%)0.744s861.10x
🐘 PostgresExpress0.335s (~)1.006s (~)0.671s901.19x
💻 LocalExpress2.457s (+16.7% 🔺)3.010s (+10.0% 🔺)0.553s308.73x
💻 LocalNext.js (Turbopack)2.544s (-9.4% 🟢)3.008s (-15.2% 🟢)0.464s309.04x
💻 LocalNitro2.614s (+19.5% 🔺)3.076s (+10.0% 🔺)0.463s309.28x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.116s (+60.3% 🔺)4.169s (+62.9% 🔺)2.053s231.00x
▲ VercelNitro2.374s (+78.5% 🔺)3.965s (+41.3% 🔺)1.591s231.12x
▲ VercelNext.js (Turbopack)3.622s (+38.2% 🔺)5.306s (+31.2% 🔺)1.684s181.71x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro0.426s (-18.0% 🟢)1.027s (~)0.600s1171.00x
🐘 PostgresNext.js (Turbopack)0.532s (+1.0%)3.087s (-0.8%)2.555s391.25x
🐘 PostgresExpress0.539s (+1.6%)1.059s (+0.9%)0.520s1141.26x
💻 LocalNitro4.917s (-49.1% 🟢)8.556s (-20.6% 🟢)3.639s1511.54x
💻 LocalExpress5.785s (-42.5% 🟢)8.667s (-22.7% 🟢)2.882s1413.57x
💻 LocalNext.js (Turbopack)5.995s (-41.1% 🟢)8.670s (-22.7% 🟢)2.675s1414.07x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro2.448s (+20.6% 🔺)4.030s (+2.4%)1.582s301.00x
▲ VercelExpress2.549s (+44.8% 🔺)4.506s (+31.1% 🔺)1.958s271.04x
▲ VercelNext.js (Turbopack)5.371s (+36.1% 🔺)7.263s (+29.7% 🔺)1.892s172.19x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.108s (-5.3% 🟢)1.996s (~)0.001s (-58.3% 🟢)2.008s (~)0.900s101.00x
💻 LocalNext.js (Turbopack)1.134s (-2.7%)1.970s (+0.6%)0.012s (-4.7%)2.020s (~)0.886s101.02x
💻 LocalExpress1.160s (~)2.005s (~)0.012s (-1.6%)2.020s (~)0.860s101.05x
🐘 PostgresExpress1.163s (~)1.997s (~)0.001s (~)2.011s (~)0.848s101.05x
🐘 PostgresNext.js (Turbopack)1.163s (~)2.000s (~)0.001s (~)2.011s (~)0.848s101.05x
💻 LocalNitro1.164s (~)2.004s (~)0.013s (+20.0% 🔺)2.020s (~)0.855s101.05x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express1.999s (~)3.472s (+17.8% 🔺)2.271s (+9.7% 🔺)6.267s (+13.7% 🔺)4.268s101.00x
▲ VercelNitro2.151s (+6.3% 🔺)3.230s (-1.5%)1.892s (+11.3% 🔺)5.603s (+1.7%)3.452s101.08x
▲ VercelNext.js (Turbopack)3.886s (+8.3% 🔺)3.868s (-1.9%)1.724s (+20.4% 🔺)6.961s (+0.6%)3.075s101.94x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express1.569s (+0.8%)2.010s (~)0.013s (+3.4%)2.027s (~)0.457s301.00x
🐘 PostgresExpress1.570s (~)2.003s (~)0.005s (+2.8%)2.027s (~)0.456s301.00x
💻 LocalNext.js (Turbopack)1.578s (-4.6%)1.971s (-1.5%)0.010s (-22.5% 🟢)2.023s (-1.8%)0.445s301.01x
🐘 PostgresNitro1.602s (+4.0%)2.102s (+4.9%)0.003s (-38.5% 🟢)2.127s (+5.1% 🔺)0.525s291.02x
💻 LocalNitro1.604s (+3.8%)2.009s (~)0.014s (+15.3% 🔺)2.028s (~)0.423s301.02x
🐘 PostgresNext.js (Turbopack)1.634s (-1.1%)2.011s (~)0.005s (-8.9% 🟢)2.027s (~)0.392s301.04x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express6.367s (+14.3% 🔺)8.277s (+20.8% 🔺)0.195s (-9.9% 🟢)9.109s (+21.6% 🔺)2.742s71.00x
▲ VercelNitro6.689s (+18.6% 🔺)7.771s (+9.2% 🔺)0.418s (+79.9% 🔺)8.645s (+10.3% 🔺)1.957s71.05x
▲ VercelNext.js (Turbopack)10.375s (+12.6% 🔺)11.316s (+9.2% 🔺)0.257s (-51.6% 🟢)12.402s (+4.3%)2.027s51.63x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express0.765s (~)1.026s (-1.9%)0.000s (-1.7%)1.051s (-0.8%)0.286s581.00x
🐘 PostgresNitro0.942s (+22.6% 🔺)1.276s (+21.9% 🔺)0.000s (+143.5% 🔺)1.309s (+21.7% 🔺)0.367s461.23x
🐘 PostgresNext.js (Turbopack)1.077s (+3.3%)1.579s (+2.7%)0.000s (+Infinity% 🔺)1.599s (+3.4%)0.522s381.41x
💻 LocalNext.js (Turbopack)1.260s (-10.7% 🟢)1.978s (~)0.001s (-42.3% 🟢)2.016s (~)0.756s301.65x
💻 LocalExpress1.411s (+9.1% 🔺)1.920s (-4.6%)0.000s (+181.3% 🔺)1.923s (-4.6%)0.512s321.84x
💻 LocalNitro1.430s (+10.4% 🔺)1.981s (-1.6%)0.000s (-3.2%)1.984s (-1.6%)0.554s311.87x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro3.295s (+10.7% 🔺)4.389s (+0.6%)0.000s (-100.0% 🟢)4.845s (~)1.550s131.00x
▲ VercelExpress3.703s (+28.2% 🔺)5.043s (+21.0% 🔺)0.000s (-100.0% 🟢)5.627s (+22.1% 🔺)1.924s111.12x
▲ VercelNext.js (Turbopack)5.261s (+17.4% 🔺)5.880s (+8.0% 🔺)0.000s (+Infinity% 🔺)6.815s (+8.5% 🔺)1.555s91.60x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

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

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro1.822s (+3.8%)2.344s (+2.3%)0.000s (-48.1% 🟢)2.359s (+2.4%)0.537s261.00x
🐘 PostgresExpress1.930s (+14.3% 🔺)2.494s (+8.4% 🔺)0.000s (-100.0% 🟢)2.528s (+9.0% 🔺)0.598s241.06x
🐘 PostgresNext.js (Turbopack)2.527s (-12.8% 🟢)3.001s (-11.4% 🟢)0.000s (-100.0% 🟢)3.029s (-11.1% 🟢)0.502s201.39x
💻 LocalExpress3.245s (-11.6% 🟢)3.901s (-6.3% 🟢)0.001s (+87.5% 🔺)3.905s (-6.3% 🟢)0.660s161.78x
💻 LocalNext.js (Turbopack)3.295s (-9.7% 🟢)3.866s (-6.2% 🟢)0.001s (-6.2% 🟢)3.904s (-6.4% 🟢)0.609s161.81x
💻 LocalNitro3.323s (-11.0% 🟢)3.967s (-6.1% 🟢)0.000s (-18.0% 🟢)3.970s (-6.1% 🟢)0.647s161.82x

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro4.886s (+13.4% 🔺)6.152s (+7.5% 🔺)0.000s (NaN%)6.612s (+6.3% 🔺)1.726s101.00x
▲ VercelExpress5.031s (+17.6% 🔺)6.310s (+15.1% 🔺)0.000s (+22.2% 🔺)6.931s (+17.0% 🔺)1.900s91.03x
▲ VercelNext.js (Turbopack)7.649s (+18.9% 🔺)8.229s (+16.9% 🔺)0.000s (+Infinity% 🔺)9.275s (+13.6% 🔺)1.626s71.57x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/21
🐘 PostgresNitro17/21
▲ VercelExpress12/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres15/21
Next.js (Turbopack)🐘 Postgres14/21
Nitro🐘 Postgres20/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)
  • 🌐 Platformatic: Community world (local development)

📋 View full workflow run

Comment threadpackages/world-local/src/storage/events-storage.ts
Resolve conflicts in @workflow/world-local where main's hook
dedup-recovery rework landed on the same event-write paths this
branch caches:
- fs.ts writeExclusive: combine main's temp-file + hard-link atomic
publish (with Windows retry) with this branch's withEnsuredDirectory
ENOENT-retry wrapper, mirroring the already-merged write().
- events-storage.ts: keep main's per-instance stepLocks/hookLocks and
the writeExclusive-based publish + canonical-eventId dedup recovery,
and add the recent-event cache on top. Extract the cache bookkeeping
into rememberStoredEvent() and call it after each successful publish
(main event path + hook_conflict path) so caching layers onto main's
stronger write semantics instead of replacing them.
Build + all 427 @workflow/world-local tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pranaygp
pranaygp requested a review from ijjk as a code ownerJune 29, 2026 21:10

CopilotAI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@VaguelySeriousVaguelySerious left a comment

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.

Defensive review — approve

I reviewed this with one question in mind: does it meaningfully widen the error surface or hurt debugability? My conclusion is no, and the design goes out of its way to avoid both. Approving.

Why the risk is contained

Blast radius is local-dev only. Every change is in @workflow/world-local, the filesystem backend used for local dev and tests. Production (world-vercel) is untouched, so even a latent bug here cannot reach deployed workflows.

Disk stays the single source of truth for existence. The event cache is only consulted for fileIds that listJSONFiles() actually returns from the directory listing (paginatedFileSystemQuery). So the cache can never resurrect a deleted event or invent one — it only avoids re-reading the content of files that are write-once and append-only. A lingering cache entry for a since-deleted file is simply never looked up.

Cached snapshots are genuinely detached, and proven so.rememberStoredEvent decodes from the exact serialized byte payload captured before the write await, and reads structuredClone the value out again. That closes both the caller-mutation window (test: "reuses locally appended events without exposing cached instances") and the shape-divergence risk — the reparse means a cached event is byte-for-byte what a disk read would have produced, including Date→ISO-string normalization in executionContext (test: "normalizes cached event metadata the same way as disk reads").

Memory is bounded several ways. 4 MiB / 1000 entries with FIFO eviction, oversized events bypass the cache, terminal runs release their history, and clear()/close() release everything. Per-instance, so instances can't pollute each other.

External directory removal is handled, not assumed away.withEnsuredDirectory retries exactly once on ENOENT after forgetting the cached dir — bounded (no loop), and a genuine ENOENT still propagates with its original error. All in-process directory-removal paths (clear()) are paired with clearCreatedFilesCache(); external removal is covered by the retry. Covered by three fs.test.ts regressions.

Debugability

Read-through/write-through to disk is preserved, so the JSON files on disk always reflect runtime state and remain fully inspectable. A cache parse failure silently degrades to a disk read (where the existing malformed-JSON warning surfaces) rather than masking anything.

Verification

  • pnpm test (world-local): 427 passed
  • pnpm typecheck: clean
  • CI: Unit Tests pass on ubuntu + windows. The two red checks are E2E Vercel Prod Tests (fastify) (a world-vercel deploy-run failure — "status":"failed" on the polling lane) and the Required-Check gate that aggregates it. This PR cannot affect a world-vercel prod lane; it matches the known prod-lane flakiness. Re-run rather than treat as a regression.

Minor, non-blocking notes

  • structuredClone runs per cache hit (≤1000 per large replay). The posted measurements still show a net win, and total retained bytes are capped, so this is fine — just noting it's the cost traded for mutation safety.
  • One narrow debugability tradeoff: if you hand-edit an event JSON in place for an active (non-terminal) run while the dev server is live, the next events.list() would serve the cached pre-edit content (deletion is safe — the dir listing drops it). Events are write-once by design so this is exotic, but it's the one case where on-disk state and served state can diverge during an active run.
  • createdDirectoriesCache is process-global and grows with distinct directories touched, but that set is tiny/fixed and strictly less of a concern than the pre-existing createdFilesCache (which grows per file).

Nicely scoped, well-tested, and the correctness footguns of caching are each addressed explicitly. 👍

@TooTallNateTooTallNate left a comment

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.

Approve — the relative-dataDir cache bug is fixed, and the regression test genuinely guards it

Re-review of d175b349f + f9c47dac9 (plus the fresh main merge). Every item from my prior REQUEST_CHANGES is addressed and verified.

The bug fix is correct and complete

The fix resolves directory once at the top of paginatedFileSystemQuery (const resolvedDirectory = path.resolve(directory)) and uses it for both the file listing and the cache-key path.join(resolvedDirectory, ...). I traced both sides of the cache to confirm the keys now agree:

  • Write side populates via cacheEvent(eventPath, ...) where eventPath = taggedPath(basedir, 'events', ...)resolveWithinBasepath.resolve(basedir, ...), which was already absolute.
  • Read side was the sole culprit: it used path.join(directory, ...) (relative when dataDir is relative), so .get(filePath) never matched the absolute key the write side set. The fix makes the read key absolute too — so write and read now produce identical keys regardless of relative/absolute dataDir.

So the cache is no longer a silent no-op in the default .workflow-data config, and the perf claim now holds in production.

The regression test actually catches the bug — I verified by reverting

The new reuses sequential-step events with a relative data directory test builds the world with path.relative(process.cwd(), testDir), runs 5 sequential steps, and asserts zero event-file readFile calls. To confirm it's a real guard, I reverted just the path.resolve fix locally and reran it:

× reuses sequential-step events with a relative data directory
AssertionError: expected [...] to have a length of +0 but got 55

55 event-file reads without the fix (the cache-no-op), 0 with it. That's exactly the failure mode I originally reproduced, now pinned.

The optional test gaps and mock cleanup are all addressed

  • listByCorrelationId cache (my #2): reuses locally appended events for correlation queries — spies readFile, asserts 0 event-file reads on the correlation path.
  • FIFO/byte eviction (my #3): evicts old events once the recent-event byte bound is exceeded writes 1 MiB-payload events to overflow the 4 MiB cap, then asserts a subsequent read does hit disk (eventFileReads.length > 0) with correct data — exercising the while eviction loop without a slow 1000-event test. Good lightweight approach.
  • Mock restoration (my #4): the suite has a top-level afterEach(() => vi.restoreAllMocks()) (line 154), so every vi.spyOn(fs, 'readFile') is restored between tests.

Verified locally

  • Full @workflow/world-local suite: 427 passed (up from 375 at my first review — the new coverage).
  • Reverted-fix reproduction confirms the regression test fails without the change; restored → green.
  • Clean merge with current main (0 conflicts).

CI: the fastify Vercel-prod E2E failure is unrelated — this PR touches only world-local, while that lane runs against world-vercel, so it can't be affected (it's the recurring Vercel-prod flake). Worth a re-run.

Everything I flagged is resolved and the non-cache parts (createdDirectoriesCache + ENOENT retry, terminal-run cache release, structuredClone mutation isolation, clear()/close() integration) were already solid. Clean perf improvement now. LGTM.

@github-actionsgithub-actionsBot mentioned this pull request Jun 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2707. Merge conflicts were resolved by AI — please review carefully. (backport job run)

Sign up for freeto 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.

5 participants

@pranaygp@TooTallNate@VaguelySerious@karthikscale3