Reuse resolved encryption keys and simplify payload key handling - #3113
Reuse resolved encryption keys and simplify payload key handling#3113NathanColosimo wants to merge 7 commits into
Conversation
🦋 Changeset detectedLatest commit: 6574d45 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
🧪 E2E Test Results❌ Some tests failed ❌ Failed E2E Tests📦 Local Production (2 failed)nextjs-turbopack-stable (1 failed):
nextjs-webpack-stable (1 failed):
E2E Test SummarySummary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
❌ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
✅ vercel-multi-region
|
📊 Workflow Benchmarkscommit Backend:
📜 Previous results (6)bd1330fTue, 28 Jul 2026 19:49:15 GMT · run logs
3765aefMon, 27 Jul 2026 21:19:47 GMT · run logs
fc47b54Mon, 27 Jul 2026 20:50:08 GMT · run logs
8213433Mon, 27 Jul 2026 20:06:10 GMT · run logs
706703bMon, 27 Jul 2026 17:32:26 GMT · run logs
f96e55fSat, 25 Jul 2026 01:02:00 GMT · run logs
ℹ️ Metric definitions & methodologyBest/P75/P90/P99 deltas compare against the most recent benchmark run on 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 ( Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the |
fc47b54 to
3765aef
Compare
…n-key # Conflicts: # packages/core/src/runtime/run.ts # packages/core/src/runtime/start.test.ts
What
PayloadKeyinto suspension handling, including the terminal pending-queue drainRun.returnValueresolve its key from the completed or failed run record it already fetched, instead of reading the same run twiceserialization/encryption.tsfrom 322 to 197 lines without changing its wire formats or cryptography{ kind: 'seal' } | { kind: 'run' }capability shapesaesKeyOfandopenSealedEnvelopehelpers plus defensive tests for values outside the typed APImain(a09d00135)The full PR is now
+202/-239againstmain(net-37).Why
Two duplicate operations exist on current
main:workflowEntrypointresolves the full runPayloadKeybefore replay. When replay suspends,handleSuspensionindependently callsgetEncryptionKeyForRun(run)and imports another AES key even though the caller already has the stronger full capability. The same duplication happens whenrunWorkflowdrains unawaited queue items at workflow completion or failure.Run.returnValuepollsworld.runs.get(runId). Once the run is completed or failed, its lazy key resolver callsworld.runs.get(runId)again solely to pass that same entity togetEncryptionKeyForRun.The key is immutable for a run, so the invocation and the in-process
Runhandle can reuse data already in hand. Nothing is cached globally or serialized.The serialization layer also represented its two SDK-owned capabilities with unrelated
Symbol.forbrands, then needed repeated object/null/brand checks to recover their types. A string discriminant crosses realms without special machinery and gives the compiler one exhaustive state model:CryptoKey: legacy symmetric read/write capability{ kind: 'run', aes, keyPair, aad }: owning run; symmetric read/write plus sealed reads{ kind: 'seal', recipientPublicKey, aad }: write-only sealed capabilityThe legacy bare
CryptoKeyremains unchanged to avoid turning this cleanup into an API migration.Current
start()behaviorThe earlier version of this PR seeded the
Runreturned bystart()with the encryption value used for the initial arguments. That is intentionally removed.The capability probe now normally returns the target run public key.
start()turns that into a write-only sealing capability withsealTo(...); it does not hold the symmetric key or X25519 private scalar needed to read return values or sealed stream data. Caching that value onRunwould therefore be incorrect.For modern cross-deployment starts, the public-key probe already removed the old
run-keyAPI request fromstart(). If the caller later awaitsrun.returnValue, one read-key lookup is still necessary so the caller can decrypt the result. This PR does not remove that necessary lookup.Exact savings
Workflow suspension
For a normal Vercel workflow invocation, the run executes on its owning deployment. Reusing the invocation
PayloadKeyremoves a second local per-run HKDF derivation and AES key import during each suspension-handling operation. It does not normally remove a network request because the owning deployment derives its run key locally. A World implementation that resolves keys remotely would also avoid that second remote lookup.Terminal pending-queue drain
A workflow that completes or fails with unawaited hooks, waits, attributes, or abort work synthesizes a final suspension so those events are persisted.
runWorkflowalready has thePayloadKey; passing it into that final suspension removes the same redundant derivation/import there. No saving occurs when the pending queue is empty because the drain exits before suspension handling.Run.returnValueWhen a run reaches
completedorfailed, reuse of the polledWorkflowRunremoves one redundantworld.runs.getbackend read. Key resolution itself still occurs once. For a cross-deployment handle, the necessary Vercel run-key API request still occurs once; for a same-deployment handle, the key is derived locally. In-progress polling is unchanged.Deliberate limits
RunhandlesRunreconstructed bygetRun()until that handle resolves its key lazilyencrorencpbytes, key derivation, sealed-payload behavior, deployment routing, or cryptographic operationsVerification
main(a09d00135)@workflow/corebuild and typecheck passed@workflow/coretests: 75 files; 1,651 passed; 3 expected failuresgit diff --checkpassed