Skip to content

Lazy hook resumption: parallel event write + queue publish - #3230

Merged
karthikscale3 merged 11 commits into
mainfrom
kk/lazy-hook-resumption
Aug 3, 2026
Merged

Lazy hook resumption: parallel event write + queue publish#3230
karthikscale3 merged 11 commits into
mainfrom
kk/lazy-hook-resumption

Conversation

@karthikscale3

@karthikscale3karthikscale3 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

resumeHook() now writes the hook_received event and dispatches the workflow queue message concurrently (Promise.allSettled) instead of sequentially, cutting a round trip off resume latency. A client-minted resumeId plus a payload digest let the direct write and the queue consumer's replay-time re-ensure converge on exactly one hook_received event via a (runId, resumeId) dedup constraint.

Builds on the unreleased #1834 rather than replacing it: the ResumedHook contract from that PR is preserved. resumeHook() resolves to a ResumedHook (a Hook plus an optional resilientResume: true flag), set only when the direct write failed transiently and the resume was recovered via the consumer's re-ensure. #1834 owns resilience; this PR owns latency (parallel dispatch + dedup).

Gating — re-evaluated per resume

The parallel fast path activates only when both the target run's consumer and the live backend independently attest dedup support — the consumer via a per-run hookResumeInputVersion execution-context marker, the backend via a fresh, response-only capability on the by-token hook lookup. Otherwise — or for oversized payloads, legacy (v1Compat) runs, or with WORKFLOW_DISABLE_LAZY_HOOK_RESUME=1 — it falls back to the original sequential path. Because both attestations are re-checked on every resume, every rollout/rollback direction degrades safely.

Notes

  • Replay dedup keys off the top-level event.resumeId (a first-class event column). The consumer materializes the event dated to resume time via the ULID embedded in resumeId (occurredAt), not the queue round-trip time.
  • Queue-publish failure is fatal; a transient event-write failure or a resumeId conflict is swallowed because the consumer re-ensures the event (and the returned hook carries resilientResume: true). Terminal-run rejections surface as HookNotFoundError.
  • Producer/consumer telemetry: workflow.hook.resume_strategy + workflow.hook.resume_fallback_reason on the resume span, workflow.hook.resilient_resume (producer) and workflow.hook.resilient_resume_materialized (consumer) for the recovery path.
  • Mirrors the existing run-start path (Promise.allSettled of run_created + queue invoke, idempotent on runId).

@karthikscale3
karthikscale3 requested review from a team and ijjk as code ownersJuly 30, 2026 21:28
@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1d147c7

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

This PR includes changesets to release 20 packages
NameType
workflowMinor
@workflow/coreMinor
@workflow/worldMinor
@workflow/world-vercelMinor
@workflow/world-localMinor
@workflow/web-sharedPatch
@workflow/world-postgresPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@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

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 1d147c7 · Mon, 03 Aug 2026 15:16:12 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep270 (-66%) 💚1377 🔴 (+26%) 🔻1416 🔴 (+24%) 🔻1543 🔴 (+1.2%)30
TTFSstream319 (+34%) 🔻1583 🔴 (+46%) 🔻1611 🔴 (+46%) 🔻1718 🔴 (+47%) 🔻30
TTFShook + stream478 (-62%) 💚1786 🔴 (+30%) 🔻1847 🔴 (+28%) 🔻1982 🔴 (+7.7%)30
STSO1020 steps (inline)158 (-8.7%)513 (-1.0%)580 (-0.9%)857 (-2.4%)1016
STSO1020 steps (queue-hop)1877 (-5.3%)3502 (+5.9%)3502 (+5.9%)3502 (+5.9%)3
WO1020 steps454686 (+1.7%)454686 (+1.7%)454686 (+1.7%)454686 (+1.7%)1
SLstream latency107 (+16%) 🔻208 🔴 (+16%) 🔻221 🔴 (-8.7%)315 🔴 (-27%) 💚30
SOstream overhead (text)119 (-13%)237 (+8.7%)254 (-6.6%)481 (+28%) 🔻30
SOstream overhead (structured)129 (+9.3%)295 🔴 (+23%) 🔻394 (+24%) 🔻707 (+58%) 🔻30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 437759ms → this run 445330ms (Δ +7571ms, +2%)

 150-200 ms ┃█ main 10 this 7 -3
200-250 ms ██████████┃██ main 86 this 70 -16
250-300 ms █████████████┃██ main 104 this 95 -9
300-350 ms ███████████████░░┃ main 98 this 120 +22
350-400 ms ████████████████┃███ main 136 this 116 -20
400-450 ms ████████████████████░░┃ main 132 this 151 +19
450-500 ms █████████████████████░░┃ main 140 this 160 +20
500-550 ms ████████████████████┃ main 135 this 141 +6
550-600 ms ███████████┃██ main 93 this 81 -12
600-650 ms ████┃█ main 38 this 31 -7
650-700 ms ██┃ main 18 this 17 -1
700-750 ms ┃ main 8 this 4 -4
750-800 ms ┃ main 3 this 9 +6
800-850 ms ┃ main 2 this 3 +1
850-900 ms ┃ main 3 this 2 -1
900-950 ms ┃ main 5 this 1 -4
1050-1100 ms ┃ main 1 this 2 +1
1150-1200 ms ┃ main 0 this 3 +3
1200-1250 ms ┃ main 2 this 0 -2
1250-1300 ms ┃ main 1 this 1 +0
1300-1350 ms ┃ main 1 this 0 -1
2050-2100 ms ┃ main 0 this 1 +1
4200-4250 ms ┃ main 0 this 1 +1

1020 steps (queue-hop)

Cumulative STSO time: main 8262ms → this run 8007ms (Δ -255ms, -3%)

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

2f9a62f

Sat, 01 Aug 2026 01:21:07 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep244 (-64%) 💚1480 🔴 (+47%) 🔻1510 🔴 (+47%) 🔻1752 🔴 (+11%)30
TTFSstream223 (-75%) 💚1386 🔴 (+48%) 🔻1436 🔴 (+51%) 🔻1493 🔴 (+54%) 🔻30
TTFShook + stream427 (-9.7%)1649 🔴 (+27%) 🔻1683 🔴 (+26%) 🔻1796 🔴 (+31%) 🔻30
STSO1020 steps (inline)178 (+38%) 🔻539 (+19%) 🔻610 (+20%) 🔻795 (+11%)1016
STSO1020 steps (queue-hop)1374 (-7.8%)3338 (+5.8%)3338 (+5.8%)3338 (+5.8%)3
WO1020 steps442977 (+14%)442977 (+14%)442977 (+14%)442977 (+14%)1
SLstream latency92 (+23%) 🔻188 🔴 (+63%) 🔻198 🔴 (+56%) 🔻213 🔴 (-37%) 💚30
SOstream overhead (text)144 (+44%) 🔻236 (+74%) 🔻331 (+95%) 🔻508 (+132%) 🔻30
SOstream overhead (structured)134 (+31%) 🔻268 🔴 (+41%) 🔻292 (+24%) 🔻571 (-12%)30

59abb1c

Fri, 31 Jul 2026 23:54:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep245 (-64%) 💚1355 🔴 (+35%) 🔻1444 🔴 (+40%) 🔻1476 🔴 (-6.5%)30
TTFSstream250 (-72%) 💚1341 🔴 (+43%) 🔻1343 🔴 (+41%) 🔻1625 🔴 (+68%) 🔻30
TTFShook + stream451 (-4.7%)1608 🔴 (+23%) 🔻1694 🔴 (+26%) 🔻1748 🔴 (+27%) 🔻30
STSO1020 steps (inline)166 (+29%) 🔻497 (+9.5%)550 (+8.3%)738 (+2.9%)1016
STSO1020 steps (queue-hop)1927 (+29%) 🔻3330 (+5.5%)3330 (+5.5%)3330 (+5.5%)3
WO1020 steps415301 (+6.5%)415301 (+6.5%)415301 (+6.5%)415301 (+6.5%)1
SLstream latency88 (+17%) 🔻168 🔴 (+46%) 🔻174 🔴 (+37%) 🔻183 🔴 (-46%) 💚30
SOstream overhead (text)113 (+13%)253 🔴 (+86%) 🔻308 (+81%) 🔻539 (+146%) 🔻30
SOstream overhead (structured)120 (+18%) 🔻192 (+1.1%)230 (-2.1%)626 (-3.2%)30

77d6ba2

Fri, 31 Jul 2026 23:11:54 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep421 (-38%) 💚1370 🔴 (+36%) 🔻1386 🔴 (+35%) 🔻1418 🔴 (-10%)30
TTFSstream1353 (+50%) 🔻1394 🔴 (+49%) 🔻1413 🔴 (+49%) 🔻1570 🔴 (+62%) 🔻30
TTFShook + stream648 (+37%) 🔻1590 🔴 (+22%) 🔻1691 🔴 (+26%) 🔻1958 🔴 (+43%) 🔻30
STSO1020 steps (inline)176 (+36%) 🔻454 (±0%)515 (+1.4%)721 (+0.6%)1016
STSO1020 steps (queue-hop)1510 (+1.3%)3316 (+5.1%)3316 (+5.1%)3316 (+5.1%)3
WO1020 steps393002 (+0.8%)393002 (+0.8%)393002 (+0.8%)393002 (+0.8%)1
SLstream latency86 (+15%)157 🔴 (+37%) 🔻193 🔴 (+52%) 🔻370 🔴 (+8.8%)30
SOstream overhead (text)100 (±0%)143 (+5.1%)167 (-1.8%)191 (-13%)30
SOstream overhead (structured)94 (-7.8%)140 (-26%) 💚172 (-27%) 💚195 (-70%) 💚30

68ef5ae

Fri, 31 Jul 2026 22:36:54 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep540 (-20%) 💚1296 🔴 (+29%) 🔻1318 🔴 (+28%) 🔻1381 🔴 (-12%)30
TTFSstream1248 (+38%) 🔻1298 🔴 (+39%) 🔻1311 🔴 (+38%) 🔻1350 🔴 (+40%) 🔻30
TTFShook + stream493 (+4.2%)1550 🔴 (+19%) 🔻1608 🔴 (+20%) 🔻1704 🔴 (+24%) 🔻30
STSO1020 steps (inline)172 (+33%) 🔻462 (+1.8%)513 (+1.0%)726 (+1.3%)1016
STSO1020 steps (queue-hop)2163 (+45%) 🔻3458 (+9.6%)3458 (+9.6%)3458 (+9.6%)3
WO1020 steps394803 (+1.2%)394803 (+1.2%)394803 (+1.2%)394803 (+1.2%)1
SLstream latency104 (+39%) 🔻140 🔴 (+22%) 🔻175 🔴 (+38%) 🔻311 🔴 (-8.5%)30
SOstream overhead (text)101 (+1.0%)148 (+8.8%)167 (-1.8%)263 (+20%) 🔻30
SOstream overhead (structured)100 (-2.0%)141 (-26%) 💚173 (-26%) 💚238 (-63%) 💚30

0251b95

Fri, 31 Jul 2026 16:10:32 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1256 (+140%) 🔻1340 🔴 (+29%) 🔻1375 🔴 (+28%) 🔻1691 🔴 (+4.4%)30
TTFSstream1268 (+34%) 🔻1351 🔴 (+35%) 🔻1395 🔴 (+36%) 🔻1446 🔴 (+12%)30
TTFShook + stream618 (+47%) 🔻1587 🔴 (+20%) 🔻1683 🔴 (+24%) 🔻1725 🔴 (-2.5%)30
STSO1020 steps (inline)141 (-13%)460 (-3.0%)513 (-3.4%)700 (+4.3%)1016
STSO1020 steps (queue-hop)2408 (+16%) 🔻3381 (+5.7%)3381 (+5.7%)3381 (+5.7%)3
WO1020 steps393710 (-2.7%)393710 (-2.7%)393710 (-2.7%)393710 (-2.7%)1
SLstream latency92 (+12%)157 🔴 (+19%) 🔻171 🔴 (+16%) 🔻354 🔴 (+65%) 🔻30
SOstream overhead (text)102 (+1.0%)169 (-1.7%)205 (-4.2%)431 (+72%) 🔻30
SOstream overhead (structured)109 (-0.9%)154 (-8.9%)185 (-2.6%)3070 🔴 (+1246%) 🔻30

1f16ba9

Thu, 30 Jul 2026 21:52:00 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep253 (+20%) 🔻1369 🔴 (+29%) 🔻1441 🔴 (+33%) 🔻1522 🔴 (+25%) 🔻30
TTFSstream364 (+35%) 🔻1366 🔴 (+27%) 🔻1385 🔴 (+26%) 🔻1401 🔴 (+19%) 🔻30
TTFShook + stream728 (+93%) 🔻1584 🔴 (+26%) 🔻1639 🔴 (+27%) 🔻1790 🔴 (+14%)30
STSO1020 steps (inline)164 (-7.9%)461 (-4.2%)536 (±0%)722 (+5.6%)1016
STSO1020 steps (queue-hop)1678 (+3.1%)2384 (-20%) 💚2384 (-20%) 💚2384 (-20%) 💚3
WO1020 steps403375 (-3.1%)403375 (-3.1%)403375 (-3.1%)403375 (-3.1%)1
SLstream latency95 (+17%) 🔻194 🔴 (+32%) 🔻216 🔴 (+18%) 🔻266 🔴 (-26%) 💚30
SOstream overhead (text)147 (+18%) 🔻321 🔴 (+47%) 🔻405 (+61%) 🔻540 (+26%) 🔻30
SOstream overhead (structured)129 (+11%)276 🔴 (+19%) 🔻326 (+7.9%)648 (+74%) 🔻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.

@github-actions

github-actionsBot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

💻 Local Development (1 failed)

astro-stable (1 failed):

  • webhookWorkflow | wrun_41KZ41YDGQ0GN7J4XVE636AW4G

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production145502391694
❌ 💻 Local Development148512081694
✅ 📦 Local Production162102271848
✅ 🐘 Local Postgres162102271848
✅ 🪟 Windows15400154
✅ 📋 Other102002121232
✅ vercel-multi-region270027
Total7383111138497
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro126028
✅ example126028
✅ express126028
✅ fastify126028
✅ hono126028
✅ nextjs-turbopack15103
✅ nextjs-webpack15103
✅ nitro126028
✅ nuxt126028
✅ sveltekit14509
✅ vite126028

❌ 💻 Local Development

AppPassedFailedSkipped
❌ astro-stable127126
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable128026
✅ express-stable128026
✅ fastify-stable128026
✅ hono-stable128026
✅ nextjs-turbopack-canary135019
✅ nextjs-turbopack-stable15400
✅ nextjs-webpack-canary135019
✅ nextjs-webpack-stable15400
✅ nitro-stable128026
✅ nuxt-stable128026
✅ sveltekit-stable14707
✅ vite-stable128026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack15400

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128026
✅ e2e-local-dev-tanstack-start-128026
✅ e2e-local-postgres-nest-stable128026
✅ e2e-local-postgres-tanstack-start-128026
✅ e2e-local-prod-nest-stable128026
✅ e2e-local-prod-tanstack-start-128026
✅ e2e-vercel-prod-nest126028
✅ e2e-vercel-prod-tanstack-start126028

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@karthikscale3
karthikscale3force-pushed the kk/lazy-hook-resumption branch from 30b9711 to 1f16ba9CompareJuly 30, 2026 21:30
…lish (rebased onto #1834 + #3145)
Rebase of #3230 onto current main (2677653 + #1834 resilient resumeHook
+ #3145 event-count-gated replay restart). Reconstructed as a single commit
since `git rebase -i` is unavailable in this environment.
Reconciliation vs the pre-rebase branch:
- Replaces #1834's version-prediction (`supportsQueueHookInput`,
`QUEUE_HOOK_INPUT_MIN_VERSION`) with #3230's capability protocol
(persisted `hookResumeInputVersion` + static `hookResumeDedup`).
- One idempotency protocol: a single `resumeId` + SHA-256 payload digest
per resume, sent to both the direct event write and the queue `hookInput`.
- Two execution tiers: backend+consumer attest dedup -> parallel
`Promise.allSettled(event write, queue publish)`; otherwise plain
sequential (no hookInput/resumeId, event-write errors propagate).
- Consumer re-ensures the `hook_received` event (keyed by resumeId/digest)
after event loading, before replay; skips when already preloaded.
- Preserves #3145: event-count guard, `preconditionReinvocations`,
in-process replay restart, `insertEventByEventId`.
- Removes #1834's resumeId-only test (never released); adds parallel +
consumer-preload + world-local dedup/producer-consumer suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

Additional Suggestion:

Replay-level dedup in the hook consumer reads resumeId from event.eventData instead of the top-level event.resumeId, so seenResumeIds is never populated and duplicate resume deliveries are never dropped.

Fix on Vercel

karthikscale3and others added 8 commits July 31, 2026 15:40
…nreleased #1834 docs/changeset
- hook.ts: dedup hook_received replay on top-level event.resumeId (the
backend now hoists it to a first-class column), with the legacy nested
eventData.resumeId retained as a deprecated parse-only fallback.
- workflow.test.ts: cover dedup across both top-level and legacy nested forms.
- resume-hook.ts: emit producer recovery telemetry when a transient
event-write failure is swallowed on the parallel path.
- resume-hook.consumer-preload.test.ts: add terminal-run (consume) and
transient-conflict (rethrow/redeliver) re-ensure cases.
- Consolidate the two overlapping changesets into resilient-resume-hook.md
and delete the redundant lazy-hook-resumption.md.
- Docs: return type back to Promise<Hook> (resume-hook.mdx), rewrite the
resilience changelog to the final parallel/deduplicated design, and correct
the WORKFLOW_DISABLE_LAZY_HOOK_RESUME resilience wording.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nsistency
- changelog/index.mdx: update the changelog entry title.
- hook.ts: update the dedup comment label to "Lazy-resume dedup".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lazy-hook-resume work had been folded into #1834's pre-existing
`resilient-resume-hook.md` changeset. Give this PR its own changeset and
delete the superseded #1834 one, whose `resilientResume: true` flag promise
no longer holds (resumeHook() returns plain Promise<Hook>).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 63d8771, which deleted #1834's superseded changeset but did
not stage the replacement. Adds this PR's own changeset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore #1834's own artifacts that #3230 had rewritten:
- .changeset/resilient-resume-hook.md (restored verbatim)
- docs/.../changelog/resilient-resume.mdx (restored verbatim)
- docs/.../changelog/index.mdx (restored verbatim)
#3230 keeps only its own changeset plus the two docs its code/config genuinely
require: the resumeHook() Promise<Hook> return type (ResumedHook is removed
from the code) and the new WORKFLOW_DISABLE_LAZY_HOOK_RESUME env var.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preserve the resilient-resume contract from #1834 on the parallelized
resumeHook() fast path instead of dropping it:
- Restore the `ResumedHook` type (Hook + optional `resilientResume`) and its
exports (`@workflow/core/runtime`, `workflow/api`); resumeHook/resumeHookImpl
return `Promise<ResumedHook>`.
- Set `resilientResume: true` on the swallow-recover branch (transient direct
write failure + successful queue dispatch), absent on the happy/sequential
paths.
- Restore the producer OTEL convention `workflow.hook.resilient_resume` and the
consumer `workflow.hook.resilient_resume_materialized`, wired where the
consumer re-ensures the event.
- Restore the consumer `occurredAt` derivation from the resume ULID so the
materialized hook_received is dated to resume time, not queue-round-trip time.
- Fix the #3230 changeset's contradictory "Still returns Promise<Hook>" line and
update the resilient-resume changelog + resume-hook API reference to the
shipped parallel/dedup behavior.
- Port the #1834 failure-path coverage into resume-hook.parallel.test.ts
(non-retryable event-write rethrow, both-fail prioritizes the queue error,
resilientResume flag + payload delivery on the recovered path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

Reviewed the rebased head (59abb1cf2) end to end as the author of #1834/#674 — this is a genuinely better design than what it supersedes, and every contract we pinned in those review rounds is honored. Local verification: full build + typecheck clean, all 45 monorepo test tasks pass (including the new parallel/consumer-preload/world-local dedup suites), and the hook e2e tests pass against the nextjs-turbopack workbench dev server, where world-local's static capability + the same-deployment marker put the parallel path live in dev. (One unrelated env note: @workflow/utils get-port tests fail if something already holds :3000 — a leftover dev server on my machine, not this PR.)

Contracts from the predecessor rounds, verified honored:

  • EntityConflictError rebase contract (pinned in #1834): the consumer re-ensure converges a matching claim as success server-side — canonical event returned, spliced into the preload via order-preserving insertEventByEventId — and rethrows a real conflict so the queue redelivers instead of acking a delivery that may carry the only payload copy. Terminal rejections checked before the retryable classification, on both writers. Exactly the pinned behavior.
  • Replay dedup retained as defense-in-depth, now keyed off the hoisted top-level event.resumeId with the nested form as fallback, and the workflow.test.ts dedup test upgraded to cover both forms (one inline note on the fallback's reachability below).
  • occurredAt from the resumeId ULID retained on the re-ensure; ResumedHook/resilientResume public contract preserved (note: the PR description still says the flag is removed — it was reconciled back in d833e57; worth updating the description before merge).
  • The version-cutoff gate is gone, and good riddance: deleting QUEUE_HOOK_INPUT_MIN_VERSION retires the entire TODO(release) re-verification hazard. Two-sided explicit attestation (per-run consumer marker + response-only backend capability, freshness-guarded so a caller can't forge it through the public API) is structurally safer — even an early-published SDK keeps the parallel path off until the live server attests. The cross-deployment start() stamping the target's marker from the health-check probe also closes the executionContext-records-the-caller caveat I noted back in #1834.
  • The 409-mapping heads-up from the server-side review is addressed: the parallel path treats 409 as expected concurrency with a converging consumer, while the sequential path keeps the historical re-key to HookNotFoundError — with the distinction documented at isHookGoneError.
  • The oversized-payload sequential fallback resolves my #1834 note about payload size on the queue message, and WORKFLOW_DISABLE_LAZY_HOOK_RESUME is documented in runtime-tuning per repo policy. The changelog rewrite is honest about the fallback trading resilience for safety.

Three non-blocking inline notes (dead legacy fallback risk, a world-local error-classification divergence, a repo-reference nit) plus one body-level note:

E2E fault-injection coverage: #1834's e2e test (stub hook_received write → 500, assert the payload still arrives) was removed and not replaced at the e2e layer. The world-local producer/consumer test covers the mechanics in-process and the regular hook e2e now exercises the parallel happy path, but nothing end-to-end exercises the recovery path (direct write fails → consumer re-ensures) through a real server boundary anymore. The old setWorld stubbing approach would be simpler now — no version simulation needed, since dev attests via the static capability + marker. Fine as a fast-follow.

Release-gate bookkeeping now satisfied on merge: the server-side dedup (deployed) + this PR are the two halves the #1834 gate was waiting for, so the "hold Version Packages" instruction dissolves once this lands.

Comment threadpackages/core/src/workflow/hook.ts Outdated
// deprecated — new writes populate the top-level field.
const resumeId =
event.resumeId ??
(event.eventData as { resumeId?: unknown }).resumeId;

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.

Non-blocking: the legacy nested fallback may be dead code on the paths it claims to serve. HookReceivedEventSchema.eventData no longer declares resumeId, and zod strips undeclared keys — so any event that round-trips through EventSchema.parse (world-local reads events from disk via readJSONWithFallback(..., EventSchema, ...); anything else that validates through the schema) loses the nested key before this consumer ever sees it. The unit test passes because it constructs Event objects in memory, bypassing parsing.

The blast radius is tiny — only events written by unreleased main builds (preview deployments) ever carried the nested form — but the comment overpromises. Either re-declare resumeId: z.string().optional() in the eventData schema (marked deprecated) so the fallback actually survives parsing, or drop the fallback and say the nested form is only honored for events that never re-parse. Both are fine; the current halfway state is the only wrong option.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f9a62f — dropped the fallback rather than re-declaring it in the schema. Dedup now keys solely off the top-level event.resumeId column; the consumer no longer reads eventData.resumeId. You're right that the nested form was dead on any parsed path (it was only ever written by unreleased preview builds and EventSchema strips it), so there was nothing real to preserve.

Repointed the replay dedup test (workflow.test.ts) accordingly: event-1 now duplicates event-0's top-levelresumeId (still must collapse), and the nested-form case is gone, so it no longer relies on constructing unparsed Event objects to exercise a path that can't survive parsing. Updated the PR description to drop the "retained as a deprecated fallback" line.

claim.payloadDigest !== params.resumePayloadDigest
) {
throw new EntityConflictError(
`hook_received resumeId "${params.resumeId}" already recorded with a different payload`

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.

Non-blocking, cross-world divergence: the digest/hookId mismatch throws EntityConflictError here, but the producer's parallel path swallowsEntityConflictError as expected convergence and reports resilientResume: true — so on world-local, idempotency-key reuse (a caller bug) would be silently classified as a successful resume delivering the old payload, whereas world-vercel surfaces the server's 422 hook-resume-id-reuse as a thrown error. Unreachable through the public API today (resumeIds are minted internally by a monotonic ULID factory), so this is about world parity, not a live bug.

A distinct error type (or reusing whatever world-vercel maps the 422 to) would make the two worlds agree that key reuse is an error, not convergence — and would keep the consumer's re-ensure from retry-looping on it via redelivery, since a mismatch can never heal. Fine as a follow-up.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and deferring to a follow-up as you suggested. This is world parity, not a live bug: resumeIds are minted internally by a monotonic ULID factory, so idempotency-key reuse is unreachable through the public API today. The follow-up will give world-local a distinct error type for (runId, resumeId)-with-mismatched-digest reuse (mapping to whatever world-vercel surfaces the 422 hook-resume-id-reuse as), so both worlds agree that key reuse is an error rather than convergence — and so the producer's EntityConflictError swallow doesn't classify a genuine mismatch as a resilient resume, and the consumer's re-ensure doesn't retry-loop on an unhealable mismatch. Tracking separately so this PR stays scoped to latency.

Comment threadpackages/world-vercel/src/index.ts Outdated
// (response-only, recomputed every by-token read). This lets a server
// rollback or kill switch drop new resumes to the sequential path
// immediately, without a redeploy of this adapter. See
// vercel/workflow-server#685.

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.

Nit: this references a workflow-server PR from the public repo. There's precedent just above (#484 on line 39), so this may be accepted practice — but when shipping #674/#1834 we deliberately kept workflow-server references out of the public repo, since the link 404s for anyone outside the org. "See the server's (runId, resumeId) dedup constraint and its by-token attestation" carries the same information without the dead link. Your call; flagging for consistency either way.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2f9a62f — dropped the vercel/workflow-server#685 link. The note keeps the same information ("the server enforces the (runId, resumeId) dedup constraint … attested per-lookup via Hook.resumeCapabilities.hookResumeDedupVersion") without the dead link, matching how we kept server refs out when shipping #674/#1834. Left the pre-existing #484 reference on line 39 alone to avoid unrelated churn.

karthikscale3and others added 2 commits July 31, 2026 17:57
…link
- Drop the legacy nested `eventData.resumeId` fallback in the hook consumer.
The nested form was only ever written by unreleased preview builds and is
stripped by `EventSchema` parsing (the `hook_received` eventData schema does
not declare it), so the fallback was dead code. Dedup now keys solely off the
top-level `event.resumeId` column. Repoint the replay dedup test to the
surviving top-level path (it previously exercised the nested form only by
building unparsed Event objects in memory).
- Remove the internal workflow-server PR reference from world-vercel's
capability note (the link 404s outside the org); the note keeps the same
information without the dead link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 31f92df (AI decision).

This is a latency optimization and new capability protocol, not a stability fix: it adds a parallel event-write/queue-publish fast path, a new WORKFLOW_DISABLE_LAZY_HOOK_RESUME env var, new exported types/constants (ResumedHook, HOOK_RESUME_INPUT_VERSION, HOOK_RESUME_DEDUP_VERSION, HookResumeCapabilitiesSchema), a new hookResumeDedup world capability, and a new (runId, resumeId) dedup contract — carrying minor bumps for five packages. It also builds on unreleased main-only work (#1834's resilient resume and #3145) and requires corresponding server-side dedup support, so it cannot stand alone on the maintenance line.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

31f92df10d295cf09c93aadd35380209c137326c

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.

3 participants

@karthikscale3@TooTallNate@VaguelySerious