Skip to content

Reuse one event-log deduplication helper - #3110

Open
NathanColosimo wants to merge 5 commits into
mainfrom
codex/reuse-event-dedupe
Open

Reuse one event-log deduplication helper#3110
NathanColosimo wants to merge 5 commits into
mainfrom
codex/reuse-event-dedupe

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What

  • keep one appendUniqueEvents helper in runtime/helpers.ts
  • replace three equivalent event-cache merge loops in the runtime
  • lazily build an ID index for non-empty one-shot merges
  • pass one precomputed ID set through paginated event loading

Why

The same event-ID merge behavior was implemented in multiple runtime branches. Keeping the small helper beside the other runtime helpers makes ordering and deduplication semantics explicit. Empty merges return without scanning the existing event log, while the pagination loop retains its ID set across pages so it does not repeatedly rescan previously loaded events.

Impact

Behavior-preserving refactor. Existing events stay in place; first-seen new IDs append in source order. Empty merges are constant-time and paginated merging remains linear in the number of loaded events.

Verification

  • pnpm --filter @workflow/core... build
  • pnpm --filter @workflow/core exec vitest run src/runtime/helpers.test.ts (38 passed)
  • pnpm --filter @workflow/core typecheck
  • focused Biome checks and git diff --check

@vercel

vercel Bot commented Jul 25, 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 27, 2026 7:36pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 27, 2026 7:36pm
example-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-express-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-nestjs-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 27, 2026 7:36pm
workflow-swc-playground Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workflow-tarballs Ready Ready Preview, Comment Jul 27, 2026 7:36pm
workflow-web Ready Ready Preview, Comment Jul 27, 2026 7:36pm

@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8002bc6

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

This PR includes changesets to release 16 packages
Name Type
@workflow/core Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow 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

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

Benchmarks are running for 8002bc6... (run logs)

Results below are from a previous run.

commit aac72ec · Mon, 27 Jul 2026 19:07:54 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 247 (-65%) 💚 1437 🔴 (+39%) 🔻 1447 🔴 (+33%) 🔻 1868 🔴 (+32%) 🔻 30
TTFS stream 219 (-74%) 💚 1322 🔴 (+38%) 🔻 1349 🔴 (+39%) 🔻 1653 🔴 (+66%) 🔻 30
TTFS hook + stream 462 (-58%) 💚 1563 🔴 (+27%) 🔻 1603 🔴 (+27%) 🔻 1723 🔴 (+17%) 🔻 30
STSO 1020 steps (1-20) 173 (-4.4%) 267 🔴 (-3.3%) 345 🔴 (+13%) 399 🔴 (+1.3%) 19
STSO 1020 steps (101-120) 194 (+4.3%) 301 🔴 (+13%) 358 🔴 (+19%) 🔻 397 🔴 (-84%) 💚 19
STSO 1020 steps (1001-1020) 497 (+11%) 558 🔴 (+8.1%) 640 🔴 (+19%) 🔻 686 🔴 (+4.4%) 19
WO 1020 steps 417329 (+8.5%) 417329 (+8.5%) 417329 (+8.5%) 417329 (+8.5%) 1
SL stream latency 101 (+31%) 🔻 174 🔴 (+40%) 🔻 190 🔴 (+32%) 🔻 260 🔴 (+2.0%) 30
SO stream overhead (text) 114 (+18%) 🔻 200 (+29%) 🔻 252 (+44%) 🔻 459 (+132%) 🔻 30
SO stream overhead (structured) 117 (+16%) 🔻 232 (+38%) 🔻 343 (+94%) 🔻 406 (-73%) 💚 30
📜 Previous results (2)

4135003

Mon, 27 Jul 2026 17:33:16 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1282 (+33%) 🔻 1380 🔴 (+19%) 🔻 1417 🔴 (+19%) 🔻 1687 🔴 (+13%) 30
TTFS stream 1305 (+355%) 🔻 1381 🔴 (+17%) 🔻 1410 🔴 (+15%) 🔻 1429 🔴 (+4.1%) 30
TTFS hook + stream 1237 (-5.4%) 1622 🔴 (+3.7%) 1633 🔴 (-0.9%) 2339 🔴 (+33%) 🔻 30
STSO 1020 steps (1-20) 190 (+1.1%) 344 🔴 (+12%) 435 🔴 (+15%) 🔻 607 🔴 (+59%) 🔻 19
STSO 1020 steps (101-120) 195 (-3.0%) 289 🔴 (-1.4%) 345 🔴 (-5.5%) 400 🔴 (+4.4%) 19
STSO 1020 steps (1001-1020) 478 (-7.7%) 529 🔴 (-16%) 💚 544 🔴 (-18%) 💚 584 🔴 (-29%) 💚 19
WO 1020 steps 397542 (-11%) 397542 (-11%) 397542 (-11%) 397542 (-11%) 1
SL stream latency 100 (-8.3%) 172 🔴 (-64%) 💚 195 🔴 (-73%) 💚 5097 🔴 (+366%) 🔻 30
SO stream overhead (text) 116 (-13%) 275 🔴 (+14%) 353 (+14%) 613 (+10%) 30
SO stream overhead (structured) 111 (-17%) 💚 195 (-20%) 💚 214 (-28%) 💚 395 (-12%) 30

d5547b6

Sat, 25 Jul 2026 01:02:27 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1241 (+73%) 🔻 1308 🔴 (+25%) 🔻 1397 🔴 (+27%) 🔻 1465 🔴 (+27%) 🔻 30
TTFS stream 1238 (+26%) 🔻 1309 🔴 (+21%) 🔻 1351 🔴 (+17%) 🔻 1778 🔴 (+39%) 🔻 30
TTFS hook + stream 1513 (+19%) 🔻 1627 🔴 (+16%) 🔻 1656 🔴 (+14%) 1898 🔴 (+13%) 30
STSO 1020 steps (1-20) 185 (-7.0%) 315 🔴 (+14%) 372 🔴 (+8.1%) 485 🔴 (+25%) 🔻 19
STSO 1020 steps (101-120) 207 (-3.7%) 266 🔴 (-5.0%) 287 🔴 (-3.7%) 338 🔴 (-10%) 19
STSO 1020 steps (1001-1020) 474 (-3.1%) 554 🔴 (-1.1%) 582 🔴 (-2.3%) 607 🔴 (±0%) 19
WO 1020 steps 403087 (-2.3%) 403087 (-2.3%) 403087 (-2.3%) 403087 (-2.3%) 1
SL stream latency 118 (+19%) 🔻 162 🔴 (+24%) 🔻 190 🔴 (+28%) 🔻 395 🔴 (+90%) 🔻 30
SO stream overhead (text) 139 (+6.9%) 196 (-9.7%) 250 (+1.6%) 492 (+82%) 🔻 30
SO stream overhead (structured) 127 (-9.3%) 193 (-13%) 208 (-16%) 💚 232 (-67%) 💚 30
ℹ️ Metric definitions & methodology

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

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

⚠️ Results below are stale and not from the latest commit. This comment will be updated when CI completes on the latest run.

Tests are running...


_Started at: _


All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 239 1694
✅ 💻 Local Development 1621 0 227 1848
✅ 📦 Local Production 1621 0 227 1848
✅ 🐘 Local Postgres 1621 0 227 1848
✅ 🪟 Windows 154 0 0 154
✅ 📋 Other 1020 0 212 1232
✅ vercel-multi-region 27 0 0 27
Total 7519 0 1132 8651
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 28
✅ example 126 0 28
✅ express 126 0 28
✅ fastify 126 0 28
✅ hono 126 0 28
✅ nextjs-turbopack 151 0 3
✅ nextjs-webpack 151 0 3
✅ nitro 126 0 28
✅ nuxt 126 0 28
✅ sveltekit 145 0 9
✅ vite 126 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 128 0 26
✅ express-stable 128 0 26
✅ fastify-stable 128 0 26
✅ hono-stable 128 0 26
✅ nextjs-turbopack-canary 135 0 19
✅ nextjs-turbopack-stable 154 0 0
✅ nextjs-webpack-canary 135 0 19
✅ nextjs-webpack-stable 154 0 0
✅ nitro-stable 128 0 26
✅ nuxt-stable 128 0 26
✅ sveltekit-stable 147 0 7
✅ vite-stable 128 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 154 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 26
✅ e2e-local-dev-tanstack-start- 128 0 26
✅ e2e-local-postgres-nest-stable 128 0 26
✅ e2e-local-postgres-tanstack-start- 128 0 26
✅ e2e-local-prod-nest-stable 128 0 26
✅ e2e-local-prod-tanstack-start- 128 0 26
✅ e2e-vercel-prod-nest 126 0 28
✅ e2e-vercel-prod-tanstack-start 126 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@TooTallNate TooTallNate 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 consolidation. The refactor is behavior-preserving — I diffed all three replaced loops against the helper and they're semantically identical (build ID set from target, append first-seen IDs in source order, keep existing entries in place). Two observations worth addressing before merge, both small.

1. The default-argument form mildly pessimizes two of the three call sites. Both replaced loops in runtime.ts were guarded by if (delta.events.length > 0) / if (loaded.events.length > 0), so an empty merge cost nothing. Default parameters are evaluated at call time, before the body runs, so targetIds = new Set(target.map(...)) now builds a full ID set over cachedEvents even when there is nothing to merge — and an early if (events.length === 0) return; inside the body wouldn't prevent it.

The empty case isn't hypothetical: the second site is the incremental-fetch path, which returns nothing new whenever the cursor is already at the tip. Measured cost of the wasted Set construction per replay iteration:

event log per empty merge × 200 replay iterations
500 0.02 ms ~5 ms
5,000 0.26 ms ~51 ms
20,000 1.11 ms ~223 ms

Modest, but it's per-iteration work proportional to log size in the replay loop — the same O(N)-per-replay shape the recent step-result memoization work removed — and the fix keeps the consolidation intact:

export function appendUniqueEvents(
  target: Event[],
  events: readonly Event[],
  targetIds?: Set<string>
): void {
  if (events.length === 0) return;
  const ids = targetIds ?? new Set(target.map((event) => event.eventId));
  // …
}

Worth noting the PR's "Paginated merging remains linear" claim is true, but the regression is on the non-paginated sites, which the summary doesn't cover.

2. ./runtime/helpers is a published subpath, so exporting appendUniqueEvents widens @workflow/core's public API — which by this repo's convention (and the same note I left on #2903) makes the changeset minor rather than patch. Alternatives, both fine by me: keep it unexported and pass the helper via the existing import if the sharing is intra-file, or export it and bump the changeset. Just flagging so the bump type stays meaningful for the v5 line.

Everything else checks out: parameter reorder applied consistently at all call sites; the paginated loop still threads its retained loadedEventIds (so pagination keeps its single-set behavior); and the withPreconditionRetry concurrency comment I asked for on #2946 is still accurate — the default argument is evaluated synchronously at call time, so "builds its dedup set synchronously right before appending" still describes the code. Verified locally: helpers 38/38 and full core 1504 (+3 pre-existing expected-fails).

CI's two failures are both the nextjs-webpack dev.test.ts HMR rebuild-count test (canary + stable) — the long-running flake family that's now hit at least five unrelated PRs; a refactor confined to event-array merging can't reach dev-server rebuild accounting.

Happy to approve as soon as the changeset bump is settled — item 1 I'd take but won't insist on.

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