Skip to content

Slot event identity (SDK 4/4): scope listByCorrelationId to a run - #3247

Closed
VaguelySerious wants to merge 1 commit into
peter/slot-ids-8-worldsfrom
peter/slot-ids-9-correlation-scope
Closed

Slot event identity (SDK 4/4): scope listByCorrelationId to a run#3247
VaguelySerious wants to merge 1 commit into
peter/slot-ids-8-worldsfrom
peter/slot-ids-9-correlation-scope

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 31, 2026

Copy link
Copy Markdown
Member

Stacked on #3246. Review the last commit only.

Why

A correlation id identifies a step or wait within its run, not across runs. Nothing enforced that while every id carried a ULID, so the distinction never mattered. It matters as soon as a run numbers its own steps: step_…001 names the first step of every slot-numbered run.

Measured on the Local World before this change — two runs, each with one step, one unscoped lookup:

listByCorrelationId('step_…001') → 2 events, runIds [wrun_01KYTTGYF6…, wrun_01KYTTGYF9…]

Two consequences:

  • Foreign runs in the page. The observability search already post-filters on runId, so nothing wrong is displayed — but it pages against a bounded page cap, so the run it wants can sit past the cap and the search reports nothing found.
  • An ambiguous cursor. The Postgres query orders and resumes by eventId. Two runs can hold the same correlation id at the same slot, so eventId > cursor skips the sibling row at the equal id. Scoped, (run_id, id) is the primary key, so the cursor is a key again.

Nothing in the runtime reads this path — it is an observability read — so this is not a correctness regression in a run. It is a read that gets quietly wrong answers under slot identity.

What

ListEventsByCorrelationIdParams (and the analytics variant) gain an optional runId.

WorldHow it scopes
LocalRestricts the scan to the run's own event files
PostgresAND run_id = $n, which also disambiguates the cursor
Vercel — analyticsRoutes to the run-scoped endpoint that already takes a correlation-id filter
Vercel — runtime readApplies the scope to the returned page; the backend index is keyed by correlation id alone, so the pagination cursor and hasMore stay the backend's

The observability search passes the run it is already looking at, and keeps its post-filter as defence for a world that cannot scope.

Leaving the param optional keeps every existing caller working, and an unscoped lookup still means "every run that numbered one the same" — which is the honest answer to the question asked.

Tests

Both slot-identity suites gain the pair, and Postgres gains the cursor case:

  • unscoped lookup matches every run that numbered a step the same (asserts the collision rather than pretending it away)
  • scoped lookup returns one run
  • Postgres: paging a scoped query whose event ids repeat across runs returns both of the run's events, in order

Local 524 pass, Postgres 184 pass (real container), world 98, core 1687 + 3 expected fail, world-vercel 317, web 94.

Follow-up

The backend index behind the Vercel runtime read is keyed by correlation id alone, so a slot-mode step_…001 is a hot key across all runs and the scope is applied client-side. Taking a run scope there is a backend-side change, tracked separately.


Stack: #3228#3234#3246#3247

Paired backend stack (world-vercel side): 6 PRs, all rebased on main and stacked; WORKFLOW_SERVER_URL_OVERRIDE points at the top of it and must be reverted before merge.

@changeset-bot

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 350af92

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

This PR includes changesets to release 20 packages
NameType
@workflow/worldMinor
@workflow/world-localMinor
@workflow/world-postgresMinor
@workflow/world-vercelMinor
@workflow/webMinor
@workflow/cliPatch
@workflow/corePatch
@workflow/vitestPatch
@workflow/web-sharedMinor
@workflow/world-testingPatch
@workflow/nitroPatch
workflowPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nuxtPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch

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 Jul 31, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

fastify (1 failed):

  • AbortController abortListenerWorkflow: signal.addEventListener fires on the deserialized step signal

E2E Test Summary

Summary
PassedFailedSkippedTotal
❌ ▲ Vercel Production146512391705
✅ 💻 Local Development149702081705
✅ 📦 Local Production163302271860
✅ 🐘 Local Postgres163302271860
✅ 📋 Other102802121240
✅ vercel-multi-region270027
Total7283111138397
Details by Category

❌ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro127028
✅ example127028
✅ express127028
❌ fastify126128
✅ hono127028
✅ nextjs-turbopack15203
✅ nextjs-webpack15203
✅ nitro127028
✅ nuxt127028
✅ sveltekit14609
✅ vite127028

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable129026
✅ express-stable129026
✅ fastify-stable129026
✅ hono-stable129026
✅ nextjs-turbopack-canary136019
✅ nextjs-turbopack-stable15500
✅ nextjs-webpack-stable15500
✅ nitro-stable129026
✅ nuxt-stable129026
✅ sveltekit-stable14807
✅ vite-stable129026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable129026
✅ express-stable129026
✅ fastify-stable129026
✅ hono-stable129026
✅ nextjs-turbopack-canary136019
✅ nextjs-turbopack-stable15500
✅ nextjs-webpack-canary136019
✅ nextjs-webpack-stable15500
✅ nitro-stable129026
✅ nuxt-stable129026
✅ sveltekit-stable14807
✅ vite-stable129026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable129026
✅ express-stable129026
✅ fastify-stable129026
✅ hono-stable129026
✅ nextjs-turbopack-canary136019
✅ nextjs-turbopack-stable15500
✅ nextjs-webpack-canary136019
✅ nextjs-webpack-stable15500
✅ nitro-stable129026
✅ nuxt-stable129026
✅ sveltekit-stable14807
✅ vite-stable129026

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable129026
✅ e2e-local-dev-tanstack-start-129026
✅ e2e-local-postgres-nest-stable129026
✅ e2e-local-postgres-tanstack-start-129026
✅ e2e-local-prod-nest-stable129026
✅ e2e-local-prod-tanstack-start-129026
✅ e2e-vercel-prod-nest127028
✅ e2e-vercel-prod-tanstack-start127028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySeriousforce-pushed the peter/slot-ids-9-correlation-scope branch from 4e89132 to 04efaecCompareJuly 31, 2026 04:50
@VaguelySerious
VaguelySeriousforce-pushed the peter/slot-ids-9-correlation-scope branch from e20895c to 73199f0CompareJuly 31, 2026 10:20
@github-actions

github-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 350af92 · Sun, 02 Aug 2026 17:16:53 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep229 (-71%) 💚1336 🔴 (+22%) 🔻1363 🔴 (+19%) 🔻1396 🔴 (-8.4%)30
TTFSstream241 (+1.3%)1366 🔴 (+26%) 🔻1400 🔴 (+26%) 🔻1674 🔴 (+43%) 🔻30
TTFShook + stream486 (-61%) 💚1628 🔴 (+19%) 🔻1643 🔴 (+14%)1672 🔴 (-9.1%)30
STSO1020 steps (inline)154 (-11%)538 (+3.9%)603 (+3.1%)823 (-6.3%)1016
STSO1020 steps (queue-hop)1430 (-28%) 💚3359 (+1.6%)3359 (+1.6%)3359 (+1.6%)3
WO1020 steps460167 (+2.9%)460167 (+2.9%)460167 (+2.9%)460167 (+2.9%)1
SLstream latency92 (±0%)171 🔴 (-5.0%)186 🔴 (-23%) 💚419 🔴 (-3.5%)30
SOstream overhead (text)127 (-6.6%)217 (±0%)389 (+43%) 🔻579 (+54%) 🔻30
SOstream overhead (structured)114 (-3.4%)215 (-10%)251 (-21%) 💚532 (+19%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 437759ms → this run 451743ms (Δ +13984ms, +3%)

 150-200 ms ┃█ main 10 this 2 -8
200-250 ms ███████┃█████ main 86 this 49 -37
250-300 ms ███████████████┃ main 104 this 100 -4
300-350 ms ███████████████░░░░░░┃ main 98 this 139 +41
350-400 ms █████████████████████░░┃ main 136 this 153 +17
400-450 ms ██████████████████┃██ main 132 this 120 -12
450-500 ms ████████████████┃█████ main 140 this 108 -32
500-550 ms ██████████████████┃██ main 135 this 118 -17
550-600 ms ███████████████░░░┃ main 93 this 123 +30
600-650 ms ██████┃ main 38 this 45 +7
650-700 ms ███┃ main 18 this 25 +7
700-750 ms █┃ main 8 this 11 +3
750-800 ms ┃ main 3 this 7 +4
800-850 ms ┃ main 2 this 6 +4
850-900 ms ┃ main 3 this 4 +1
900-950 ms ┃ main 5 this 3 -2
950-1000 ms ┃ main 0 this 1 +1
1050-1100 ms ┃ main 1 this 0 -1
1200-1250 ms ┃ main 2 this 0 -2
1250-1300 ms ┃ main 1 this 0 -1
1300-1350 ms ┃ main 1 this 1 +0
4600-4650 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 8262ms → this run 7134ms (Δ -1128ms, -14%)

1000-1500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 1 +1
1500-2000 ms ┃███████████████████████ main 1 this 0 -1
2000-2500 ms ░░░░░░░░░░░░░░░░░░░░░░░┃ main 0 this 1 +1
2500-3000 ms ┃███████████████████████ main 1 this 0 -1
3000-3500 ms ███████████████████████┃ main 1 this 1 +0
📜 Previous results (3)

09f8e32

Sat, 01 Aug 2026 20:52:27 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep328 (-58%) 💚1305 🔴 (+19%) 🔻1321 🔴 (+15%) 🔻1357 🔴 (-11%)30
TTFSstream1237 (+420%) 🔻1277 🔴 (+18%) 🔻1311 🔴 (+18%) 🔻1519 🔴 (+30%) 🔻30
TTFShook + stream1570 (+25%) 🔻1650 🔴 (+20%) 🔻1661 🔴 (+15%) 🔻1678 🔴 (-8.8%)30
STSO1020 steps (inline)157 (-9.2%)496 (-4.2%)549 (-6.2%)723 (-18%) 💚1016
STSO1020 steps (queue-hop)1638 (-17%) 💚3485 (+5.4%)3485 (+5.4%)3485 (+5.4%)3
WO1020 steps420928 (-5.8%)420928 (-5.8%)420928 (-5.8%)420928 (-5.8%)1
SLstream latency85 (-7.6%)169 🔴 (-6.1%)182 🔴 (-25%) 💚254 🔴 (-41%) 💚30
SOstream overhead (text)97 (-29%) 💚149 (-32%) 💚165 (-39%) 💚233 (-38%) 💚30
SOstream overhead (structured)101 (-14%)152 (-37%) 💚188 (-41%) 💚247 (-45%) 💚30

d8f5792

Fri, 31 Jul 2026 21:46:43 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep226 (-78%) 💚1374 🔴 (+18%) 🔻1470 🔴 (+18%) 🔻1505 🔴 (-3.5%)30
TTFSstream240 (-38%) 💚1353 🔴 (+14%)1398 🔴 (+12%)1445 🔴 (-62%) 💚30
TTFShook + stream401 (-24%) 💚1599 🔴 (+7.8%)1618 🔴 (+3.4%)1651 🔴 (-10%)30
STSO1020 steps (inline)162 (-21%) 💚502 (-11%)582 (-12%)848 (-17%) 💚1015
STSO1020 steps (queue-hop)2691 (+20%) 🔻3417 (+9.0%)254886 (+6039%) 🔻254886 (+6039%) 🔻4
WO1020 steps701452 (+37%) 🔻701452 (+37%) 🔻701452 (+37%) 🔻701452 (+37%) 🔻1
SLstream latency121 (+8.0%)223 🔴 (+11%)243 🔴 (-16%) 💚518 🔴 (-27%) 💚30
SOstream overhead (text)188 (+17%) 🔻818 🔴 (+110%) 🔻996 🔴 (+60%) 🔻2088 🔴 (-25%) 💚30
SOstream overhead (structured)164 (-6.8%)360 🔴 (-14%)424 (-23%) 💚476 (-99%) 💚30

f55cdf1

Fri, 31 Jul 2026 21:03:08 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1302 (+86%) 🔻1422 🔴 (+27%) 🔻1436 🔴 (+5.7%)1597 🔴 (-5.2%)30
TTFSstream270 (+0.7%)1403 🔴 (+27%) 🔻1432 🔴 (+27%) 🔻1546 🔴 (+31%) 🔻30
TTFShook + stream1303 (-3.6%)1645 🔴 (+6.1%)1698 🔴 (+0.9%)1857 🔴 (-52%) 💚30
STSO1020 steps (inline)178 (-15%)468 (-16%) 💚511 (-20%) 💚682 (-35%) 💚1016
STSO1020 steps (queue-hop)2226 (-10%)3425 (-2.8%)3425 (-4.4%)3425 (-4.4%)3
WO1020 steps398148 (-20%) 💚398148 (-20%) 💚398148 (-20%) 💚398148 (-20%) 💚1
SLstream latency99 (-14%)143 🔴 (-34%) 💚154 🔴 (-38%) 💚372 🔴 (-5.6%)30
SOstream overhead (text)102 (-40%) 💚163 (-46%) 💚180 (-53%) 💚254 (-100%) 💚30
SOstream overhead (structured)97 (-50%) 💚140 (-70%) 💚145 (-83%) 💚230 (-100%) 💚30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

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, 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) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

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 · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

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 Best column shows the fastest (warm-start) sample for comparison.

A correlation id identifies a step or wait within its run, not across
runs. That was invisible while every id carried a ULID, and it stops
being invisible once a run numbers its own steps: `step_…001` names the
first step of every slot-numbered run, so an unscoped lookup answers
with one event per run, and a cursor — an event id — cannot tell two
such rows apart.
`ListEventsByCorrelationIdParams` gains an optional `runId`. The Local
and Postgres Worlds filter on it, which also makes their cursor a key
again; the Vercel World routes analytics to the run-scoped endpoint it
already has and applies the scope to the page for the runtime read,
whose backend index is keyed by correlation id alone. The observability
search passes the run it is already looking at, and keeps its own
post-filter for a world that cannot scope.
@VaguelySerious

Copy link
Copy Markdown
MemberAuthor

Superseded by #3280, which carries this commit on main instead of stacked behind #3246, and makes runId required rather than optional. Closing this one.

@github-actions

Copy link
Copy Markdown
Contributor

Event Log Race Repro

2 of 14 latest repro runs hit event-log regressions.

Run History

Metric2026-08-02 17:18 UTC #1
logs / deploy
Result2/14 regressions
Total14
completed12
CORRUPTED_EVENT_LOG2
USER_ERROR0
RUNTIME_ERROR0
stuck0
other0
infra0
Config14 runs / step-storm 6, hook-storm 6, hook-sleep 2 / c8 / 6x8
Timingwatchdog 2500ms / step 2200±250ms / stagger 400ms / poke 750ms / timeout 240000ms

Latest Scenario Breakdown

ScenarioTotalcompletedCORRUPTED_EVENT_LOGUSER_ERRORRUNTIME_ERRORstuckotherinfra
step-storm64200000
hook-storm66000000
hook-sleep22000000

Latest Non-Completed Runs

ScenarioAttemptOutcomeStatusError codeRun
step-storm6CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZ1QEFKR0GPMFKB12F3NNG7G
step-storm1CORRUPTED_EVENT_LOGfailedCORRUPTED_EVENT_LOGwrun_41KZ1QEFHA0GWQD6BRSHMCC7GX

The event-log-race-repro-results artifact carries a window of the committed log around the divergent event, for a sample of the corruptions.

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

Labels

event-log-race-reproRun the event log race reproduction job

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@VaguelySerious