Skip to content

[nest] Harden the NestJS integration for production - #3695

Open
VaguelySerious wants to merge 2 commits into
mainfrom
peter/nest-production-hardening
Open

[nest] Harden the NestJS integration for production#3695
VaguelySerious wants to merge 2 commits into
mainfrom
peter/nest-production-hardening

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Production-readiness pass over @workflow/nest. Every item below was reproduced against workbench/nest before being fixed, and the fix re-verified the same way.

Correctness bugs on the workflow routes

Webhook bodies were silently corrupted or dropped. The controller re-serialized the already-parsed Express body with JSON.stringify. Measured by sending to real webhook tokens and reading back what the workflow received:

sent content-type received (before)
{"message": "one", "n": 1} application/json {"message":"one","n":1}
<order id="7">…</order> application/xml ""
a=1&b=2 x-www-form-urlencoded ""
6 raw bytes octet-stream {"type":"Buffer","data":[…]}

All four returned a success status, so a webhook signed over its raw body (Stripe, GitHub, Shopify, Slack) could not verify. The body is now recovered from req.rawBody, a Buffer/string body, or the unread request stream, in that order; all four cases are byte-exact. The re-serializing fallback remains for an app without rawBody: true and warns once.

Response bodies were decoded as UTF-8 via .text(), so four bytes ff fe 00 01 arrived as eight bytes of U+FFFD. They are now written as bytes. Headers.forEach also kept only the last set-cookie; getSetCookie() keeps them all.

Only POST was wired on the flow route even though the generated bundle exports GET/HEAD/OPTIONS as aliases. getWorkflowPort() probes with HEAD and treats non-200 as "not a workflow server", so local port detection never identified a Nest app. All four methods now answer.

app.setGlobalPrefix() broke everything silently. The routes moved; the generated callback URLs did not. Runs were created and then every queue delivery 404s forever. WorkflowModule now reads the prefix during onModuleInit (which NestJS runs after route registration) and adopts it, with a basePath option for a sub-path applied outside NestJS. Verified end to end: a run under setGlobalPrefix('api') completes with zero 404s, where before it looped indefinitely.

skipBuild with no bundles booted healthy and then answered every workflow request with ERR_MODULE_NOT_FOUND. Startup now fails with the missing filenames and the command that produces them. A load failure at request time is a 503 with the same message instead of a raw stack.

A step importing an @Injectable() service failed the --vercel build with Could not resolve "class-validator". The optional-peer externals list applied only to the app function; the workflow function is built by the shared VercelBuildOutputAPIBuilder, which had none. Confirmed causal with a negative control: removing the one line reproduces the failure, restoring it builds clean.

API and lifecycle gaps

  • forRootAsync for options from ConfigService, and WORKFLOW_MODULE_OPTIONS exported. Options now flow through DI; configureWorkflowController stays as a deprecated fallback (its process-global state made two apps in one process overwrite each other).
  • manageWorldLifecycle (opt-in) starts a self-hosted World's workers with the app and closes them on shutdown, replacing per-app bootstrap boilerplate.
  • preloadBundles (default on) keeps ~1.6MB of module evaluation off the first queue delivery.
  • skipBuild defaults to true on Vercel, so app modules no longer need a process.env.VERCEL branch.
  • watch is pinned off and deprecated: the builder discarded the esbuild contexts createCombinedBundle returns in watch mode, so nothing rebuilt and the contexts leaked.
  • init merges into an existing .swcrc rather than replacing it. The docs put --force in prebuild, which discarded any SWC config the app had.
  • --base-path, --sourcemap, --max-duration, --runtime and --app-function are reachable from the CLI; the builders already accepted them.
  • config.externalPackages is honoured in the final combined and webhook esbuild passes (@workflow/builders), which previously ignored it.

NestJS DI is documented as unavailable

Workflows and steps do not run inside the Nest application, and this was undocumented. The docs now state plainly that the injector, providers, request-scoped context and AsyncLocalStorage (nestjs-cls), guards, interceptors, pipes, filters and the Nest Logger are all unreachable from "use workflow" and "use step" code, why (a step's bundle gets its own copy of any app file it imports, so a class token is a different object and app.get() raises UnknownElementException; on Vercel the workflow function is a separate function from the app), and what to write instead. No behaviour change: importing a Nest service into a step now builds, it simply gives an uninjected instance.

Tests

34 → 101 unit tests: conversion in both directions (raw body, unparsed stream, Buffer, repeated headers, HTTP/2 pseudo-headers, binary response, multiple cookies, Fastify reply), option resolution, base-path reconciliation, bundle validation, forRootAsync, and the .swcrc merge.

The full local e2e suite passes against the workbench app: 134 passed, 7 skipped, 0 failed. Repo-wide typecheck, biome ci, and the docs snippet typecheck are clean.

🤖 Generated with Claude Code

Fixes correctness bugs on the workflow routes and closes the API gaps that made
a production deployment depend on undocumented setup.

Request and response fidelity:
- Recover the request body from `req.rawBody`, a Buffer/string body, or the
  unread request stream, in that order. Re-serializing a parsed body with
  JSON.stringify was silently rewriting webhook payloads: pretty-printed JSON
  lost its whitespace (breaking HMAC verification), a Buffer became
  {"type":"Buffer",...}, and a content type no parser claimed was dropped
  entirely while the sender still got a 2xx.
- Write response bodies as bytes rather than through `.text()`, which replaced
  every non-UTF-8 byte with U+FFFD, and keep every set-cookie value.

Routing:
- Serve GET/HEAD/OPTIONS on the flow route, which the generated bundle already
  exports. HEAD is what getWorkflowPort() probes to identify a workflow server,
  so a 404 there made local port detection fall back to an arbitrary port.
- Adopt `app.setGlobalPrefix()` for generated callback and webhook URLs, and add
  a `basePath` option for a sub-path applied outside NestJS. A global prefix
  previously moved the routes without moving the URLs, so runs were created and
  then every delivery 404s forever with no diagnostic.

Startup and configuration:
- Fail startup when `skipBuild` is set and the bundles are absent, instead of
  reporting a healthy boot and answering ERR_MODULE_NOT_FOUND per request.
- Default `skipBuild` to true on Vercel, where the bundles ship in the Build
  Output and the filesystem is read-only.
- Add `forRootAsync`, export `WORKFLOW_MODULE_OPTIONS`, and provide the options
  through DI instead of the process-global `configureWorkflowController`, which
  two apps in one process overwrote for each other. It stays as a deprecated
  fallback.
- Add opt-in `manageWorldLifecycle` so a self-hosted World's workers start with
  the app and close on shutdown, and `preloadBundles` to keep the first queue
  delivery off the module-load path.
- Report a load failure as 503 naming the build command that produces the
  missing file, rather than letting a module-resolution stack escape as a 500.
- Pin `watch` off: the builder discards the esbuild contexts
  createCombinedBundle returns in watch mode, so nothing rebuilt and the
  contexts leaked.

Build:
- Externalize NestJS's absent optional peers from the workflow function too, not
  only the app function. A step importing an @Injectable() service pulled
  @nestjs/common into flow.func and failed the whole --vercel build on
  `Could not resolve "class-validator"`.
- Honour config.externalPackages in the final combined and webhook esbuild
  passes, which previously ignored it.
- `init` merges into an existing .swcrc instead of replacing it, so the --force
  in a prebuild script no longer discards the app's own SWC configuration.
- Expose --base-path, --sourcemap, --max-duration, --runtime and --app-function,
  which the builders accepted but the CLI could not reach.

Docs state plainly that the Nest injector, providers, request-scoped context,
guards, interceptors and the Nest Logger are unreachable from "use workflow" and
"use step" code, why (per-bundle module copies give a distinct class identity,
and on Vercel the workflow function is a separate function), and what to write
instead.

Unit tests go from 34 to 101, covering the conversion in both directions, option
resolution, base-path reconciliation, bundle validation, and the .swcrc merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 52d679d

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

This PR includes changesets to release 16 packages
Name Type
@workflow/nest Patch
@workflow/builders Patch
workflow Patch
@workflow/astro Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/vitest Patch
@workflow/world-testing Patch
@workflow/nuxt Patch
@workflow/core Patch
@workflow/web-shared Patch
@workflow/web 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

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 20, 2026 6:51pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 20, 2026 6:51pm
example-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-astro-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-express-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-fastify-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-hono-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-nestjs-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-nitro-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-nuxt-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-python-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-sveltekit-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workbench-vite-workflow Ready Ready Preview Aug 20, 2026 6:51pm
workflow-docs Ready Ready Preview, v0 Aug 20, 2026 6:51pm
workflow-swc-playground Ready Ready Preview Aug 20, 2026 6:51pm
workflow-tarballs Ready Ready Preview Aug 20, 2026 6:51pm
workflow-web Ready Ready Preview Aug 20, 2026 6:51pm

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

⚠️ Flaky E2E Tests (passed on retry)

These tests failed at least once and passed on a retry. A recurring entry here is a real race worth investigating.

  • addTenWorkflow (hono)
  • addTenWorkflow (nitro)
  • hookClaimOnlyMutexWorkflow - hook works as a pure run mutex without payload data (nextjs-webpack)
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running (nextjs-webpack)
  • promiseAllWorkflow (nextjs-turbopack)
  • promiseRaceWorkflow (tanstack-start)

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • cold-start-warmup · suite warmup (tanstack-start) · at 18:52:49Z · abandoned wrun_01M0G88RRZD0D2CXQP708YK34A
  • run-pickup-stall · pathsAliasWorkflow - TypeScript path aliases resolve correctly (nextjs-webpack) · at 18:59:08Z · abandoned wrun_01M0G8MPT2SGKBBCYPKHFAYX54

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3578 0 742 4320
✅ 💻 Local Development 3922 0 558 4480
✅ 📦 Local Production 3922 0 558 4480
✅ 🐘 Local Postgres 3922 0 558 4480
✅ 🪟 Windows 320 0 0 320
✅ 🌐 Cross-language Conformance 9 0 132 141
✅ vercel-multi-region 27 0 0 27
Total 15700 0 2548 18248
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 132 0 28
✅ astro-quickjs 132 0 28
✅ example-node 132 0 28
✅ example-quickjs 132 0 28
✅ express-node 132 0 28
✅ express-quickjs 132 0 28
✅ fastify-node 132 0 28
✅ fastify-quickjs 132 0 28
✅ hono-node 132 0 28
✅ hono-quickjs 132 0 28
✅ nest-node 132 0 28
✅ nest-quickjs 132 0 28
✅ nextjs-turbopack-node 157 0 3
✅ nextjs-turbopack-quickjs 157 0 3
✅ nextjs-webpack-node 157 0 3
✅ nextjs-webpack-quickjs 157 0 3
✅ nitro-node 132 0 28
✅ nitro-quickjs 132 0 28
✅ nuxt-node 132 0 28
✅ nuxt-quickjs 132 0 28
✅ python-node 8 0 152
✅ sveltekit-node 151 0 9
✅ sveltekit-quickjs 151 0 9
✅ tanstack-start-node 132 0 28
✅ tanstack-start-quickjs 132 0 28
✅ vite-node 132 0 28
✅ vite-quickjs 132 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 134 0 26
✅ astro-stable-quickjs 134 0 26
✅ express-stable-node 134 0 26
✅ express-stable-quickjs 134 0 26
✅ fastify-stable-node 134 0 26
✅ fastify-stable-quickjs 134 0 26
✅ hono-stable-node 134 0 26
✅ hono-stable-quickjs 134 0 26
✅ nest-stable-node 134 0 26
✅ nest-stable-quickjs 134 0 26
✅ nextjs-turbopack-canary-node 141 0 19
✅ nextjs-turbopack-canary-quickjs 141 0 19
✅ nextjs-turbopack-stable-node 160 0 0
✅ nextjs-turbopack-stable-quickjs 160 0 0
✅ nextjs-webpack-canary-node 141 0 19
✅ nextjs-webpack-canary-quickjs 141 0 19
✅ nextjs-webpack-stable-node 160 0 0
✅ nextjs-webpack-stable-quickjs 160 0 0
✅ nitro-stable-node 134 0 26
✅ nitro-stable-quickjs 134 0 26
✅ nuxt-stable-node 134 0 26
✅ nuxt-stable-quickjs 134 0 26
✅ sveltekit-stable-node 153 0 7
✅ sveltekit-stable-quickjs 153 0 7
✅ tanstack-start-node 134 0 26
✅ tanstack-start-quickjs 134 0 26
✅ vite-stable-node 134 0 26
✅ vite-stable-quickjs 134 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 134 0 26
✅ astro-stable-quickjs 134 0 26
✅ express-stable-node 134 0 26
✅ express-stable-quickjs 134 0 26
✅ fastify-stable-node 134 0 26
✅ fastify-stable-quickjs 134 0 26
✅ hono-stable-node 134 0 26
✅ hono-stable-quickjs 134 0 26
✅ nest-stable-node 134 0 26
✅ nest-stable-quickjs 134 0 26
✅ nextjs-turbopack-canary-node 141 0 19
✅ nextjs-turbopack-canary-quickjs 141 0 19
✅ nextjs-turbopack-stable-node 160 0 0
✅ nextjs-turbopack-stable-quickjs 160 0 0
✅ nextjs-webpack-canary-node 141 0 19
✅ nextjs-webpack-canary-quickjs 141 0 19
✅ nextjs-webpack-stable-node 160 0 0
✅ nextjs-webpack-stable-quickjs 160 0 0
✅ nitro-stable-node 134 0 26
✅ nitro-stable-quickjs 134 0 26
✅ nuxt-stable-node 134 0 26
✅ nuxt-stable-quickjs 134 0 26
✅ sveltekit-stable-node 153 0 7
✅ sveltekit-stable-quickjs 153 0 7
✅ tanstack-start-node 134 0 26
✅ tanstack-start-quickjs 134 0 26
✅ vite-stable-node 134 0 26
✅ vite-stable-quickjs 134 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 134 0 26
✅ astro-stable-quickjs 134 0 26
✅ express-stable-node 134 0 26
✅ express-stable-quickjs 134 0 26
✅ fastify-stable-node 134 0 26
✅ fastify-stable-quickjs 134 0 26
✅ hono-stable-node 134 0 26
✅ hono-stable-quickjs 134 0 26
✅ nest-stable-node 134 0 26
✅ nest-stable-quickjs 134 0 26
✅ nextjs-turbopack-canary-node 141 0 19
✅ nextjs-turbopack-canary-quickjs 141 0 19
✅ nextjs-turbopack-stable-node 160 0 0
✅ nextjs-turbopack-stable-quickjs 160 0 0
✅ nextjs-webpack-canary-node 141 0 19
✅ nextjs-webpack-canary-quickjs 141 0 19
✅ nextjs-webpack-stable-node 160 0 0
✅ nextjs-webpack-stable-quickjs 160 0 0
✅ nitro-stable-node 134 0 26
✅ nitro-stable-quickjs 134 0 26
✅ nuxt-stable-node 134 0 26
✅ nuxt-stable-quickjs 134 0 26
✅ sveltekit-stable-node 153 0 7
✅ sveltekit-stable-quickjs 153 0 7
✅ tanstack-start-node 134 0 26
✅ tanstack-start-quickjs 134 0 26
✅ vite-stable-node 134 0 26
✅ vite-stable-quickjs 134 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 160 0 0
✅ nextjs-turbopack-quickjs 160 0 0

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 9 0 132

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 52d679d · Thu, 20 Aug 2026 19:06:35 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 368 (-64%) 💚 1410 🔴 (+24%) 🔻 1429 🔴 (+22%) 🔻 1724 🔴 (+41%) 🔻 30
TTFS stream 1254 (+604%) 🔻 1417 🔴 (+27%) 🔻 1436 🔴 (+27%) 🔻 1567 🔴 (+34%) 🔻 30
TTFS hook + stream 499 (±0%) 1655 🔴 (+15%) 1688 🔴 (+15%) 1784 🔴 (+15%) 🔻 30
Fan-out TTFS Promise.all(100 steps) 542 (+36%) 🔻 771 (-14%) 919 (-47%) 💚 2100 (+11%) 10
Fan-out TTLS Promise.all(100 steps) 4640 (-3.1%) 5298 (-14%) 6788 (+8.4%) 9234 (+28%) 🔻 10
STSO 1020 steps (inline) 120 (±0%) 231 (-6.5%) 261 (-13%) 347 (-31%) 💚 1019
WO 1020 steps 221586 (-6.7%) 221586 (-6.7%) 221586 (-6.7%) 221586 (-6.7%) 1
CRTT first chunk (pooled) 95 (+3.3%) 137 (+3.8%) 239 (+58%) 🔻 488 (+196%) 🔻 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 120 (-2%) 208 (+43%) 584 (+121%) 4195 (+279%) 249 (+69%) 10
size sweep (100/s, 160B-12KB) 118 (-9%) 151 (-32%) 635 (+21%) 999 (+28%) 119 (-56%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 127 (+5%) 133 (-10%) 242 (-5%) 701 (±0%) 337 (-35%) 3
replay eve-gpt-5.6-sol-2000t (1x) 120 (-6%) 121 (-20%) 173 (-32%) 1204 (+44%) 525 (+5%) 2
replay eve-gpt-5.6-sol-2000t (2x) 114 (-7%) 174 (-17%) 232 (-28%) 1083 (-67%) 490 (-19%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 237191ms → this run 221429ms (Δ -15762ms, -7%)

100-150 ms  ┃                         main  11  this  16    +5
150-200 ms  █████████████┃            main 293  this 314   +21
200-250 ms  █████████████████████░░┃  main 480  this 549   +69
250-300 ms  ████┃█                    main 132  this 110   -22
300-350 ms  ┃█                        main  53  this  21   -32
350-400 ms  ┃                         main  26  this   2   -24
400-450 ms  ┃                         main   5  this   3    -2
450-500 ms  ┃                         main   7  this   3    -4
500-550 ms  ┃                         main   4  this   0    -4
550-600 ms  ┃                         main   3  this   1    -2
600-650 ms  ┃                         main   1  this   0    -1
650-700 ms  ┃                         main   2  this   0    -2
700-750 ms  ┃                         main   1  this   0    -1
800-850 ms  ┃                         main   1  this   0    -1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg        p50          p90           p99     n
control  ······▇█▃▁·▁·  185.8 (+37%)  101 (-3%)  584 (+121%)  4195 (+279%)  3000
sweep    ······██▁▁▁··  136.2 (-12%)  100 (-7%)   635 (+21%)    999 (+28%)  3000
gw 1x    ·····▁██▁▁···  116.9 (-12%)  98 (-11%)    242 (-5%)     701 (±0%)  5295
eve 1x   ·····▁█▆▁▁▁··  121.2 (-12%)  90 (-17%)   173 (-32%)   1204 (+44%)  5186
eve 2x   ·····▁▄█▂▁▁··  154.3 (-21%)  129 (-8%)   232 (-28%)   1083 (-67%)  7779

RTT over stream progress (avg per tenth of stream, bars scaled min→max):

control  ▁▁▂▁▁▁▁▁▃█  118–538ms
sweep    ▃▄▂▁▁▄▅▃█▅  103–193ms
gw 1x    █▁▃▄▅▁▃▂▂▁  96–171ms
eve 1x   ▃▁▁▁▁▁▇█▂▁  91–220ms
eve 2x   ▆▁▂▂▁▄▄▄▃█  111–233ms

RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max):

sweep  ▄▅█▅▅▃▁  132–140ms

Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max):

control  ▁▂▂▁▁▁▁▁█▂  34–115ms
sweep    ▅▄▃▂▃▅▁▃█▃  37–58ms
gw 1x    █▁▃▄▄▂▅▅▁▂  28–40ms
eve 1x   ▄▂▂▁▂▃█▄▃▃  19–28ms
eve 2x   █▁▂▁▂▄▃▂▂▆  21–34ms
ℹ️ Metric definitions & methodology

Streams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach.

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). = main, = this run, = fill.

The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, · = empty) and mean RTT/positive-CDV profile lines over stream progress and chunk size. Histograms, avgs, and profiles merge exactly across runs; p50–p99 are percentile-of-percentiles. Per-index rows live in the artifacts.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles

Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t eaf22f5946e7c61f3c65c7006d550df180cfabd4e706254a09f22aec0cfb420d · gateway-gpt-5.4-nano-2000t 6f24ac518b6b83ff1d0e85a5fe78230db192716d66a7fc6b2fe022752001d041

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies; CRTT inside the workflow (excludes the api.vercel.com read path).

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 41 total

fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim.txt

Comment thread packages/nest/src/workflow.module.ts
@VaguelySerious
VaguelySerious marked this pull request as ready for review August 20, 2026 18:46
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code owners August 20, 2026 18:46
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
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.

1 participant