Skip to content

feat: add bounded ZSTD encoder/decoder pool with memory management#327

Open
aron-muon wants to merge 11 commits intobuildbarn:mainfrom
aron-muon:zstd-memory-management
Open

feat: add bounded ZSTD encoder/decoder pool with memory management#327
aron-muon wants to merge 11 commits intobuildbarn:mainfrom
aron-muon:zstd-memory-management

Conversation

@aron-muon
Copy link

@aron-muon aron-muon commented Mar 2, 2026

Summary

The existing CAS gRPC client creates a new zstd.Encoder and zstd.Decoder for every ByteStream request. Under high concurrency (e.g., large Bazel builds with many parallel actions), this leads to unbounded memory growth, GC pressure, and potential OOM — each encoder allocates ~4MB and each decoder ~8MB, and the klauspost/compress/zstd library spawns internal goroutines that leak without explicit Close() calls (klauspost/compress#264).

This PR introduces a BoundedZstdPool that replaces per-request allocation with a memory-bounded, concurrency-limited pool, and integrates it into the CAS blob access client.

Approach

BoundedZstdPool — a new pool type that:

  • Reuses encoders/decoders via sync.Pool to avoid per-request allocation
  • Caps concurrent operations using golang.org/x/sync/semaphore, bounding peak memory to a predictable budget (~320MB with defaults: 16 encoders + 32 decoders)
  • Provides backpressure via context-aware blocking when the pool is at capacity, returning codes.ResourceExhausted on timeout
  • Sets finalizers on wrappers to clean up leaked instances, preventing goroutine leaks from the underlying zstd library

CAS client integrationcas_blob_access.go changes:

  • Get path: the zstdByteStreamChunkReader now acquires a decoder from the pool and releases it on Close(), instead of creating a new zstd.Reader per read
  • Put path: acquires a pooled encoder with defer ReleaseEncoder(), replacing inline zstd.NewWriter calls
  • Adds NewCASBlobAccessWithPool constructor for injecting a custom pool (useful for testing and per-client tuning); the existing NewCASBlobAccess continues to work via a lazily-initialized default pool

Testing

  • Unit tests for BoundedZstdPool: acquire/release, concurrency limits, context cancellation, TryAcquire, PooledReadCloser, nil-safety, and concurrent stress test
  • Integration tests for CAS client: pool exhaustion with backpressure, encoder release verification (sequential puts with pool size 1), decoder release verification (sequential gets with pool size 1)
  • Benchmarks comparing pooled vs non-pooled encoder performance
  • Refactored expectGetCapabilitiesWithZSTD test helper to reduce duplication

- Use shared ZSTD encoder/decoder pool instead of creating new instances per request
- Add NewCASBlobAccessWithPool for custom pool configurations
- Decoders and encoders are acquired from pool with backpressure (blocks when at capacity)
- Proper cleanup with defer to ensure encoders/decoders return to pool
- Memory usage now bounded by pool configuration (default ~320MB peak)
@aspect-workflows
Copy link

aspect-workflows bot commented Mar 2, 2026

Test

1 test target passed

Targets
//pkg/blobstore/sharding/integration:integration_test [k8-fastbuild]                 130ms

Total test execution time was 130ms. 28 tests (96.6%) were fully cached saving 7s.

@aron-muon aron-muon force-pushed the zstd-memory-management branch from 42c50e4 to 92b8cd9 Compare March 2, 2026 12:07
- Fix SetDefaultZstdPool bug where alreadyInit was never true, so the
  panic for double-initialization never fired
- Replace max64 helper with built-in max (Go 1.21+)
- Fix nil error sent to channel in concurrent test when data mismatches
- Fix BenchmarkZstdNoPool to use zstd.NewWriter directly for fair comparison
- Add TestSetDefaultZstdPoolAfterInitPanics to verify the bug fix
- Document context.Background() limitation in chunk reader Read()
Copy link
Member

@EdSchouten EdSchouten left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you're trying to make the best out of the entire situation. But let me use this opportunity to go on record and say this:

This is a clear demonstration of why I so absolutely hate the way the Remote API working group added support for compression to REv2. Having all of this bloat in bb_storage is just awful.

)

// ZstdPoolConfig holds validated configuration for a BoundedZstdPool.
type ZstdPoolConfig struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point in adding this entire structure if we don't provide any facilities for controlling these values?

Copy link
Author

@aron-muon aron-muon Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed ZstdPoolConfig entirely. The pool parameters are now passed directly within the protobuf configuration fields

- Remove finalizers (we own the code, no need for safety nets)
- Delete zstd_config.go (config struct without protobuf integration is pointless)
- Remove global state (defaultZstdPool, sync.Once, SetDefaultZstdPool)
- Remove null-defaulting pattern (callers must pass pool explicitly)
- Consolidate to single NewCASBlobAccess constructor with required pool param
- Remove unused exports (TryAcquire*, AcquireDecoderAsReadCloser, PooledReadCloser)
- Create pool at configuration site when compression is enabled
@aron-muon aron-muon requested a review from EdSchouten March 2, 2026 12:46
@aron-muon
Copy link
Author

I know you're trying to make the best out of the entire situation. But let me use this opportunity to go on record and say this:

This is a clear demonstration of why I so absolutely hate the way the Remote API working group added support for compression to REv2. Having all of this bloat in bb_storage is just awful.

I can easily understand that. The compression support in REv2 adds a lot of plumbing. Happy to keep the implementation as lean as possible

Replace the hardcoded encoder/decoder pool limits with a
ZstdCompressionConfiguration protobuf message on
GrpcBlobAccessConfiguration. The presence of the message
enables compression; its absence disables it entirely
with no pool allocated.

This addresses maintainer feedback that there should be no
default values, as deployments range from Raspberry Pi to
128-core EC2 instances.
@aron-muon
Copy link
Author

Hey @EdSchouten , mind letting the workflows run?
We are running the changes here in production within our Bazel cache, and are really pleased with the memory management

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants