Skip to content

Marten 9.41.0

Choose a tag to compare

@jeremydmiller jeremydmiller released this 29 Sep 17:02
· 57 commits to master since this release

Upgrade if you use IsolationLevel.Serializable. The first item below is a silent lost update, and it has been present since the write-retry pipeline shipped. The two items after it change behaviour you may be relying on.

🛑 Data integrity: Serializable sessions could silently lose an update

#5528. A session opened with IsolationLevel.Serializable (or RepeatableRead) did not reliably protect against a lost update. PostgreSQL detected the write conflict correctly and Marten discarded the detection by retrying.

Two serializable sessions each load the same document at Amount = 100. One commits 100 - 500. The other should be refused with ConcurrentUpdateException; instead it committed 100 - 350 on top, and the first session's write was gone with no error raised anywhere. From the server's own log:

[14197] ERROR:  could not serialize access due to concurrent update
[14197] ROLLBACK
[14196] BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE    -- the replay
[14196] insert ... on conflict (id) do update ...         -- same values, same mt_version
[14196] COMMIT

WriteRetryClassifier (#5262) listed 40001 serialization_failure as safe to replay, on the reasoning that the server had rolled the transaction back and said so — which is true, and turned out to be necessary but not sufficient. A replay re-issues the operations already computed, rather than re-running the application code that produced them, and a serialization failure means precisely that the snapshot those operations were derived from is no longer a valid basis for them. The replay opens a new transaction with a fresh snapshot, so the conflict is gone and the write lands.

40001 is now never replayed and surfaces as ConcurrentUpdateException, which is what Marten already translated it to. If you opted into Serializable you should expect to start seeing that exception — handle it by re-reading and recomputing, which is the only place the conflict can be resolved. Nothing inside Marten requests these isolation levels, so the async daemon and every default ReadCommitted session are unaffected.

40P01 deadlock_detected stays retryable deliberately: a deadlock aborts over lock ordering rather than a rejected snapshot, and replaying it is the textbook remedy at ReadCommitted. A deadlock retry under Serializable replays stale-snapshot work for the same structural reason, and the honest fix needs the isolation level plumbed into the classifier — recorded as a known gap rather than guessed at.

This went unnoticed because the test that catches it lives in StressTests, which has had no CI job since 2026-08-08.

⚠️ Behaviour change: the HotCold leadership lock is session-scoped again

#5526. Events.UseAdvisoryLockTransaction now defaults to false, so HotCold leader election uses a session-scoped pg_try_advisory_lock rather than a transaction-scoped pg_try_advisory_xact_lock. The transaction-scoped lock had been the default since 8.37.3 (April 2026).

The reason is what the transaction-scoped lock costs while it works: the leader holds an open transaction for as long as it holds leadership, which on a long-lived daemon is an indefinitely long-running transaction sitting on the connection — with everything that implies for vacuum and for anything watching transaction age.

The transaction-scoped lock is still the better fit behind PgBouncer in transaction pooling mode, which can hand a session-scoped lock's server connection to another client. If that is your deployment, opt back in explicitly:

builder.Services.AddMarten(opts =>
{
    opts.Connection(connectionString);
    opts.Events.UseAdvisoryLockTransaction = true;
})
.AddAsyncDaemon(DaemonMode.HotCold);

⚠️ Breaking: DefaultTenantUsageDisabledException is no longer a MartenException

#5514. Marten.Exceptions.DefaultTenantUsageDisabledException now derives from JasperFx.Events.DefaultTenantUsageDisabledException.

JasperFx.Events carries this exception so that code can handle the refusal once across Critter Stack stores, and its own contract is that stores subclass it. Polecat did; Marten did not. The consequence was not an error anywhere — a store-agnostic catch (JasperFx.Events.DefaultTenantUsageDisabledException) compiled, read as if it handled the refusal, and let it through unhandled on Marten only. That is the failure mode this fixes.

Multiple inheritance is not available, so it is one base or the other, and the cross-store catch was judged worth more than the marker base. If you rely on a broad catch (MartenException) to handle this refusal, it will no longer be caught — catch the exception itself, or the JasperFx base. The message is unchanged, including the single-argument overload's behaviour of appending to the standard prefix.

Also gone with the reparenting: the protected (SerializationInfo, StreamingContext) constructor, because the JasperFx base declares none to chain to. Binary serialization of exceptions has been obsolete since .NET 8.

This is one exception, not the whole family. The other four reparentings — including #5476 for ArchivedStreamException, which additionally changes what an existing catch (InvalidStreamOperationException) sees — remain on the 10.0 milestone.

Event store: two new IEventStore members

#5527, carried by the JasperFx 2.76.0 bump. Both ship as default interface implementations upstream, which is the point of implementing them here: the defaults are a hard-coded true and a NotSupportedException, so a store that has not overridden them is indistinguishable from one that has.

  • HasEventStore (JasperFx/jasperfx#914) exposes Marten's own EventGraph.IsActive — any registered event type other than Archived, or any projection or subscription. A monitoring tool enumerating IEventStore could not tell a store that has an event store from a document-only store that never will, so it had to treat "nothing to report" as ambiguous with "could not read" on every polling interval. RegisteredShardNames() was not a substitute: a store with event types and no projections reports an empty list while holding real progression rows. Computed on each read rather than cached, because an event type registered lazily on first append makes a store that started document-only active.
  • CompactStreamAsync(streamId, tenantId) and its streamKey sibling (JasperFx/jasperfx#910) run the stream-state read and the compaction on a session opened for that tenant. This is the action-side twin of 9.40.0's OpenReadOnlyEventStore(tenantId): a compaction policy could select a tenant's streams through that reader and then compact none of them, because the tenant-less call opened the default session — refused outright where Advanced.DefaultTenantUsageEnabled = false, and on any conjoined store reading stream state where that tenant's stream is not. A null tenant keeps the previous store-global behaviour, and tenant id casing runs through TenantIdStyle exactly as every other LightweightSession(tenantId) call does.

Still unmeasured, and worth knowing before you rely on it: where the default tenant is enabled, nobody has established what the tenant-less overload does for a conjoined or database-per-tenant stream — no-op, "stream not found", or compacting a same-keyed default-tenant stream.

Dependencies

JasperFx 2.76.0 and Weasel 9.36.0. The Weasel bump is not optional — 9.36.0 takes JasperFx 2.76.0 as its floor.

Weasel 9.36.0 is mostly drift reconciliation for stores Marten does not use (MySQL, Oracle, SQL Server). Two parts reach PostgreSQL:

  • IAdvisoryLock.FindHolderAsync is implemented for Postgres (weasel#650), so when a projection agent stops because two processes believed they owned the same shard, the other holder can be named rather than only inferred. Note the measurement that changed that implementation: pg_locks stores the advisory key as two 32-bit halves, so a negative lock id sign-extends and the obvious query reports a held lock as unheld.
  • Changing a computed column's definition now drops and recreates the indexes and foreign keys that depend on it (weasel#638). That migration previously succeeded on PostgreSQL while silently dropping the index.