Skip to content

feat: Incremental CBOR reader adoption - #4555

Merged
dscpinheiro merged 7 commits into
feature/4.1from
cbor-reader-adoption
Oct 29, 2026
Merged

dscpinheiro merged 7 commits into
feature/4.1from
cbor-reader-adoption

Conversation

@sichanyoo

@sichanyoo sichanyoo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adopt the .NET 11 incremental CborReader APIs for CBOR deserialization. Read path only; serialization is untouched.

Incremental decode path on all targets. CborStreamReader now uses CborReader.SlideData(...) and CborReaderState.NeedsMoreData from System.Formats.Cbor 11.0, which keep parsing state across chunk boundaries, replacing the hand-rolled buffering. The package ships net462, netstandard2.0, net10.0 and net11.0 assets, so every target uses the same reader (net8.0 gets the netstandard2.0 asset).

Refills. Reads refill while the reader reports NeedsMoreData; SkipValue refills in a TrySkipValue loop. Indefinite-length strings and decimal fractions span several CBOR items, so they can still cross a chunk edge after that check; for those (initial byte 0x5F, 0x7F or 0xC4) a failed read is retried after a refill until it succeeds or the stream is exhausted. Any other content error propagates.

Pooled buffer. Fill() moves unconsumed bytes to the front of a pooled ArrayPool<byte> buffer and appends the next chunk, so the reader never sees a split item. The buffer only grows when unconsumed bytes already fill it, so a malformed length header can't force a large allocation.

Lax conformance. Required for incremental reads (stricter modes reject a non-final block), with AllowMultipleRootLevelValues = true. The stream is read lazily, so an event-stream response is handed on undecoded.

Errors. The reader's InvalidOperationException for structural misuse on a malformed payload is wrapped as CborContentException, matching the previous reader.

Package reference. System.Formats.Cbor moves from 9.0.5 to 11.0.0-rc.2.26504.105 on all targets (both csproj files and the nuspec). Move to 11.0.0 once .NET 11 GA is on nuget.org.

Performance

Numbers are the AWSJSON mean / CBOR mean ratio within a single run (>1.00 = CBOR faster), which cancels out runtime and machine differences. Same harness and instance types for all four runs:

  • net10-base — .NET 10 runtime, before this change (feature/4.1: old reader, System.Formats.Cbor 9.0.5).
  • net10-chg — same runtime, this change (incremental reader, System.Formats.Cbor 11.0 RC2).
  • net11-base — .NET 11 RC2 + System.Formats.Cbor 11.0 RC2, incremental path forced off (old reader).
  • net11-chg — same runtime + package, this change (incremental reader on).

The net11 pair differs only in reader code. The net10 pair also differs in package version, so it shows what a .NET 10 consumer gets on upgrade. The net10 pair was measured at c487235e5e2d; the net11 pair is from earlier runs.

m7i.xlarge (x86) — deserialization

id net10-base net10-chg net10 A/B Δ net10 A/B % net11-base net11-chg net11 A/B Δ net11 A/B %
GetItemOutput_S 0.979 1.385 +0.406 +41.5% 1.122 1.725 +0.603 +53.7%
GetItemOutput_M 0.958 1.333 +0.375 +39.1% 1.109 1.612 +0.503 +45.4%
GetItemOutput_L 1.043 1.409 +0.366 +35.1% 1.110 1.552 +0.442 +39.8%
GetItemOutputBinary_S 0.889 1.238 +0.349 +39.3% 0.997 1.501 +0.504 +50.6%
GetItemOutputBinary_M 0.890 1.216 +0.326 +36.6% 1.015 1.474 +0.459 +45.2%
GetItemOutputBinary_L 0.998 1.304 +0.306 +30.7% 1.012 1.424 +0.412 +40.7%
mean 0.959 1.314 +0.355 +37.0% 1.061 1.548 +0.487 +45.9%

m7g.xlarge (arm) — deserialization

id net10-base net10-chg net10 A/B Δ net10 A/B % net11-base net11-chg net11 A/B Δ net11 A/B %
GetItemOutput_S 0.924 1.375 +0.451 +48.8% 1.104 1.753 +0.649 +58.8%
GetItemOutput_M 0.968 1.411 +0.443 +45.8% 1.109 1.715 +0.606 +54.6%
GetItemOutput_L 1.061 1.548 +0.487 +45.9% 1.096 1.685 +0.589 +53.7%
GetItemOutputBinary_S 0.871 1.266 +0.395 +45.4% 1.010 1.603 +0.593 +58.7%
GetItemOutputBinary_M 0.926 1.325 +0.399 +43.1% 1.008 1.591 +0.583 +57.8%
GetItemOutputBinary_L 1.053 1.488 +0.435 +41.3% 1.020 1.576 +0.556 +54.5%
mean 0.967 1.402 +0.435 +45.0% 1.058 1.654 +0.596 +56.3%

Headline: deserialization improves on both runtimes, across all GetItemOutput* sizes and payload types:

  • .NET 10: +37.0% (x86), +45.0% (arm); combined 0.963 → 1.358 (+41.0%).
  • .NET 11: +45.9% (x86), +56.3% (arm); combined 1.059 → 1.601 (+51.1%).

The change wins by a similar margin within each runtime, so the gain comes from the reader, not .NET 11.

Serialization is unchanged (write path untouched): PutItemRequest_* ratios stay within run-to-run noise in both pairs (±6% on net10).

Motivation and Context

CborStreamReader hand-rolled its buffering because System.Formats.Cbor had no incremental API. The 11.0 SlideData / NeedsMoreData APIs remove that workaround on every target and make CBOR deserialization measurably faster relative to AWSJSON.

Testing

  • CborStreamReaderTests: lazy element-count validation (a declared array count is returned, the error surfaces when the body runs out, and a header can't force a ~100 MB allocation); boundary sweeps that place an indefinite-length text string, byte string and decimal at every offset 0–399 with a 100-byte buffer; and truncation inside each of those values.
  • Unit tests pass on net8.0, net10.0 and net11.0; net472 builds.
  • Benchmarks: the net11 pair ran with the runner on net11.0 and the old reader forced off for the base, holding runtime and package constant. The net10 pair ran feature/4.1 (a0103d77f9a2) and this branch (c487235e5e2d) back to back on the .NET 10.0.14 runtime. The runner (SdkPerformanceTargets) stays on net10.0, since Catapult perf hosts only have the .NET 10 runtime.

Dry-runs

DotNet Dry-run ID: DRY_RUN-9ff02a83-9140-4402-a0e6-484173d41ba6 (my dev account)

  • Pending
  • Completed successfully
  • Failed

PowerShell Dry-run ID: DRY_RUN-cdb6476f-8476-4d31-ae43-4efa82d524ef (my dev account)

  • Pending
  • Completed successfully
  • Failed

Breaking Changes Assessment

  • What functionality was changed? CBOR deserialization uses the incremental CborReader APIs on all targets; System.Formats.Cbor moves from 9.0.5 to 11.0.0-rc.2.26504.105. Returned values and exception types are unchanged.
  • How will this impact customers? Faster CBOR deserialization (measured on .NET 10 and .NET 11) and a newer System.Formats.Cbor dependency.
  • Why does this need to be a breaking change / non-breaking alternatives? Not breaking: no public API or wire-format changes.
  • Are best practices being followed? Yes — pooled ArrayPool<byte> buffer, bounded growth, and static (non-capturing) read delegates.
  • How have you tested this breaking change? N/A (non-breaking); see Testing.
  • Has a senior/+ engineer been assigned to review this PR? TBD

Screenshots (if appropriate)

N/A

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have read the README document
  • I have added tests to cover my changes
  • All new and existing tests passed

License

  • I confirm that this pull request can be released under the Apache 2 license

@sichanyoo
sichanyoo requested review from a team as code owners October 20, 2026 16:28
@sichanyoo
sichanyoo requested review from dscpinheiro and sankettangade and removed request for a team October 20, 2026 16:28
{
return readOperation(this, _internalCborReader);
}
catch (CborContentException) when (!_streamExhausted)

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.

If the payload is actually malformed (not just truncated), this keeps refilling and retrying until the stream is exhausted, so the whole response ends up in the buffer before we throw.

Worth checking whether the retry is needed for anything other than SkipValue and indefinite-length strings, since PeekState already guarantees single-token reads won't fail from truncation (SkipValue has TrySkipValue in the new API).

Example test:

[Fact]
public void MalformedByte_DoesNotBufferRestOfStream()
{
    // [ 0x1C (reserved additional info), then 2 MB of padding ]
    var bytes = new byte[2 * 1024 * 1024];
    bytes[0] = 0x82; bytes[1] = 0x1C;
    var stream = new MemoryStream(bytes);
    using var reader = new CborStreamReader(stream);

    reader.ReadStartArray();
    Assert.Throws<CborContentException>(() => reader.ReadInt32());
    Assert.True(stream.Position < 2 * AWSConfigs.CborReaderInitialBufferSize);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Rewrote ExecuteRead to drive refills proactively off PeekState() == NeedsMoreData and removed the catch (CborContentException) when (!_streamExhausted) retry. A content error now propagates immediately instead of refilling to the end of the stream, so a malformed token fails fast.

For the two cases you flagged: single-token reads are covered by the proactive NeedsMoreData loop, and SkipValue now uses TrySkipValue in a refill loop (returns false without consuming when it needs more data), so neither path depends on exceptions for control flow now.

I added your MalformedByte_DoesNotBufferRestOfStream test (adapted to the test fixture's buffer size) & it passes: on the malformed byte the stream position and buffer stay small rather than draining the 2 MB.

Comment thread extensions/test/CborProtocol.Tests/CborStreamReaderTests.cs
Comment thread extensions/test/CborProtocol.Tests/CborStreamReaderTests.cs Outdated
@boblodgett

Copy link
Copy Markdown
Contributor

Also as mentioned dry run for .NET is required on this one especially if changing the benchmarks to .NET 11. Also verify the changed tests are being run by the build system under non .NET framework targets.

Comment thread extensions/src/AWSSDK.Extensions.CborProtocol/Internal/CborStreamReader.cs Outdated
@sichanyoo
sichanyoo force-pushed the cbor-reader-adoption branch from c43cca5 to 3242f07 Compare October 28, 2026 16:44
@github-actions

Copy link
Copy Markdown
Backward compatibility review required - approval needed from one of the reviewers: normj, boblodgett

The incremental reader is no longer gated on net11, so every target needs the
11.0 package. The NetFramework project and the nuspec still referenced 9.0.5,
which broke the net472 build and would have shipped a 9.0.5 dependency.
System.Formats.Cbor 11.0 depends on System.ValueTuple 4.6.2, which treats
net471+ as in-box, so net472 no longer publishes System.ValueTuple.dll.
It also has no net8.0 asset, so net8.0 uses the netstandard2.0 build and
now publishes Microsoft.Bcl.HashCode.dll.
The incremental reader is no longer gated on net11, so a net10.0 runner already
exercises it through System.Formats.Cbor 11.0. The Catapult perf test hosts only
have the .NET 10 runtime, so a net11.0 runner fails to start.
@sichanyoo

Copy link
Copy Markdown
Contributor Author

Dry-run succeeded in v4.1 dev stack.

PeekState only waits for the next item's header, so indefinite-length strings
and decimal fractions could still run past the buffered chunk and throw. For
values starting with 0x5F, 0x7F or 0xC4, refill and retry until the read
succeeds or the stream is exhausted. Adds boundary-sweep and truncation tests.
@sichanyoo
sichanyoo requested a review from boblodgett October 28, 2026 23:05
@dscpinheiro
dscpinheiro merged commit ca4aa25 into feature/4.1 Oct 29, 2026
8 of 10 checks passed
@dscpinheiro
dscpinheiro deleted the cbor-reader-adoption branch October 29, 2026 15:30
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.

4 participants