RFC: gzip-compress serialized payload refs (specVersion 5)#2394
RFC: gzip-compress serialized payload refs (specVersion 5)#2394pranaygp wants to merge 4 commits into
Conversation
Add a composable 'gzip' format prefix layer to the serialization pipeline (compress before encrypt: encr(gzip(devl))), cutting stored payload bytes by ~70-87% on real-world-style workloads. Compression is gated on run specVersion 5 (new SPEC_VERSION_SUPPORTS_COMPRESSION) and on target-deployment capabilities for cross-deployment writes; payloads under 1KB or that don't compress meaningfully are stored unchanged. Reads dispatch on the format prefix so both compressed and uncompressed data are always readable. WORKFLOW_DISABLE_COMPRESSION=1 disables writes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 78a9a3e The changes in this PR will be included in the next version bump. This PR includes changesets to release 21 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 |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Nitro stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests▲ Vercel Production (2 failed)example (1 failed):
nextjs-turbopack (1 failed):
Details by Category❌ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new composable gzip serialization format prefix in @workflow/core to gzip-compress serialized payload refs before encryption, gated behind specVersion 5 to keep compatibility explicit. It also bumps @workflow/world’s SPEC_VERSION_CURRENT to 5 and wires compression gating through runtime write paths (start args, step I/O, errors, hooks), including cross-deployment capability probing.
Changes:
- Add a gzip compression/decompression layer to the serialization pipeline and integrate it across workflow/step/client serialization + hydration paths.
- Bump spec version to 5 (
SPEC_VERSION_SUPPORTS_COMPRESSION) and export it from@workflow/world, with unit tests validating the compatibility contract. - Add capability-table gating for cross-deployment payload writes, plus benchmarks and test coverage for compression behavior.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/world/src/spec-version.ts | Adds spec v5 constant for compression and bumps SPEC_VERSION_CURRENT to 5. |
| packages/world/src/spec-version.test.ts | Tests new spec constants and requiresNewerWorld contract (incl. v4-reader simulation). |
| packages/world/src/index.ts | Re-exports SPEC_VERSION_SUPPORTS_COMPRESSION. |
| packages/core/src/workflow.ts | Gates workflow return-value payload compression on run specVersion >= 5. |
| packages/core/src/serialization/types.ts | Adds SerializationFormat.GZIP format prefix constant. |
| packages/core/src/serialization/step.ts | Applies compress-before-encrypt on step serialization; decompress-after-decrypt on reads. |
| packages/core/src/serialization/index.ts | Re-exports compression utilities from serialization module. |
| packages/core/src/serialization/compression.ts | Implements composable gzip wrapper + conditional compression thresholds/kill switch. |
| packages/core/src/serialization/compression.test.ts | Adds tests for compression layer behavior, nesting with encryption, hydration, and capability gating. |
| packages/core/src/serialization/codec.ts | Adds compression?: boolean option to write-side serialization options. |
| packages/core/src/serialization/client.ts | Applies compress-before-encrypt and decompress-after-decrypt for client-mode serializer. |
| packages/core/src/serialization.ts | Threads compression flag through dehydrate APIs; adds read-side decompression where appropriate. |
| packages/core/src/serialization-format.ts | Adds browser-safe gzip detection and sync/async hydration support for gzip-prefixed values. |
| packages/core/src/runtime/suspension-handler.ts | Gates step argument/metadata compression on run specVersion >= 5. |
| packages/core/src/runtime/step-handler.ts | Gates step error/output compression on step entity specVersion >= 5. |
| packages/core/src/runtime/step-handler.test.ts | Updates expectations for new dehydrateStepError call signature/args. |
| packages/core/src/runtime/step-executor.ts | Threads run specVersion into step execution and gates compression accordingly. |
| packages/core/src/runtime/start.ts | Adds cross-deployment capability probing for gzip support; compresses workflow args when safe. |
| packages/core/src/runtime/resume-hook.ts | Gates hook payload compression on run specVersion and deployment capabilities. |
| packages/core/src/runtime.ts | Gates run error compression where run specVersion is available; threads run specVersion into step execution. |
| packages/core/src/capabilities.ts | Adds gzip to FORMAT_VERSION_TABLE with min core version gating. |
| packages/core/scripts/benchmark-compression.mjs | Adds deterministic benchmark script to measure compression savings on representative workloads. |
| .changeset/gzip-ref-compression-world.md | Changeset for @workflow/world spec version bump to 5. |
| .changeset/gzip-ref-compression-core.md | Changeset for @workflow/core gzip-compressed serialized payload refs feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Whether the current runtime can compress/decompress. CompressionStream | ||
| * is a web standard available in Node.js 18+, browsers, and edge | ||
| * runtimes; this guard exists for exotic runtimes only. | ||
| */ | ||
| function isCompressionAvailable(): boolean { | ||
| return ( | ||
| typeof CompressionStream === 'function' && | ||
| typeof DecompressionStream === 'function' | ||
| ); | ||
| } |
| if (!(data instanceof Uint8Array)) return data; | ||
| if (peekFormatPrefix(data) !== SerializationFormat.GZIP) return data; | ||
|
|
||
| if (!isCompressionAvailable()) { |
| /** | ||
| * Synchronously gunzip a payload when running on Node.js. | ||
| * | ||
| * This module is browser-safe, so `node:zlib` is resolved dynamically via | ||
| * `process.getBuiltinModule` (no static Node dependency, invisible to | ||
| * browser bundlers). Returns `undefined` when sync decompression isn't | ||
| * available in the current runtime — callers fall back to leaving the | ||
| * data un-hydrated (the async `hydrateDataWithKey` path handles | ||
| * decompression in browsers via `DecompressionStream`). | ||
| */ |
TooTallNate
left a comment
There was a problem hiding this comment.
Approve — the right design at the right seam; three concrete convergence asks with the snapshot-runtime compression work
I built the branch, ran all the suites locally (20 compression tests, 55 world, 1209 core — all green), and reproduced the benchmark table exactly (deterministic/seeded as claimed, including the 402.1 KB → 108.4 KB simulated agent run). The design fundamentals are correct and well-argued:
- SDK-side, pre-encryption placement is the only placement that works —
encr(gzip(devl)), verified instep.ts/client.tswherecompress()runs between prefixing andencryptData(). This matches the layering the snapshot-runtime compression work (#1300) arrived at independently, for the same reason: ciphertext doesn't compress. - The conditional logic is production-minded: 1 KB floor, ≥5% savings requirement (so already-compressed binary never pays a permanent decompression tax), env kill switch that only affects writes. The benchmark's "random binary still wins 24.7%" result — gzip clawing back devalue's base64 4/3× penalty almost exactly — is a great catch and a real argument for compressing even apparently-incompressible payloads.
pipeThroughTransformgets the classic CompressionStream deadlock right (write-before-read with a handled mirrored rejection) — large payloads would wedge a naiveawait write(); read()sequence.- specVersion 5 as the compatibility contract is the honest mechanism: old SDKs get a typed
RunNotSupportedErrorup front instead of per-payload format errors. I verified the gating at every call site in the table (start's probe-AND-spec gate,runSpecVersionthreading throughStepExecutorParams, resume-hook's target-spec-AND-capability gate matching theencrprecedent). Community worlds are unaffected sincestart()derives the run's spec fromworld.specVersion— a spec-4 world keeps creating spec-4 runs with no compression. The local-dev writer-stamped V1-step-handler edge is honestly documented and matches the existingencr/framing behavior. - The
TODO(release)cutoff (5.0.0-beta.16) is correct as of today (beta.15 shipped yesterday) — and the marker pattern has already proven its worth twice on the framing PR.
Convergence with #1300 (snapshot-runtime compression) — three asks
#1300 carries its own packages/core/src/serialization/compression.ts with a different shape: sync node:zlib codecs, zstd-preferred with gzip fallback (feature-detected at zlib.zstdCompressSync, Node ≥ 22.15), prefix-dispatched reads. On its 8 MB QuickJS heap snapshots, zstd-3 beats gzip-6 on ratio (4.29× vs 4.02×) and compress speed (18 ms vs 127 ms — 7×). These two modules will collide at the same path, and the second to land has to reconcile. Asks:
-
Reserve the
zstdprefix now. AddZSTD: 'zstd'toSerializationFormat(bothserialization/types.tsand the browser-safeserialization-format.tscopy) even with no write path using it here. Cost: two lines. Benefit: the format namespace can't drift between the two branches, and a reader hitting a zstd payload from a snapshot-runtime writer gets a precise "zstd payload requires …" error instead of genericUnsupported serialization format. -
Fix the semantic conflict in the
GZIPconstant docs before they fork. This PR documentsgzipas "inner payload has its own format prefix"; #1300 documents it as "inner is raw bytes". Same prefix, contradictory contracts. Both are locally true — which is exactly the resolution: compression prefixes mark the codec only; inner structure is caller-defined (refs recurse intohydrateData, snapshots hand raw bytes to the snapshot loader). One sentence of wording alignment now saves a confusing archaeology session later. -
Plan the module merge as one file, two APIs. The natural shape: this PR's async conditional gzip API for browser-reachable payload refs, plus #1300's sync zstd-preferred API for Node-only consumers — and the browser-safety trick is already in this PR:
gunzipSyncIfAvailableresolvesnode:zlibviaprocess.getBuiltinModule, which is precisely how the merged module can host sync zstd codecs without growing a static Node dependency.
On gzip-only for payload refs: I think it's the right call, but the stated reason should be sharpened. The out-of-scope note says zstd is "not a web standard" — the operative constraint is specifically the browser read path: the web o11y UI decompresses post-decrypt via DecompressionStream, which standardizes only gzip/deflate/deflate-raw. Snapshots never reach a browser, which is why #1300 can prefer zstd unconditionally and this path can't. Worth a sentence in the module docs, because it's the criterion a future "add zsd1 here?" decision turns on: when browsers ship zstd in DecompressionStream (or a wasm decoder becomes acceptable in the web bundle), the prefix system makes it a drop-in for refs too. Node-side availability is already a non-issue for writes.
Smaller notes
pipeThroughTransform/gunzipAsyncare duplicated betweencompression.tsandserialization-format.ts. If deliberate (keepingserialization-format.tsdependency-free for browser bundles), a cross-reference comment would prevent drift; the module-merge in ask 3 is the natural moment to unify.- On the world-local open question: I'd keep it uniform (as this PR does). The "local files were greppable" ship sailed at spec v2 base64-encoding;
hydrateDataalready gives the CLI sync decompression, so a--raw-style inspection view is cheap if local-debugging demand materializes. Option B (CBOR for world-local) is attractive but orthogonal — neither should block this. - The rare uncompressed error-write paths (max-deliveries, replay-budget) are fine as-is — error payloads under 1 KB would pass through anyway.
CI
Still in progress as I write this. The nextjs-webpack dev lane and express prod lane failures match this cycle's known baseline flakes; the nextjs-turbopack canary failure has no logs yet — worth a look once the run completes, since canary lanes exercise the freshest Next integration against these serialization changes. Flagging for a re-check, not blocking on it given the full e2e suite passed locally per the validation notes (and my local unit runs corroborate).
Approving — please land the zstd prefix reservation and the constant-docs alignment with this PR; the module merge can be whoever-lands-second's job as long as both sides know the plan.
|
Amending one conclusion from my review after discussing with Nathan: I framed the browser read path as the constraint keeping this gzip-only — that's a soft constraint, not a hard one. The web o11y UI only ever reads payloads, so the browser story needs only a decoder, and a wasm zstd decoder is a solved problem — Nathan has a working implementation with exactly the right shape: That changes the zstd calculus from "blocked on web standards" to "a follow-up with known cost":
Why it's worth the follow-up: compression runs at every step boundary, so write-side CPU is a per-step tax — the snapshot-runtime benchmarks showed zstd ~7× faster than gzip at compression with a better ratio, and the ratio delta compounds with this PR's motivating workload (the same growing payload re-serialized N times). The wasm asset cost (~133 KB, lazy-loaded only when a zstd payload is actually encountered) is modest for an o11y dashboard. None of this blocks the current PR — the prefix system is what makes the whole sequence migration-free, which is the strongest argument for the design as submitted. But "zstd: out of scope" should read as "zstd: staged follow-up with a working decoder in hand," and the module-merge with the snapshot-runtime compression work (ask 3 in my review) is the natural vehicle for step 2's Node side. |
Split the compression benchmark into reproducible size and CPU scripts sharing deterministic workloads (lib/workloads.mjs). The CPU benchmark measures serialize/deserialize overhead per payload, total CPU across thousands of events, and compares gzip levels/brotli/deflate. Documents how to run the size, CPU, and end-to-end (bench.bench.ts) benchmarks against local and Vercel in scripts/README.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Track gzip payload compression on both the serialize (write) and
deserialize (read) paths via span attributes:
workflow.serialization.{operation,compressed,uncompressed_bytes,
stored_bytes,compression_ratio}. Sizes are measured at the compression
boundary (pre-encryption), so they reflect compression's effect rather
than the at-rest size.
The compression codec stays pure — compress/decompress optionally
populate a CompressionStats sink, threaded through CodecOptions to the
mode serializers and read by the dehydrate/hydrate wrappers, which set
attributes on the active span. Telemetry failures are swallowed so they
can never break the serialize/deserialize data path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RFC: Compress serialized payload refs with gzip
Summary
Worlds now store gzip-compressed payloads. Every serialized payload (step inputs/outputs, workflow arguments/return values, errors, hook payloads) is wrapped in a new composable
gzipformat prefix before it reaches the World storage layer, cutting stored payload bytes by ~70–87% on real-world-style workloads (benchmarks below). Compression is gated on a new specVersion 5 so the compatibility contract is explicit and testable.Motivation
The event log re-serializes full payloads at every step boundary — an AI agent workflow that threads a growing chat history through 10 steps stores the conversation 10 times. Payloads are devalue-encoded JSON-ish text, which is highly compressible, and several storage backends amplify the bytes further (DynamoDB inline refs and world-local JSON files base64-encode binary, a 4/3× penalty that compression also claws back). Smaller payloads also push more Vercel-world refs under the 3750-byte inline cutoff, which means fewer S3 round-trips on replay — a latency win, not just storage.
Design
Compression must live in the SDK, not the server
On Vercel, payloads are AES-256-GCM encrypted client-side with a per-run key before the world ever sees them. Encrypted bytes are incompressible, so server-side compression (e.g. in
remote-ref.tsS3/DynamoDB writes) would be a no-op for the dominant case. Compressing in@workflow/core's serialization pipeline — before encryption — is the only placement that works, and it benefits every world (vercel, postgres, local) from one seam.A composable format layer, mirroring encryption
The serialization pipeline already supports composable format prefixes (
devl,encr). This PR addsgzipas a sibling layer inpackages/core/src/serialization/compression.ts, mirroringencryption.ts:Because readers dispatch on the prefix at every layer, one read path transparently handles compressed, uncompressed, encrypted, and any nesting of the three — new SDKs read both old and new data structurally, not via special cases.
Conditional compression
WORKFLOW_DISABLE_COMPRESSION=1is a write-side kill switch. Reads are unaffected.specVersion 5: the compatibility contract
SPEC_VERSION_CURRENTis bumped to 5 (SPEC_VERSION_SUPPORTS_COMPRESSION). The contract:requiresNewerWorld()→RunNotSupportedErrormachinery — a clear, typed "this run requires a newer SDK" instead of a cryptic per-payload format error. Since v5 is still in beta, this is the natural cut point: the v4 SDK cannot read/write/cancel v5 runs, but the first non-beta v5 client handles both v4 and v5 runs.Write-side gating, per call site
start()workflow argumentsgzip)run.specVersion ≥ 5(run record in scope)run.specVersion ≥ 5threaded viaStepExecutorParams.runSpecVersionspecVersion ≥ 5(stamped by the same-deployment orchestrator)run.specVersion ≥ 5run_failed)run.specVersion ≥ 5where the run record is in scope; otherwise uncompressedresumeHook)run.specVersion ≥ 5AND target deployment capability (getRunCapabilities) — same pattern as the existingencrgateCross-deployment writes reuse the existing capabilities machinery: a
gzipentry inFORMAT_VERSION_TABLE(capabilities.ts) keyed on the target run'sworkflowCoreVersion, exactly likeencrandframedByteStreamsbefore it.Observability
hydrateData(sync, used by the CLI and server o11y on Node) decompresses synchronously vianode:zlibresolved throughprocess.getBuiltinModule— no static Node dependency, so the module stays browser-safe.hydrateDataWithKey(async, used by the web UI's decrypt flow) decompresses via the web-standardDecompressionStream, handlingencr(gzip(devl))and baregzip(devl).isCompressedData()helper mirrorsisEncryptedData()for UI affordances.Backwards/forwards compatibility
RunNotSupportedErrorKnown edge (documented tradeoff): in local dev, upgrading the SDK mid-run and continuing an old spec-4 run keeps its payloads uncompressed on the orchestrator paths (run-record gating) but the V1 step-handler path gates on the step entity's writer-stamped specVersion, which can compress step outputs of an old run after an upgrade. Deployed runs are pinned to their deployment (skew protection), so this cannot happen in production. The same writer-stamped behavior already exists for
encrand byte-stream framing.Benchmarks
All benchmark code lives in
packages/core/scripts/and is reproducible — shared deterministic workloads inlib/workloads.mjs, run instructions inscripts/README.md. Two dimensions: storage size and CPU cost.Storage size —
benchmark-compression-size.mjsRaw serialized payload bytes handed to World storage, compression off vs on:
Simulated 10-step AI agent run (event log total): 402.1 KB → 108.4 KB (73.0% smaller). Incompressible binary still wins 24.7% because devalue base64-encodes
Uint8Array(4/3×) and gzip recovers that. Backends that base64 binary (DynamoDB inline refs, world-local JSON) see ~33% larger absolute savings. Text workloads are seeded non-repetitive prose to avoid overstating ratios.CPU cost —
benchmark-compression-cpu.mjsCompression is a world-independent CPU cost on the serialize (write) and deserialize (read) paths — the same
@workflow/corecode runs before any World is touched. So these absolute numbers hold for every backend; the world only sets the baseline the cost is compared against. Real shipping path (WebCompressionStream('gzip')), µs per op, on an M-series laptop:Stress — thousands of events (≈6.6 KB e-commerce payload, ser+deser per event, modelling a long workflow + replay):
So even a 10,000-event run adds only ~1.1s of total CPU spread across its entire lifetime. Costs scale with payload size; the
<1 KBthreshold makes small payloads free.Algorithm comparison (informational,
node:zlibsync — not the shipping path): the script also compares gzip levels 1/6/9, brotli, and deflate. Headline finding:CompressionStream('gzip')runs at ≈ zlib level 6, but gzip level 1 is ~8× faster for only 2–3 pp less savings (e.g. AI chat: 123µs/71.1% vs 1051µs/74.0%).CompressionStreamexposes no level knob, so capturing that would mean dropping tonode:zlib(losing edge/browser portability) — filed as a follow-up, not done here.End-to-end runtime —
bench.bench.ts(pnpm bench:local)The existing stress-workflow harness, run twice against a local
nextjs-turbopackdev server — compression on (default) vs off (WORKFLOW_DISABLE_COMPRESSION=1), diffingbench-timings-*.json:End-to-end, compression's CPU is within run-to-run noise (±10%) — and net faster on the larger cases, because the highly compressible 10 KB payload shrinks enough that smaller filesystem IO outweighs the gzip CPU. (Caveat: the harness's
'x'.repeat(10240)payload is pathologically compressible; the microbenchmark above with realistic payloads is the cleaner CPU signal.) The takeaway: orchestration + queue + IO dominate per-step wall-clock, so the sub-millisecond gzip CPU disappears in the noise.Vercel
Now enabled.
@workflow/world-verceladvertisesspecVersion: 5(this PR), so new Vercel runs are created compressible — payloads on Vercel are nowencr(gzip(devl)). This is unblocked by the server-side companion vercel/workflow-server#520 (merged), which formally declared spec-5 support (payloads stay opaque to the server; the bump is the contract that lets the SDK stamp spec-5 runs). The ordering held: server first, then this SDK bump.Measurement: the
bench.bench.tsharness runs against a real Vercel labs deployment via theBenchmark Vercel (nextjs-turbopack / nitro-v3 / express)jobs in.github/workflows/benchmarks.ymlon every PR, comparing this branch against themainbaseline (spec-4, no compression) and posting the delta as a sticky PR comment — that comment is the Vercel compression result. For ad-hoc A/B on a real app, deploy the PR tarball and toggleWORKFLOW_DISABLE_COMPRESSION=1as a Vercel project env var (off baseline) vs unset (on);scripts/README.mddocuments the command. Expectation: the relative impact on Vercel is the smallest of any backend — a Vercel step's wall-clock is dominated by queue dispatch, AES-GCM encryption, S3/DynamoDB writes, and HTTP round-trips (100s of ms), so the sub-ms gzip CPU is a tiny fraction.Verified end-to-end against the
nextjs-turbopackworkbench (world-local): new runs are created withspecVersion: 5, large step outputs and error payloads appear on disk with thegzipprefix, small payloads stay as readabledevlpassthrough, and workflows complete and replay correctly.Observability
The serialize (write) and deserialize (read) paths emit OpenTelemetry span attributes so compression's impact shows up per-step in any OTel backend (incl. Vercel's), without manual storage inspection:
workflow.serialization.operationserialize(write) ordeserialize(read)workflow.serialization.compressedworkflow.serialization.uncompressed_bytesworkflow.serialization.stored_bytesworkflow.serialization.compression_ratioSizes are at the compression boundary (pre-encryption), so they measure compression's effect, not at-rest size (which adds the
encrenvelope + base64 on some backends). Attributes land on the active span — typically the dedicatedstep.dehydrate/step.hydratespan, otherwise the enclosing run/start span. The compression codec stays pure:compress/decompresspopulate aCompressionStatssink threaded throughCodecOptions; the dehydrate/hydrate wrappers set the attributes. Telemetry failures are swallowed and never affect serialization.A "Compressed / saved N%" badge in the web trace viewer (deriving from
hydrateDataWithKeyafter it peelsencr→gzip) is a possible follow-up; the attributes above are the foundation.Open question: should world-local compress? (please discuss)
This PR compresses uniformly across all worlds, including world-local — no special casing. That's a deliberate simplification, but it's up for debate:
fs.tsjsonReplacer), so compression doesn't change much in practice — base64(devalue) was already opaque to grep.devltext payloads as plain readable strings in the JSON files. Best DX for local debugging and LLM agents grepping run data.Either follow-up is cheap; the read path handles both formats forever regardless.
Out of scope / future work
CompressionStream('gzip')runs at ≈ level 6 and that level 1 (or zstd) would cut the CPU ~8× for ~2–3 pp less savings — butCompressionStreamhas no level knob, so this meansnode:zlib(loses edge/browser portability) or a futurezsd1codec. The open-ended prefix system means any such codec needs no migration machinery. gzip viaCompressionStream(Node 18+/browsers/edge, zero deps) is the portable baseline shipped here.remote-ref.ts(workflow-server): only worth revisiting if DynamoDB WCU data says so.Testing
compression.test.ts: round-trips,encr(gzip(devl))nesting order (white-box), small-payload passthrough, incompressible-discard, kill switch, mode serializers, o11y sync+async hydration, capability-table gating.spec-version.test.ts: spec 5 constants,requiresNewerWorldaccept/reject matrix, v4-reader simulation.@workflow/core(1209 tests),@workflow/world,@workflow/world-local,@workflow/world-vercel,@workflow/world-postgres,workflow,@workflow/cli.nextjs-turbopackdev server with compression active.packages/core/scripts/— seescripts/README.md.🤖 Generated with Claude Code