Repository navigation
v1.28.0: Brotli (br) response compression - #192
Merged
Merged
Conversation
Mechanical changes required by raising the SDK constraint to `>=3.13.5` (next commit, for `brotli_neo`): - `dart format`: the formatter style follows the language version. - `dart fix --code=prefer_initializing_formals`: private named parameters (`this._credential`). The public parameter names are unchanged, so no call site changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `brotli_neo` (pure Dart) for Brotli, next to GZip, negotiated with the
request's `Accept-Encoding` (q-values, `q=0`, `*`):
- JSON payloads (`AutoCompressSink`), other API responses
(`compressResponse`) and static files (`createCompressionMiddleware`).
- Static files cache: identity, `gzip` and `br` variants (transcoding
`gzip` to `br` when preferred).
- Falls back to the next accepted encoding if the Brotli encoder fails.
- Fix: JSON payloads of 512+ bytes were GZip-compressed even without
`Accept-Encoding: gzip`.
- `Accept-Encoding` parsed (was a substring match).
- `Vary: Accept-Encoding` on compressible responses.
- `APIServerConfig.compressionEncodings` and `brotliQuality`
(args, `server.*` config, JSON).
- `Content-Encoding: br` request payloads (`decompressPayload`), with
`maxPayloadLength` enforced while decoding.
- Dart SDK `>=3.13.5`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #192 +/- ##
==========================================
+ Coverage 95.55% 95.61% +0.05%
==========================================
Files 73 75 +2
Lines 25179 25747 +568
==========================================
+ Hits 24061 24619 +558
- Misses 1118 1128 +10
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- `APIBrotli.decode`: retry at `maxOutputSize` (the decoder stopped at the last buffer doubling below the limit, rejecting valid payloads). - ERROR path and `apiResponse2` branch: JSON not compressed in `resolveBody` (its headers were lost: a compressed body without `Content-Encoding`); compressed later by `compressResponse`. - `canCompressResponse`: no `1xx`/`204`/`206`/`304` or `Content-Range`. - Static files: high quality only for cacheable responses (`APIServerResponseCache.isCacheable`, `resolveCompression`). - Cache: failed conversions not retried; compressed `404` keeps its `Content-Encoding` and checks `Accept-Encoding`; `Accept-Encoding` parsed once per request. - `x-gzip` taken as `gzip`. - `resolveCompressionEncodings`: unknown encodings ignored (logged), defaults if none is known (was: compression silently disabled). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Review fixes (2a85474)A code review found 10 issues. All of them are fixed, each with a regression test:
Local runs: |
- `APIBrotli`: one-shot `brotliEncode`/`brotliDecode`. The decoder buffer grows up to `maxOutputSize`, so the `decodeInto` retry is no longer needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nsistency - `APIBrotli.encodeAsync`: a large Brotli encoding runs in `Isolate.run` (`isolateMinLength`: 128 KB at q9+, 256 KB at q6+, 1 MB below, ~5ms of inline encoding). Used by `compressResponse` and the cache alternatives; concurrent requests for the same cached file share one encoding. - API responses are compressed only by `compressResponse` (JSON included), with the headers of the final `Response`. - `compressResponse`: `HEAD` only gets `Vary` (the body isn't read); `Accept-Encoding` parsed once; the uncompressed length is set in the `Response.context` (`uncompressedContentLength`). - `APIServerResponseCache.isCacheable` uses the uncompressed length, as `resolveCompression` does (a file over the limit isn't cached at q5). - Cache: a content that can't be decoded (e.g., `deflate`) doesn't mark the target encodings as failed (`isConvertible`); an alternative encoded asynchronously is only cached if its entry is still cached. - `resolveCompressionEncodings`: `false` (bool or string), `off` and `no` disable the compression; single parsing loop. - `normalizeContentCoding`/`acceptsContentEncodingFrom` shared by the cache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- New `APIIsolatePool` (`bones_api_utils_isolate_pool.dart`): long-lived worker isolates (state kept between runs), at most `maxIsolates` (default: half of the processors, 1..4), FIFO queue when all are busy, workers idle for `idleTimeout` (default 30s) exit (the idle timer runs in the worker, so the pool never keeps the program alive), `RemoteError` on failure, a worker that exits is replaced, `close`. - `APIBrotli.encodeAsync` uses `APIBrotli.isolatePool` instead of `Isolate.run`: no per-encoding table initialization (~2.5ms at q9), and bounded concurrency (30 concurrent 256 KB q9 encodings: event loop stall ~2ms vs ~60-100ms with `Isolate.run`). `isolateMinLength` lowered to ~1.5ms of encoding: 32 KB at q9+, 64 KB at q6+, 256 KB below. - `APIBrotli.encoder` (`@visibleForTesting`): tests of the encoder failure fallbacks (`compressResponse`, `BrotliSink`, cache alternatives, isolate). - Fix: the failed encodings of a cached entry are shared with its alternatives (a `gzip` alternative created after a `br` failure preferred `br` again, failed `validate` and was re-created on each request). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `compressionIsolatePool` (replaces `APIBrotli.isolatePool`): one pool shared by Brotli and GZip. - New `APIGZip` (`encode`, `encodeAsync`, `isolateMinLength`): the `dart:io` zlib is native but synchronous, blocking the event loop. Large encodings run in the pool from ~1.5ms of work: 192 KB at level 9, 384 KB at 6+, 768 KB below. In the pool a GZip encoding costs the same as inline (+0.1ms); 30 concurrent 1 MB level 4: event loop stall ~2ms vs ~67ms inline. - `encodeContentAsync`/`APIResponseCompression.encodeAsync` (used by `compressResponse` and the static files cache) use `APIGZip.encodeAsync`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tests - `br` request payloads: decoded in the `compressionIsolatePool` (`APIBrotli.decodeAsync`, keeping the `BrotliException`), and limited to `APIServer.defaultMaxBrotliPayloadLength` (64 MB) when `maxPayloadLength` is not defined (a few bytes of Brotli can decode to GBs). - Pool size: `APIIsolatePool.defaultMaxIsolatesFor(workers:)` (half of the processors divided by the workers, 2..4); each `APIServerWorker` configures its pool (`configureCompressionIsolatePool`, only replaced if unused). - `APIIsolatePool`: a worker shutting down (`close`) while its computation fails to be sent now exits (it was kept alive). - Tests: pool (sizing, async computations, large data, distinct workers, idle/dispatch race, doesn't keep its isolate alive, close while spawning), `decodeAsync` and "decompression bombs", `configureCompressionIsolatePool`, `*` with `q=0`, `minLength`, status/headers/context kept, gzip-only server with a br-only client, `Future`/`Stream` payloads, HEAD of large JSON, br-only and no-compression servers (no `autoCompress`), pool sized by the workers, cache 304 and memory with alternatives, sink reuse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Brotli (
br) response compression next to GZip, usingbrotli_fast^1.0.0(pure Dart: no native library, no FFI, no package dependencies). It also fixes howAccept-Encodingis handled.Accept-Encodingis now parsed properly: q-values,q=0and*. Each response uses the best encoding fromAPIServerConfig.compressionEncodings(default[br, gzip]). On a tie, the server's order wins.compressResponse, using the headers of the final response.AutoCompressSinkgeneralizesAutoGZipSinkfor directresolveBodycallers.AutoGZipSinkis kept and works as before.createCompressionMiddleware).gzipandbrvariants per file. Agzipfile is converted tobrwhen the client prefers it.Vary: Accept-Encodingis sent with responses that can be compressed.Content-Encoding: brpayloads are decompressed (decompressPayload), andmaxPayloadLengthis enforced while decoding. IfmaxPayloadLengthisn't set, decoding stops at 64 MB (APIServer.defaultMaxBrotliPayloadLength), because a few bytes of Brotli can decode to gigabytes. Decoding runs in the isolate pool (APIBrotli.decodeAsync), so it doesn't block the event loop. A corrupted body still throws aFormatException.server.*config entry and a JSON field:compressionEncodings: an empty list,false,none,offornodisables compression.brotliQuality:0..11, default5. Cached static files use at least9.Fix: JSON was gzipped without
Accept-Encoding_jsonEncodePayloadgzipped every JSON payload of 512 bytes or more, whatever the client sent inAccept-Encoding. It now compresses only with an encoding the client accepts.APIServer.resolveBody/resolveBodySynccalled without the newcompressionparameter keep the old behavior (always GZip), for direct callers.Breaking: Dart SDK
>=3.13.5brotli_fastrequires it. Raising the SDK constraint changes the language version, so this PR also has to:prefer_initializing_formals(private named parameters). Public parameter names are unchanged, so no call site changes;languageVersiongoes from3.10.0to3.13.0).Those are in separate commits, so the feature commit (
v1.28.0: Brotli ...) can be reviewed on its own.Using
brotli_fastbrotliEncodeon the whole body, which is the library's fastest path. Its streaming encoder keeps all the input in memory anyway, and our bodies are already complete bytes.BrotliSinkbuffers the input, encodes on close, and falls back to GZip if the encoder fails.brotliDecode(..., maxOutputSize: ...). The decoder buffer grows up to the limit, so no retry is needed.Benchmark (AOT, 592 KB entity-list JSON,
brotli_fastvsdart:iogzip)Decoding (q5) takes 0.7 ms. Outputs are checked against the reference
brotliCLI in the tests.--brotli-quality 6is worth considering when bandwidth matters more than CPU.Isolate pool for compression
APIBrotli.encodeAsync,APIGZip.encodeAsyncandAPIBrotli.decodeAsyncrun incompressionIsolatePool, one pool shared by Brotli and gzip. It's a newAPIIsolatePool(lib/src/bones_api_utils_isolate_pool.dart):maxIsolatesworkers. Extra encodes wait in a FIFO queue, so 30 simultaneous large files never means 30 isolates.APIServerWorkers, between 2 and 4 (APIIsolatePool.defaultMaxIsolatesFor). Each worker sizes its own pool at startup (configureCompressionIsolatePool), so 16 server workers don't each start 4 encoder isolates.Isolate.run) rebuilds them every time, costing about +1.5 ms at q6 and +2.5 ms at q9.idleTimeout(default 30 s) is shut down and spawned again on demand. The idle timer runs inside the worker, and an idle worker's port doesn't keep the isolate alive, so the pool never keeps a program from exiting.RemoteError. A worker that dies is replaced, andclose()stops all workers, including one that is still starting.Benchmark (AOT, entity-list JSON, Apple Silicon with 10 cores, pool of 4).
One encode at a time, median:
Isolate.run30 encodes at once, total time / longest event-loop stall in the main isolate:
Isolate.run(30 isolates)A 1 ms periodic timer measures the stalls, so ~1.2 ms is the floor. Spawning and collecting 30 isolates blocks the main isolate for 21–112 ms. The pool keeps it under 2 ms, and its total time is a little longer only because it uses 4 of the 10 cores.
So an encode moves to the pool once it would take about 1.5 ms inline (
APIBrotli.isolateMinLength): 32 KB at q9+, 64 KB at q6+, 256 KB below. Smaller encodes stay inline. Concurrent requests for the same cached file share one encode.Gzip in the same pool. Gzip uses native zlib, but
ZLibEncoder.convertruns synchronously on the calling isolate, so a large gzip encode blocks the event loop too. In the pool it costs the same as inline (+0.1 ms at most):30 gzip encodes at once, total time / longest event-loop stall:
The threshold uses the same ~1.5 ms rule (
APIGZip.isolateMinLength): 192 KB at level 9, 384 KB at 6+, 768 KB below.Tests
test/bones_api_utils_compression_test.dartcoversAccept-Encodingparsing and negotiation, Brotli round trips at every quality,maxOutputSize, corrupted input,compressResponse, the middleware, and interop with thebrotliCLI (skipped when the CLI isn't installed).AutoCompressSink/BrotliSink(sink tests),brcache variants, config args/JSON/apiConfig,brrequest payloads.brJSON/text/static, no compression withoutAccept-Encoding(regression),q=0,HEAD,FutureandStreampayloads, and large files encoded in the pool with concurrent requests. Servers with gzip only,bronly (noHttpServer.autoCompress) and no compression.test/bones_api_utils_isolate_pool_test.dart: results, the concurrency limit and queueing, never more thanmaxIsolatesworkers, worker reuse, async computations, large data, idle disposal and respawn, the idle-timeout race, errors, unsendable computations and results, a dying worker,close(also while a worker starts), and the pool not keeping its isolate alive.APIBrotli.encoder,@visibleForTesting):compressResponse,BrotliSink, cache variants, and the pool.brrequest payloads: the default 64 MB limit, a "decompression bomb",maxPayloadLengthtaking precedence, anddecodeAsynckeepingBrotliException.br/gzip variants, unsupported encodings (deflate), and variants not cached when their entry is removed during an encode.dart format,dart analyze --fatal-infos,dependency_validatorand the full suite, including the Docker DB suites, all pass, and so doesensure_build.Futurepayload #193 fixes requests hanging when a route'sFuturepayload fails (a bug already onmaster).🤖 Generated with Claude Code