Skip to content

Implement MAX_EVENTS_PER_RUN limit#2986

Open
shalabhc wants to merge 5 commits into
mainfrom
shalabhc/event-limit-enforcement
Open

Implement MAX_EVENTS_PER_RUN limit#2986
shalabhc wants to merge 5 commits into
mainfrom
shalabhc/event-limit-enforcement

Conversation

@shalabhc

@shalabhc shalabhc commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Enforces a server provided limit for events-per-run. We publish a limit that that was previously not enforced. Separately a server change will return the limit. Once a run's event log exceeds the limit, the runtime fails it with a new MAX_EVENTS_EXCEEDED code instead of letting a runaway workflow (e.g. an unbounded step loop) grow the event log without bound.

Checked at the top of the replay loop so it fails without retry and is attributed to user code.

Testing

Unit tests for the handler, the limit constant/override, and the error attribution

pnpm exec vitest run src/runtime/event-limit.test.ts src/describe-error.test.ts

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 78308f6

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

This PR includes changesets to release 20 packages
Name Type
@workflow/errors Patch
@workflow/core Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Patch
@workflow/world-local Patch
@workflow/world-postgres Patch
@workflow/world-vercel Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/nuxt Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite 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 Jul 17, 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, Comment Jul 17, 2026 11:13pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 17, 2026 11:13pm
example-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-express-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 11:13pm
workflow-swc-playground Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workflow-tarballs Ready Ready Preview, Comment Jul 17, 2026 11:13pm
workflow-web Ready Ready Preview, Comment Jul 17, 2026 11:13pm

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

The benchmark run for 631e071 failed. See the run logs for details.

commit 310f227 · Fri, 17 Jul 2026 19:14:49 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1274 (+0.6%) 1699 🔴 1733 🔴 3652 🔴 30
TTFS hook + stream 1665 (+25%) 1943 🔴 2031 🔴 3686 🔴 30
STSO 1020 steps (1-20) 299 (±0%) 356 🔴 398 🔴 470 🔴 19
STSO 1020 steps (101-120) 301 (-2.6%) 308 🔴 405 🔴 555 🔴 19
STSO 1020 steps (1001-1020) 713 (+14%) 764 🔴 773 🔴 808 🔴 19
WO stream 1274 (+0.6%) 1699 1733 3652 30
WO hook + stream 1665 (+25%) 1943 2031 3686 30
SL stream 3344 (+235%) 5806 🔴 5933 🔴 6050 🔴 30
SL hook + stream 3984 (+104%) 5648 🔴 5729 🔴 6639 🔴 30
📜 Previous results (1)

330a548

Fri, 17 Jul 2026 16:29:55 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1352 (+25%) 1721 🔴 1783 🔴 1905 🔴 30
TTFS hook + stream 1645 (+25%) 2064 🔴 2121 🔴 2384 🔴 30
STSO 1020 steps (1-20) 300 (+7.6%) 333 🔴 436 🔴 521 🔴 19
STSO 1020 steps (101-120) 474 (+24%) 491 🔴 581 🔴 616 🔴 19
STSO 1020 steps (1001-1020) 866 (-29%) 938 🔴 999 🔴 1100 🔴 19
WO stream 1352 (+25%) 1721 1783 1905 30
WO hook + stream 1645 (+25%) 2064 2121 2384 30
SL stream 2575 (+135%) 5877 🔴 6059 🔴 6309 🔴 30
SL hook + stream 4108 (+138%) 5761 🔴 5825 🔴 7096 🔴 30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the P10 column shows the warm-start floor for comparison.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1453 0 230 1683
✅ 💻 Local Development 1617 0 219 1836
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
✅ vercel-multi-region 27 0 0 27
Total 7378 0 1064 8442

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
✅ nextjs-turbopack 150 0 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
✅ nuxt 126 0 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@vercel
vercel Bot temporarily deployed to Preview – workflow-docs July 17, 2026 18:58 Inactive
@shalabhc
shalabhc marked this pull request as ready for review July 17, 2026 19:08
@shalabhc
shalabhc requested review from a team and ijjk as code owners July 17, 2026 19:08
);
} catch (err) {
// Best-effort: the run stops anyway when the loop returns; log why.
runLogger.warn('Unable to mark run as failed after exceeding event limit', {

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.

This might be a problem: if it fails, and then the runtime returns, the workflow is stuck in "active" for unknown reasons. Let me double check what happens when we fail to create run_failed in a regular failure scenario

Comment thread packages/core/src/runtime/constants.ts Outdated
*/
export const MAX_EVENTS_PER_RUN = 25_000;

export const MAX_EVENTS_BUFFER = 100;

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.

The buffer would only be needed world-side, as a safeguard in case the client side fails to terminate correctly. We can remove the buffer from this PR

@VaguelySerious VaguelySerious 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.

Reviewed the diff and traced how it sits in the replay loop and the existing terminal-error machinery. Design is sound — the guard is correctly placed after the hasRecordedTerminalRunEvent check and before runWorkflow, inside the try at runtime.ts:1164 (catch at 1489), so it can't fire on an already-terminal run and re-checks every loop iteration. The "deterministic → no retry, no process.exit" rationale is right. A few suggestions below, the first being the main one.

Main suggestion: throw a dedicated error and let the existing catch write run_failed

The guard lives inside the try whose catch (line 1489) already funnels non-suspension, non-retryable errors into the terminal run_failed writer (~2440): classifyRunErrordehydrateRunErrorworld.events.create({eventType:'run_failed'}), with EntityConflictError/RunExpiredError-already-finished handling, turbo awaitRunReady(), and span/stack remapping all built in.

I traced a thrown error through it: not a WorkflowSuspension, not PreconditionFailedError, isRetryableWorldError → false (so no risk of false redelivery/loop), so it writes run_failed and returns. That means the whole bespoke event-limit.ts create path is duplicating machinery the catch already owns.

The one catch: classifyRunError (classify-error.ts:110) has explicit branches for ReplayDivergenceError and CorruptedEventLogError, then falls through to USER_ERROR. A plain FatalError would be recorded as USER_ERROR, silently dropping the new MAX_EVENTS_EXCEEDED code (and the MAX_EVENTS_HINT would never fire). So the idiomatic move — matching the two precedents already in that function — is:

  1. Add a MaxEventsExceededError class.
  2. Add one branch to classifyRunError returning RUN_ERROR_CODES.MAX_EVENTS_EXCEEDED.
  3. Throw it from the guard instead of calling handleEventLimitExceeded.

That deletes ~40 lines of duplicated run_failed plumbing, inherits the catch block's already-terminal / turbo / stack-remap handling (i.e. finding #1 and #4 below come for free), and keeps the distinct error code + attribution. The current self-contained handler is defensible for explicitness, but it re-solves problems the terminal path already solves.

Other findings (all non-blocking)

  1. Best-effort catch conflates "already terminal" with real failure. The sibling MAX_DELIVERIES handler (runtime.ts:420) special-cases EntityConflictError/RunExpiredError and returns silently, only logging the loud message for genuine backend failures. handleEventLimitExceeded funnels everything into one warn('Unable to mark run as failed…'), so a concurrent handler terminating the run between the guard's terminal check and this events.create yields a misleading warning. Worth mirroring the sibling's discrimination (moot if you adopt the throw approach — the catch already does this).

  2. getMaxEventsPerRun() is called twice (once for maxEvents, once for limit:). Compute once.

  3. limit passed to the handler excludes the buffer, so the message reads "maximum of 25000 events" while the run actually reached ~25100 (eventCount is the real count). Probably intended since 25k is the published number — worth a one-line comment so it doesn't read as an off-by-buffer bug.

  4. No awaitRunReady() before the write, unlike every other run_failed writer here. Fine in practice — a run with >25k events necessarily has a durable run_started, so the turbo backgrounded-run_started ordering concern doesn't apply. (Also comes for free with the throw approach.)

Nit confirmed fine: dropping dehydrateRunError's compression arg matches the MAX_DELIVERIES sibling, so that's consistent.

Comment thread workbench/nextjs-turbopack/workflows/test_blob_violation.ts Outdated
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.

2 participants