Pool compression buffer in encodeStreamMsgAllowCompressAndBatch#7871
Pool compression buffer in encodeStreamMsgAllowCompressAndBatch#7871
Conversation
Reduce GC / memory used churn by avoiding double allocation happening on messages that are over 8KB. ``` go test -run='^$' -bench='BenchmarkEncodeStreamMsg|BenchmarkEncodeDecodeStreamMsg' -benchmem ./server/ ``` Signed-off-by: Waldemar Quevedo <wally@nats.io>
8107cca to
773f900
Compare
neilalexander
left a comment
There was a problem hiding this comment.
Would it be better to reuse an existing pool, e.g. nbPoolGet and nbPoolPut, or do you have a strong preference for a new one?
|
Claude finished @neilalexander's task in 4m 55s —— View job PR Review: Pool compression buffer in encodeStreamMsgAllowCompressAndBatch
OverviewThe PR avoids a double allocation during S2 compression by pooling the temporary scratch buffer ( Critical Bug: Potential panic in batch message pathThe key change in the compression block is: // jetstream_cluster.go:9430-9442
if shouldCompress {
nbuf := getCompressBuf(s2.MaxEncodedLen(elen))
nbuf[0] = byte(compressedStreamMsgOp)
ebuf := s2.Encode(nbuf[1:], buf[opIndex+1:])
if len(ebuf) < len(buf) { // ← potentially too permissive
buf[opIndex] = byte(compressedStreamMsgOp)
copy(buf[opIndex+1:], ebuf)
buf = buf[:len(ebuf)+opIndex+1] // ← can panic
}
putCompressBuf(nbuf)
}For non-batch messages ( For batch messages (
The original code was safe here because it resliced the much larger Fix: change the compression condition to: if len(ebuf)+opIndex+1 < len(buf) {This ensures the final encoded message (batch prefix + compressed payload) is actually smaller than the original, which is the correct semantic check, and guarantees the reslice is in-bounds. Minor: Dead code// jetstream_cluster.go:9432
nbuf[0] = byte(compressedStreamMsgOp)This byte is never used. Minor:
|
| Severity | Issue |
|---|---|
| 🔴 Bug | Potential panic when opIndex > 0 (batch messages) and compressed payload is marginally smaller than len(buf) |
| 🟡 Minor | nbuf[0] = byte(compressedStreamMsgOp) is dead code |
| 🟡 Minor | putCompressBuf causes a heap allocation per call |
| ℹ️ Info | New dedicated pool is the right choice over nbPool for variable-size compression buffers |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 773f900347
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if len(ebuf) < len(buf) { | ||
| buf = nbuf[:len(ebuf)+opIndex+1] | ||
| buf[opIndex] = byte(compressedStreamMsgOp) | ||
| copy(buf[opIndex+1:], ebuf) | ||
| buf = buf[:len(ebuf)+opIndex+1] |
There was a problem hiding this comment.
Compare compressed length to payload span before copying
For batched messages (opIndex > 0), s2.Encode can return output slightly larger than the uncompressed stream payload (for incompressible data this is typically src+5), and the current guard len(ebuf) < len(buf) still passes because len(buf) includes the batch prefix. In that case copy(buf[opIndex+1:], ebuf) truncates ebuf to the payload span, but the subsequent reslice keeps the larger length, producing a corrupted compressed frame (and potentially decode failures on replication) for large incompressible batched payloads.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 773f900347
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if len(ebuf) < len(buf) { | ||
| buf = nbuf[:len(ebuf)+opIndex+1] | ||
| buf[opIndex] = byte(compressedStreamMsgOp) | ||
| copy(buf[opIndex+1:], ebuf) | ||
| buf = buf[:len(ebuf)+opIndex+1] |
There was a problem hiding this comment.
Compare compressed size to payload before in-place copy
In encodeStreamMsgAllowCompressAndBatch, the guard len(ebuf) < len(buf) is checking against the full batched frame size instead of the compressible payload size (len(buf)-opIndex-1). For batch entries (opIndex > 0), incompressible S2 output can be a few bytes larger than the original payload but still smaller than the full frame, so this branch is taken and copy(buf[opIndex+1:], ebuf) truncates the compressed bytes to the old payload length; the subsequent reslice then exposes invalid trailing bytes, producing corrupt compressed batch records (and can panic if the new length exceeds cap(buf)).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 773f900347
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if len(ebuf) < len(buf) { | ||
| buf = nbuf[:len(ebuf)+opIndex+1] | ||
| buf[opIndex] = byte(compressedStreamMsgOp) | ||
| copy(buf[opIndex+1:], ebuf) | ||
| buf = buf[:len(ebuf)+opIndex+1] |
There was a problem hiding this comment.
Compare compressed size against payload window
For batched messages (opIndex > 0), this branch copies ebuf into buf[opIndex+1:], but the guard checks len(ebuf) < len(buf) instead of comparing against len(buf)-opIndex-1. When S2 output is slightly larger than the raw payload (which can happen for incompressible data) but still smaller than the full batch frame, copy truncates the compressed bytes and buf = buf[:len(ebuf)+opIndex+1] can extend past the written data (or panic if beyond capacity), producing corrupted compressed batch entries.
Useful? React with 👍 / 👎.
Cherry-picked from wq/encode-stream-msg-allocs branch (PR nats-io#7871). Reduces GC pressure by pooling S2 compression scratch buffers. https://claude.ai/code/session_01LU1gqVBKHyaQdkJ6K9Ur35
Apply feedback from Claude and Codex reviewers: 1. Fix critical bug: compressed length comparison `len(ebuf) < len(buf)` did not account for batch prefix offset. For batched messages where opIndex > 0, the comparison must be against the payload window size `len(buf)-opIndex-1` to avoid data truncation or corruption. 2. Fix heap escape in putCompressBuf: taking `&buf` of a slice parameter causes the slice header to escape to heap on every call. Instead, return the pool pointer from getCompressBuf and pass it back to putCompressBuf directly. 3. Remove dead code: `nbuf[0] = byte(compressedStreamMsgOp)` was never used since the op byte is written into buf, not nbuf. https://claude.ai/code/session_01LU1gqVBKHyaQdkJ6K9Ur35
Try avoiding double allocation happening on messages that are over 8KB by using sync.Pool, release buffer once compression done.
Signed-off-by: Waldemar Quevedo wally@nats.io