Skip to content

Make multipart content collection linear - #7157

Merged
tim-smart merged 3 commits into
mainfrom
claude/multipart-perf-pass
Aug 9, 2026
Merged

Make multipart content collection linear#7157
tim-smart merged 3 commits into
mainfrom
claude/multipart-perf-pass

Conversation

@tim-smart

@tim-smart tim-smart commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Performance pass over the multipart parsing stack (MultipartParser internals and the Multipart wrapper), driven by CPU profiles and a new benchmark.

Fix: byte collection was quadratic. The collector reallocated and copied the entire accumulator on every pull, so File.contentEffect cost O(n²) in the number of upstream chunks. The linear collector now lives at Channel.mkUint8Array, buffers chunks, and concatenates once. Stream.mkUint8Array, Stream.mkArrayBuffer, and multipart file collection all reuse it. Collecting a 16MiB file delivered in 64KiB per-pull batches goes from 272ms to 3.5ms (~78x); all other measured paths are unchanged.

The public Channel.mkUint8Array documentation records that source channels must not reuse or mutate emitted buffers before collection completes. A Channel regression covers several arrays across multiple emitted chunks, and a multipart regression collects exact file bytes through File.contentEffect across multiple pulls and a split trailing-boundary seam.

case (median) before after
contentEffect, 16MiB file, per-chunk pulls 272ms 3.5ms
contentEffect, 16MiB file, single batch 2.6ms 2.7ms
channel drain, 16MiB file 0.99ms 0.99ms
raw parser, 16MiB file / 64KiB chunks 0.96ms 0.97ms
raw parser, 100 small fields 96µs 96µs

Simplification: header parser character tables. The two ~400-line array literals are now loop-built Uint8Arrays. Their parser accept/reject behavior is exhaustively equivalent across all 256 byte values and benchmark-neutral. The old name table stopped at byte 126, so bytes 127–255 changed from missing entries to zeroes; both representations reject those bytes because the parser accepts only entries equal to 1.

Added packages/effect/benchmark/http/multipart.ts covering the raw parser, channel drain, and content collection. The non-streaming collection cases are single-pull controls because Stream.fromArray batches their chunks; the rechunk(1) streaming case is the regression guard for accumulation across pulls.

Profiled but left alone

  • The raw parser spends ~77% of its time in Node's native Buffer.indexOf boundary scan and runs at ~17GB/s; instrumentation showed the chunk-seam concat path copies only ~0.1MiB per 16MiB parse, so no rewrite is warranted.
  • The fields path (~1M parts/s) is dominated by header parsing; a manual ASCII key decoder and other micro-tweaks measured neutral-to-negative and were dropped.

Validation

  • Channel, Stream, and Multipart suites: 366/366
  • Headers suite: 7/7
  • Root pnpm check
  • Oxlint and dprint on changed files
  • changeset status --since origin/main
  • Multipart benchmark executed end-to-end after sharing the Channel collector
  • 300-iteration fuzz comparing collected file bytes against the source across random chunk splits

🤖 Generated with Claude Code

Multipart.collectUint8Array reallocated and copied the full accumulator
on every pull, making contentEffect quadratic in the number of upstream
chunks. Buffer the chunks and concatenate once instead.

Also replaces the multipart header parser's character tables with
loop-built Uint8Arrays (identical entries, verified byte for byte) and
adds a multipart benchmark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 63b96fe

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

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest 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

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.92 KB 6.92 KB 0.00 KB (0.00%)
batching.ts 9.72 KB 9.72 KB 0.00 KB (0.00%)
brand.ts 6.60 KB 6.60 KB 0.00 KB (0.00%)
cache.ts 10.59 KB 10.59 KB 0.00 KB (0.00%)
config.ts 20.91 KB 20.91 KB 0.00 KB (0.00%)
differ.ts 19.77 KB 19.77 KB 0.00 KB (0.00%)
http-client.ts 21.52 KB 21.52 KB 0.00 KB (0.00%)
logger.ts 10.81 KB 10.81 KB 0.00 KB (0.00%)
metric.ts 8.86 KB 8.86 KB 0.00 KB (0.00%)
optic.ts 6.68 KB 6.68 KB 0.00 KB (0.00%)
pubsub.ts 14.86 KB 14.86 KB 0.00 KB (0.00%)
queue.ts 11.54 KB 11.54 KB 0.00 KB (0.00%)
schedule.ts 10.71 KB 10.71 KB 0.00 KB (0.00%)
schema-class.ts 19.48 KB 19.48 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.41 KB 29.41 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.63 KB 25.63 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.55 KB 13.55 KB 0.00 KB (0.00%)
schema-string.ts 11.09 KB 11.09 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.38 KB 15.38 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.52 KB 21.52 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.00 KB 24.00 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.74 KB 18.74 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.57 KB 18.57 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.43 KB 18.43 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.59 KB 22.59 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.08 KB 19.08 KB 0.00 KB (0.00%)
schema.ts 18.73 KB 18.73 KB 0.00 KB (0.00%)
stm.ts 12.59 KB 12.59 KB 0.00 KB (0.00%)
stream.ts 9.67 KB 9.67 KB 0.00 KB (0.00%)

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

ℹ️ Minor suggestions only — the collection is now linear and the header tables are byte-for-byte equivalent; two optional follow-ups below.

Reviewed changes

  • Linear collection of file content (packages/effect/src/unstable/http/Multipart.ts): collectUint8Array now buffers emitted chunks and concatenates once instead of copying the whole accumulator on every pull, taking File.contentEffect from O(n²) to O(n) in the number of upstream chunks.
  • Loop-built header character tables (packages/effect/src/unstable/http/MultipartParser/internal/headers.ts): the two large array literals were replaced with Uint8Array(256) built from the RFC 7230 token/value character sets.
  • New benchmark (packages/effect/benchmark/http/multipart.ts) covering the raw parser, the channel drain path, and content collection in both single-batch and per-pull modes.

I verified the two central claims directly: I extracted the old character tables from the diff and compared them against the new loop-built construction — 0/256 differing entries for both (the old 127-entry name table returned undefined for bytes ≥ 127, which the parser treats identically to the new 0), and I read Channel.runFold / runForEach / runWith to confirm the rewrite preserves semantics (same run machinery, same element granularity, same error/interruption propagation). I also ran Multipart.test.ts (10 pass), Headers.test.ts (7 pass), the package typecheck, oxlint + dprint on the changed files, and the new benchmark end-to-end (runs clean; ~1.6ms parser / ~4–5ms collect for 16MiB, matching the claimed scaling).

ℹ️ Coverage: the new collection path isn't pinned by a committed test

The only committed test that exercises contentEffect is a single 5-byte single-chunk case (test/unstable/http/HttpServerRequest.test.ts:132). The PR body describes a 300-iteration random-chunk-split fuzz that validated byte-level equivalence, but it wasn't committed. Since this rewrite is the behavioral core of the fix, a committed multi-chunk content-collection test (even a couple of fixed chunk splits, or the fuzz with a seeded RNG) in test/unstable/http/Multipart.test.ts would pin the new collector against regressions and double as the regression test for the quadratic bug this PR fixes.

Technical details
# Commit a content-collection regression test

## Affected sites
- packages/effect/test/unstable/http/Multipart.test.ts — no test feeds multiple upstream chunks through `File.contentEffect` / `collectUint8Array`
- packages/effect/test/unstable/http/HttpServerRequest.test.ts:132 — only existing `contentEffect` coverage ("hello", 5 bytes)

## Required outcome
- A test that builds a multipart body whose file payload is split across multiple chunks (e.g. arbitrary splits, or a small seeded fuzz over chunk boundaries), collects it via `part.contentEffect`, and asserts the bytes match the source exactly — including a slice that straddles the trailing boundary delimiter (the seam case).

## Suggested approach (optional)
- Reuse the `Multipart.makeChannel` + `Stream.pipeThroughChannel` idiom already present in `Multipart.test.ts`, chunking a fixed payload at varied boundaries and across the trailing boundary delimiter.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/effect/src/unstable/http/Multipart.ts Outdated

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

✅ No new issues found — the follow-up commit addresses both items from the prior review, and the incremental changes are clean.

Reviewed changes

  • Simplified collectUint8Array (packages/effect/src/unstable/http/Multipart.ts:644-667): the Effect.suspend + captured elements/length state from the previous revision is now a Channel.runFold into a lazy Array<Uint8Array> accumulator with a single trailing concat pass — same O(n) behavior, no shared state across runs (LazyArg init), and identical error/interruption propagation through runFold's underlying runWith machinery. The added JSDoc gotcha (source channel must not reuse/mutate emitted buffers) accurately documents the retained-to-completion aliasing.
  • Committed multi-pull regression test (packages/effect/test/unstable/http/Multipart.test.ts:41-67): feeds three chunks through Stream.rechunk(1), splits the file content (abcdef) across chunks 1–2 and the trailing \r\n--boundary--\r\n delimiter mid-boundary across chunks 2–3, then asserts File.contentEffect yields the exact bytes. This directly pins the across-pulls accumulation, not just a single-batch case. Verified again at 11/11 pass.
  • Changeset relocated to .changeset/ root: the original placement under .changeset/pre/ was in the archive directory where "Version Packages" renames consumed changesets (see the R100 renames in fb75264aa); pending changesets belong at the root (as with safe-json-schema-patterns.md). The move is a correction, not a regression.
  • Comment-only updates to the benchmark (clarifying which cases are single-pull controls vs. the rechunk(1) regression guard) and to the header tables (documenting the 127–255 zero-fill rationale).

Validation this run: Multipart.test.ts 11/11, Headers.test.ts 7/7, tsc -b on packages/effect, dprint + oxlint on the four changed files — all clean.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@tim-smart
tim-smart enabled auto-merge (squash) August 9, 2026 21:17
@tim-smart
tim-smart merged commit d901928 into main Aug 9, 2026
18 of 19 checks passed
@tim-smart
tim-smart deleted the claude/multipart-perf-pass branch August 9, 2026 21:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant