Skip to content

Bound per-connection memory: adaptive network buffer budget, session buffer caps, and connection admission - #2157

Merged
Badrish Chandramouli (badrishc) merged 90 commits into
mainfrom
badrishc/network-buffer-bloat
Sep 28, 2026
Merged

Badrish Chandramouli (badrishc) merged 90 commits into
mainfrom
badrishc/network-buffer-bloat

Conversation

@badrishc

@badrishc Badrish Chandramouli (badrishc) commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Nothing bounds the memory a connection holds.

  • Network buffers. Each connection owns a send buffer and a receive buffer, and the receive buffer grows on demand and is never given back. The pool's byte cap bounds only the idle free list; buffers checked out to live connections are unbounded, so network memory is connections x per-connection size with no ceiling.
  • Per-session buffers. The RESP parse state root buffer is sized by the argument count a client sends and kept forever, so one wide command permanently enlarges the session. The per-session scratch buffers ratchet the same way. All are pinned.
  • Latency histograms. Twelve long[] are rented at connect time, before a session records anything, and held for its lifetime whether or not it stays active.
  • Connection count. There was no limit at all.

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

target = clamp(floor, ceiling, PreviousPowerOf2(budget / liveBufferCount))

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 reached before 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_connections is the signal in that case.

Configuration

Two new flags:

Flag Default Bounds
--network-buffer-memory-budget 1g (0 disables) live network buffers, process-wide
--latency-monitor-precision 2 histogram value resolution; 1 is ~8x smaller per session

Plus maxclients (Redis-compatible), rejected_connections in INFO STATS, and targetBufferSize, liveBufferCount, pressureShrinks, idleShrinks and total_output_buffer_rentals in INFO 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 as maxclients, 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

  • Network buffers at the incident's shape (9,212 live buffers, 1 GB budget): 128 KB target steps to 64 KB, ~1.08 GiB to ~590 MB. The budget sizes the per-connection base, so it is an adaptive target rather than a hard ceiling: demand-driven growth stays unclamped by design, and a connection that grows within the poolable range and then idles forever still holds what it grew. The floors bound the target from below as well — 16 KB receive, 64 KB send, the send floor higher because an undersized send buffer pushes oversized responses onto the pooled-rental path — so the aggregate tracks the budget only while the target is above them. At the default maxclients of 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 what main does, and to the adapted base while the budget binds. maxclients is the hard bound on the aggregate.
  • Parse state: 413 KB to 185 KB per session, end to end.
  • Histograms: idle connection 378 KB to 0; a live session pays for the types it touches rather than all six; quiesced sessions release entirely; --latency-monitor-precision 1 is 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 and CLUSTER subcommands, and BLPOP, which removed the element before failing to write it and so lost it outright. Every while (!TryWrite...) SendAndReset() site in libs/server/Resp and libs/cluster was 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.TryPop leaving the vacated slot populated; ScratchBufferBuilder allocating 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 pessimal batchSize = 1 where 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 at batchSize = 100, -0.64%.

Validation

73 files, +8,189 / -284, of which 72% is tests. All seven suites green in Release on net10.0: Garnet.test 1,286, acl 466, scripting 638, cluster 151, cluster.migrate 54, cluster.replication 105, cluster.replication.tls 105, zero failures. The fixtures over the changed code also run green on net8.0. dotnet format clean; 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.

Copilot AI balanced review requested due to automatic review settings September 18, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.

Comment thread libs/server/Metrics/Latency/GarnetLatencyMetricsSession.cs
Comment thread metrics/HdrHistogram/LongHistogram.cs
Comment thread libs/server/Resp/RespServerSession.cs
Comment thread test/standalone/Garnet.test/ConnectionMemoryScalingTests.cs Outdated
Comment thread test/standalone/Garnet.test/SessionBufferRetentionTests.cs Outdated
Comment thread test/standalone/Garnet.test/SessionBufferRetentionTests.cs Outdated
Comment thread libs/common/Memory/NetworkBufferBudget.cs Outdated
Comment thread libs/server/Servers/GarnetServerTcp.cs
@badrishc
Badrish Chandramouli (badrishc) force-pushed the badrishc/network-buffer-bloat branch 5 times, most recently from 507ed8e to c50531f Compare September 21, 2026 21:09
@badrishc
Badrish Chandramouli (badrishc) force-pushed the badrishc/network-buffer-bloat branch 3 times, most recently from b7dd36e to d69ee71 Compare September 23, 2026 19:06
Comment thread libs/common/Networking/NetworkHandler.cs
Comment thread libs/common/Networking/NetworkHandler.cs Outdated
Comment thread libs/common/Networking/NetworkHandler.cs Outdated
@badrishc
Badrish Chandramouli (badrishc) force-pushed the badrishc/network-buffer-bloat branch 2 times, most recently from 78ae8a4 to 8c9852b Compare September 25, 2026 02:16
Comment thread libs/server/Metrics/Latency/GarnetLatencyMetricsSession.cs
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
Comment thread libs/server/Servers/ConnectionLimit.cs
Comment thread libs/server/Servers/GarnetServerTcp.cs Outdated
Comment thread libs/server/Transaction/TransactionManager.cs Outdated
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
…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
@badrishc
Badrish Chandramouli (badrishc) merged commit 20bbc05 into main Sep 28, 2026
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)
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.

3 participants