Repository navigation
Remove the per-write allocations from the chunked object AOF path, and bound chunk buffering - #2182
Merged
Merged
Conversation
Object-store WriteLogUpsert switched from one contiguous serialization plus Log.Enqueue to EnqueueObjectChunked, so every destination-set write allocated a ChunkHeaderWriter<THeader>, a ChunkWriteState, a ChunkedObjectSerializer<ChunkWriteState, TInput> with its circular byte[], a ChunkStreamWriter, a BinaryWriter and a UTF8Encoding. TsavoriteLog.Chunked.cs drops ChunkHeaderWriter and ChunkHeaderWriter<THeader>. ChunkWriteState now holds the header template in a byte buffer with a Reset<THeader> / WriteHeader / Clear lifecycle, and both ChunkWriteState and ChunkedObjectSerializer<ChunkWriteState, TInput> are rented from and returned to thread-static caches shared with EnqueueChunkedSpan. Renting nulls the cache slot so a reentrant write gets its own instance. ChunkedObjectSerializer binds the consumer, serializer and value object per write and reuses the ring buffer and ChunkStreamWriter across writes. Clear() drops the key, input, value object and context so a cached instance roots nothing. GarnetLog.ChunkBufferSize is removed. It sized the ring at min(HeapMemorySize, aofPageSize / 2), which reached 16 MB per write on the large object heap with the default 32 MB AOF page. The ring is now a fixed 64 KB TsavoriteLog.ChunkedObjectRingBufferSize, below the LOH threshold; a larger value simply drains into more chunk records. BinaryObjectSerializer<T> shares one static UTF8Encoding and caches the BinaryWriter while the stream is unchanged, and EndSerialize flushes instead of disposing, which is identical behaviour under leaveOpen: true. Migration and diskless replication use the same path and benefit as well. BDN Operations.SetOperations on net10.0, AOF parameter, before -> after: SUnionStore 180,000 B -> 92,000 B (-880 B/command), 278.8 -> 214.2 us SInterStore 118,400 B -> 52,000 B (-664 B/command), 221.5 -> 189.2 us SDiffStore 122,400 B -> 56,000 B (-664 B/command), 201.8 -> 191.1 us AOF allocation now equals each benchmark's non-AOF figure, so object AOF logging is allocation-free at steady state, and is below the pre-regression baseline of 136,000 / 94,400 / 98,400 B. Gen0 is halved.
The --op / --opparams option is parsed in the host process, where BaseConfig
mutates the OperationsBase.Params{None,ACL,AOF,AAD} statics, and the provider
yielded only the selected values. BenchmarkDotNet identifies a parameter value by
its position in the provider's sequence, and the generated per-job process
re-invokes the provider and selects by that position. That process never runs
Program.Main, so its statics keep their default true values and it yields all
four. With --op aof the host yielded [AOF] and recorded position 0, while the
child yielded [None, ACL, AOF, AAD] and selected None at position 0, so a row
labelled AOF measured the None configuration.
OperationParamsProvider now yields all four unconditionally, so the sequence is
identical in both processes, and OperationParamsFilter applies the selection as
an IFilter registered in BaseConfig. BenchmarkDotNet fixes parameter positions
while building the cases and applies filters afterwards, so excluding a case
cannot renumber the remaining ones. Every configured filter must admit a case,
so this composes with the --filter globs.
OperationParams takes useAad explicitly rather than defaulting it, and rejects
more than one aspect per value, which is what every provider already yields and
what lets the filter map a value onto a single Params* flag. The providers pass
the arguments by name. The mutual-exclusion check for the two auth modes is
subsumed by that rule and is retained commented out, to be restored if
combinations are ever allowed. The check throws rather than asserting because
benchmarks run against a Release build.
Verified by inspecting the generated per-job source: with --op aof it selects
position 2, which is AOF in the unconditional sequence, and an unfiltered run
emits positions 0 through 3. CI passes no --op and is unaffected.
The three chunked-record paths that accumulate a serialized object value each built a List<byte[]>, one ToArray() per arriving segment, and held every array until the value was complete. Segment sizes follow transport framing, so the arrays were odd-sized and, at the sizes involved, landed on the large object heap and were promoted while the whole payload stayed reachable. A 6 GB object retained roughly 1,536 such arrays. PooledChunkList packs arriving spans into uniform 64 KB buffers rented from ArrayPool<byte>.Shared: below the 85,000-byte large-object-heap threshold and within the pooled size classes, so accumulating an arbitrarily large value allocates nothing at steady state. Packing also decouples buffer size from the transport's segment size, so odd-sized segments no longer produce odd-sized arrays. The buffers are exposed as a ReadOnlySequence<byte> exactly as before, so a value may still exceed 2 GB and is still deserialized as a stream with no contiguous copy. Converted sites: AofChunkedRecordReader.AppendChunk AOF replay ChunkedRecordReassembler Phase.ObjectData migration import MigrationChunkWriterAccumulator.Consume migration export ReadOnlySequenceBuilder now links chunks as ReadOnlyMemory<byte> so a segment can be bounded to its valid bytes within a larger rented array; its List<byte[]> overload had no remaining callers and is removed. MigrationChunkWriterAccumulator exposes ChunkCount/GetChunk in place of the List<byte[]> property, and ValueLength reads the accumulated total rather than summing per-chunk lengths. Buffers are returned on reset, and for AOF replay immediately after the value is deserialized. A missed return is not a correctness problem, since the array is simply collected instead of reused, but returning a buffer that is still referenced would be, so returns are placed only where the sequence is provably no longer in use. This does not change how much of a value is held at once: the accumulated bytes are still a full second copy alongside the materialized object.
A chunked object value was accumulated in full and then deserialized, so the
serialized bytes were a complete second copy of the object alongside the
materialized form. This adds a path that deserializes the value while its chunks
arrive, so only a bounded window of serialized bytes is held.
GarnetObjectSerializer deserialization is synchronous - a BinaryReader driving a
constructor - so it cannot be parked on an await and runs on its own thread while
the replay thread feeds it. StreamingObjectValueDeserializer joins the two with a
bounded queue of pooled buffers: the replay thread copies each arriving segment
in, blocking only while the queue is full, and the worker drains it through a
Stream. The copy means the worker never holds a pointer into the replay entry
buffer or the log's own memory, both of which are reclaimed as soon as the record
is processed. Deserialize(Stream) builds a reader local to the call, because
BinaryObjectSerializer keeps its reader in an instance field that interleaved
deserializations would otherwise clobber.
Streaming is offered only where blocking is safe, which is narrower than it
first appears:
- canStream is passed by the caller and defaults to false. RecoverLogDriver and
RecoverReplayTask pass !isProtected, because the bulk-consume path runs the
consumer under the log epoch when the record is resident in the in-memory log
buffer, and blocking there would stall log truncation and page shifting.
SingleLogRecover passes true, having copied the entry out. Replication replay
passes false: it is fed by the network, so the blocked thread would be the one
that must deliver the remaining chunks.
- Records that will be deferred are excluded. A value buffered for a
transaction or the fuzzy region would be held in materialized form for the
whole deferral, which for a collection is several times the serialized bytes
it replaces, and would have to be disposed on every discard path.
- Admission is non-blocking and capped. A replay thread that waited for a slot
could not reach the chunks that would release one, since the in-flight values
are fed by that same thread.
IHeapObject is IDisposable, so a materialized value that never reaches the store
is disposed: on the skip and fuzzy-buffer paths, and for records still in
progress when a replay context is torn down. Ownership is released once the store
takes the object, so a published value is not disposed twice.
Note that this is currently inert for the dominant workload. Whole-object upserts
are produced by RENAME, which wraps itself in an internal transaction to lock
both keys, so replay sees an open transaction when the value's chunks arrive and
the deferral exclusion above declines to stream. The tests pin that behaviour
rather than assert an outcome the design cannot produce.
The ring a streamed object value is serialized through was sized per write as min(HeapMemorySize, aofPageSize / 2), reaching 16 MB on the large object heap for a single write with the default 32 MB AOF page. Sizing it down to stay under the LOH threshold would have been the wrong fix: because a chunk record is allocated per drain, the ring also bounds how much value data one record can carry, so a small ring multiplies the emitted record count. A 6 GB object drains into roughly 1,536 records at 4 MB and roughly 98,304 at 64 KB. The ring is now rented from the log's SectorAlignedBufferPool, which is not GC-heap memory, so its size carries no large-object-heap cost and the question reduces to picking a good record size. IStreamBuffer.BufferSize (4 MB) is the size the object-log streaming path already uses for this job. It also sits at or above MinPartialAllocSize, which matters because page-tail packing only splits an allocation when both halves reach that size: a ring below 1 MB silently stops chunk records from filling a page tail. Renting per write also means a cached serializer holds no buffer between writes. The AOF path caches its serializer thread-statically and indefinitely, so a managed ring would stay pinned to that thread for the life of the process even while idle. The pool is passed per write rather than held, because a cached instance is keyed only by thread and input type and so may be reused across logs with different pools. The network path (migration, diskless replication) keeps a managed ring: those instances live for one operation and use the ring throughout, so renting would gain nothing, their sizes are caller-chosen rather than pool size classes, and they have no log from which to obtain a pool. Structurally the pool arrives through SetObjectWriteTarget, which only the object drive mode reaches, so the manual mode has nowhere to receive one; BeginSerialize now asserts this rather than leaving a null ring to fail later. The constructor documents the full choice.
The streaming path deserialized a chunked object value while its chunks arrived,
so the serialized bytes were never held as a second whole copy. It never engaged,
and could not: the exclusions it needs and the records it would apply to have no
overlap.
- Whole-object upserts come from RENAME, which wraps itself in an internal
transaction to lock both keys. Replay is therefore inside a transaction when
the chunks arrive, and the record is buffered until commit rather than
dispatched. A buffered record holds its value in whichever form it was
accumulated, and for a collection the materialized object is several times
the size of the bytes it would replace, so streaming is declined.
- Deserialization is synchronous, so streaming needs a second thread fed by the
replay thread. That thread cannot block while it holds the log epoch (the
bulk-consume path runs the consumer under it for records resident in the log
buffer) or while it is the thread that must deliver the remaining chunks
(replication replay, fed from the network).
Between them these cover every path a chunked object value currently arrives on,
so the machinery was inert: a worker pool, a bounded handoff, a termination
protocol and an object-ownership model, none of it reachable. Carrying it as dead
code costs more in review and maintenance than it saves, and it can be recovered
from history if a non-transactional whole-object upsert path appears.
The reasoning is recorded as comments where object chunks are accumulated, in the
AOF reader and in both migration directions, so the question does not have to be
re-derived. GarnetObjectSerializer.Deserialize(Stream), added only for this, goes
with it; the ReadOnlySequence overload remains.
PooledChunkList rented 64 KB blocks from ArrayPool<byte>.Shared. The size was chosen to stay under the large-object-heap threshold, which applies to a plain byte[] but is the wrong constraint here: SectorAlignedBufferPool hands out pinned arrays that it reuses, so a large block costs no repeated large-object allocation. Renting from it instead frees the buffer size to be chosen for what it actually governs, the number of segments in the resulting ReadOnlySequence. At 4 MB, matching the size the object-log streaming path uses, a 6 GB value forms roughly 1,536 segments rather than roughly 98,304, each of which is a ReadOnlySequenceSegment allocation. Buffers are shared across every chunk accumulation, so AOF replay and cluster migration draw from the same blocks. SectorAlignedMemory gains AsReadOnlyMemory(offset, length). Unlike the Span accessors on that type it is derived from the backing array and aligned_offset rather than from aligned_pointer: Memory<T> stores an object reference and cannot point at a raw address, so a pointer-based form is not expressible without a MemoryManager<T>, and going through the array avoids that object entirely. This is what lets a sequence segment span pooled buffers at no per-buffer cost. Also removes three members with no callers: GetArrayAndUnalignedOffset on both SectorAlignedMemory and BlittableFrame, and AvailableSpan and AvailableValidSpan on SectorAlignedMemory. (DiskReadBuffer declares its own AvailableSpan, which is the one ObjectLogReader uses; SectorAlignedMemory's was unreferenced.) Corrects the chunked-ring comment, which claimed the pool's memory "is not GC-heap memory". It is: SectorAlignedMemory is a pinned array from GC.AllocateArray<byte>(size, pinned: true). The properties that justify the ring size are that the blocks are pooled and reused and land on the pinned object heap rather than the large object heap, not that they are off-heap.
The AOF and migration record-layout docs still described the write ring as a fixed 64 KB buffer sized to stay under the large-object-heap threshold, and object-value accumulation as a List<byte[]>. Both now describe the pooled 4 MB ring and PooledChunkList, including the two constraints that fix the ring size (it bounds how much value data one chunk entry carries, and must stay at or above MinPartialAllocSize for page-tail packing). Also records why an object value is accumulated rather than deserialized as its chunks arrive, on both the AOF replay side (RENAME runs as an internal transaction, so the record is buffered until commit; and the feeding thread may hold the log epoch or be the one delivering the chunks) and the migration receive side (chunks span network commands, so blocking the receive thread would deadlock the connection).
The doc went straight from framing into chunk header bit layouts, so how chunks actually move was only recoverable by reading the whole section, and the paths that reuse the same machinery (cluster migration, diskless replication) were documented only in the companion doc. New section 5 maps every path in one table - producer side, carrier, consumer side - and separates the two buffer roles that are easy to conflate: the producer ring, whose size sets how many chunks are emitted and so affects the on-log/on-wire shape, and the consumer accumulator, whose size affects only segment count. It also records why migration must accumulate while diskless replication can stream (synchronous versus asynchronous send, and the store epoch not surviving an await), and calls out that disk-based replication ships checkpoint file byte ranges rather than records, so none of this applies to it until the replica replays the AOF. Existing sections 5 and 6 renumber to 6 and 7; internal anchors updated.
The builder had a single real consumer (PooledChunkList.AsSequence); the AOF reader's GetValueSequence called it directly rather than through AsSequence. Move the segment-linking into AsSequence and route the AOF reader through it, so the sequence is built in one place and the chunk buffers stay encapsulated.
Ted Hart (TedHartMS)
requested review from
Badrish Chandramouli (badrishc) and
Vasileios Zois (vazois)
and
a balanced review from Copilot
September 29, 2026 09:45
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Several error, discard, and teardown paths fail to return pooled buffers, and the public API removal needs compatibility handling.
Review effort: Balanced
Findings: 3
Open (5)
Reassembler leaks pooled blocks on errors and teardown · New Removed public API breaks source and binary compatibility · New Discard paths leak pooled accumulator buffers · New Return pooled buffers in finally after deserialization failures · New UnifiedOutput disposal leaks accumulator buffer rentals · New
What changed in this PR
Reduces allocation pressure in chunked AOF, migration, and replication paths while correcting BDN parameter filtering. The description matches the implementation, but the removed public helper is an unmarked breaking API change.
Changes:
- Reuses chunk serializers, writers, state, and pooled ring buffers.
- Adds pooled chunk accumulation for replay and migration.
- Fixes BDN
--opparamsfiltering and updates documentation.
| File | Description |
|---|---|
website/docs/dev/migration-replication-record-layout.md |
Documents pooled chunk accumulation. |
website/docs/dev/aof-record-layout.md |
Documents chunk lifecycle and sizing. |
libs/storage/Tsavorite/cs/src/core/Utilities/BufferPool.cs |
Adds array-backed read-only memory access. |
libs/storage/Tsavorite/cs/src/core/TsavoriteLog/TsavoriteLog.Chunked.cs |
Caches chunk state and serializers. |
libs/storage/Tsavorite/cs/src/core/Index/Interfaces/IObjectSerializer.cs |
Reuses encoding and binary writers. |
libs/storage/Tsavorite/cs/src/core/Allocator/ObjectSerialization/ChunkedObjectSerializer.cs |
Supports reusable, pooled serialization rings. |
libs/storage/Tsavorite/cs/src/core/Allocator/BlittableFrame.cs |
Removes unused array-offset API. |
libs/server/MigrationChunkWriterAccumulator.cs |
Uses pooled object-value chunks. |
libs/server/AOF/GarnetLog.cs |
Uses fixed pooled ring sizing. |
libs/server/AOF/AofProcessor.ChunkReplay.cs |
Returns replay chunk buffers after deserialization. |
libs/server/AOF/AofChunkedRecordReader.cs |
Accumulates replay values in pooled blocks. |
libs/common/ReadOnlySequenceBuilder.cs |
Removes the prior public sequence helper. |
libs/common/PooledChunkList.cs |
Introduces pooled sequence accumulation. |
libs/cluster/Session/ChunkedRecordReassembler.cs |
Pools migration receive buffers. |
libs/cluster/Server/Migration/MigrateSessionCommonUtils.cs |
Sends pooled chunks without flattening. |
benchmark/BDN.benchmark/Program.cs |
Registers operation-parameter filtering. |
benchmark/BDN.benchmark/Operations/RangeIndexOperations.cs |
Stabilizes range-index parameter ordinals. |
benchmark/BDN.benchmark/Operations/OperationsBase.cs |
Makes parameter enumeration unconditional. |
benchmark/BDN.benchmark/Operations/OperationParamsFilter.cs |
Filters BDN cases by selected operation mode. |
benchmark/BDN.benchmark/Operations/OperationParams.cs |
Enforces single-aspect parameter values. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Moving chunk accumulation from `List<byte[]>` to the pooled `PooledChunkList` changed what dropping an accumulation costs. A `byte[]` was reclaimed by the GC with no further consequence; a pool buffer reserves its share of the pool's cacheable budget when it is allocated and releases it only when it is returned, so a rental dropped to the GC permanently consumes that budget. Enough of them and the pool can no longer cache at all, and every subsequent rent falls back to a fresh allocation — silently restoring the behavior this branch set out to remove. Several paths discarded an accumulation without returning its buffers: - AOF replay dispatched object values and returned the chunks only after `Deserialize` succeeded, so a corrupt or incompatible serialized object abandoned the rental. The return now runs in a `finally`, and `ReplayOp` additionally returns in a `finally` around the whole dispatch. - A record skipped as belonging to a prior checkpoint version returned before dispatch, so its chunks were never released. `ShouldSkipRecord` now reports whether it buffered the record for later replay, and the skip path releases the record when it did not. - `TransactionGroup.Clear()` dropped the buffered operations of an aborted transaction. It now returns their chunk buffers; its contract is to discard, so a caller must not clear a group it still intends to replay. - `AofReplayContext.Dispose()` left `chunkedReader.inProgress`, the fuzzy-region buffer, the buffered transaction groups, and the active transactions holding their rentals. A truncated AOF tail is the normal outcome of a crash, so a partially-accumulated record at the end of replay is expected rather than exceptional. All four are now drained. - Cluster migration and replication reset the per-connection reassembler only after a record completed, so a connection torn down mid-record kept its buffers. `ChunkedRecordReassembler` is now `IDisposable` and `ClusterSession.Dispose()` disposes it. - The migration send side reset its accumulator only when capturing the *next* record, so the last record of a migration — and the in-flight record of one that failed mid-send — never returned. `MigrationChunkWriterAccumulator` is now `IDisposable`, `UnifiedOutput.Dispose()` disposes it, and the two migration send loops dispose the output rather than just its `SpanByteAndMemory`. Returning an already-returned list is a no-op, so the overlapping paths compose. The `PooledChunkList` remarks claimed a missed return was not a correctness problem; they now describe the budget cost.
The paths that discard a chunk accumulation overlap by design: a chunked operation releases its buffers as it is dispatched, and the container that held it is then cleared, which releases them again. Correctness therefore rests on `PooledChunkList.Reset()` being idempotent, and the failure it must not have is worse than a leak: returning one block twice pushes it onto a pool free list twice, and those lists are singly linked through the block itself, so the chain is corrupted and two renters are later handed the same memory. `CHECK_FREE` is not compiled in, so nothing would report it. `Reset()` was idempotent only because `buffers.Clear()` followed the loop. It now drops each reference before handing the buffer back, so no block can be returned twice even if the list is reset again while the loop is in progress. `TransactionGroup.Clear()` is renamed to `Discard()`, naming what it does now that it releases buffers rather than only dropping references, and matching `AofReplayContext.DiscardFuzzyRegionBuffer` and `AofChunkedRecordReader.DiscardInProgress`. The comment at its caller records why discarding an already-dispatched group is safe, since that is the overlap the idempotence requirement comes from. `PooledChunkListTests` covers the ownership contract: reset twice, reset and reuse across rounds, round-trip across buffer boundaries with irregular append sizes, and the empty case. Each rents past the pool afterwards and asserts no block is handed out twice, which is the observable form of the corruption. Verified to fail (7 of 8 cases) when `Reset` is made to return each buffer twice.
`BeginReplayOp` was called outside the try that releases the accumulator's pooled chunk buffers, so a throw from it abandoned the rental. It can throw: it waits on the vector-replication event, and only `ObjectDisposedException` is swallowed there. Rather than assert a no-throw contract on code this method does not own, both `ShouldSkipRecord` and `BeginReplayOp` now run inside the try. `isBuffered` is declared outside the try so the finally can read it even if `ShouldSkipRecord` throws, in which case it retains its initial `false` and the record is released. That is the correct bias: it is set only after `AddFuzzyRegionOperation` returns, so a throw from the add means the record never reached the fuzzy-region buffer and is still this method's to release. This also removes the duplicate release the old early-return branch carried, leaving one release site. `BeginReplayOp` gains a remark recording that it may throw and that a caller holding a resource must call it from within the releasing scope, so the constraint is not rediscovered. Both `ShouldSkipRecord` overloads now say what they do. The name suggests a single outcome, but returning true covers two: the record belongs to a prior checkpoint version and is dropped, or it is a new-version record inside the fuzzy region and is buffered for replay at the end of that region. Only the chunked overload needs to tell them apart, because only its record owns something that must be released; the non-chunked one buffers a copy of the record bytes and owns nothing. Renamed `buffered` to `isBuffered`. `AofChunkedRecordReader.DiscardInProgress` is renamed to `DiscardInProgressAccumulations`: the original parses as "a discard is in progress" rather than "discard the in-progress accumulations".
A checkpoint brackets a span of the AOF with CheckpointStartCommit and CheckpointEndCommit. A replica replaying that span - the fuzzy region - defers the records belonging to the new store version, takes its own checkpoint at the end marker, and only then replays what it deferred. A transaction is deferred differently from an ordinary write: its operations are collected into a TransactionGroup, and the group, not the individual records, is what must be set aside and replayed. A transaction committing inside that span was lost, and could wedge replay entirely. Four defects compounded: - AddToFuzzyRegionBuffer enqueued the group, and the ClearSessionTxn() that followed emptied that same object, so the queued group held no operations. - ProcessFuzzyRegionTransactionGroup, holding the only Dequeue, was called from a switch arm that AddOrReplayTransactionOperation's early return made unreachable for every TxnCommit, so the queue was never drained. - The deferred commit marker was dispatched through ReplayOp, whose switch has no TxnCommit case, so replay threw "Unknown AOF header operation type TxnCommit" and the replica stopped applying the stream. - Groups were deferred on inFuzzyRegion alone. Ordinary records are deferred only when storeVersion > CurrentVersion; a group of the old version was therefore deferred past the replica's own checkpoint and then skipped as stale. Each of the four is sufficient on its own to lose the transaction. The mechanism, in six parts: 1. The defer decision applies the version test ShouldSkipRecord uses for a single record, to the group as a unit, keyed on the commit record's version. An old-version group replays immediately, as it must: the checkpoint being taken captures that version. 2. The two branches no longer share a tail. Deferring removes the session's entry from activeTxns without clearing it, so the group reaches the buffer intact and ownership passes to the buffer. Replaying immediately still clears, releasing what the operations hold. 3. ReplayOperation carries a SequenceNumber, set from the commit's logAddressSequenceNumber when the marker is recorded. The value is in hand at defer time and not recoverable from the marker bytes later, and ProcessTransactionGroup needs it for the multi-log release barrier. 4. ProcessFuzzyRegionOperations recognizes the deferred commit marker - already recorded, previously inert - and replays its group at the marker's position in the deferred stream. The position is what orders the transaction among the surrounding records; draining the queue separately would apply groups out of order. This also keeps the marker out of ReplayOpDispatch, which is what threw. 5. ProcessFuzzyRegionTransactionGroup becomes that replay's only entry point. It keeps the FIFO dequeue, since markers and groups are enqueued in lockstep, and adds two things: an empty-queue check that reports a lost transaction rather than surfacing Dequeue's InvalidOperationException, and a finally that releases the group's operations once nothing else refers to them. The now-unreachable arm in ProcessAofRecordInternal is removed. 6. Abandoning a region discards the queued groups along with the markers. A group is reachable only through its marker, so dropping markers alone would leave the two out of step and the next region's markers would dequeue the wrong groups. This is reachable: a CheckpointStartCommit arriving while already in a fuzzy region abandons the previous one. ClusterReplicationFuzzyRegionTxnTests covers this. The region is not the checkpoint: the markers are written entering IN_PROGRESS and WAIT_FLUSH, so it spans only the wait for previous-version transactions to drain. Left alone that is about two transactions wide, and a test built on small transactions passes against the unfixed code. One writer therefore issues large transactions, each holding the drain open while the other writers' short transactions commit inside it. The test fails on unmodified main and passes with the fix.
In cluster mode the keys of each command queued into a MULTI are recorded as
the transaction is built, and verified together at EXEC. They were recorded as
pointers into the network receive buffer, and kept valid by deferring a copy:
when a later queued command observed that recvBufferPtr had changed, it copied
every key recorded so far into the transaction's scratch buffer.
That deferral is unsound, because the buffer is not merely replaced. A receive
buffer that fills is grown by DoubleNetworkReceiveBuffer, which allocates the
larger buffer, copies the bytes, and then DISPOSES the old buffer, returning it
to the pool it came from. The pointer change the copy keys off of is the same
event that releases the memory the copy reads. By the time the next queued
command triggers it, the source bytes are gone.
A 6000-command MULTI of a single key reported
CROSSSLOT Keys in request do not hash to the same slot
which cannot be true of one key. The deferred copy ran, found 4680 keys
recorded, and every one of the 4680 already read back as zero bytes; those are
the keys EXEC then verified.
SaveKeyArgSlice now copies each key into txnScratchBufferAllocator as it is
recorded, while the bytes are still live, so a recorded key never points into
the receive buffer and no later copy is needed. CopyExistingKeysToScratchBuffer,
saveKeyRecvBufferPtr and both pointer-comparison checks are removed.
This also covers the other ways the buffer moves underneath a retained pointer:
ShrinkNetworkReceiveBuffer likewise disposes the old buffer, and
ShiftNetworkReceiveBuffer compacts in place, moving the bytes without changing
the buffer's address at all - which a pointer comparison cannot detect. Copying
at record time is independent of all three.
Rejecting a valid transaction is the benign symptom. The same bytes feed slot
verification, so keys read out of a recycled buffer that happen to hash
consistently would pass a transaction whose keys really do span slots.
ClusterLargeTransactionSlotTests covers same-slot and single-key transactions
large enough to grow the buffer, and asserts that a genuinely cross-slot
transaction is still rejected. That case is driven through the raw RESP client
because StackExchange.Redis rejects a cross-slot transaction client-side and
never reaches the server. The two large cases fail without this change.
Ted Hart (TedHartMS)
force-pushed
the
tedhar-aof-chunk-perf
branch
from
September 30, 2026 14:32
5c6c11b to
5545d0d
Compare
WATCH copies its keys into a scratch allocator so they do not depend on the
network receive buffer. That allocator was the transaction manager's, which is
reset whenever a transaction completes - including an internal transaction, the
kind SMOVE, LMOVE and RENAME wrap themselves in, which commits with
internal_txn: true and so resets the allocator without clearing the watch
container.
A watch outlives those transactions, so its slices survive the reset pointing
into rewound space, and the next transaction's keys are written over them:
WATCH {aaa}watched -> copied at allocator offset 0
SMOVE {aaa}src {aaa}dst m -> internal txn commits, allocator rewinds
MULTI; SET {aaa}q v -> queued key written at offset 0
EXEC -> watched slice reads '{aaa}queuedd'
The container now owns its allocator, so nothing else can write over a key
that is still being watched.
This matters more since queued transaction keys began being copied at record
time: before that, only the realloc-triggered CopyExistingKeysToScratchBuffer
wrote to the transaction allocator, so overwriting a watched key needed a
receive-buffer growth mid-transaction. Now every queued key writes there, and
the first one lands on top of the watched key.
The damage is confined to what reads the bytes rather than the hash captured at
WATCH time. ValidateWatchVersion compares that captured hash, so abort
semantics are unaffected; slot verification agrees whenever the keys share a
hash tag. What does read them is SaveKeysToLock, so EXEC took its shared lock
on a key the client never watched, leaving the watched key unserialized against
concurrent writers for the length of the transaction.
ClusterWatchKeyLifetimeTests covers WATCH across an internal transaction, which
was untested. It does not detect this defect - it passes either way, for the
reasons above - and says so; the evidence is the slice contents shown above.
WATCH identifies a key in the version map by its hash alone. The map is a fixed-size array indexed by hash & sizeMask that stores no key bytes, so keys whose hashes alias to the same slot share a version and a write to either is seen by a watcher of the other. That is a deliberate tradeoff, and it errs conservatively: a collision can add a spurious EXEC abort but can never mask a real modification. Record the rationale on WatchedKeySlice.hash, document the remaining WatchedKeySlice fields, and add brief pointers from WatchVersionMap and WatchedKeysContainer. Comment-only.
run_bdnperftest.ps1 passed -f $framework, which only selects the framework the BDN host process runs on. BDN's own --fw option, which selects which framework jobs it launches, was never passed, so it kept its default of "all" and every CI matrix entry ran both the .NET 8 and .NET 10 jobs. The results analyzer matches rows on method and param with no framework column, so a regression in either framework failed both matrix entries. Pass --fw $framework so a run launches and is gated on one framework. This strictly reduces the rows the analyzer checks: every expected value already had to accommodate whichever framework allocated most. Drop -e BDNRUNPARAM=$framework. Nothing reads that variable. Raise expected_SMoveTwice_None to 12800 to cover .NET 8. PR #2095 rerouted SMOVE through RMW as SREM plus SADD, and SetObjectImpl.SetRemove's pre-.NET 9 branch calls Set.Remove(field.ToArray()), allocating a throwaway array per lookup; .NET 9 and later use the allocation-free span overload. Measured: .NET 10 6,400 B, .NET 8 12,800 B. A single expected value is shared by all frameworks, so it takes the higher one, as entries such as expected_SIsMember_None already do.
PooledChunkList.GetChunk(int) becomes an indexer. The type is the chunk collection, so this[i] alongside Count reads as the collection access it is, and matches how callers already use it, in a loop over Count. MigrationChunkWriterAccumulator keeps a named GetChunk. That type is not a collection; it also carries a key and an overflow value, so an unnamed indexer there would not say which of the three it returns. AofReplayContext.DiscardFuzzyRegionBuffer and AddToFuzzyRegionBuffer become internal. Both are new in this branch and both are reached only from AofReplayCoordinator in this assembly, which is also why the chunked reader's DiscardInProgressAccumulations was already internal.
ClusterLargeTransactionSlotTests built a ConnectionMultiplexer per test and never disposed it, so each of the fixture's seven cases left one running. Every cluster fixture in the assembly binds the same reserved ports, and the leaked multiplexers have AbortOnConnectFail false, so each kept retrying node 0's endpoint and reconnected to whichever server bound it next. ClusterManagementTests.ClusterClientList counts the client connections on each node and allows at most two, so it saw the seven zombies plus its own and failed with normal=8 on nodeIx=0. That test passed alone and passed with its own fixture, and failed only once the leaking fixture had run, which made it look like load-related flakiness. Running the two fixtures together reproduces it in seven seconds. Hold the multiplexer in a field and dispose it in TearDown, before the servers so it stops reconnecting while they shut down. Also drop a duplicated summary block on LargeCrossSlotTransactionIsRejected.
Vasileios Zois (vazois)
approved these changes
Oct 6, 2026
Ted Hart (TedHartMS)
added a commit
that referenced
this pull request
Oct 7, 2026
Resolves a semantic conflict: git merged the text cleanly but the result did not compile. Main's #2182 replaced ChunkedObjectSerializer's single `buffer` field with a managedBuffer/pooledBuffer pair behind a `Ring` accessor, while this branch still carried GetWritableSpan() => buffer. That method had no callers on either side of the merge. It was orphaned by b9e879f, which dropped the objectId-slot encoding and scratch copy it existed to serve, so it is removed rather than rewired to Ring.
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
PR #2039 changed object-store
WriteLogUpsertfrom "serialize the whole value contiguously, thenLog.Enqueue" toEnqueueObjectChunked, so that values larger than a log page (and larger than 2 GB) can be written. The chunking iscorrect, but every chunked write built its machinery from scratch, and the buffer it streamed through was sized off the
AOF page.
Two consequences:
WriteLogUpsertof an object value allocated aChunkHeaderWriter<THeader>, aChunkWriteState, aChunkedObjectSerializer<ChunkWriteState, TInput>and its ringbuffer, a
ChunkStreamWriter, aBinaryWriter, and aUTF8Encoding.GarnetLog.ChunkBufferSize=min(HeapMemorySize, aofPageSize / 2), i.e. up to 16 MB with the default 32 MB AOF page — a fresh LOH array perwrite, then dropped.
On the read side, an arriving chunked object value was accumulated into a
List<byte[]>, one array per arrivingsegment. Segment sizes are dictated by transport framing, so those arrays are odd-sized (unpoolable) and, for a large
value, numerous, allocated, and promoted while the whole payload is retained.
What this changes
Write path — reuse instead of rebuild.
ChunkHeaderWriter/ChunkHeaderWriter<THeader>are deleted.ChunkWriteStatenow holds the header template in abyte buffer with a
Reset<THeader>/WriteHeader/Clearlifecycle, so the header no longer needs a genericwriter object per write.
ChunkWriteStateandChunkedObjectSerializer<ChunkWriteState, TInput>are rented from and returned to athread-static cache (shared with
EnqueueChunkedSpan). Renting nulls the cache slot, so a reentrant write gets itsown instance rather than corrupting the one in flight.
ChunkedObjectSerializerbinds its consumer, serializer, and value object per write; the ring and theChunkStreamWriterare allocated once and reused.Clear()drops key, input, value object, and context, so a cachedinstance roots nothing.
BinaryObjectSerializer<T>shares one staticUTF8Encodingand caches itsBinaryWriterwhile the stream isunchanged;
EndSerializeflushes rather than disposes (identical behavior underleaveOpen: true). Migration anddiskless replication get this too.
SectorAlignedBufferPoolinstead of being allocated per write, so its size nolonger implies an allocation at all.
Read path — pooled, uniform accumulation.
PooledChunkList(libs/common) packs arriving spans into uniform 4 MB buffers rented from a sharedSectorAlignedBufferPool, and exposes them as oneReadOnlySequence<byte>(no contiguous copy, so a value may exceed2 GB). Both chunked readers — AOF replay's
ChunkedAccumulatorand cluster migration'sChunkedRecordReassembler—use it in place of
List<byte[]>.segment count, matching the object-log streaming path, and keeps the sequence to one segment per few megabytes
rather than per few tens of kilobytes.
Ring size. The write ring is 4 MB (
TsavoriteLog.ChunkedObjectRingBufferSize) rather than page-derived. Note thisis the one size in the change that is not purely an implementation detail: the ring bounds how much value data one
chunk entry carries, so it sets the number of chunk entries emitted for a given value, and is therefore visible in the
log. Its lower bound is
MinPartialAllocSize(below that, page-tail packing stops working).Returning what was rented. Pooling changes what dropping an accumulation costs. A
byte[]was reclaimed by the GCwith no further consequence; a pool buffer reserves its share of the pool's cacheable budget when it is allocated and
releases it only when it is returned, so a rental dropped to the GC permanently consumes that budget — enough of them
and the pool stops caching entirely and every rent falls back to a fresh allocation, silently reinstating what this PR
removes. Every path that discards an accumulation therefore returns its buffers: AOF replay returns in a
finallyaround both the object deserialize and the whole dispatch, and on the prior-checkpoint-version skip (distinguished from
a fuzzy-region deferred record, which must keep them);
TransactionGroup.Discard()returns those of the operationsit drops;
AofReplayContext.Dispose()drains the in-progress reader, the fuzzy-region buffer (markers and thetransaction groups paired with them) and the active transactions — a truncated AOF tail is the normal outcome of a
crash, so a partially-accumulated record at the end of replay is expected;
ChunkedRecordReassembleris disposed byClusterSession.Dispose(), covering a connection torn down mid-record; andMigrationChunkWriterAccumulatorisdisposed via
UnifiedOutput.Dispose(), covering a migration's final record and one that fails mid-send. Returning analready-returned list is a no-op, so the overlapping paths compose.
Replica transaction loss during a primary checkpoint (included)
Found while verifying the buffer-release paths above, and fixed here because it lives in the code this PR already
touches. It is a pre-existing defect on
main, not a regression from this branch.A checkpoint brackets a span of the AOF with
CheckpointStartCommitandCheckpointEndCommit. A replica replayingthat span — the fuzzy region — defers the records belonging to the new store version, takes its own checkpoint at the
end marker, and only then replays what it deferred. A transaction is deferred differently from an ordinary write: its
operations are collected into a
TransactionGroup, and the group, not the individual records, is what must be setaside and replayed.
A transaction committing inside that span was lost, and could wedge replay entirely. Four defects compounded, each
sufficient on its own:
AddToFuzzyRegionBufferenqueued the group, and theClearSessionTxn()that followed emptied that same object, sothe queued group held no operations.
ProcessFuzzyRegionTransactionGroup, holding the onlyDequeue, was called from a switch arm thatAddOrReplayTransactionOperation's early return made unreachable for everyTxnCommit, so the queue was neverdrained.
ReplayOp, whose switch has noTxnCommitcase, so replay threwUnknown AOF header operation type TxnCommitand the replica stopped applying the stream.inFuzzyRegionalone. Ordinary records are deferred only whenstoreVersion > CurrentVersion; a group of the old version was therefore deferred past the replica's own checkpointand then skipped as stale.
The mechanism, in six parts:
ShouldSkipRecorduses for a single record, to the group as a unit,keyed on the commit record's version. An old-version group replays immediately, as it must: the checkpoint being
taken captures that version.
activeTxnswithout clearingit, so the group reaches the buffer intact and ownership passes to the buffer. Replaying immediately still clears,
releasing what the operations hold.
ReplayOperationcarries aSequenceNumber, set from the commit'slogAddressSequenceNumberwhen the marker isrecorded. The value is in hand at defer time and not recoverable from the marker bytes later, and
ProcessTransactionGroupneeds it for the multi-log release barrier.ProcessFuzzyRegionOperationsrecognizes the deferred commit marker — already recorded, previously inert — andreplays its group at the marker's position in the deferred stream. The position is what orders the transaction among
the surrounding records; draining the queue separately would apply groups out of order. This also keeps the marker
out of
ReplayOpDispatch, which is what threw.ProcessFuzzyRegionTransactionGroupbecomes that replay's only entry point. It keeps the FIFO dequeue, sincemarkers and groups are enqueued in lockstep, and adds an empty-queue check that reports a lost transaction rather
than surfacing
Dequeue'sInvalidOperationException, plus afinallythat releases the group's operations oncenothing else refers to them. The now-unreachable arm in
ProcessAofRecordInternalis removed.so dropping markers alone would leave the two out of step and the next region's markers would dequeue the wrong
groups. This is reachable: a
CheckpointStartCommitarriving while already in a fuzzy region abandons the previousone.
The fix is self-contained: verified by porting it onto unmodified
main, where it builds clean and the new testpasses, while the same test fails without it. Its only ties to the rest of this PR are naming (
DiscardvsClear)and one added line that releases pooled buffers, which exist only because of this PR.
ClusterReplicationFuzzyRegionTxnTestscovers this. The region is not the checkpoint: the markers are written enteringIN_PROGRESSandWAIT_FLUSH, so it spans only the wait for previous-version transactions to drain. Left alone thatis about two transactions wide, and a test built on small transactions passes against the unfixed code. One writer
therefore issues large transactions, each holding the drain open while the other writers' short transactions commit
inside it.
Queued transaction keys aliased the receive buffer (included)
Also found while testing the above, also pre-existing on
main.In cluster mode the keys of each command queued into a
MULTIare recorded as the transaction is built, and verifiedtogether at EXEC. They were recorded as pointers into the network receive buffer, and kept valid by deferring a copy:
when a later queued command observed that
recvBufferPtrhad changed, it copied every key recorded so far into thetransaction's scratch buffer.
That deferral is unsound, because the buffer is not merely replaced. A receive buffer that fills is grown by
DoubleNetworkReceiveBuffer, which allocates the larger buffer, copies the bytes, and then disposes the old buffer,returning it to the pool it came from. The pointer change the copy keys off of is the same event that releases the
memory the copy reads — by the time the next queued command triggers it, the source bytes are gone.
A 6000-command
MULTIof a single key reportedCROSSSLOT Keys in request do not hash to the same slot, whichcannot be true of one key. Instrumenting the deferred copy showed it running, finding 4680 keys recorded, and every one
of the 4680 already reading back as zero bytes — those are the keys EXEC then verified.
SaveKeyArgSlicenow copies each key intotxnScratchBufferAllocatoras it is recorded, while the bytes are stilllive, so a recorded key never points into the receive buffer and no later copy is needed.
CopyExistingKeysToScratchBuffer,saveKeyRecvBufferPtr, and both pointer-comparison checks are removed — the fix isa net deletion.
This also covers the other ways the buffer moves underneath a retained pointer:
ShrinkNetworkReceiveBufferlikewisedisposes the old buffer, and
ShiftNetworkReceiveBuffercompacts in place, moving the bytes without the buffer'saddress changing at all — which a pointer comparison cannot detect. Copying at record time is independent of all three.
Rejecting a valid transaction is the benign symptom. The same bytes feed slot verification, so keys read out of a
recycled buffer that happen to hash consistently would pass a transaction whose keys really do span slots.
ClusterLargeTransactionSlotTestscovers same-slot and single-key transactions large enough to grow the buffer, andasserts that a genuinely cross-slot transaction is still rejected — that case is driven through the raw RESP client,
because
StackExchange.Redisrejects a cross-slot transaction client-side and never reaches the server. The two largecases fail without this change.
The rest of the codebase was audited for the same pattern — state holding a pointer into the receive buffer across a
network read.
SUBSCRIBE/PSUBSCRIBE(ByteArrayWrapper.CopyFrom) heap-allocates its copy;TxnKeyEntriesstoresonly a precomputed hash; and the blocking commands (
BLPOP,BLMOVE,BLMPOP,BZPOPMIN,BZMPOP) hold thenetwork thread inside
AsyncUtils.BlockingWait, so no read boundary is crossed while their arguments are live.WATCHalso copies, but into a shared allocator — see below.
Watched keys shared the transaction allocator (included)
Copying queued keys at record time made this reachable, so it is fixed here rather than left for later.
WATCHcopies its keys into a scratch allocator so they do not depend on the receive buffer. That allocator was thetransaction manager's, which is reset whenever a transaction completes — including an internal transaction, the kind
SMOVE,LMOVEandRENAMEwrap themselves in, which commits withinternal_txn: trueand so resets the allocatorwithout clearing the watch container. A watch outlives those transactions, so its slices survive the reset pointing
into rewound space and the next transaction's keys are written over them:
That last line is observed, not inferred. The container now owns its allocator, so nothing else can write over a key
that is still being watched.
The fix above is what makes this reachable in practice: previously only the realloc-triggered
CopyExistingKeysToScratchBufferwrote to the transaction allocator, so overwriting a watched key needed areceive-buffer growth mid-transaction; now every queued key writes there and the first one lands on the watched key.
The damage is confined to what reads the bytes rather than the hash captured at WATCH time.
ValidateWatchVersioncompares that captured hash, so abort semantics are unaffected, and slot verification agrees whenever the keys share a
hash tag. What does read them is
SaveKeysToLock— so EXEC took its shared lock on a key the client never watched,leaving the watched key unserialized against concurrent writers for the length of the transaction.
ClusterWatchKeyLifetimeTestscovers WATCH across an internal transaction, which was otherwise untested. It doesnot detect this defect — it passes either way, for the reasons above — and its remarks say so; the evidence is the
slice contents shown above.
API surface touched
Garnet.common.ReadOnlySequenceBuilder(public, added by Large-object support in AOF, Migration, and diskless Replication #2039, shipped in v2.1.5+) is removed. Its body moves intoPooledChunkList.AsSequence(). Both of its call sites were the two chunked readers, and both had already changedsignature in this branch when
List<byte[]>becamePooledChunkList— so the shippedFromChunks(List<byte[]>)signature was gone regardless; this removes a one-consumer wrapper rather than leaving a shim that no longer matches.
SectorAlignedMemory.AsReadOnlyMemory(offset, length)is added —Memory<T>holds an object reference and cannotpoint at a raw address, so the sequence segments are derived from the pinned array rather than from
aligned_pointer.This avoids needing a
MemoryManager<byte>per buffer.ChunkedRecordReassemblerandMigrationChunkWriterAccumulatorbecomeIDisposable, andUnifiedOutput.Dispose()now also disposes its
Accumulator(previously onlySpanByteAndMemory).SectorAlignedMemory.GetArrayAndUnalignedOffset,BlittableFrame.GetArrayAndUnalignedOffset,SectorAlignedMemory.AvailableSpan, andSectorAlignedMemory.AvailableValidSpan— none had any caller.GarnetLog.ChunkBufferSizeis removed (superseded by the fixed ring size).Streaming deserialization: considered, removed
An earlier revision deserialized a chunked object value as its chunks arrived, holding only a bounded window of
serialized bytes. It is removed because it cannot engage on any path that currently delivers a chunked object value:
transaction when the chunks arrive, so the record is buffered until commit rather than dispatched — and a buffered
record must hold its value in whatever form it was accumulated. For a collection, the materialized object is several
times the size of its serialized bytes, so buffering the object is strictly worse than buffering the bytes.
it holds the log epoch or is the thread servicing the data source.
The reasoning is recorded in the docs at each point that describes chunk accumulation, so the option is not silently
re-litigated.
BDN harness fixes (included)
Two independent defects in the benchmark harness, both found while measuring this change.
--opparamsselected the wrong parameter set. The flag is parsed only in thehost process, where it mutates the
OperationsBase.ParamsNone/ACL/AOF/AADstatics. BDN's spawned per-job child neverruns our
Program.Main, so there those statics keep their defaults; the generated child re-invokes the params providerand selects by ordinal. With
--op aof, the host's provider yields[AOF](ordinal 0) while the child's yields[None, ACL, AOF, AAD]— so a row labelledAOFactually executedNone. CI is unaffected(
run_bdnperftest.ps1passes no--op).Fixed by making
OperationParamsProvider()unconditional (stable ordinals in both processes) and filtering benchmarkcases in the host with an
IFilter(OperationParamsFilter). Filtering a case cannot shift ordinals, so the failuremode is removed by construction; BDN ANDs config filters with
--filter, so it composes with CI usage. To make thepositional dependency harder to get wrong, the provider now names its arguments and
OperationParamsdrops the= falsedefault onuseAad.Every CI job ran both frameworks.
run_bdnperftest.ps1passed-f $framework, which selects only the frameworkthe BDN host process runs on. BDN's own
--fwoption, which selects which framework jobs it launches, was neverpassed, so it kept its default of
all. The results analyzer matches rows on method and param with no frameworkcolumn, so a regression in either framework failed both matrix entries — which is why a .NET 8-only regression was
reported against the .NET 10 job.
Fixed by passing
--fw $framework, so a run launches and is gated on one framework. This strictly reduces the rowsthe analyzer checks and so cannot introduce new failures: every expected value already had to accommodate whichever
framework allocated most. The dead
-e BDNRUNPARAM=$frameworkis dropped — nothing reads that variable.With that in place,
expected_SMoveTwice_Nonerises 6,400 → 12,800 to cover .NET 8. This is a pre-existingregression unrelated to AOF: #2095 rerouted
SMOVEthrough RMW asSREM+SADD, andSetObjectImpl.SetRemove'spre-.NET 9 branch calls
Set.Remove(field.ToArray()), allocating a throwaway array per lookup, where .NET 9+ uses theallocation-free span overload. Measured .NET 10 6,400 B, .NET 8 12,800 B. One expected value is shared by all
frameworks so it takes the higher, as entries like
expected_SIsMember_Nonealready do. The time regression from#2095 (two extra RMWs) is left alone here. Per-framework expected values are now feasible should they be wanted,
since each run is gated on a single framework.
Results
BDN
Operations.SetOperations,AOFparam, net10.0, CI-equivalent parameters:Allocation with AOF enabled now exactly equals each benchmark's non-AOF (
None) figure — object AOF logging isallocation-free at steady state — and is below the pre-#2039 baseline (136,000 / 94,400 / 98,400 B). Gen0 halved.
The same holds on net8.0, confirmed by a full
Operations.SetOperationsgate run: SUnionStore 91,200 B, SInterStore51,200 B, SDiffStore 55,200 B, each identical under
AOFandNone.Docs
website/docs/dev/aof-record-layout.mdgains a "Chunk management at a glance" section: one table covering how chunksmove on write and read for AOF, migration, and diskless replication, plus why disk-based replication is not record
chunking (it ships checkpoint file byte ranges), and a second table distinguishing the producer-side ring from the
consumer-side accumulator — including which of the two is visible in the wire/log format.
website/docs/dev/migration-replication-record-layout.mdis updated to match the pooled accumulation.website/docs/benchmarking/overview.mddocuments the harness's framework parameter and the rule that one expectedvalue is shared across frameworks, so it must take the highest.
Testing
New:
PooledChunkListTests(pooled-buffer ownership — reset twice, reset and reuse, sequence round-trip acrossbuffer boundaries, empty case; each asserts the pool never hands out one block twice, and all were verified to fail
when
Resetis made to double-return),ClusterReplicationFuzzyRegionTxnTests,ClusterLargeTransactionSlotTests, andClusterWatchKeyLifetimeTests. The first three were each verified to failwithout their fix; the last covers previously untested behavior but does not discriminate, as its remarks record.
Passing: 197
Garnet.test.cluster(full assembly, twice consecutively), 111Garnet.test.cluster.replication, 43standalone transaction tests, 13
RespAofChunkTests(including huge-object AOF recover), 342 Tsavorite, 780Garnet.test.collections, 21Garnet.testAOF, 56Garnet.test.cluster.migrate(includingClusterMigrateHugeObjectChunked), and thediskless-sync huge-object chunked tests. The full
Operations.SetOperationsBDN gate passes on net8.0, 48/48.One self-inflicted test defect, worth recording because of how it presented.
ClusterLargeTransactionSlotTestsbuilt aConnectionMultiplexerper test and never disposed it, leaving one running for each of its seven cases. Every clusterfixture in the assembly binds the same reserved ports and those multiplexers have
AbortOnConnectFailfalse, so eachkept retrying node 0's endpoint and reconnected to whichever server bound it next.
ClusterManagementTests.ClusterClientListallows at most two client connections per node, so it saw the seven zombiesplus its own and failed with
normal=8. It passed alone and passed with its own fixture, failing only once the leakingfixture had run, which I initially mistook for load-related flakiness; running the two fixtures together reproduces it
in seven seconds. The multiplexer is now disposed in
TearDown. No other connection in the cluster test tree is leftundisposed.