erigontech / erigontech/erigon
rpc: one-shot gzip buffers whole responses for a Content-Length that nginx, Caddy and net/http all drop
- Dominant language
- Go
- Stars
- 3.6k
- Forks
- 1.5k
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 455
Description
We lost important http feature "to remove `Content-Length` header on chunked response" (in compression-enabled case)
For us it means: unbounded buffer compression output instead of periodically flushing it to Client (even on large responses)
Every other server in this space, including Go's own `net/http`, delete header and flush to client small compressed buffers
## When nginx and Caddy drop `Content-Length`
This is not a compression special case. RFC 9112 allows exactly two framings for an HTTP/1.1 body: a declared `Content-Length`, or chunked. You can only declare a length you know *before* the headers go out, so the header is dropped whenever the size is not known up front.
**nginx** drops it for:
- gzip/brotli output — the compressed size is unknowable without compressing
- `proxy_buffering off` — bytes are relayed as they arrive
- an upstream that itself replies chunked, passed through
- `sub_filter`, SSI, the `addition` module — anything that rewrites or splices the body
- FastCGI/uWSGI/SCGI apps that do not declare a length
It **keeps** the header where the size is free — static files, where `stat()` already provided it. nginx sets `Content-Length` when it costs nothing and gives it up the moment it would cost buffering. Its compression buffers are fixed and small (`gzip_buffers 32 4k`, ~128 KB per request) precisely because it never accumulates a whole response.
**Caddy** is more striking, because it is Go. Its `encode` module deletes the header explicitly, but underneath every Caddy handler inherits the stdlib default described below. So it drops the header for essentially every dynamic response over 2 KB — reverse-proxy streaming, templates, SSE, body rewriting — without any module deciding to.
## What `net/http` does by default
`net/http` buffers a response into a `bufio.Writer` of `bufferBeforeChunkingSize = 2048` and then decides:
```go
if w.handlerDone.Load() && ... && !header.has("Content-Length") {
w.contentLength = int64(len(p))
setHeader.contentLength = strconv.AppendInt(cw.res.clenBuf[:0], int64(len(p)), 10)
}
```
If the whole response fit in that 2 KB buffer, it sets an exact `Content-Length`. If it did not, it flushes and switches to chunked. It never accumulates a whole response.
**So `net/http` would already do the right thing on its own.** Left alone, any erigon RPC response over 2 KB goes chunked with no `Content-Length`, automatically. The one-shot gzip handler actively overrides that default: it accumulates the full body specifically to restore a header the stdlib had deliberately dropped.
## HTTP/1.1 vs HTTP/2 vs HTTP/3
| | framing | `Content-Length` |
|---|---|---|
| HTTP/1.1 | `Content-Length` **or** chunked | required unless chunked |
| HTTP/2 | DATA frames with END_STREAM | optional metadata; chunked does not exist |
| HTTP/3 | QUIC streams with FIN | optional metadata; chunked does not exist |
Chunked transfer-encoding exists only in HTTP/1.1. In HTTP/2 and HTTP/3 the framing layer already delimits the body, `Content-Length` is optional, and sending it is purely informational. So this whole problem is HTTP/1.1-specific, and it disappears for any client speaking h2/h3.
## Erigon's problem and current status
Introduced in #20665 (`rpc: compression with libdeflate`). libdeflate is a **whole-buffer** compressor by design — its Go binding exposes `Compress(src, dst) (n int, err error)` and the C library documents that it deliberately cannot stream. That path therefore had no choice but to hold the entire response in memory, and once it had, `n` *was* the exact compressed length. The header came for free. It was a consequence of the library's API shape, not a goal.
#22882 replaced libdeflate with `klauspost/compress`, which **does** stream. The constraint that forced buffering disappeared; the buffering, and the header it enabled, stayed.
Measured on the current code (`node/rpcstack.go`, one-shot path, synthetic block-shaped JSON, concurrent clients):
| payload | cpu=1 | cpu=8 | cpu=32 | B/op |
|---|---|---|---|---|
| 16 KB | 336 MB/s | 1,889 | 1,778 (regresses) | 6.7 KB |
| 256 KB | 1,058 | 7,660 | 12,216 | 6.7 KB |
| 2 MB | 851 | 6,374 | 9,987 | **2.38 MB** |
Allocations are flat to 256 KB and then explode, because `common/pool/pool.go` refuses to retain oversized buffers:
```go
const MaxBufferCap = 1 << 20
func PutBuffer(b *bytes.Buffer) {
if b.Cap() > MaxBufferCap { return } // dropped, never pooled
buffers.Put(b)
}
```
Any response over 1 MB grows its buffer past the cap and is discarded rather than returned. Raising the cap to 16 MB confirms the cause — 2,455,498 → 8,139 B/op, ~300x less garbage — but it trades the problem for retained RSS rather than removing it.
Two related regressions from the same PR:
1. **The compressor pool lost its bound.** The libdeflate path used a bounded channel with explicit RAM accounting: `make(chan *libdeflate.Compressor, min(512, max(64, GOMAXPROCS*8)))`, documented as `pool peak RAM = cap × 653 KiB`. It was replaced by two unbounded `sync.Pool`s. Measured retained size of a pooled klauspost writer: **795 KiB at BestSpeed** — larger than the 653 KiB object the old bound protected. `sync.Pool` bounds by time (cleared each GC, one generation of victim-cache grace), not by size, so a burst can retain concurrency × 795 KiB with no ceiling.
2. **The pool observability went with it** — `libdeflate_pool_{hit,miss,overflow}_total` were removed and nothing replaced them.
## The codebase already proves the header is optional
`gzipResponseWriter.Flush()` on the streaming path does exactly what nginx and Caddy do:
```go
w.ResponseWriter.Header().Set("Content-Encoding", "gzip")
w.ResponseWriter.Header().Del("Content-Length")
```
`debug_trace*` and `trace_filter` already serve compressed responses with no `Content-Length` over chunked encoding today. If clients tolerate that for traces — the largest responses erigon produces — they will not break on `eth_getLogs`.
## Proposed solution
Adopt the stdlib's own pattern: keep `Content-Length` where it is cheap, drop it where it is not.
- Accumulate up to a threshold. If the response fits, compress one-shot and keep the exact `Content-Length` — this covers the overwhelming majority of RPC traffic, which the 6.7 KB/op figures at 16 KB and 256 KB already show is the cheap path.
- Above the threshold, stop accumulating and hand off to the **existing** streaming path, which already deletes `Content-Length` and emits chunked. The machinery in `Flush()` is complete; today only an explicit hook triggers it, never size.
A natural threshold is `pool.MaxBufferCap` itself: above the point where the buffer can no longer be pooled, streaming is strictly better, so a buffer is never grown just to be thrown away.
This removes the large-buffer problem rather than tuning it, and makes the `MaxBufferCap` and pool-bound questions apply only to small responses, where the current pooling already works well.
### Which responses would lose the header
Only compressed responses above the threshold — in practice `eth_getLogs` over wide ranges, `eth_getBlockByNumber` on large blocks, and anything already streamed. Small responses keep it. Uncompressed responses below `minGzipBodySize` keep it.
### Follow-ups, not required for the above
- Restore a bound and metrics for the compressor pool (a bounded channel expresses a ceiling that `sync.Pool` cannot).
- `rpc/jsonstream/factory.go:26` — `InitialBufferSize = 4096`. On a several-hundred-MB trace this means tens of thousands of write calls through the gzip middleware; raising it was flagged as note 7 in #22882 and is still untested.
- The 16 KB payload regresses from cpu=8 to cpu=32 (1,889 → 1,778 MB/s) while 256 KB keeps scaling. Something serializes on small requests; needs a profile.
### Caveat to check before acting
Whether any consumer in the fleet keys on `Content-Length` for one-shot RPC responses — a proxy config, or a client with a fixed-size read path. That is a fleet question rather than a code question, and it is the only thing standing between this and simply deleting the problem.
## Measurement notes
Numbers above are from a `RunParallel` benchmark of the one-shot gzip handler with synthetic block-shaped JSON, on an M4 Max. The existing `node/rpcstack_gzip_bench_test.go` cannot answer throughput questions: its loop is strictly sequential (`for i := 0; i < b.N; i++ { client.Do(req) }`), it reports `µs/req`, and its helper type is `latencyStats`. The throughput claims in #22882 came from external `rpctest` runs, not from that benchmark.
Contributor guide
Assessment
This issue has not been assessed yet.