Skip to content

feat: ETag / If-None-Match (304) support on StreamOne and StreamAggregate (fixes #5010) - #5015

Merged
jeremydmiller merged 3 commits into
JasperFx:masterfrom
erdtsieck:erdtsieck-etag-support-issue-5010
Jul 22, 2026
Merged

feat: ETag / If-None-Match (304) support on StreamOne and StreamAggregate (fixes #5010)#5015
jeremydmiller merged 3 commits into
JasperFx:masterfrom
erdtsieck:erdtsieck-etag-support-issue-5010

Conversation

@erdtsieck

Copy link
Copy Markdown
Contributor

Summary

Adds HTTP conditional-request support (ETag / If-None-Match -> 304 Not Modified) to StreamOne<T> and StreamAggregate<T> in Marten.AspNetCore, so polling clients can avoid re-downloading unchanged documents/aggregates. Closes #5010.

StreamOne<T>

  • New EmitETag init-only property, defaults to true.
  • When enabled, WriteSingle<T> deserializes the already-buffered JSON to get an entity instance, then calls the public IQuerySession.MetadataForAsync<T>() to read mt_version and format it as a quoted-GUID ETag header.
  • If the incoming If-None-Match matches, responds 304 with an empty body instead of the JSON payload.
  • Getting from the Linq queryable back to its owning session requires the internal IMartenLinqQueryable.Session property, so a scoped [InternalsVisibleTo("Marten.AspNetCore")] was added to Marten's AssemblyInfo.cs. Everything reached beyond that cast (QuerySession, IMartenSession, IQuerySession) is already public.

StreamAggregate<T>

  • New EmitETag init-only property, defaults to true.
  • Uses the already-public IEventStoreOperations.FetchStreamStateAsync to cheaply read the stream's long version before folding/snapshot work, so a 304 cache hit skips that work entirely.
  • ETag is a quoted integer, e.g. "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) + count based ETag.

Tests

Added Alba-based integration tests in Marten.AspNetCore.Testing covering, for both StreamOne and StreamAggregate:

  • ETag header present on a normal hit
  • 304 returned (empty body) when If-None-Match matches the current version
  • Full 200 body returned when If-None-Match is stale
  • EmitETag = false suppresses the header/behavior entirely (backwards-compatible opt-out)

All 53 tests in Marten.AspNetCore.Testing pass locally (net9.0 and net10.0).

Incidental fix

While getting the test harness running, found IssueService.csproj was missing a direct PackageReference to JasperFx.Events.SourceGenerator as an analyzer -- NuGet analyzer assets don't flow transitively across a ProjectReference to Marten, so SingleStreamProjection/Snapshot<T> types declared in IssueService (like Order) had no generated dispatcher and every test touching an event-sourced aggregate failed with InvalidProjectionException. Fixed by adding the same analyzer reference EventSourcingTests.csproj already uses. This was a pre-existing issue unrelated to this feature, confirmed to reproduce on the unmodified baseline.

Docs

Updated docs/documents/aspnetcore.md with a new "ETag / conditional request support" section explaining the behavior, the EmitETag opt-out, and a request/response example. markdownlint and cspell both pass clean on the full docs/**/*.md tree.

…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
erdtsieck force-pushed the erdtsieck-etag-support-issue-5010 branch from e91be44 to a23ec65 Compare July 22, 2026 10:02
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.
@jeremydmiller
jeremydmiller merged commit b6dc558 into JasperFx:master Jul 22, 2026
9 checks passed
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>
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.
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.

ETag / If-None-Match (304) support on StreamOne, StreamMany and StreamAggregate

2 participants