Skip the body copy on a StreamOne 304, and close the revision-ETag test gaps - #5157
Merged
Merged
Conversation
…st gaps Follow-up to #5121. The ETag values and 304 semantics are unchanged. Efficiency: - Read mt_version off the row BEFORE the payload and consult a shouldWriteBody predicate, so a conditional-request hit never grows the pooled MemoryStream or copies the document. Column order is free here: readers access by GetOrdinal and Marten never opens them with CommandBehavior.SequentialAccess. The row still comes back from Postgres, so this saves the copy, not the read. Applies to the Guid path identically. - Drop the Convert.ToInt64(GetFieldValueAsync<object>()) box on the revision read in favor of a GetFieldType branch, keeping the #4614 both-widths handling. - Collapse StreamOneWithVersion/StreamOneWithRevision, which differed only in the CLR type read back, into one method behind a numericRevision flag. Tests: - Pin the single round trip on the revision path (the Guid path already had one). - Pin that a 304 leaves the response buffer empty. - Cover ILongVersioned (bigint) alongside IRevisioned (integer) on purpose. - Cover 404-emits-no-ETag and EmitETag = false on a revisioned document. - Cover the IRevisioned + UseOptimisticConcurrency route into #5121's guard, which is the likeliest real-world one and was untested. Verified against master that it previously failed as DDL with two mt_version columns. Docs: - Correct the claim that EventProjection outputs emit no ETag. They are not aggregate projection targets, so ProjectionDocumentPolicy leaves them on the plain-document default and they emit a Guid ETag that changes on every projection write -- a usable cache validator, but not a stream version. - Document #5121's fail-fast guard, which shipped undocumented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeremydmiller
marked this pull request as ready for review
August 3, 2026 13:58
This was referenced Aug 3, 2026
This was referenced Aug 4, 2026
This was referenced Aug 4, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #5121 (merged as 0e8daed), addressing the review points raised there. No behavior change to the ETag values themselves — same headers, same 304 semantics — plus one documentation correction where the shipped docs describe behavior the code does not have.
Efficiency
A
304no longer buffers the document body.VersionSelectClauseappendsmt_versionafterdata, but every reader accesses columns byGetOrdinal(name)and Marten never opens readers withCommandBehavior.SequentialAccess— so the row is fully buffered and read order is free.StreamOneWithVersionnow reads the version first and consults ashouldWriteBodypredicate before copying the payload. On a conditional-request hitWriteSingledeclines the body, so the pooledMemoryStreamis never grown and the document is never copied. The row still comes back from Postgres either way; this saves the copy, not the read. Benefits the Guid path identically.No more boxing on the revision read.
Convert.ToInt64(GetFieldValueAsync<object>(...))is replaced with aGetFieldTypebranch pickingGetFieldValueAsync<int>or<long>directly — same #4614 both-widths robustness, one less allocation per request on the projection read path.The two duplicated reader methods collapse into one.
StreamOneWithVersionandStreamOneWithRevisionwere near-verbatim copies differing only in the CLR type read back; they are now a single method taking anumericRevisionflag, andMartenLinqQueryProviderhas one branch instead of two.StreamOneJsonResult/StreamOneReadResultgainBodyWritten.Test coverage
Six new tests, all Alba end-to-end through
theHost.Scenarioagainst real minimal-API endpoints except where noted:stream_one_with_revision_etag_executes_a_single_db_command— the companion to the existing Guid-path acceptance test. The revision flavor is the projection read-model path, so pin that document + ETag resolve in ONE round trip. (DirectWriteSingleagainst a countingIMartenSessionLogger, matching the existing test's shape.)stream_one_does_not_buffer_the_document_body_on_a_304— pins the change above: a 20KB projected document with a matchingIf-None-Matchleaves the response buffer at zero length.stream_one_emits_revision_etag_for_long_versioned_document— newLongVersionedIssueNote : ILongVersionedkeeps thebigintcolumn whereIRevisionednarrows tointeger(UseTenantPartitionedEvents is incompatible with optimistic appends / FetchForWriting (aggregate handlers) #4614), so the pair now covers both widths on purpose rather than incidentally.stream_one_returns_404_without_etag_for_a_revisioned_document— the 404-leaks-no-header pin existed only for the Guid path.stream_one_suppresses_etag_on_a_revisioned_document_when_emit_etag_is_false— likewise for theEmitETag = falseopt-out, and asserts the document is still served rather than only the header dropped.interface_driven_revisions_plus_optimistic_concurrency_fails_fast(EventSourcingTests) — the likeliest real-world route into Derive the StreamOne ETag from the numeric revision for revisioned documents #5121's new guard and the one neither existing guard test covers: nothing in the configuration says "numeric revisions",VersionedPolicyturns them on because the type implementsIRevisioned, and a fluentUseOptimisticConcurrency(true)layers the Guid version on top. Verified against master before the guard: this reached the database asMartenSchemaException: DDL Execution ... Failedfrom twomt_versioncolumns.Documentation correction
docs/documents/aspnetcore.mdclaimedEventProjectionoutput documents "emit no ETag unless they opt into a versioning flavor themselves". They do emit one.ProjectionDocumentPolicyonly forces numeric revisions onto aggregate projection targets, so anEventProjection's output keeps the plain-document default — Guid version metadata — and emits a quoted GUID ETag that changes on every projection write. That is a usable cache validator but is not a stream version and does not line up withStreamAggregate<T>.stream_one_emits_a_guid_etag_for_an_event_projection_output_documentpins the real behavior against a newOrderTouchProjectionin IssueService.Also: #5121's fail-fast guard shipped undocumented.
docs/documents/concurrency.mdnow carries a warning naming both routes into the invalid state, since neither reads as "I asked for both flavors".Deliberately not included
UseOptimisticConcurrency/UseNumericRevisionsasymmetry.MartenRegistry.UseNumericRevisionsclearsMetadata.Version.Enabled, butUseOptimisticConcurrencydoes not clearMetadata.Revision.Enabled— so one order is last-wins and the reverse is now a hard bootstrap throw. Making it symmetric (and narrowing the guard to projection targets viaProjections.TryFindAggregate) is a behavior change to document configuration that deserves its own PR and its own discussion.WriteSinglewith aSelect()projection throwsIndexOutOfRangeException : Field not found in row: data. Reproduces identically on master at de63fc5, so it is a pre-existing StreamOne<T> ETag: inline mt_version (eliminate 2nd round trip) + reverse where T:notnull + ETag test hardening (pre-9.18, follow-up to #5015) #5027/StreamOne<T> ETag: inline mt_version (one round trip) + drop notnull + ETag test hardening (#5027) #5030 regression, not a Derive the StreamOne ETag from the numeric revision for revisioned documents #5121 one:FindMapping(typeof(T))for the projected/anonymousTreturns a fresh mapping whoseMetadata.Version.Enableddefaults to true, soVersionSelectClausegets appended to a select that has nodatacolumn. Filed as StreamOne/WriteSingle with a Select() projection throws "Field not found in row: data" when emitETag is on (regression in 9.18.0) #5158.Verified locally (net10.0)
Marten.AspNetCore.Testing 107/107 · EventSourcingTests guard file 3/3 · LinqTests, DocumentDbTests, EventSourcingTests full suites clean.
markdownlintandcspellclean on the changed docs.🤖 Generated with Claude Code