feat: ETag / If-None-Match (304) support on StreamOne and StreamAggregate (fixes #5010) - #5015
Merged
jeremydmiller merged 3 commits intoJul 22, 2026
Conversation
…gate (fixes JasperFx#5010) - StreamOne<T>: adds EmitETag (default true). When enabled, WriteSingle looks up the document's mt_version (via IQuerySession.MetadataForAsync, reached through a new InternalsVisibleTo grant on IMartenLinqQueryable) and sets it as a quoted ETag response header. An incoming If-None-Match that matches short-circuits to 304 with an empty body. - StreamAggregate<T>: adds EmitETag (default true). Uses the event stream's version (via the already-public IEventStoreOperations.FetchStreamStateAsync) as the ETag, checked before the aggregate is folded so a cache hit skips that work entirely. - StreamMany<T> is intentionally left out of scope per the issue's own guidance that a collection-wide ETag is harder to derive cheaply. - Adds ETagHelpers for ETag formatting and If-None-Match matching (weak-etag prefix and wildcard aware). - Adds integration tests (Alba) covering ETag header presence, 304 on match, 200 on stale If-None-Match, and EmitETag=false opt-out for both types. - Fixes IssueService.csproj to reference JasperFx.Events.SourceGenerator directly as an analyzer (NuGet analyzer assets don't flow transitively across a ProjectReference to Marten), which was causing all Marten.AspNetCore.Testing tests touching event-sourced aggregates to fail with InvalidProjectionException in this environment. - Documents the new ETag support in docs/documents/aspnetcore.md.
erdtsieck
force-pushed
the
erdtsieck-etag-support-issue-5010
branch
from
July 22, 2026 10:02
e91be44 to
a23ec65
Compare
erdtsieck
added a commit
to erdtsieck/marten
that referenced
this pull request
Jul 22, 2026
… WIP
StreamingMinimalEndpoints.cs had picked up a /minimal/issue/{id:guid}/no-etag
endpoint referencing StreamOne<T>.EmitETag, a property that belongs to a
different, unrelated PR (JasperFx#5015) and doesn't exist on this branch. This broke
CI compilation. Removed the stray endpoint; the two StreamPagedByCursor
endpoints this PR actually needs are unaffected.
This was referenced Jul 22, 2026
jeremydmiller
added a commit
that referenced
this pull request
Jul 22, 2026
…rdening (#5027) (#5030) Fetch the document's mt_version inline with the document in the original single round trip instead of via a follow-up MetadataForAsync query, and harden the ETag coverage. Follow-up to #5015; ships before 9.18. Production change (one round trip): - New VersionSelectClause<T> decorator piggy-backs `d.mt_version as mt_etag_version` onto the single-document streaming query, mirroring the StatsSelectClause `count(*) OVER()` seam. Aliased so it never collides with an mt_version column the inner clause may already select. - MartenLinqQueryProvider.StreamOneWithVersion<T> / MartenLinqQueryable .StreamJsonFirstOrDefaultWithVersion stream the data and read the version off the same DbDataReader (JsonStreamingExtensions.StreamOneWithVersion), returning a StreamOneJsonResult(found, version). - WriteSingle now uses that path: no second MetadataForAsync query, no re-deserialization of the buffered JSON. Document types with version metadata disabled (no mt_version column) return a null version and emit no ETag rather than a constant zero-Guid (guards against false 304s). Constraint reversal: - Dropped the `where T : notnull` tightening from StreamOne<T> and WriteSingle<T> (it existed only because MetadataForAsync<T> is constrained notnull). Loosening a constraint is non-breaking, and this ships before 9.18 so the tightened form never reaches a release. Test hardening: - etag_helpers_tests: unit coverage for the ETagHelpers branches — `*` wildcard, `W/` weak-validator stripping, multi-value comma lists, multiple header values (InternalsVisibleTo added for the test project). - Version-metadata-disabled document (VersionlessDoc) emits no ETag. - 404 path emits no ETag. - Single-command acceptance: StreamOne with EmitETag=true executes exactly one DB command (verified with a command-counting session logger). Claude-Session: https://claude.ai/code/session_01JQ9NHbg31EWJmrQK9EN6i8 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Aug 2, 2026
jeremydmiller
pushed a commit
that referenced
this pull request
Aug 4, 2026
…sion (#5166) The ETag support added in #5015 selects mt_version alongside the payload and aliases the payload to "data", because the streaming reader looks it up by that name. It aliases by POSITION, on the strength of a comment in DocumentTable.SelectColumns claiming "the order of the selection is data, id, everything else". That order is not what the method does. It puts the id first whenever the id is selected at all, and IdColumn.ShouldSelect is storageStyle != QueryOnly. So the select list starts d.data only for a QueryOnly session; through any identity-tracking session it starts d.id, d.data. Aliasing field 0 then put the alias on the id column, the reader looked up "data", found the id, and streamed the document's id as the entire response body: GET /minimal/issue/{id} 200 OK Content-Length: 38 ETag: "1" "422c81ae-d73c-48ac-be1f-4eb65eefb606" A 200 whose payload does not deserialize into the document type. Reported against 9.22.3 from an application whose endpoints receive a session from Wolverine's Marten integration rather than a QueryOnly IQuerySession. Every existing endpoint in IssueService takes IQuerySession, which is why the whole ETag test suite passed over it. This adds the missing shape — one endpoint streaming through an IDocumentSession — and matches the payload column by name instead of by position. The positional fallback stays for an inner clause that projects rather than selects the column (SelectDataSelectClause's jsonb_build_object under a Select(), #5158), where there is no column name to match and the projection is the only candidate anyway. The new test fails on main with "the body must be the document, not its id" and passes with the fix. Marten.AspNetCore.Testing is 114 green on both TFMs.
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.
Summary
Adds HTTP conditional-request support (
ETag/If-None-Match->304 Not Modified) toStreamOne<T>andStreamAggregate<T>inMarten.AspNetCore, so polling clients can avoid re-downloading unchanged documents/aggregates. Closes #5010.StreamOne<T>EmitETaginit-only property, defaults totrue.WriteSingle<T>deserializes the already-buffered JSON to get an entity instance, then calls the publicIQuerySession.MetadataForAsync<T>()to readmt_versionand format it as a quoted-GUIDETagheader.If-None-Matchmatches, responds304with an empty body instead of the JSON payload.IMartenLinqQueryable.Sessionproperty, so a scoped[InternalsVisibleTo("Marten.AspNetCore")]was added to Marten'sAssemblyInfo.cs. Everything reached beyond that cast (QuerySession,IMartenSession,IQuerySession) is already public.StreamAggregate<T>EmitETaginit-only property, defaults totrue.IEventStoreOperations.FetchStreamStateAsyncto cheaply read the stream'slongversion before folding/snapshot work, so a304cache hit skips that work entirely."42".StreamMany<T>Left out of scope for this PR, per the issue's own guidance that a collection-wide ETag is harder to derive cheaply -- happy to follow up separately if there's appetite for a weak
max(mt_version) + countbased ETag.Tests
Added Alba-based integration tests in
Marten.AspNetCore.Testingcovering, for bothStreamOneandStreamAggregate:ETagheader present on a normal hit304returned (empty body) whenIf-None-Matchmatches the current version200body returned whenIf-None-Matchis staleEmitETag = falsesuppresses the header/behavior entirely (backwards-compatible opt-out)All 53 tests in
Marten.AspNetCore.Testingpass locally (net9.0 and net10.0).Incidental fix
While getting the test harness running, found
IssueService.csprojwas missing a directPackageReferencetoJasperFx.Events.SourceGeneratoras an analyzer -- NuGet analyzer assets don't flow transitively across aProjectReferencetoMarten, soSingleStreamProjection/Snapshot<T>types declared inIssueService(likeOrder) had no generated dispatcher and every test touching an event-sourced aggregate failed withInvalidProjectionException. Fixed by adding the same analyzer referenceEventSourcingTests.csprojalready uses. This was a pre-existing issue unrelated to this feature, confirmed to reproduce on the unmodified baseline.Docs
Updated
docs/documents/aspnetcore.mdwith a new "ETag / conditional request support" section explaining the behavior, theEmitETagopt-out, and a request/response example.markdownlintandcspellboth pass clean on the fulldocs/**/*.mdtree.