Skip to content

Remove the per-write allocations from the chunked object AOF path, and bound chunk buffering - #2182

Merged
Ted Hart (TedHartMS) merged 25 commits into
mainfrom
tedhar-aof-chunk-perf
Oct 6, 2026
Merged

Ted Hart (TedHartMS) merged 25 commits into
mainfrom
tedhar-aof-chunk-perf

Conversation

@TedHartMS

@TedHartMS Ted Hart (TedHartMS) commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Problem

PR #2039 changed object-store WriteLogUpsert from "serialize the whole value contiguously, then Log.Enqueue" to
EnqueueObjectChunked, so that values larger than a log page (and larger than 2 GB) can be written. The chunking is
correct, but every chunked write built its machinery from scratch, and the buffer it streamed through was sized off the
AOF page.

Two consequences:

  1. Allocation on every destination-set write. Each WriteLogUpsert of an object value allocated a
    ChunkHeaderWriter<THeader>, a ChunkWriteState, a ChunkedObjectSerializer<ChunkWriteState, TInput> and its ring
    buffer, a ChunkStreamWriter, a BinaryWriter, and a UTF8Encoding.
  2. A large-object-heap allocation per write. The ring was GarnetLog.ChunkBufferSize =
    min(HeapMemorySize, aofPageSize / 2), i.e. up to 16 MB with the default 32 MB AOF page — a fresh LOH array per
    write, then dropped.

On the read side, an arriving chunked object value was accumulated into a List<byte[]>, one array per arriving
segment. 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. ChunkWriteState now holds the header template in a
    byte buffer with a Reset<THeader> / WriteHeader / Clear lifecycle, so the header no longer needs a generic
    writer object per write.
  • ChunkWriteState and ChunkedObjectSerializer<ChunkWriteState, TInput> are rented from and returned to a
    thread-static cache (shared with EnqueueChunkedSpan). Renting nulls the cache slot, so a reentrant write gets its
    own instance rather than corrupting the one in flight.
  • ChunkedObjectSerializer binds its consumer, serializer, and value object per write; the ring and the
    ChunkStreamWriter are allocated once and reused. Clear() drops key, input, value object, and context, so a cached
    instance roots nothing.
  • BinaryObjectSerializer<T> shares one static UTF8Encoding and caches its BinaryWriter while the stream is
    unchanged; EndSerialize flushes rather than disposes (identical behavior under leaveOpen: true). Migration and
    diskless replication get this too.
  • The ring is now rented from the log's SectorAlignedBufferPool instead of being allocated per write, so its size no
    longer implies an allocation at all.

Read path — pooled, uniform accumulation.

  • New PooledChunkList (libs/common) packs arriving spans into uniform 4 MB buffers rented from a shared
    SectorAlignedBufferPool, and exposes them as one ReadOnlySequence<byte> (no contiguous copy, so a value may exceed
    2 GB). Both chunked readers — AOF replay's ChunkedAccumulator and cluster migration's ChunkedRecordReassembler —
    use it in place of List<byte[]>.
  • Pool blocks are pinned and reused, so the 4 MB buffer size costs no repeated LOH allocation; it is chosen for
    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 this
is 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 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 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 finally
around 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 operations
it drops; AofReplayContext.Dispose() drains the in-progress reader, the fuzzy-region buffer (markers and the
transaction 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; ChunkedRecordReassembler is disposed by
ClusterSession.Dispose(), covering a connection torn down mid-record; and MigrationChunkWriterAccumulator is
disposed via UnifiedOutput.Dispose(), covering a migration's final record and one that fails mid-send. Returning an
already-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 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, each
sufficient on its own:

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

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 an empty-queue check that reports a lost transaction rather
    than surfacing Dequeue's InvalidOperationException, plus 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.

The fix is self-contained: verified by porting it onto unmodified main, where it builds clean and the new test
passes, while the same test fails without it. Its only ties to the rest of this PR are naming (Discard vs Clear)
and one added line that releases pooled buffers, which exist only because of this PR.

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.

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

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 — the fix is
a net deletion.

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 the buffer's
address 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.

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.

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; TxnKeyEntries stores
only a precomputed hash; and the blocking commands (BLPOP, BLMOVE, BLMPOP, BZPOPMIN, BZMPOP) hold the
network thread inside AsyncUtils.BlockingWait, so no read boundary is crossed while their arguments are live. WATCH
also 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.

WATCH copies its keys into a scratch allocator so they do not depend on the 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'

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

ClusterWatchKeyLifetimeTests covers WATCH across an internal transaction, which was otherwise untested. It does
not 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 into
    PooledChunkList.AsSequence(). Both of its call sites were the two chunked readers, and both had already changed
    signature in this branch when List<byte[]> became PooledChunkList — so the shipped FromChunks(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 cannot
    point 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.
  • ChunkedRecordReassembler and MigrationChunkWriterAccumulator become IDisposable, and UnifiedOutput.Dispose()
    now also disposes its Accumulator (previously only SpanByteAndMemory).
  • Dead members removed while adjacent: SectorAlignedMemory.GetArrayAndUnalignedOffset,
    BlittableFrame.GetArrayAndUnalignedOffset, SectorAlignedMemory.AvailableSpan, and
    SectorAlignedMemory.AvailableValidSpan — none had any caller.
  • GarnetLog.ChunkBufferSize is 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:

  • Whole-object upserts come from RENAME, which wraps itself in an internal transaction. Replay is therefore inside a
    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.
  • Deserialization is synchronous, so streaming needs a second thread fed by the replay thread, which cannot block where
    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.

--opparams selected the wrong parameter set. The flag is parsed only in the
host process, where it mutates the OperationsBase.ParamsNone/ACL/AOF/AAD statics. BDN's spawned per-job child never
runs our Program.Main, so there those statics keep their defaults; the generated child re-invokes the params provider
and 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 labelled AOF actually executed None. CI is unaffected
(run_bdnperftest.ps1 passes no --op).

Fixed by making OperationParamsProvider() unconditional (stable ordinals in both processes) and filtering benchmark
cases in the host with an IFilter (OperationParamsFilter). Filtering a case cannot shift ordinals, so the failure
mode is removed by construction; BDN ANDs config filters with --filter, so it composes with CI usage. To make the
positional dependency harder to get wrong, the provider now names its arguments and OperationParams drops the
= false default on useAad.

Every CI job ran both frameworks. run_bdnperftest.ps1 passed -f $framework, which selects only 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. The results analyzer matches rows on method and param with no framework
column, 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 rows
the analyzer checks and so cannot introduce new failures: every expected value already had to accommodate whichever
framework allocated most. The dead -e BDNRUNPARAM=$framework is dropped — nothing reads that variable.

With that in place, expected_SMoveTwice_None rises 6,400 → 12,800 to cover .NET 8. This is a pre-existing
regression unrelated to AOF: #2095 rerouted SMOVE through RMW as SREM + SADD, and SetObjectImpl.SetRemove's
pre-.NET 9 branch calls Set.Remove(field.ToArray()), allocating a throwaway array per lookup, where .NET 9+ uses the
allocation-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_None already 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, AOF param, net10.0, CI-equivalent parameters:

Benchmark Allocated before Allocated after Per-command Time before Time after
SUnionStore 180,000 B 92,000 B −880 B 278.8 µs 214.2 µs
SInterStore 118,400 B 52,000 B −664 B 221.5 µs 189.2 µs
SDiffStore 122,400 B 56,000 B −664 B 201.8 µs 191.1 µs

Allocation with AOF enabled now exactly equals each benchmark's non-AOF (None) figure — object AOF logging is
allocation-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.SetOperations gate run: SUnionStore 91,200 B, SInterStore
51,200 B, SDiffStore 55,200 B, each identical under AOF and None.

Docs

website/docs/dev/aof-record-layout.md gains a "Chunk management at a glance" section: one table covering how chunks
move 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.md is updated to match the pooled accumulation.
website/docs/benchmarking/overview.md documents the harness's framework parameter and the rule that one expected
value is shared across frameworks, so it must take the highest.

Testing

New: PooledChunkListTests (pooled-buffer ownership — reset twice, reset and reuse, sequence round-trip across
buffer boundaries, empty case; each asserts the pool never hands out one block twice, and all were verified to fail
when Reset is made to double-return), ClusterReplicationFuzzyRegionTxnTests,
ClusterLargeTransactionSlotTests, and ClusterWatchKeyLifetimeTests. The first three were each verified to fail
without 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), 111 Garnet.test.cluster.replication, 43
standalone transaction tests, 13
RespAofChunkTests (including huge-object AOF recover), 342 Tsavorite, 780 Garnet.test.collections, 21
Garnet.test AOF, 56 Garnet.test.cluster.migrate (including ClusterMigrateHugeObjectChunked), and the
diskless-sync huge-object chunked tests. The full Operations.SetOperations BDN gate passes on net8.0, 48/48.

One self-inflicted test defect, worth recording because of how it presented. ClusterLargeTransactionSlotTests built a
ConnectionMultiplexer per test and never disposed it, leaving one running for each of its seven cases. Every cluster
fixture in the assembly binds the same reserved ports and those multiplexers have AbortOnConnectFail false, so each
kept retrying node 0's endpoint and reconnected to whichever server bound it next.
ClusterManagementTests.ClusterClientList allows at most two client connections per node, so it saw the seven zombies
plus its own and failed with normal=8. It passed alone and passed with its own fixture, failing only once the leaking
fixture 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 left
undisposed.

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.

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.

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 High severity · 2 Medium severity

Open (5)
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 --opparams filtering 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.

Comment thread libs/cluster/Session/ChunkedRecordReassembler.cs
Comment thread libs/common/PooledChunkList.cs
Comment thread libs/server/AOF/AofChunkedRecordReader.cs
Comment thread libs/server/AOF/AofProcessor.ChunkReplay.cs Outdated
Comment thread libs/server/MigrationChunkWriterAccumulator.cs
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.
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.
Comment thread libs/common/PooledChunkList.cs Outdated
Comment thread libs/server/AOF/ReplayCoordinator/AofReplayContext.cs Outdated
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.
@TedHartMS
Ted Hart (TedHartMS) merged commit 8f48266 into main Oct 6, 2026
227 checks passed
@TedHartMS
Ted Hart (TedHartMS) deleted the tedhar-aof-chunk-perf branch October 6, 2026 19:55
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.
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