#45 Write TryGetAsync payload directly into the caller buffer - #59
Conversation
The IBufferWriter overload of TryGetAsync copied the payload twice: transport buffer -> CacheEntry.Data -> caller buffer. Add a per-read, destination-bound deserializer (BufferWritingCacheEntryDeserializer) that writes the payload straight into the caller's buffer on a hit, eliminating the intermediate array on the common path (a single copy; true zero-copy is impossible because the payload must land in the caller's buffer). The write only happens for a genuine hit, so the "nothing written on a miss" contract holds: undeserializable framing, absolutely-expired, and not-found all leave the buffer untouched. Sliding-expiration entries keep the materialized two-copy path because refreshing their TTL re-writes the value and therefore needs the bytes. Factor the shared header parse into CacheEntryBinarySerializer.TryReadHeader and extract UpdateEntryExpirationAsync so the array and buffer read paths agree byte-for-byte and refresh sliding TTLs through one implementation. Add integration tests: exact-payload hit, empty-payload hit, and nothing-written on miss / undeserializable / absolutely-expired / sliding-past-absolute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matthew DeVenny <matt@codecargo.com>
There was a problem hiding this comment.
Pull request overview
Implements the “single-copy” TryGetAsync(string, IBufferWriter<byte>, …) fast path by deserializing directly into the caller-provided buffer (eliminating the intermediate byte[] on the common hit path), while keeping behavior aligned with the existing array-based read path for expiration, eviction, and telemetry.
Changes:
- Added a destination-bound deserializer (
BufferWritingCacheEntryDeserializer) that streams the payload into the caller’sIBufferWriter<byte>on genuine hits. - Factored shared binary framing parsing into
CacheEntryBinarySerializer.TryReadHeaderso array and buffer read paths validate/locate payload identically. - Added integration tests covering exact-payload hits, empty payload hits, and “nothing written on miss/invalid/expired” behavior for the buffer overload.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| test/IntegrationTests/Cache/TryGetAsyncBufferTests.cs | Adds integration coverage for buffer-path hit/miss/expired/undeserializable semantics. |
| src/NatsDistributedCache/NatsCache.cs | Replaces the buffer overload implementation with a single-copy read core and shares sliding-TTL refresh logic. |
| src/NatsDistributedCache/CacheEntryBinarySerializer.cs | Extracts header parsing/validation into TryReadHeader to share framing rules across read paths. |
| src/NatsDistributedCache/BufferWritingCacheEntryDeserializer.cs | Introduces a per-read deserializer that conditionally writes payload directly into the caller buffer. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
mtmk
left a comment
There was a problem hiding this comment.
Good win on the common path, and keeping sliding entries on the materialized path is the right call. One thing I would like fixed before merge; everything below that is optional.
Please fix: a failing destination write is reported as an undeserializable entry
Moving destination.Write into Deserialize puts caller-supplied code inside NATS's deserialization callback, where exceptions are swallowed:
NatsMsg<T>.Build(NatsMsg.cs:398-414, v3.0.1) catches anything the deserializer throws, setsheaders.Error = new NatsDeserializeException(...)and returnsdata = default.NatsKVStore.TryGetEntryAsync(NatsKVStore.cs:365-376) treats that as success and returnsValue = null, Error = <exception>.TryGetAndRefreshBufferAsyncseesresult == null, logsLogUndeserializableEntryat Debug, tags the missundeserializable, and dropskvEntry.Error.
The concrete trigger is HybridCache: it hands this overload a RecyclableArrayBufferWriter<byte>.Create(MaximumPayloadBytes) (1 MiB default), and that writer's Advance throws InvalidOperationException("Max length exceeded") on quota. An oversized L2 entry, written either by a node with a higher limit or through plain IDistributedCache.Set which is not quota checked, now looks like data corruption at Debug level. That pollutes the signal UndeserializableEntryTagsMissReasonUndeserializable exists to protect: a burst of oversized entries becomes indistinguishable from a stalled JSON to binary migration. Before this PR the same throw came out of destination.Write(result) in TryGetAsync and hit LogSwallowedException at Warning with the exception attached, so this is a net loss.
Suggested shape, catching at the write site:
try
{
WritePayload(remaining);
}
catch (Exception ex)
{
// The destination writer failed (e.g. HybridCache's payload quota), which is not corrupt data.
// Carry it out so the read core surfaces an error instead of an undeserializable miss.
return new CacheEntryBufferReadResult(
new CacheEntry { AbsoluteExpiration = absoluteExpiration },
payloadWritten: false,
absolutelyExpired: false,
destinationFailure: ex);
}and in the core, before the PayloadWritten check:
if (result.DestinationFailure is { } failure)
{
// Reaches TryGetAsync's catch: scope.SetError, Warning with the exception, and false to the caller.
ExceptionDispatchInfo.Capture(failure).Throw();
}Catching here rather than inspecting kvEntry.Error in the core avoids depending on NATS's error-wrapping behaviour, which is the layer that hides this in the first place, and keeps corrupt framing on the miss path. A blanket throw kvEntry.Error would also throw on NatsHeaderParseException, making GetAsync fail the caller's operation on a corrupt entry rather than degrading to a miss.
This is also a telemetry fix relative to main, where the scope completes as hit inside GetAndRefreshAsync before destination.Write runs, so a quota failure records a hit while the caller gets false.
One doc point to fold in: for a multi-segment payload, segments written before the throw stay committed, so the buffer holds a partial write on a false return. Harmless for HybridCache, which discards the writer, but the "nothing written on a miss" contract should say the buffer state is unspecified when the destination write fails.
Optional
- The deserializer re-implements
IsAbsolutelyExpiredinstead of calling it, and the XML doc asserts the two agree.TimeProviderExpirationUnitTestspins that predicate, including the exact-instant boundary, for theNatsCachecopy only. A shared static overCacheEntrywould keep them from drifting. WritesNothingOnAbsolutelyExpiredEntryandSlidingEntryPastAbsoluteExpirationWritesNothinguse ~1-2s expirations withTask.Delaypolling, so NATS is likely to reap the key first and the read lands onNotFound. Both assertions pass either way, so the intended branch may never run.ExpiredEntryReadTagsMissReasonExpiredAndRecordsNoRemoveOperationhas the deterministic recipe: long real TTL plusFakeTimeProvider.Advance, and asserting the miss reason pins the branch. Also drops ~8s of sleeping.- No unit tests for
BufferWritingCacheEntryDeserializer. The multi-segment branch ofWritePayloadis almost certainly never executed, since integration payloads are single-segment;CacheEntryBinarySerializerTests.Deserialize_MultiSegmentSequence_RoundTripshas the mirror worth copying, along with bad-header-returns-null and the expiry boundary. No server needed. - No telemetry coverage for the new core. This adds a second instrumented read path and the existing suite drives miss reasons entirely through
GetAsync. - Both cores ignore
kvEntry.Errorfor logging. A corrupt-headers entry and a legacy JSON envelope produce the identical Debug line; including the error separates them. - Roughly 50 lines of scope, expiry, eviction and revision-conflict handling now exist twice, kept in sync by comments. Worth a look at whether the array path can run over the buffer path with a pooled writer.
CacheEntryBufferReadResult's two bools makepayloadWritten && absolutelyExpiredrepresentable, and it allocates aCacheEntrythat is discarded on both the fast and expired paths. An outcome enum plus a nullable entry removes both.- The
IsSingleSegmentbranch inWritePayloadis redundant; theforeachcovers it and the enumerator is a struct. - Six
->arrows are written as U+2192 in the new file;src/currently has none. - The "nothing written on a miss" contract is stated in the file header, again in
<remarks>, again per branch, and again inNatsCache. One canonical statement plus references would read better. TryGetAsyncstill calls this "a zero-copy detail" while the PR text carefully argues single-copy.LegacyJsonEntryandWriteRawEntryAsyncare now in a third test file; candidate forTestBase.
Requested fix: a failing destination write is no longer reported as an undeserializable miss. Moving destination.Write inside the deserializer put it where NATS swallows exceptions; now a writer failure (e.g. HybridCache's payload quota) is caught at the write site and carried out as a DestinationFailure outcome, which the read core rethrows so TryGetAsync logs a Warning and records an error -- as on main -- instead of a Debug undeserializable miss. Also, per review: - Unify the array and buffer read paths into one GetAndRefreshAsync, removing ~50 lines kept in sync by comments; the deserializer materializes for the array/sliding paths and streams for the buffer fast path. - Replace the two-bool buffer result with a CacheEntryReadOutcome enum; the hit and expired paths allocate nothing beyond the payload. - Share the absolute-expiry predicate as a static so the paths cannot drift. - Log the KV entry's error to separate corrupt framing from a legacy envelope. - Make the absolute-expiry tests deterministic with FakeTimeProvider; add unit tests for CacheEntryReadDeserializer (incl. multi-segment and the boundary) and buffer-path telemetry (destination failure, expired). - Move LegacyJsonEntry/WriteRawEntryAsync to TestBase; drop the redundant IsSingleSegment branch; use ASCII arrows; fix the "zero-copy" wording. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Matthew DeVenny <matt@codecargo.com>
|
Thanks for the thorough review. Addressed in b902d12. Required fix — destination-write failures no longer read as undeserializable misses. The writer exception is now caught at the write site in the deserializer and carried out as a Optional items — all addressed:
118 unit + 88 integration tests pass; build clean on net8.0/net10.0 under |
Part of #45 (zero-copy
TryGetAsync). One of three independent PRs splitting that issue.What
The
IBufferWriter<byte>overload ofTryGetAsynccopied the payload twice: transport buffer →CacheEntry.Data→ caller buffer. This adds a per-read, destination-bound deserializer (BufferWritingCacheEntryDeserializer) that writes the payload straight into the caller's buffer on a hit, eliminating the intermediate array on the common path.CacheEntryBinarySerializer.TryReadHeaderand extractUpdateEntryExpirationAsync, so the array and buffer read paths agree byte-for-byte and refresh sliding TTLs through one implementation.Design
Testing
dotnet build -p TreatWarningsAsErrors=true: clean, net8.0 + net10.0.🤖 Generated with Claude Code