Skip to content

Derive the StreamOne ETag from the numeric revision for revisioned documents - #5121

Merged
jeremydmiller merged 2 commits into
JasperFx:masterfrom
uniquelau:stream-one-revision-etag
Aug 3, 2026
Merged

Derive the StreamOne ETag from the numeric revision for revisioned documents#5121
jeremydmiller merged 2 commits into
JasperFx:masterfrom
uniquelau:stream-one-revision-etag

Conversation

@uniquelau

@uniquelau uniquelau commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Addresses #5120.

What

StreamOne<T>'s ETag gate only reads the Guid mt_version flavor — which projection-target documents are forbidden from having (numeric revisions are forced by ProjectionDocumentPolicy, by design per #2978/#3057/#3097).

Net effect: the common CQRS read-model shape (a SingleStreamProjection document served by StreamOne<T>) can never emit an ETag, even though its mt_version already holds the source stream's version — the exact value StreamAggregate<T> serves as its ETag.

Commit 1 teaches the ETag path numeric revisions: StreamOneJsonResult widens to carry either flavor, revision mode piggy-backs the same VersionSelectClause/mt_etag_version select (no new SQL machinery; column read robustly at either width per #4614), and WriteSingle formats via the existing ETagHelpers.Format(long) — the If-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 what StreamAggregate serves 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): CompileAndValidate now throws an actionable InvalidDocumentException when both Metadata.Version.Enabled and Metadata.Revision.Enabled are set. Today that state surfaces as a raw duplicate-key ArgumentException from projection storage, or invalid DDL with two mt_version columns — DocumentStorageDescriptorBuilder already documents the never-both assumption. The only fluent sequence newly rejected is UseNumericRevisions(true) followed by UseOptimisticConcurrency(true) on one store, which never worked (duplicate-column DDL). We deliberately did not make UseOptimisticConcurrency(true) clear Revision.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 binds DBNull as the expected version), a silent runtime failure instead of a bootstrap error. We also considered and rejected moving ProjectionDocumentPolicy to post-policies — it would silently override explicit user configuration and reorder ConfigureAggregateMapping.

Known non-goals / scope notes

  • The compiled-query overload (StreamOne<TDoc, TOut>) does not participate in ETag/304 handling (pre-existing; the same VersionSelectClause piggy-back would extend it later if wanted).
  • Revision-derived ETags are deterministic: a projection-logic change + rebuild that doesn't advance the stream won't invalidate cached responses (ProjectionVersion bumps don't reset the revision) — matching shipped StreamAggregate semantics; documented.
  • Multi-stream/ILongVersioned targets emit their own monotonic per-document revision — a valid ETag, but not a stream version; documented.
  • Malformed If-None-Match tokens behave exactly as on the Guid path (never match → 200) — inherited, unchanged.
  • EventProjection outputs 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; plain IRevisioned document emits its revision), 2 failing-first guard tests beside Bug_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… and rolling_range_partitioning… reproduce identically on clean master (pre-existing), and Bug_4187_ancillary… + divergent_application_assembly… pass in isolation on this branch (full-suite interference).

🤖 Generated with Claude Code

…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
uniquelau marked this pull request as ready for review August 2, 2026 14:36
@jeremydmiller
jeremydmiller merged commit 0e8daed into JasperFx:master Aug 3, 2026
10 checks passed
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>
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.

2 participants