Repository navigation
Bound per-connection memory: adaptive network buffer budget, session buffer caps, and connection admission - #2157
Merged
Badrish Chandramouli (badrishc) merged 90 commits intoSep 28, 2026
Conversation
Copilot started reviewing on behalf of
Badrish Chandramouli (badrishc)
September 18, 2026 00:46
View session
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent histogram returns can corrupt pooled storage, sustained workloads can churn pinned buffers, and several regression tests use incorrect socket or EVALSHA calls.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds adaptive memory controls across networking and RESP sessions, connection admission, safer large-response chunking, and extensive regression coverage.
Changes:
- Introduces adaptive network-buffer budgets and
maxclients. - Reclaims session buffers and latency histograms.
- Supports oversized RESP responses and bounds Lua script caches.
File summaries
| File | Description |
|---|---|
libs/client/GarnetClientMetrics.cs |
Handles returned histograms |
libs/cluster/Session/ClusterSession.cs |
Improves output flushing |
libs/cluster/Session/RespClusterBasicCommands.cs |
Chunks large responses |
libs/cluster/Session/RespClusterReplicationCommands.cs |
Chunks replication responses |
libs/cluster/Session/RespClusterSlotManagementCommands.cs |
Chunks slot responses |
libs/common/Garnet.common.csproj |
Exposes internals for tests |
libs/common/Memory/LimitedFixedBufferPool.cs |
Adds adaptive accounting |
libs/common/Memory/NetworkBufferBudget.cs |
Implements buffer targeting |
libs/common/Memory/PoolEntryTypes.cs |
Adds pool metrics |
libs/common/NetworkBufferSettings.cs |
Configures buffer floors |
libs/common/Networking/GarnetSaeaBuffer.cs |
Adapts SAEA buffers |
libs/common/Networking/GarnetTcpNetworkSender.cs |
Adapts send buffers |
libs/common/Networking/LightConcurrentStack.cs |
Clears removed entries |
libs/common/Networking/NetworkHandler.cs |
Manages receive buffers |
libs/common/Networking/TcpNetworkHandlerBase.cs |
Makes TLS startup asynchronous |
libs/common/RespMemoryWriter.cs |
Adds chunked writing |
libs/common/SimpleStack.cs |
Supports stack cleanup |
libs/host/Configuration/Options.cs |
Adds configuration flags |
libs/host/GarnetServer.cs |
Shares resource controls |
libs/host/defaults.conf |
Defines defaults |
libs/server/ArgSlice/ScratchBufferBuilder.cs |
Adds shrink checkpoints |
libs/server/Config/RuntimeServerConfig.cs |
Supports runtime maxclients |
libs/server/Config/ServerConfigType.cs |
Registers configuration key |
libs/server/Lua/LuaOptions.cs |
Adds script-cache limit |
libs/server/Lua/ScratchBufferNetworkSender.cs |
Handles large Lua output |
libs/server/Lua/SessionScriptCache.cs |
Adds bounded LRU cache |
libs/server/Metrics/GarnetServerMetrics.cs |
Tracks new counters |
libs/server/Metrics/GarnetServerMonitor.cs |
Reclaims idle histograms |
libs/server/Metrics/Info/GarnetInfoMetrics.cs |
Reports new metrics |
libs/server/Metrics/Latency/GarnetLatencyMetrics.cs |
Manages pooled histograms |
libs/server/Metrics/Latency/GarnetLatencyMetricsSession.cs |
Lazily manages session metrics |
libs/server/Metrics/Latency/LatencyMetricsEntry.cs |
Returns histogram storage |
libs/server/Metrics/Latency/LatencyMetricsEntrySession.cs |
Adds lazy reclamation |
libs/server/Metrics/Slowlog/RespSlowlogCommands.cs |
Handles large output |
libs/server/Resp/ACLCommands.cs |
Chunks large ACL output |
libs/server/Resp/ArrayCommands.cs |
Chunks array responses |
libs/server/Resp/BasicCommands.cs |
Chunks basic responses |
libs/server/Resp/ClientCommands.cs |
Chunks client responses |
libs/server/Resp/Objects/ListCommands.cs |
Protects large list output |
libs/server/Resp/Objects/SetCommands.cs |
Protects large set output |
libs/server/Resp/Objects/SortedSetCommands.cs |
Protects sorted-set output |
libs/server/Resp/Parser/SessionParseState.cs |
Caps retained arguments |
libs/server/Resp/PubSubCommands.cs |
Chunks subscription output |
libs/server/Resp/RespServerSession.cs |
Integrates session caps |
libs/server/Resp/RespServerSessionOutput.cs |
Adds atomic-first output helpers |
libs/server/Resp/Vector/RespServerSessionVectors.cs |
Chunks vector responses |
libs/server/Servers/ConnectionLimit.cs |
Implements shared admission |
libs/server/Servers/GarnetServerBase.cs |
Tracks connection metrics |
libs/server/Servers/GarnetServerOptions.cs |
Defines resource options |
libs/server/Servers/GarnetServerTcp.cs |
Enforces admission and budgets |
libs/server/Storage/Session/MainStore/MainStoreOps.cs |
Supports safe large output |
libs/server/Transaction/TransactionManager.cs |
Manages transaction buffers |
libs/server/Transaction/TxnKeyEntry.cs |
Corrects pinned allocation |
metrics/HdrHistogram/LongHistogram.cs |
Pools histogram arrays |
test/standalone/Garnet.test.scripting/SessionScriptCacheBoundTests.cs |
Tests script-cache bounds |
test/standalone/Garnet.test/ClusterLargeResponseChunkingTests.cs |
Tests cluster chunking |
test/standalone/Garnet.test/ConnectionLimitTests.cs |
Tests admission limits |
test/standalone/Garnet.test/ConnectionMemoryScalingTests.cs |
Tests memory scaling |
test/standalone/Garnet.test/GarnetServerConfigTests.cs |
Tests new configuration |
test/standalone/Garnet.test/LargeResponseChunkingTests.cs |
Tests oversized responses |
test/standalone/Garnet.test/LatencyHistogramMemoryTests.cs |
Tests histogram reclamation |
test/standalone/Garnet.test/LightConcurrentStackTests.cs |
Tests slot clearing |
test/standalone/Garnet.test/NetworkBufferBudgetTests.cs |
Tests adaptive budgeting |
test/standalone/Garnet.test/OutputBufferRentalTests.cs |
Tests output rentals |
test/standalone/Garnet.test/SessionBufferRetentionTests.cs |
Tests retention caps |
test/standalone/Garnet.test/SessionBufferShrinkTests.cs |
Tests shrink hysteresis |
test/standalone/Garnet.test/TestUtils.cs |
Exposes test configuration |
test/standalone/Garnet.test/TlsHandshakeStallTests.cs |
Tests TLS accept liveness |
website/docs/getting-started/configuration.md |
Documents new settings |
Review details
- Files reviewed: 69/69 changed files
- Comments generated: 9
- Review effort level: Balanced (auto)
Note
Copilot is running an experiment and ran this review at Balanced.
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/network-buffer-bloat
branch
5 times, most recently
from
September 21, 2026 21:09
507ed8e to
c50531f
Compare
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/network-buffer-bloat
branch
3 times, most recently
from
September 23, 2026 19:06
b7dd36e to
d69ee71
Compare
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/network-buffer-bloat
branch
2 times, most recently
from
September 25, 2026 02:16
78ae8a4 to
8c9852b
Compare
Per-connection network buffers were unbounded in practice: the receive buffer only shrank when it exceeded maxReceiveBufferSize, so a buffer that doubled 128K -> 256K -> 512K never shrank and every connection permanently retained its all-time peak. The TLS transport buffer had no shrink path at all. GarnetServerTcp also discarded its networkBufferSize argument and hardcoded 128 KB, so the per-connection floor was unconfigurable. - Add --network-buffer-size, --network-max-receive-buffer-size and --network-buffer-pool-size, plumbed through GarnetServerOptions into GarnetServerTcp, which now honors them. Defaults match today's behavior. - Shrink a grown receive buffer to the smallest size class that keeps 2x headroom over the buffered bytes, after 16 consecutive receives that fit. Buffers above maxReceiveBufferSize skip the hysteresis since the pool cannot recycle them. - Add the same shrink path for the TLS transport receive buffer, called only from the reader sites that already consider doubling. - Track liveBytes, peakLiveBytes, pooledBytes and a shared byte budget in LimitedFixedBufferPool, exposed through INFO BPSTATS. - Return the outstanding responseObject in DisposeNetworkSender so a session torn down mid-response does not leak its PoolEntry. Co-authored-by: Copilot <[email protected]>
Adds the three --network-buffer-* options to the configuration table, warns instead of silently correcting an inverted size pair, and covers defaults, overrides, power-of-2 rounding, the inverted pair and invalid values in GarnetServerConfigTests. Co-authored-by: Copilot <[email protected]>
A RESP session sizes SessionParseState.rootBuffer to the largest array arity the client has ever sent and keeps it for the life of the connection. One high-arity command therefore enlarges the session permanently, and the cost scales with connection count. The same ratchet exists in ScratchBufferBuilder, whose Reset() zeroes the offset but never releases the backing array. Add BufferShrinkPolicy, a shared cap-plus-hysteresis helper, and apply it at both sites. A buffer above the cap is released only after the configured number of consecutive batches that did not need the extra capacity, so a session with recurring large requests keeps its buffer. Both caps are configurable and default-on: --session-parse-state-max-retained-args (default 1024) --session-scratch-buffer-max-retained-size (default 64k) Measured end to end with a forced compacting GC, 200 sessions each issuing one 20,000-argument EXISTS followed by sustained single-key work: 327 KB/session retained before, 98 KB/session after. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The batch-boundary shrink releases a pinned array, so anything holding a reference into it across that boundary would dangle rather than merely read stale bytes. Two paths cross the boundary by design: a MULTI..EXEC window, which re-parses queued commands from the network buffer, and WATCH, which copies key bytes into the transaction scratch allocator so they outlive the receive buffer. Add tests that force the shrink to fire inside both windows. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
A response element larger than the send buffer cannot be written atomically. The chunking helper for this already exists and SCAN uses it, but several commands still wrote unbounded user data through the atomic path, where TryWriteBulkString cannot make progress and SendAndReset throws, killing the session mid-response. KEYS fails today with any key larger than the 128 KB default send buffer. Give the shared WriteBulkString helper an atomic-first fallback: write in place, retry once on a flushed buffer, and only chunk an element that cannot fit an empty buffer. Small elements keep exactly today's path, so multi-element replies pay nothing. Route KEYS, COMMAND GETKEYS, COMMAND GETKEYSANDFLAGS and the pub/sub channel and pattern echoes through it. Two related defects found while covering this: ClusterSession.SendAndReset silently did nothing on an empty buffer, so a caller's retry loop spun forever rather than failing. It now throws, as the RESP session already does. MainStoreOps.GETDEL asserted its output was inline immediately after the RMW, before the spill-to-pooled-memory case is handled. Any value large enough to spill tripped it, so GETDEL of a large value failed on every debug build. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The fallback added for over-sized response elements flushed before retrying, but SendAndReset throws when there is nothing to send. That is exactly the over-sized case where the element is the first thing written to the response, so the chunking path was unreachable there and the session still died. Every earlier test emitted an array header first, which left the buffer non-empty and masked it. Flush only when output is actually buffered, otherwise chunk directly. CLIENT GETNAME is the reachable instance: the name is the whole response, so a name larger than the send buffer killed the session. Give the ASCII bulk-string helper the same fallback and route CLIENT GETNAME through it. Move both fallbacks to NoInlining helpers so the 49 call sites of the inlined fast path keep their previous code size. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
Every session was given twelve HdrHistograms when its connection was accepted, before it had recorded anything. At two significant digits each rents a 32 KB array from the shared pool, so a connection cost 384 KB of latency monitoring whether or not it ever issued a command. A dump of a server holding several thousand connections showed 887 MB in these arrays. Allocate the histograms for a latency type on the first value recorded into it. A session typically records into three of the six types, and an idle connection records into none, so it now pays nothing. Add --latency-monitor-precision to size the histograms. It defaults to 2, which is what the histograms were built with, so accuracy is unchanged unless it is lowered. Dropping it to 1 shrinks each histogram several-fold in exchange for reporting percentiles to 10% rather than 1% resolution. Fix three defects on the release path that lazy allocation widens the window for. LongHistogram.Return() now drops its reference to the array it hands back, so a later record throws rather than silently writing into an array the pool has re-rented to another session. The session's pending work is cancelled before its histograms are released rather than after. GarnetLatencyMetricsSession publishes its metrics as unavailable before releasing them, and the hot path reads the field once and checks it. Dropping the array reference exposed a use-after-return in GarnetClient, which copied its histogram after Dispose had already handed the array back; it now reports no metrics instead of reading a recycled array. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The precision knob was only covered by tests that constructed histograms directly, so a broken binding between the command line and the allocation would have gone unnoticed and silently degraded resolution. Assert the size the server reports through LATENCY HISTOGRAM, the size of the per-session histograms reached through the live sessions, and that the option parses to 2 by default and rejects values outside the 0-5 range HdrHistogram accepts. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
A session records its latency after the reply has been written, so a test that read the histograms as soon as the reply arrived could find none allocated yet. Poll until one appears. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The buffer pool's byte ceiling bounds only its idle free list. Buffers checked out by live connections are bounded by nothing, so their footprint is connections times per-connection size, which is what grew unchecked with thousands of clients. Add NetworkBufferBudget, which divides a configured total by the live buffer count and publishes the largest base size that keeps the two consistent. It is a division rather than a control loop: buffer sizes move in factor-of-two steps, so any controller with a narrower deadband cannot converge and limit-cycles between two size classes. Growth therefore requires the quotient to reach twice the published target, matching the step it actuates. One budget is shared by every listener. Each endpoint previously built its own pool and was handed the whole ceiling, so with N endpoints the effective limit was N times the configured value. Only server listener pools participate; replication and migration keep their own unbounded behaviour so that a sync burst cannot drive live client connections down. The target is published but consumed by no allocation site yet. Landing the signal on its own makes a drift in the live buffer count observable in isolation, which matters because the target is budget divided by that count: a count that ratchets up does not degrade gracefully, it collapses the target to the floor permanently. The count rides exactly the two paths that already maintain the pool's live byte accounting and nothing else. Defaults are inert: 1 GB against a 128 KB buffer size means adaptation does not begin until 8,192 live buffers, and the target clamps at the configured size so it can only ever lower it. --network-buffer-memory-budget 0 disables it. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The budget was published but consumed by nothing. Wire it to the receive side: a new connection's buffer starts at the published target rather than the configured size, and a grown buffer shrinks back toward that target. Only the base size is governed. Demand-driven doubling is untouched, so a connection that needs a large buffer still reaches it; pressure changes what a connection starts and settles at, never what it is allowed to grow to. The allocation size and the shrink floor move together. Clamping only the allocation would leave a connection allocated below the configured size unable to shrink at all, because the shrink path both floors at that size and returns early when the buffer is already at or under it. A pool cannot recycle a buffer outside its size classes, so NetworkBufferSettings gains an explicit minimum. Deriving it from the three configured sizes, as before, gives no way to express "recycle down to 16 KB while new connections still start at 128 KB", and a clamped buffer would have been dropped rather than pooled. The per-level idle entry count derived from a byte budget is capped in the same change, since a smaller minimum size class otherwise raises it proportionally; the cap sits above the value today's defaults produce, so it is inert until the two are combined. Budget accounting is now inert when disabled rather than merely unread. The disabled budget is a shared singleton, so unbudgeted pools were accumulating a count on it that was meaningless but would have become misleading the moment anything read it. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
GarnetSaeaBuffer is the single allocation point behind both GarnetTcpNetworkSender call sites, so clamping there covers the send side entirely. Send buffers never grow, so an oversized response is chunked through whatever buffer it was given, and every consumer reads the length off the pool entry rather than off the configured setting -- which is what makes the size safe to adapt. The clamp uses the separate, higher send floor so that a shrunken send buffer does not push custom raw string responses into repeated ArrayPool rentals. Adds an end-to-end test that a 1 MB value and a key larger than the send buffer both survive while the budget is pinned at its floor, and tightens the shrink assertion now that both directions adapt. Neutering the clamp fails that assertion. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The allocator now keeps its current buffer across Reset and releases an over-sized one at a ShrinkCheckpoint, so the fixture asserts the retained capacity at each point and drives two checkpoints to clear the hysteresis. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The residual left after a fully consumed request is zero, so sizing the replacement from it always resolved to the base. A connection issuing one oversized request per batch then re-grew through every intermediate size class on every request. Floor the replacement at the largest poolable size at both the socket and TLS transport sites; the idle hysteresis path still takes it down to the base once the traffic no longer needs the capacity. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
Release drops the counts array while the reply-processing thread may still be recording, so the record path tolerates a released array and the copy path reads the field once instead of testing IsReturned and then dereferencing. Also make the quiesce-threshold test observe empty windows: it recorded a value and never cleared it, so the windows it asserted over were not empty and a threshold that never restarted its run still passed. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
A TLS connection decrypts into a second receive buffer that grows through its own code path, so the above-max release there was unexercised while its plain-socket twin was pinned. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The above-max branch releases a buffer the pool cannot recycle. Flooring the replacement at the largest poolable size matches origin/main and saves a connection that needs the capacity on every request from re-growing through every size class, but it must not apply while the budget is binding: the target is derived from a buffer count, so a connection parked at the maximum costs the same single unit as one at the floor and the budget gets no feedback from it. Under pressure the replacement is now the adapted base, which the pool keeps and the budget can see. Also capture the counts array once in ClearCounts and CopyCountsInto, read the client call count off the snapshot the percentiles come from, and record the single-read contract on the field itself. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
The plaintext and TLS receive paths gate the above-max release independently. Only the plaintext site was exercised, so the TLS gate at NetworkHandler.cs:801 could be removed with the whole fixture still green. Parameterize the test over both transports. Guard the counts array in CopyCountsInto, matching the single-read invariant documented on the field and the five sibling capture sites. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
`ScratchBufferCapDoesNotChangeLargeArgumentResults` compared a per-session memory figure across two arms and failed intermittently. Two measurements show it cannot discriminate: swapping the arms moves each figure by about 130 KB per session and flips the sign of the difference, because the arm that runs second also measures the residue of the server instance the first one disposed; and releasing the buffer at every reset rather than at the checkpoint -- the churn such an assertion claims to catch -- moves it by less than that, because churn leaves garbage the forced collection reclaims rather than retention. The test keeps both arms and the reply validation they drive, which is what its name has always claimed, and the remarks state why no magnitude is asserted. `NetworkBufferBudget` documents the per-direction floors as the third reason the budget is a target rather than a ceiling: once the target reaches them, the aggregate grows with the connection count again. Co-authored-by: Copilot <[email protected]> Copilot-Session: f7ebed6a-7162-459d-849f-6a9d3a31eb30
Comparing capacity across iterations cannot see the pathology the test exists to catch: a policy that released on every batch regrows before the next sample, so consecutive post-boundary capacities are equal and the churn counts as zero. Sampling before and after each boundary counts every release. Co-authored-by: Copilot <[email protected]>
The setting is HdrHistogram significant digits: relative accuracy at every magnitude, not a fixed time unit. Stating it that way, with the memory each step costs, lets an operator pick a value without knowing HdrHistogram. Co-authored-by: Copilot <[email protected]>
The receive buffer grows to fit and maxReceiveBufferSize bounds only what a connection retains, so a chunk far above it must still be accepted. Dribbled in small slices so the request sits partially buffered across many receive passes, where the above-max shrink runs against a live partial request. Also drop a qualifier from the latency precision help text. Co-authored-by: Copilot <[email protected]>
Earlier edits flipped the BOM on seven files, which showed up as a one-line change to each file header with no content behind it. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
A receive releases its buffer in UpdateNetworkBuffers, which runs after Process has already written the reply, so a client that has read its response can still be ahead of the release. Sampling BPSTATS once at that point read the pre-release numbers on a loaded runner and failed with the full oversized buffer still live. Poll until the counters settle. The wait uses only the out-of-band INFO connection, so it does not hand the connection under test the extra receive that would let a buffer released one pass too late off the hook: suppressing the immediate release still fails the assertion. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
The network and transport receive buffers ran the same three-regime shrink policy over their own occupancy and countdown, written out twice. Extract it as TryPlanReceiveBufferShrink, which owns the countdown bookkeeping and the budget's shrink accounting, and leave each caller with nothing but the buffer swap. Rename both entry points to TryShrink*, and give the transport buffer a ShrinkTransportReceiveBuffer mirroring ShrinkNetworkReceiveBuffer. Drop the cached configuredReceiveBufferSize/configuredSendBufferSize fields and read them off networkBufferSettings, as maxReceiveBufferSize already is on the same paths. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
BaseReceiveBufferSize is read on every receive, so reaching the configured size through networkBufferSettings puts a dependent load on that path. BDN Network.BasicOperations.InlinePing measures it at +1.1ns (89.8 -> 90.9) on net10 and +1.9ns (85.7 -> 87.6) on net8, the net8 range clearing baseline entirely. Restoring the fields returns both to baseline (89.8 / 85.8). The shrink consolidation is unaffected and stays. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
Every other method on the session captures the metrics field once before dereferencing it. ReclaimQuiescedHistograms re-read it for the null check and again inside the loop, so it relied on the dispose lock for that safety where its peers do not. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
Badrish Chandramouli (badrishc)
force-pushed
the
badrishc/network-buffer-bloat
branch
from
September 25, 2026 18:07
fd94d9d to
6731f34
Compare
Vasileios Zois (vazois)
approved these changes
Sep 25, 2026
Replayed transaction procedures run their prepare phase against the watch API, and every watched key is copied into the transaction scratch allocator. The replay session never reaches the network batch boundary that drives the shrink checkpoint, so the buffer one wide procedure grew stayed pinned for the lifetime of a replica. The replay path now reports a boundary per AOF record and the window is counted in TransactionManager, so the caller does not carry the cadence. The allocator shrinks only when nothing is outstanding, so a boundary reported mid-transaction is safe. Also apply two review nits: a u8 span literal for the max-clients error instead of a materialized array, and a corrected comment on the connection-limit registration lock, which was crediting the lock with reader safety that the volatile publication provides. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
Replayed procedures grow both scratch allocators, not just the transaction one: the prepare phase copies watched keys into the transaction allocator, and the procedure builds its arguments from the session allocator. The session allocator is reset at the start of every procedure rather than when one ends, so a completed procedure leaves it oversized with a non-zero offset, and a shrink checkpoint declines in that state. Reset it at the replay boundary, as the network batch boundary does, and only outside a transaction, whose slices are still live. Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
Badrish Chandramouli (badrishc)
requested a review
from Vasileios Zois (vazois)
September 26, 2026 06:34
…ds at the call site "Checkpoint" is load-bearing in this codebase: Tsavorite uses it for durable, versioned, recoverable snapshots, and Garnet's AOF replay handles genuine CheckpointStartCommit markers a few lines from where this hook was called. The scratch-buffer hook means something unrelated -- periodically sample a buffer's capacity and release it if it did not grow -- so it is renamed to Trim, matching ArrayPool<T>.Trim, which applies the same hysteresis. The replay hook also counted its own interval while the network one had the caller count, so TrimAfterReplayedRecord ran on every record but trimmed on one in sixty-four. The counter moves to AofReplayContext, alongside the session it governs, so both paths show the cadence at the call site and TrimReplayBuffers means "trim now". Co-authored-by: Copilot <[email protected]> Copilot-Session: a2361ed7-3bec-4fb8-9a6f-b4408448c160
Vasileios Zois (vazois)
approved these changes
Sep 28, 2026
Badrish Chandramouli (badrishc)
merged commit Sep 28, 2026
20bbc05
into
main
331 of 333 checks passed
x@01 (x-at-01)
added a commit
to webc-fork/garnet
that referenced
this pull request
Oct 4, 2026
…twork buffer budget, session buffer caps, connection admission (microsoft#2157)
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.
Problem
Nothing bounds the memory a connection holds.
connections x per-connection sizewith no ceiling.long[]are rented at connect time, before a session records anything, and held for its lifetime whether or not it stays active.Each of these scales linearly with connection count, which is the dimension that produced the incident this addresses: 57 GB committed, 52.8 GB of
byte[], with 1.08 GiB of network buffers across 9,212 arrays and 887 MB of histograms across 27,030 arrays.Mechanism
A process-wide network buffer budget, shared by every listener. It publishes a target base size
recomputed whenever a buffer is acquired or released, and consumed at the allocation sites (network receive, TLS transport receive and send, SAEA send) and at the return sites. The ceiling is the configured buffer size, so the target only ever lowers the base; under a slack budget every comparison is arithmetically inert and behaviour is byte-for-byte what it was. Demand-driven growth is never clamped: a connection that needs 1 MB still gets it. Under pressure a grown buffer shrinks back toward the target after a short run of small receives, and an over-target buffer is dropped on return rather than pooled. A buffer grown past the largest poolable size is released on the pass that grew it, since the pool cannot recycle it.
Per-session caps on the parse state root buffer and the scratch buffers, trimmed at a periodic batch boundary and only when the buffer did not grow since the previous trim, so a session that keeps needing the capacity reallocates at most once every few dozen batches. AOF replay counts the same interval in replayed records, because a replay session never reaches a network batch boundary. Replayed stored procedures grow both scratch allocators: the prepare phase runs against the watch API, which copies every watched key into the transaction allocator, and the procedure builds its arguments from the session allocator. The session allocator is reset when a procedure starts rather than when one ends, so a completed procedure leaves it oversized with a non-zero offset, which a bare trim declines to release; the replay path resets it first, as the network batch boundary does. Without this the buffers the widest replayed procedure grew stay pinned for the lifetime of a replica.
Lazy and reclaimable histograms. Allocated on first record rather than at connect, and dropped when a session records nothing for four consecutive monitor windows. The reference is dropped rather than returned to the shared pool, so a concurrent recorder can only write into an object graph it holds the sole reference to.
Connection admission.
maxclients, default 10000, process-wide across listeners,CONFIG SET-able. A refused plaintext connection is sent-ERR max number of clients reachedbefore the close. A refused TLS connection is closed without a reply, because the peer is mid-handshake and a RESP error there is a protocol violation that surfaces as a certificate failure;rejected_connectionsis the signal in that case.Configuration
Two new flags:
--network-buffer-memory-budget1g(0disables)--latency-monitor-precision21is ~8x smaller per sessionPlus
maxclients(Redis-compatible),rejected_connectionsinINFO STATS, andtargetBufferSize,liveBufferCount,pressureShrinks,idleShrinksandtotal_output_buffer_rentalsinINFO BPSTATS.The remaining sizes the budget derives from are resolved internally rather than exposed, since the budget exists to pick them from the live connection count.
Behaviour change to note: the pre-existing
--network-connection-limit, aliased at runtime asmaxclients, defaults to 10000 where it previously defaulted to unlimited, matching Redis. A deployment carrying more than 10000 concurrent peer links must raise it. Replica, gossip and migration links arrive on the same listener and count against it, which Redis does not do for cluster bus links.Effect
maxclientsof 10000 the send target is already pinned at its floor: ~0.92 GiB across 10000 plaintext connections, which carry two budgeted buffers each, and ~1.6 GiB for the same count of TLS connections, which carry four. Past ~10.9k plaintext connections the aggregate exceeds the budget rather than tracking it. A buffer grown past the largest poolable size cannot be recycled on return, so it is released on the pass that grew it — to that size while the budget is slack, which is whatmaindoes, and to the adapted base while the budget binds.maxclientsis the hard bound on the aggregate.--latency-monitor-precision 1is a further 8x.Also fixed
Responses larger than the send buffer killed the session at the 128 KB default —
KEYS,CLIENT GETNAME,COMMAND GETKEYS,SUBSCRIBE, several ACL andCLUSTERsubcommands, andBLPOP, which removed the element before failing to write it and so lost it outright. Everywhile (!TryWrite...) SendAndReset()site inlibs/server/Respandlibs/clusterwas enumerated and the unbounded ones converted to an atomic-first helper that falls back to chunking only when an item does not fit an empty buffer, so small elements stay on the existing path.ClusterSession.SendAndReset()no longer silently no-ops on an empty buffer.Also: a stale non-TLS transport alias that rooted every shrunk receive buffer indefinitely;
LightConcurrentStack.TryPopleaving the vacated slot populated;ScratchBufferBuilderallocating twice the requested size for exact-power-of-two requests; transaction key entries allocated pinned on one path and unpinned on the other; and check-then-dereference reads of the session latency histogram array, on both the record and the reclaim path.Performance
No resolvable regression. Two independent six-round runs against
main, pinned, with the three arms interleaved within each round so load drift cancels. Medians of the per-round deltas:InlinePing-0.72 and -0.47 ns,GetFound+0.80 and +0.90 ns, at the pessimalbatchSize = 1where every per-batch cost lands undiluted on one operation. All four sit inside a +/-1 ns noise floor, which an unchanged control arm reproduces. Amortized atbatchSize = 100, -0.64%.Validation
73 files, +8,189 / -284, of which 72% is tests. All seven suites green in Release on
net10.0:Garnet.test1,286,acl466,scripting638,cluster151,cluster.migrate54,cluster.replication105,cluster.replication.tls105, zero failures. The fixtures over the changed code also run green onnet8.0.dotnet formatclean; 0 warnings / 0 errors on both target frameworks.Every assertion added is mutation-checked: the mutation is applied, the build is confirmed to succeed, and the test is confirmed to fail for the stated reason. Where a fix has no discriminating test — a memory-ordering capture, whose only observable is codegen; the source a copied histogram derives its total from, which only a racing recorder separates; and the per-session scratch retention figure, which is dominated by the residue of the preceding server instance in the same process — that is stated in the source rather than covered by a test that passes against both implementations.