Skip to content

perf(snapshot): defer inbound plaintext UTF-8 byte count to first access (#53)#54

Merged
moisesja merged 1 commit into
mainfrom
feat/lazy-snapshot-bytecount-53
Jul 20, 2026
Merged

perf(snapshot): defer inbound plaintext UTF-8 byte count to first access (#53)#54
moisesja merged 1 commit into
mainfrom
feat/lazy-snapshot-bytecount-53

Conversation

@moisesja

Copy link
Copy Markdown
Owner

Closes #53.

What

InboundMessageSnapshot.Utf8ByteCount is now a lazily computed, memoized property instead of being eagerly computed in the constructor. One production file changed; EnvelopeReader, ProtocolDispatcher, and ObserverDelivery are deliberately untouched.

Why

Every successful UnpackAsync registers a snapshot, but only ObserverDelivery's byte-budget admission ever reads its UTF-8 size. Unpack-only consumers and dispatcher graphs without observers were paying an O(plaintext) Encoding.UTF8.GetByteCount scan (bounded by MaxReceiveBytes, default 1 MiB) per inbound message for a value nothing read. This was surfaced by the PR #51 adversarial review and deliberately deferred out of that security PR (see the issue's "Why this wasn't fixed in #51").

This implements the issue's preferred option (1): defer the byte count, keep the security placement.

Mapping to the issue's acceptance criteria

  1. "No Encoding.UTF8.GetByteCount (or equivalent O(plaintext) work) is performed on the unpack path when the snapshot's byte size is never read."
    The constructor no longer scans; the scan happens on first access of Utf8ByteCount, and only ObserverDelivery reads it. RegisterVerified_DoesNotScanPlaintextForByteCount proves the backing field is still unset after registration.
  2. "The verified-at-unpack immutability binding is unchanged."
    RegisterVerified stays exactly where it was in UnpackAsync; no registration gating, no relocation. All existing feat(discover-features): initiator round-trip + inbound observer seam (#49, #50) #51 correlator/observer trust tests (cross-tenant reply rejection, snapshot immutability, byte-budget admission) pass unmodified: 595 core + 159 interop, 0 failed.
  3. "A micro-benchmark or targeted test demonstrates the unpack-only path no longer scans."
    InboundMessageSnapshotTests pins the lazy contract structurally: no scan on registration, exact byte count for a multibyte payload on first read (byte count ≠ char count, so the assertion is meaningful), memoized thereafter.

On the unsynchronized lazy compute

Deliberate benign race, documented in the code: GetByteCount is deterministic over the immutable PlaintextJson and int writes are atomic, so concurrent first readers at worst each compute the same value once. No lock, no Lazy<T> allocation on a hot path.

🤖 Generated with Claude Code

…ess (#53)

Every successful UnpackAsync registers an InboundMessageSnapshot, but only
ObserverDelivery's byte-budget admission reads Utf8ByteCount. Move the
Encoding.UTF8.GetByteCount scan from the constructor to a lazily computed,
memoized property so unpack-only consumers and observer-less dispatcher
graphs stop paying an O(plaintext) pass per inbound message.

Registration stays at the unpack boundary: the verified-at-unpack
immutability binding from #51 is untouched. The unsynchronized lazy compute
is a benign race — deterministic value over an immutable string, atomic int
write; worst case is a duplicate computation of the same number.

New tests pin the contract: no scan on registration (backing field still
unset), exact byte count for multibyte plaintext on first read, memoized
thereafter. Full suite (595 core + 159 interop) passes unchanged.

Closes #53

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@moisesja moisesja self-assigned this Jul 20, 2026
@moisesja moisesja added this to the 1.3.0 milestone Jul 20, 2026

@moisesja moisesja left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

A clean, correctly scoped perf change. I reviewed the diff, the full InboundMessageSnapshot, and every consumer of Utf8ByteCount.

What I verified

Scope is honest. One production file touched. RegisterVerified stays at the unpack boundary, so the #51 verified-at-unpack immutability binding is genuinely unchanged — not just claimed.
Byte-budget accounting stays balanced (the main correctness risk). ObserverDelivery reserves OutstandingBytes += snapshot.Utf8ByteCount on admission (ObserverDelivery.cs:154) and releases the same value on all three exit paths (:158, :199, :351). Because the lazy value is deterministic over the immutable PlaintextJson and memoized on first read, reserve and every release read an identical int — the ledger can't drift.
The "benign race" is genuinely benign. int reads/writes are atomic per ECMA, and every first-reader computes the same GetByteCount, so there's no torn read and no path to an under-count that could sneak a payload past the budget. The -1 sentinel is safe (a real count is never negative; empty plaintext → 0). Skipping Lazy/locking on this hot path is the right call.
Tests pin the contract meaningfully. The multibyte payload makes byte count > char count, so the exact-size and memoization assertions are non-trivial, and RegisterVerified_DoesNotScanPlaintextForByteCount proves the deferral structurally.
Nit (non-blocking): the test couples to the private _utf8ByteCount field name via reflection. Acceptable — the MissingFieldException message tells a future renamer exactly what to fix.

No missing tests, tight scope, clear messages. LGTM.

@moisesja
moisesja merged commit 6ca028d into main Jul 20, 2026
2 checks passed
@moisesja
moisesja deleted the feat/lazy-snapshot-bytecount-53 branch July 20, 2026 17:52
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.

Perf: InboundMessageSnapshot is registered (and its UTF-8 size computed) on every UnpackAsync, even when no observer/correlator consumes it

1 participant