Skip to content

Marten 9.47.0

Choose a tag to compare

@jeremydmiller jeremydmiller released this 07 Oct 10:57
· 44 commits to master since this release

The FetchForWriting / FetchLatest consistency release, plus a bounded async-daemon shutdown.

Read the behaviour changes before upgrading. Two of them can change how your projections and
subscriptions behave on every deployment, and two more refuse a batch shape that used to be accepted.

⚠️ Behaviour changes

Subscription and projection drains are now bounded by StopAndDrainTimeout (#5610)

Via JasperFx 2.81.0 (#953 and #983). When a shard stops — host shutdown, or reassignment
to another node — the page in flight finishes, pages still queued are skipped and resumed by the next
owner from committed progression, and a page still running at Projections.StopAndDrainTimeout
(default 5 seconds) is cancelled with its transaction rolled back.

This fixes a real failure: a node that loses a shard's lock no longer keeps applying its backlog
alongside the new owner, an overlap that could end in ProgressionProgressOutOfOrderException and a
Stopped shard. Graceful shutdown is also bounded now, rather than waiting out the whole backlog and
potentially overrunning a Kubernetes termination grace period.

If your projection or subscription pages take longer than 5 seconds, raise the timeout:

opts.Projections.StopAndDrainTimeout = 30.Seconds();

Otherwise those pages are cancelled and re-processed on every shutdown and rebalance. Delivery was
always at-least-once, so no guarantee changed, but duplicates become much more likely — and side effects
performed outside the Marten session (HTTP calls, sends that bypass the transactional outbox) are not
rolled back with the page. Note this applies to async projections as well as subscriptions; the upstream
note covers only subscriptions and is out of date.

A batched FetchLatest or expected-version FetchForWriting must be first in its batch (#5604)

For an aggregate projected with ProjectionLifecycle.Async only. Both now bracket their reads in
begin transaction isolation level repeatable read read only … end so those reads share one snapshot,
and PostgreSQL accepts that only as a transaction's first statement. So:

  • batch.Events.FetchLatest<T>(id) and batch.Events.FetchForWriting<T>(id, version) have to be the
    first operation in their batch, and
  • neither can share a batch with a FetchForExclusiveWriting.

Both are refused at the enlisting call, naming the call to move, rather than surfacing later as a bare
25001. Inline and Live aggregates are unaffected, and inside a caller-owned transaction no bracket is
emitted so neither rule applies. If you batch a FetchLatest behind a Load, move it to the front.

New SnapshotRaceException from a batched exclusive fetch (#5613)

A batched FetchForExclusiveWriting on an Async aggregate now throws Marten.Exceptions.SnapshotRaceException
in the rare case where the daemon commits a snapshot between that batch's two reads. Retry the batch, or
fetch outside a batch — outside a batch Marten retries for you and you will never see it.

Fixes

FetchForWriting no longer destroys a caller-owned transaction (#5611)

The shared-snapshot bracket above has always been emitted by FetchForWriting(id), and it was emitted
unconditionally — including on a session already inside a transaction you own
(SessionOptions.ForTransaction, an ambient TransactionScope, or an explicit BeginTransaction). Two
outcomes, both wrong:

  • if your transaction had already run a statement, 25001 aborted it; or
  • if it had not, the bracket's trailing end committed your transaction. Later writes autocommitted
    one at a time, your own Rollback() threw This NpgsqlTransaction has completed, and the data you
    meant to discard was already permanent.

The bracket is now conditional. Those callers keep their transaction and give up the shared snapshot,
which is the right way round — a read race is recoverable, a wrongly committed transaction is not. A
caller who wants both opens their own transaction at RepeatableRead.

Null or stale aggregates from an Async-lifecycle fetch (#5604, #5613)

Three paths read the snapshot document and the events after it as separate statements, so a daemon
snapshot write landing in between made the delta query exclude the very events the snapshot read never
saw — returning null for a live stream, or silently stale state that SaveChanges accepted:

  • FetchForWriting(id, expectedVersion) — fixed with the shared snapshot
  • FetchLatest and StreamLatestJson — same
  • FetchForExclusiveWriting — could not use the bracket (it already owns a transaction, and its
    for update is on mt_streams, which the daemon never touches), so it now detects the
    disagreement and redoes the fetch

Other fixes

  • #5605 Gap-liveness detection scoped its xid clause cluster-wide instead of to the current
    database, so unrelated activity elsewhere on the cluster could affect it.
  • #5601, #5603 Inline EF Core projections: a save no longer needs more than one connection, and a
    retried commit no longer loses its projection row.
  • #5600 Native AOT: an unregistered strong-typed document id used to fail with
    MissingMethodException: No parameterless constructor defined for type 'ValueTypeIdSelectClause…'
    while the first document's mapping was built. It now names the id, names AOT as the cause, and prints
    the exact RegisterValueTypeId<TDocument, TId, TInner>() call to add.
  • #5619 Reflective method lookups that Marten invokes now fail naming the type and member instead
    of flowing a null onwards into a NullReferenceException.

Features

  • #5615 FillFactor() and StorageParameter() on a document mapping, so a hot document table can
    carry its own fillfactor and autovacuum thresholds through normal schema migration.
  • #5622 opts.Events.DcbTagVersionFillFactor for the DCB tag-version side table. Opt-in on
    purpose
    : measured on PostgreSQL 17, a small tag set already gets 100% HOT updates and this changes
    nothing, while a 200k-value tag set goes 92.9% → 100% HOT and stops heap growth — at 45% more disk
    permanently. See the DCB docs for the numbers.

Dependencies

  • JasperFx and JasperFx.Events 2.81.0
  • Weasel 9.42.0