Derive the StreamOne ETag from the numeric revision for revisioned documents - #5121
Merged
jeremydmiller merged 2 commits intoAug 3, 2026
Merged
Conversation
…ments Projection-target documents are forced onto numeric revisioning by ProjectionDocumentPolicy, so the Guid-only ETag gate in StreamOneWithVersion meant the common CQRS read-model shape (a SingleStreamProjection document served by StreamOne) could never emit an ETag — even though its mt_version already holds the source stream's version, the exact value StreamAggregate serves as its ETag. Widen StreamOneJsonResult to carry either flavor, piggy-back the same mt_version select in revision mode (reading the column robustly at either width per JasperFx#4614), and format the ETag with the existing long overload so If-None-Match/304 behaves identically to the Guid path. Types with neither flavor enabled still emit no ETag. Documents from an EventProjection are not forced into revisioning and emit no ETag unless they opt in; the compiled-query overload does not participate.
…document CompileAndValidate only checked the UseNumericRevisions/UseOptimisticConcurrency mode flags, but the metadata Enabled bits are what emit columns — and both flavors map to the same physical mt_version column. Schema.For<T>() .UseOptimisticConcurrency(true) on a projection-target document (which ProjectionDocumentPolicy has already forced onto numeric revisions, and whose fluent overrides run after the policies) left both bits enabled, surfacing as a raw duplicate-key ArgumentException from projection storage or invalid DDL with two mt_version columns at migration time. DocumentStorageDescriptorBuilder already assumes the two can never both be enabled. Throw an actionable InvalidDocumentException naming the two settings instead. Deliberately does not make UseOptimisticConcurrency(true) clear Metadata.Revision.Enabled: that would let a projection target slip past the guard into a mapping that breaks on the second inline apply with a ConcurrencyException, which is strictly worse than failing at bootstrap.
uniquelau
marked this pull request as ready for review
August 2, 2026 14:36
jeremydmiller
added a commit
that referenced
this pull request
Aug 3, 2026
…st gaps (#5157) 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>
This was referenced Aug 3, 2026
This was referenced Aug 4, 2026
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.
Addresses #5120.
What
StreamOne<T>'s ETag gate only reads the Guidmt_versionflavor — which projection-target documents are forbidden from having (numeric revisions are forced byProjectionDocumentPolicy, by design per #2978/#3057/#3097).Net effect: the common CQRS read-model shape (a
SingleStreamProjectiondocument served byStreamOne<T>) can never emit an ETag, even though itsmt_versionalready holds the source stream's version — the exact valueStreamAggregate<T>serves as its ETag.Commit 1 teaches the ETag path numeric revisions:
StreamOneJsonResultwidens to carry either flavor, revision mode piggy-backs the sameVersionSelectClause/mt_etag_versionselect (no new SQL machinery; column read robustly at either width per #4614), andWriteSingleformats via the existingETagHelpers.Format(long)— theIf-None-Match/304 branch is shared code, so behavior is identical to the Guid path by construction. For a single-stream target the ETag equals whatStreamAggregateserves for the same stream, so the two read styles are cache-compatible (Inline lifecycle; an Async-lifecycle document can lag the stream head).Commit 2 (separable — say the word and we split it into its own PR referencing #2978):
CompileAndValidatenow throws an actionableInvalidDocumentExceptionwhen bothMetadata.Version.EnabledandMetadata.Revision.Enabledare set. Today that state surfaces as a raw duplicate-keyArgumentExceptionfrom projection storage, or invalid DDL with twomt_versioncolumns —DocumentStorageDescriptorBuilderalready documents the never-both assumption. The only fluent sequence newly rejected isUseNumericRevisions(true)followed byUseOptimisticConcurrency(true)on one store, which never worked (duplicate-column DDL). We deliberately did not makeUseOptimisticConcurrency(true)clearRevision.Enabled: that would route a projection target past the guard into the Guid-only mapping that breaks on the second inline apply (ConcurrencyException— the projection path bindsDBNullas the expected version), a silent runtime failure instead of a bootstrap error. We also considered and rejected movingProjectionDocumentPolicyto post-policies — it would silently override explicit user configuration and reorderConfigureAggregateMapping.Known non-goals / scope notes
StreamOne<TDoc, TOut>) does not participate in ETag/304 handling (pre-existing; the sameVersionSelectClausepiggy-back would extend it later if wanted).ProjectionVersionbumps don't reset the revision) — matching shippedStreamAggregatesemantics; documented.ILongVersionedtargets emit their own monotonic per-document revision — a valid ETag, but not a stream version; documented.If-None-Matchtokens behave exactly as on the Guid path (never match → 200) — inherited, unchanged.EventProjectionoutputs are not forced into revisioning and emit no ETag unless they opt in; documented.Tests
4 new tests beside the existing ETag pins in
Marten.AspNetCore.Testing(projection-target emits"1", honors 304, advances to"2"with stale-tag 200; plainIRevisioneddocument emits its revision), 2 failing-first guard tests besideBug_2978_*, and the existing no-ETag pin extended to state the full negative condition (its subject has neither flavor).Suites (net10.0 locally; net9 deferred to CI — no local runtime): Marten.AspNetCore.Testing 101/101 · DocumentDbTests 1089/0 · EventSourcingTests 1604/0 · DaemonTests 260/0 · LinqTests 1421/0 · CoreTests 515 passed with 4 failures, all triaged as not-ours:
Bug_4185_codegen_conflict…androlling_range_partitioning…reproduce identically on clean master (pre-existing), andBug_4187_ancillary…+divergent_application_assembly…pass in isolation on this branch (full-suite interference).🤖 Generated with Claude Code