Skip to content

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

Description

@erdtsieck

Motivation

We converted the read-only detail endpoints of a healthcare claims platform to StreamOne<T> / StreamAggregate<T>. Frontends poll several of these detail endpoints (invoice details, upload feedback) while a user has the screen open. Streaming already removed the serialize cost, but every poll still ships the full body — invoices with hundreds of claim lines are easily 100KB+ — even when nothing changed.

Marten already stores exactly the change token we need, next to the data it streams:

  • documents: the mt_version column (and mt_last_modified);
  • event-sourced aggregates: the stream version FetchLatest resolves anyway.

So a conditional-GET fast path is one extra column in the select that is already happening.

Proposal

Teach the streaming result types conditional requests:

  1. On every response, set ETag: "<version>" (document mt_version for StreamOne/StreamMany single sources, stream version for StreamAggregate; optionally Last-Modified from mt_last_modified).
  2. When the request carries If-None-Match matching the current version, respond 304 Not Modified with an empty body.

For StreamOne the version can be read in the same query that fetches the document — a match means the body bytes are simply not written (one round-trip either way, zero bytes on the wire on a hit). For StreamAggregate, FetchLatest already knows the stream version before touching the snapshot, so a match can skip the snapshot/fold work entirely. For StreamMany a weak collection ETag (e.g. max(mt_version) + count over the filtered set) is a possible follow-up but has more caveats — fine to scope the first cut to the single-source types.

// Endpoint code does not change:
[WolverineGet("/api/v1/institution-invoice/{id}")]
public static StreamOne<InstitutionInvoice> Get(string id, IQuerySession session)
    => new(session.Query<InstitutionInvoice>().Where(x => x.Id == id));

// First request:
//   200 OK, ETag: "17", body: {...}
// Poll while unchanged:
//   GET ... If-None-Match: "17"  ->  304 Not Modified, no body

Opt-out (or opt-in) flag on the result type if someone's contract cannot tolerate the extra headers:

=> new(query) { EmitETag = false };

Why in Marten and not user land

The version travels in Marten's own metadata columns; only the streaming write path can read it in the same round-trip as the data and decide not to write the body. User code would need a separate metadata query per request, which erases most of the win.

Impact

For polled detail endpoints this beats the streaming conversion itself: a poll cycle where nothing changed goes from full-body every N seconds to a single indexed read returning zero body bytes. It composes with any HTTP cache in front (browser, CDN, YARP) for free.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions