NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
NuGet · #1830 most downloaded on NuGet
.NET Transactional Document DB and Event Store on PostgreSQL
Last release today
11 Oct 2026
Ships on a steady schedule
a new release about every 9 days
Some releases are documented
notes for 32 of the last 60 stable releases
14 versions withdrawn
withdrawn after publishing
127 years old
435 releases · first in 1900
Document diagnostics: filter and order by document properties ( #869 , #5631 )
IDocumentStoreDiagnostics.QueryDocumentsAsync now applies DocumentQueryOptions.Where / OrderBy / Arguments instead of refusing them. The text goes onto Query<T>() through JasperFx 2.84.0's Dynamic LINQ helper, so member casing, enum storage, duplicated fields, conjoined tenancy, soft deletes and hierarchies are Marten's own; only the SELECT list is swapped for the diagnostics read's columns, so the JSON comes back byte-exact with its metadata, and the total counts the filtered rows. Operator text is never spliced into SQL, and the read issues no DDL.
Measured against a LINQ-to-objects oracle over 83 shapes and three storage configurations; every shape is either right or refused:
<> / a negated comparison on a nullable member unless the text says what null should do (JasperFx's SqlNullSemantics). Date-part members and inline in (…) lists are refused honestly by the provider.DateTime arguments compare correctly against timestamp without time zone locators.Count / LongCount predicates through the Where parser, with null semantics and bound parameters (#5625).StartStream that loses a race for the same stream id now throws ExistingStreamIdCollisionException instead of a raw 23505, so QuickWithServerTimestamps behaves like Rich and Quick (#5628).JasperFx 2.84.0
One column per quarter.
The FetchForWriting / FetchLatest consistency release, plus a bounded async-daemon shutdown.
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.
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.
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 theFetchForExclusiveWriting.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.
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.
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:
25001 aborted it; orend committed your transaction. Later writes autocommittedRollback() threw This NpgsqlTransaction has completed, and the data youThe 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.
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 snapshotFetchLatest and StreamLatestJson — sameFetchForExclusiveWriting — could not use the bracket (it already owns a transaction, and itsfor update is on mt_streams, which the daemon never touches), so it now detects thexid clause cluster-wide instead of to the currentMissingMethodException: No parameterless constructor defined for type 'ValueTypeIdSelectClause…'RegisterValueTypeId<TDocument, TId, TInner>() call to add.NullReferenceException.FillFactor() and StorageParameter() on a document mapping, so a hot document table canopts.Events.DcbTagVersionFillFactor for the DCB tag-version side table. Opt-in onMostly a resiliency and Native AOT release. Five behaviour changes — the retry ones are the most likely to be noticed, because Marten now surfaces fai
Mostly a resiliency and Native AOT release. Five behaviour changes — the retry ones are the most likely to be noticed, because Marten now surfaces failures it used to absorb.
A command timeout is no longer retried on reads (#5566, #5580). An attempt that timed out has already spent the whole CommandTimeout, so retrying it three times held the caller for four timeouts instead of one. This covers both shapes: Npgsql's client-side CommandTimeout, and a server-side statement_timeout, which arrives as 57014 query_canceled with no TimeoutException anywhere in the exception. Connection-pool exhaustion arrives in the same shape and so is no longer retried either — a deliberate trade, documented in the resiliency guide along with what to do instead.
Sticky, ambient and external session lifetimes now fail loudly (#5582, #5598), via the new SessionTransactionUnusableException. Two things used to go wrong silently. A broken connection reported ConnectionState.Closed, so Marten reopened it — onto a different backend — and handed the command the stale transaction, which Npgsql accepts; a Serializable session then quietly answered from a newer snapshot. And a command timeout inside a transaction aborted it, after which every later call returned a bare 25P02 naming neither the timeout nor the way out. For caller-owned transactions Marten records the cause and reports it but rolls nothing back — the scope is still yours.
The async serializable session builders now produce a serializable session (#5594). OpenSerializableSessionAsync(new SessionOptions()) previously returned a session that was neither serializable nor auto-closing: it held one connection open for its lifetime with no transaction. These two methods now pin IsolationLevel to Serializable and begin the transaction eagerly. A non-Serializable level passed to them is overridden rather than silently honoured.
The async daemon spends one retry budget instead of two (#5593). ResilientEventLoader and the QuerySession inside it were both given the same pipeline, so a handled failure ran up to sixteen times.
A retried write no longer accumulates exceptions across attempts (#5591). The exception list was shared, and ExecuteBatchPagesAsync branches on its count — so one operation failing twice turned a rethrow into an AggregateException, making the caller's exception type depend on how many retries happened.
Strong-typed document ids now work (#5589, #5579), with one registration per document type:
opts.Schema.For<Invoice>();
opts.RegisterValueTypeId<Invoice, InvoiceId, Guid>();Previously a single strong-typed id anywhere in the store killed the application while the first document's mapping was built. The identity path closes a chain of generics over your id type, and a native image has no instantiation for them unless something roots it; naming all three types is what roots them. Under a JIT the call does nothing — unregistered types still take the reflective path. Not yet covered: F# single-case discriminated-union ids, and .IsOneOf over a strong-typed id. The intent is for Marten.SourceGenerator to emit these registrations so the call becomes unnecessary.
Also AOT: a decimal comparison in a LINQ filter is documented with a tested workaround (#5597) — it is the BCL expression-tree factory rather than Marten, but it only shows up from a native binary. Marten's AOT runtime gate now covers strong-typed ids and decimals, so these stay pinned.
PrefixSearch quotes each word as a tsquery lexeme (#5571, #5568), so a search box containing &, |, !, (, : or a tab no longer produces 42601: syntax error in tsquery. Not an injection — the term was already a parameter — but the documented contract says each word is treated as a prefix, and raw input has to be escaped to honour that.AdvisoryLock shutdown release is bounded, and the vendored copy moves back to Weasel (#5572, #5576).catch-and-Console.WriteLine-and-rethrow blocks removed (#5584) — they bypassed IMartenSessionLogger and any configured ILogger, and one of them also closed a connection on the line above the one that opened it.JasperFx 2.80.2 (#5590) and Weasel 9.41.0 (#5596, #5576). Both carry Native AOT work of their own on the identity path — JasperFx's ValueTypeInfo wrappers and aggregate identity sources, Weasel's non-generic IIdentification facade.
🤖 Generated with Claude Code
This one is largely the community's release. Four of the six changes below were found, diagnosed, or fixed by people outside the core team, and two of
This one is largely the community's release. Four of the six changes below were found, diagnosed, or fixed by people outside the core team, and two of them are bugs that would have been very hard to find from the inside.
@Hawxy — two LINQ regressions (#5556). A captured nullable in a where clause was throwing: a Nullable<T> never survives boxing, so an empty one boxes to null and PropertyInfo.GetValue had no target, breaking the thoroughly ordinary where !captured.HasValue || x.Id == captured. HasValue and Value are now read off the boxed form. The same PR fixes literals in select statements surviving paging, stats, and version select clauses.
@Hawxy — inline-to-async on an empty database (#5561). FetchHighestEventSequenceNumber read mt_events_sequence.last_value, which PostgreSQL reports as 1 with is_called = false before anything has drawn from the sequence. SubscribeAsInlineToAsync used that as its starting position, so on an empty store the shard started at 1 against a high water mark of 0 and the agent failed with "The last committed number (1) cannot be higher than the high water mark (0)". A new store is exactly when you least want that.
@tomasherceg — ordering after a grouped projection (#5557, shipped as #5563). CompileGroupBy transferred the downstream Limit and Offset but never the OrderingExpressions — even though the comment sitting over that block had claimed OrderBy was transferred for as long as it existed. A GroupBy(...).Select(...) that was then ordered came back in whatever order PostgreSQL felt like, with no order by in the SQL and no error, so a paged one took an arbitrary page.
Tomáš found it, diagnosed it, and landed the hard part — re-expressing the ordering against the projected shape rather than the document. He also flagged his own uncertainty about edge cases, which is what sent us looking and turned up two more: a scalar projection (Select(g => g.Key), Select(g => g.Count())) has no projected members, so ordering one threw instead of ordering by its single value; and OrderBySql() reported Invalid OrderBy() expression ''. Both now work. His commit is in the tree with his authorship.
@chrisbbe — the identity probe's side effects (#5562). The best bug report we have had in a while: a minimal repro, the stack trace, the mechanism, and a correct suggested fix.
Identity resolution handed its candidate filter to a traversal that applies it to every property and field, and only afterwards picks the id by [Identity] or by the name Id. That filter is not free to ask — for any public struct shaped like a strong-typed id it registers the type on the global PostgresqlProvider singleton and closes an open generic. So an ordinary non-identity property got remapped process-wide the moment its document or event type was mapped, turning an Optional<string> used only for a nickname into varchar for everything, LINQ included. Under Native AOT the generic construction brought startup down outright with a MissingMethodException.
Both probes now split into a pure shape test and the effectful registration; the traversal asks the cheap question and only the chosen member gets the expensive one.
⚠️ That exposed a second thing worth knowing about. RegisterValueType fills StoreOptions.ValueTypes but has never registered the Postgres mapping, so a value type used as a duplicated field rather than as an id only ever worked because the probe happened to reach it on some document. If you call opts.RegisterValueType<T>() you were doing the right thing and relying on an accident. That registration now belongs to RegisterValueType.
Batch reads are one round trip again (#5550). JasperFx 2.79 added LoadManyAsync to IDocumentReadOperations and FetchManyForWriting to IEventStoreOperations, both default-implemented. The defaults are correct, and both make one round trip per id.
Marten has sixteen LoadManyAsync overloads and not one of them takes the token last, so the contract's (ids, token) call matched none of them and silently bound to the default. Nothing failed — the shared compliance suite loads 2,500 documents through it, 2,500 round trips at a time, passing.
| before | now | |
|---|---|---|
LoadManyAsync, 25 ids |
25 round trips | 1 |
LoadManyAsync, 2,500 ids |
2,500 | 1 |
FetchManyForWriting, 10 streams |
10 | 1 |
FetchManyForWriting is new public surface on Marten: one handle per id in the order asked for, a handle (not a gap) for a stream that does not exist yet, and each handle keeping its own starting version so SaveChangesAsync guards every stream it appended to and no other.
::: One lifecycle keeps the sequential shape
An aggregate projected with ProjectionLifecycle.Async is fetched inside begin transaction isolation level repeatable read read only, and PostgreSQL only accepts that as the first statement in a transaction — so two of them cannot share a batch. FetchManyForWriting detects that lifecycle and falls back to fetching sequentially: same results, same ordering, same per-stream guards, no round-trip saving. Inline, Live and Snapshot get the fast path.
:::
See FetchManyForWriting and Loading Documents by Id for the details.
Dependency bumps from Dependabot: fast-uri (#5558), brace-expansion (#5559), dompurify (#5560).
Bumps the JasperFx family to 2.79.1 and lands three fixes. Two of them change behaviour, one in a way that changes what a query returns.
Bumps the JasperFx family to 2.79.1 and lands three fixes. Two of them change behaviour, one in a way that changes what a query returns.
#5549 — a nested Any() with a plain equality silently returned nothing. A sub-query is parsed against a member tree rooted at the element type, so inside Middles.Any(m => m.Bottoms.Any(...)) the Bottoms member reports d.data -> 'Bottoms' — within that parse, d.data stands for one Middle. Nothing in the member tree records that Bottoms lives under Middles; the containment filter is re-anchored when the enclosing Any() lifts it out. That re-anchoring moved the jsonb payload but left the locator pointing at the inner collection, so the query went out as
d.data -> 'Bottoms' @> '[{"Middles":[{"Name":"Bill"}]}]'Well-formed SQL against a key the document does not have. No error, no rows.
Three shapes of the same query diverged, which is what made this hard to pin down from the outside: a sibling predicate (m.Color == Blue && m.Bottoms.Any(...)) built the containment through a different path and was always correct, StartsWith is not containment-eligible and fell to a jsonpath filter that re-rooted properly, and only the plain single-equality nested Any() was wrong.
Four more failures came out of the same defect once the generated SQL was dumped across every nesting shape:
| Shape | Before |
|---|---|
the nested Any() negated, or as an OR branch, or three levels deep |
same wrong locator |
m.Bottoms.Any(x) && m.Bottoms.Any(y) — the workaround suggested in the issue |
the two merged into one payload element: one of the two values was silently dropped, and a single element had to satisfy both |
| three levels deep ending in a value collection | NotSupportedException |
a value-collection Any()/Contains() beside a sibling predicate |
NullReferenceException |
⚠️ Behaviour change. t.Xs.Any(p) && t.Xs.Any(q) now matches when two different elements satisfy the two predicates. That is what the query asks for, and it is more rows than 9.43 returned. If you were relying on the old answer, you wanted t.Xs.Any(x => p(x) && q(x)).
#932 — DocumentTypesAsync lists document sub-classes. The contract was silent on this and the stores split: Polecat listed sub-classes, Marten and Fisher did not, and a store-agnostic type picker could not tell which answer it had. Settled in favour of listing them, because every member taking a documentTypeName already accepts a sub-class name and narrows to its rows — a picker that could not offer one was hiding a capability the contract guarantees.
Marten now emits an entry per registered sub-class alongside each mapped root. A sub-class entry carries its own mt_doc_type alias — the discriminator its rows actually hold, which is the string you pass back to QueryDocumentsAsync — and the root's schema, because the root's table is where those rows live. DocumentTypeRef.RootTypeName names the root and is null for a root itself; IsSubClass reads it.
⚠️ Behaviour change. A store with document hierarchies returns more entries from DocumentTypesAsync than it did in 9.43. IsSubClass tells them apart. RootTypeName is init-only, so the positional DocumentTypeRef shape a store compiled against an older JasperFx calls is unchanged.
A composite projection could intermittently pause and stop advancing (#938). ProjectionExecution disposed its batch unconditionally through an await using. For a composite member that batch is the parent composite's, shared by design — so the member's scope-exit disposal raced the parent's execute, which then threw ObjectDisposedException out of Block.WaitForCompletionAsync and left the shard Paused with no progression row at all.
It takes a member on that path, so the exposed shape is a composite of an aggregation plus a plain event projection; an all-aggregation composite runs on GroupedProjectionExecution, which already guarded the disposal. Intermittent because the stages run in parallel and the race can fall either way — measured at roughly one run in eight on one such composite.
If you run composite projections with a non-aggregation member and have seen a shard quietly stop advancing, this is a strong candidate. Fixed in JasperFx 2.79.1.
JasperFx 2.79.1. Alongside #932 and #938 above: #930 adds LoadManyAsync to IDocumentReadOperations and FetchManyForWriting to IEventStoreOperations, #931 default-implements the 2.77 IDocumentStoreDiagnostics members so a store built against an older JasperFx still loads, and #933 ratchets abstract interface members so contract growth stays deliberate.
The new members arrive default-implemented and Marten has not yet overridden FetchManyForWriting / LoadManyAsync — the defaults are correct but call the single-id member in a loop. Batching them against Marten's existing batch-query API is tracked separately.
Bumps the JasperFx family to 2.78.0 and lands four issues, one of them a data-corruption fix worth reading even if you skip the rest.
Bumps the JasperFx family to 2.78.0 and lands four issues, one of them a data-corruption fix worth reading even if you skip the rest.
#5539 — a zero-event quick append could permanently wedge a stream. mt_quick_append_events called with zero events read mt_streams.version without a lock and ended with a blind write-back, so under READ COMMITTED it could overwrite a concurrent appender's version and leave the stream row below its own events. Every later append then computed a version that already existed and failed on pk_mt_events_stream_and_version — permanently, with no recovery short of repairing the row by hand. A non-empty append could never do this: its first insert collides on that same primary key and rolls back, which is why only the empty call was exposed. The write-back is now skipped when nothing was appended, and uses greatest() so it can never lower a version whoever calls it.
Field context: a production console on 9.32.0 sat at mt_streams.version 608676 against max(mt_events.version) 608678 for 42 days, roughly 28 failed appends a minute. If you have a stream failing every append on that constraint, this is likely why.
#5541 — ShardState.LastUpdated, liveness rather than progress. Sequence cannot distinguish caught up with nothing new from no longer maintained: both hold the same number forever. The progression row's last_updated now reaches ShardState and ProjectionProgressRow, and — the part that needed a product change — an idle high-water cycle now restamps the HighWaterMark row. Previously an idle cycle issued no SQL at all, so that stamp froze at the last real mark advance and read as "abandoned weeks ago" on a perfectly healthy store. The restamp never touches last_seq_id, so it cannot nudge the mark.
#5543 — IDocumentStoreDiagnostics adopts the defined contract (#870). Five behaviours were previously "whatever the store happened to do", and Marten was on the wrong side of all five:
| Before | Now | |
|---|---|---|
| Soft deletes | returned as live | excluded unless asked; a load by id returns the row flagged |
| Hierarchies | every sub-class in the table | filtered to the requested type; each row reports its own |
| Missing tenant | all tenants | the default tenant |
"" tenant |
a tenant literally named "" |
the default tenant |
| Ids | id::text = @id |
converted to the stored identity type — uses the PK index, matches an upper-case Guid |
Also: LoadDocumentAsync returning StoredDocument with metadata, populated DocumentQueryResult.Documents, a new IDocumentStoreDiagnosticsWriter (save-from-JSON and delete through a normal session, with an expected-version guard), DocumentStoreUsage.SerializerCasing, and structured index / duplicated-field descriptors. Query criteria Marten cannot yet translate are refused rather than silently ignored.
#5544 — DocumentQueryOptions.AllTenants. The explicit replacement for the all-tenants read that #5543 removed. Conjoined tenancy reads every tenant, ordered by tenant then id so paging cannot repeat a row. A store spreading tenants across databases refuses rather than quietly returning only the default tenant's rows.
⚠️ Behaviour change for diagnostics consumers: a null or blank TenantId now means the default tenant, not every tenant. If you relied on the old behaviour, set AllTenants = true.
#5542 — asynchronous aggregate events are ordered by stream sequence.
Internals: a diagnostic document read now resolves a tenant's database through TryFindDatabase rather than FindOrCreateDatabase, so asking a read-only question about an unknown tenant can no longer provision that tenant.
Mostly fixes, and two of them only became visible once the one underneath them was gone. Nothing here requires action on upgrade unless you use Isolat
Mostly fixes, and two of them only became visible once the one underneath them was gone. Nothing here requires action on upgrade unless you use IsolationLevel.RepeatableRead or above, or batch a FetchForWriting for an Async-snapshot aggregate.
#5536. ApplyAllDatabaseChangesOnStartup() against an already-migrated schema could fail with:
42710: constraint "fkey_mt_events_stream_id" for relation "mt_events" already exists
The foreign key was not the problem. PostgreSQL truncates identifiers at 63 characters, so a longer DatabaseSchemaName was stored truncated while Marten went on querying for the name it had asked for — found none of its objects, concluded they were all missing, and re-issued DDL the server resolved against the truncated schema, where they already existed. The first apply passed and the second failed, with an error pointing nowhere near the schema name.
DatabaseSchemaName is now truncated to 63 characters so the two sides agree. This does not move anyone's data: the server was already truncating it, and Marten now simply uses the name the server actually stored. Two distinct long names sharing their first 63 characters still collide, exactly as they already do in PostgreSQL.
#5529. The advisory lock guarding startup migrations retried on a fixed 50 / 100 / 250ms ladder — about 400ms to win it. That is ample for the multi-replica rolling deploy it was designed for, where the loser only has to outlast a peer's finished migration. It is not enough when more than one store shares a database inside one host, where the loser has to outlast a migration that is still running: it failed startup, and the message's own advice (ResourceMigrationFailureMode.ContinueOnFailures) would have started the application against a half-migrated schema.
New knob, defaulting to 10 seconds:
opts.ApplyChangesLockTimeout = 30.Seconds(); // many stores on one database, or a large schemaThis was held back until it could be measured. While weasel#659 was unfixed the lock was stranded by any migration that threw, and a longer wait provably changed nothing. Weasel 9.37.0 releases it on the failure path, and only then did the residue show up as a genuine race worth budgeting for.
RepeatableRead upwards#5528, completing what 9.41.0 started. Marten's retry replays the operations it has already computed, not the application code that computed them — and from RepeatableRead up, the session's transaction is pinned to one snapshot. So any successful replay commits decisions derived from a snapshot that no longer exists. That is exactly the silent lost update 9.41.0 fixed for 40001, and 40P01 (deadlock) had the same flaw for the same structural reason.
The write-retry pipeline is now skipped entirely for RepeatableRead, Serializable and Snapshot. This is deliberately broader than refusing those two SQLSTATEs: class 53, 55, 57P03 and 58 all abort a snapshot-holding transaction too, so singling out class 40 would have left the identical hole open one error code over.
If you use those isolation levels, expect to see failures surface that were previously retried away. Handle them by re-reading and recomputing — the only place a snapshot conflict can be resolved. ReadCommitted is the default and is unchanged: it takes a fresh snapshot per statement, so replaying precomputed SQL still means what it meant, and every existing application keeps full retry behaviour.
FetchForWriting says what it needs#5535. For an aggregate projected with ProjectionLifecycle.Async, the non-exclusive FetchForWriting wraps its reads in begin transaction isolation level repeatable read read only … end so they share one snapshot — and PostgreSQL accepts that only as the first statement in a transaction. Enlisted after anything else in a batch, it failed at Execute() with 25001: SET TRANSACTION ISOLATION LEVEL must be called before any query, naming an isolation level the caller never mentioned and saying nothing about ordering. Nothing about CreateBatchQuery() hints that one of its members has to go first.
It is now refused at the enlisting call, with a message that names the aggregate and the constraint. The position that worked still works.
Weasel 9.37.0, whose weasel#659 is the reason most of #5529 went away: ApplyAllConfiguredChangesToDatabaseAsync released the global migration lock only on its success paths, so a migration that threw left it held and locked every other replica out of migrating — while the node that stranded it reported nothing. Disposing the connection was not enough, because Npgsql resets a pooled connection when it is next used rather than when it is returned, so the lock outlived the apply for as long as that connection sat idle.
Also in 9.37.0, and worth reading if you use them: SQL Server 2025 JSON indexes are no longer emitted as DROP INDEX on every apply (weasel#661), Oracle descending indexes and co-migrated views settle (weasel#660), TableDelta.HasChanges() answers instead of throwing for a table that does not exist yet (weasel#658), and the reconnection loop no longer abandons a connection per attempt (weasel#664).
DISABLE_TEST_PARALLELIZATION is gone from the CI workflow and the contributor docs. Nothing ever read it, so it looked like a control and was not one — including to us, while diagnosing #5529. Test parallelisation is disabled by CollectionBehavior in AssemblyInfo.cs, and both files now say where to change it.
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
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.
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.
#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);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.
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.
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.Minor rather than patch for one reason: the source-generated DetermineAction now decides one action per batch rather than per event. See the behaviour
Minor rather than patch for one reason: the source-generated DetermineAction now decides one action per batch rather than per event. See the behaviour change below before upgrading if you archive streams from a projection.
Archiving projections: one action per batch, not per event (JasperFx/jasperfx#889, via the JasperFx 2.75.2 bump). The source-generated DetermineAction used to decide per event; it now decides once per batch. This changes what an archiving projection does when a single batch mixes an archive with later events for the same stream. StreamArchivingCompliance gained facts for it and Marten passes them, but if you depend on the old per-event evaluation, test before upgrading.
TenantIdStyle is now applied where the tenant id is stamped and keyed, not only where a tenant is resolved (#5516). Under ForceLowerCase, these three previously wrote or matched the raw id:
BulkInsertEventsAsync / BulkInsertEventStreamAsync resolved the right database or partition and then stamped the raw id on every mt_streams / mt_events row — rows invisible to any normalised read under conjoined tenancy, and the wrong key inside the right partition under UseTenantPartitionedEvents.TenantIsOneOf(...) filtered on the raw values, so a mixed-case id matched nothing.DeleteAllTenantDataAsync used the raw id as its DELETE ... WHERE tenant_id = :p parameter, so it matched no rows and reported success — the one to look at hardest, since callers are usually honouring an erasure request.If you have been calling these with mixed-case tenant ids, data already written under the raw casing stays where it is; it will not be found by a normalised read. ForTenant(string) also now normalises its nested-session cache key, which makes ForTenant("RED") and ForTenant("red") one session rather than two.
IEventStore.OpenReadOnlyEventStore(tenantId) is implemented (#5513). With Advanced.DefaultTenantUsageEnabled = false — the automatic state once database-per-tenant tenancy is configured — the entire IReadOnlyEventStore surface was unreachable, because the tenant-less overload opens the store's default session and is refused before any tenant scope applies. That is why QueryStreamStates(tenantId)'s own parameter could not work around it. A null tenant keeps the previous store-global behaviour.The JasperFx 2.75.2 bump (#5518) carries three changes that came out of #5495, where a user's aggregates compiled with no dispatcher and the runtime message could not tell them why:
[assembly: JasperFxSourceGeneratorApplied] in every assembly it processes, so "the generator never ran in that assembly" — a csproj problem — is distinguishable at runtime from "the generator ran and declined your type" — a code-shape problem. The exception says which.CS0433. This happens when a project references the standalone JasperFx.Events.SourceGenerator package as well as Marten, which bundles it: both copies emit identical file paths and the file-scoped evolvers mangle to the same name. If you hit that error — recognisable because it names the same assembly on both sides — remove the standalone package reference; Marten already carries the analyzer.<JasperFxEventsRequireSourceGenerator>true</JasperFxEventsRequireSourceGenerator> on projects that declare aggregates, and a missing analyzer becomes build error JFXEVT900 rather than a failure at the first FetchForWriting.Marten is now enrolled in DocumentConjoinedTenancyCompliance (#5517) — the first shared document coverage of conjoined tenancy on any store — with all 10 facts running. ConjoinedEventTenancyCompliance goes from 16 passing with 2 skipped to 18 passing with none, the two skips having been waiting on the read-only overload above.
Two of these enrollments exposed that Marten's own commit-listener compliance facts had been passing vacuously — the harness never installed the listener the suites register, so "no phantom deletion was reported" was trivially true of a store reporting nothing at all. Fixed in the #5518 bump; no product change was needed, and both halves of that fact now pass for real.
JasperFx 2.75.2 and Weasel 9.35.2.
Weasel 9.35.x is mostly EF Core and SQL Server work, plus weasel#634: an exhausted DropSchema retry is now reported instead of returning success.
The bundled analyzer does flow transitively across a ProjectReference (#5508) — the composite-configuration page claimed the opposite. The claim was true of the standalone analyzer package, which Marten sets PrivateAssets="all" on, but #4557 bundles the analyzer DLL into Marten's own package and that copy flows.
Fault the projection batch when an operation cannot be configured ( #5497 ) by @erdtsieck in #5498
Full Changelog: V9.39.0...v9.39.1
This release fixes two critical SQL injection vulnerabilities in Marten's LINQ provider. Both were reported privately and are disclosed today alongsid…
This release fixes two critical SQL injection vulnerabilities in Marten's LINQ provider. Both were reported privately and are disclosed today alongside this version. If your application routes untrusted input into either surface below, upgrade.
Affects 9.13.0 – 9.38.0. Fixed in 9.39.0.
A GroupBy(...).Where(g => ...) HAVING comparison rendered its constant or closure-captured operand with ToString() and appended it to the command text with no quoting, escaping, or parameterization. Because Marten put no quotes around the operand at all, an attacker did not need to break out of a literal — they supplied the entire literal and everything after it.
// `filter` from untrusted input
.GroupBy(x => x.Color).Where(g => g.Max(p => p.Name) == filter)Impact is filter and authorization bypass, and subquery-based disclosure, at the session's database privilege. Numeric operands were never injectable.
Note on impact: Marten executes through NpgsqlBatch, which refuses multi-statement command text, so stacked-statement modification is not reachable through this sink.
Fix: a HAVING operand is now either SQL derived from the document model or a value from the caller's expression, and those are distinct types rather than both being string. Values always bind as parameters.
Affects 9.31.0 – 9.38.0. Fixed in 9.39.0.
OrderByTextRank / ThenByTextRank interpolated the caller-supplied regConfig into SQL as a '...'::regconfig literal with no validation. This is the same class as CVE-2026-45288, returning on a path added after that fix: the validator existed, but as a private member of FullTextWhereFragment, so the ranking path could not reuse it and did not re-implement it.
You are affected only if your application passes untrusted input as the regConfig argument. It is an optional parameter defaulting to english, so most applications never pass it at all.
Fix: the check is now a shared validator, applied in FullTextIndexResolver.ResolveVector — the sink every full-text path funnels through — so present and future callers are covered by construction rather than by remembering. The weighted-index and Marten.PgVector paths were folded onto the same validator.
Two advisories for vulnerabilities already fixed in earlier releases are published alongside this one:
Select projection. Fixed in 9.15.3.UseTenantPartitionedEvents. Fixed in 9.22.1.If you are on a version older than those, upgrade.
StreamAction rather than by regex over the PostgreSQL error detail. Id, AggregateType and the expected version are now correct regardless of whether Npgsql redacted the detail — which it does unless the connection string carries Include Error Detail=true, so this was the common production case. The fallback message no longer passes PostgreSQL's own text through.DisabledTenantException instead of UnknownTenantIdException, so an operator who disabled a tenant is no longer told it does not exist. The new type derives from UnknownTenantIdException, so existing catch blocks and OnException<T>() policies keep working.GroupJoin/SelectMany, and GROUP BY rendering over a join.UnknownEventTypeException had its closing quote and period transposed, so copying the alias out of the message picked up a stray period. It now also names SkipUnknownEvents.StreamIdentity and the overloads to use.$ that leaked into the text, an unhelpful dictionary message, and a shared closing sentence naming the escape hatches on the generic operator refusals.MasterTableTenancy no longer says "Default tenant does not supported".schema/migrations.md claimed CreateOrUpdate never drops objects (it drops columns, indexes and foreign keys the model no longer declares) and that None throws on drift (it neither creates nor validates). Both corrected, along with the defaults.Three inherited behaviour changes worth knowing about:
AssertDatabaseMatchesConfigurationAsync / db-assert now always throw DatabaseValidationException. Previously a delta that was Invalid and not rebuildable escaped as SchemaMigrationException — the worst drift, and precisely the case a catch (DatabaseValidationException) missed.UpdateRevision() hint, not only when the document implements IRevisioned/ILongVersioned.AutoCreate.All's drop-and-recreate branch now logs a destructive-change warning before it runs, and Migrator.RefuseDestructiveChanges (default off) can turn it into a refusal.Eight changes since 9.37.0. Most of this release is silent-wrongness: a permanent sequence gap that stalls the async daemon, telemetry spans attached
Eight changes since 9.37.0. Most of this release is silent-wrongness: a permanent sequence gap that stalls the async daemon, telemetry spans attached to the wrong parent, a DbContext nothing ever disposed, and a vector projection that embedded stale text. Three of them were reported from production by the community.
A VectorProjection declaring MapFromAggregate registered anything other than Async is now refused when the store is built (#5451, #5468). The class remarks always said Async was required; nothing enforced it, so an Inline registration built without complaint and then embedded text that missed the event that triggered it. Measured: the first save wrote no row at all, because live aggregation reads committed events and the stream had none yet; every later save wrote the state as of the previous event. The content hash recorded that stale text as current and nothing failed.
If you have such a registration it will now fail at DocumentStore.For(...) with a message naming MapFromAggregate and ProjectionLifecycle.Async. Event-mapped vector projections registered Inline are unaffected and remain supported.
A failed batch no longer leaves a permanent gap in the event sequence (#5454, #5456). Under QuickWithServerTimestamps — the V9 default — a StartStream took a per-event INSERT route whose seq_id came from an inlined nextval() that nothing read back. The events kept Sequence 0, and tombstone creation skips exactly those, so any failure later in the same batch (a losing UpdateRevision, a duplicate document INSERT) rolled back with sequence numbers drawn and nothing to fill them. The async daemon's high-water detector then stalled for every tenant in the database until it had seen the gap for StaleSequenceThreshold. A StartStream now goes through the same mt_quick_append_events function an ordinary append already used, which returns its sequence numbers. Reported and fixed by @vpetrevski.
mt_quick_append_events no longer writes a new stream's mt_streams row twice (#5464). It inserted the row with version 0 and then let the trailing UPDATE set the final version — two heap tuples and two WAL records per stream creation, in one transaction, where nothing outside it could observe the intermediate value. This became the default stream-creation path with #5456, so it was worth removing. A one-event StartStream measured 471µs on the old per-event route, 511µs through the function, and 480µs with this change (PostgreSQL 17.10, medians of 3). FetchForWriting against a stream that does not exist yet gets the same saving.
An EF Core projection's DbContext is disposed (#5457, #5466). EfCoreProjectionStorage owns a context built once per tenant per batch, and nothing disposed it on any path — IProjectionStorage<,> declares no disposal contract, so the type that owned it had nothing to hook. Disposal now belongs to the transaction participant, whose lifetime already equals a batch. A second, unconditional leak is fixed alongside it: the live-aggregation fallback registered its owner only when the session was an ITransactionParticipantRegistrar, and a plain IQuerySession — what AggregateStreamAsync passes — is not one, so nobody released an eagerly-opened connection. Reported by @wpei-infotrack, with investigation from @arnelirobles.
An absent key no longer projects as an explicit JSON null in a Select() (#5461, #5462).
MartenTracing passed parentActivity?.ParentId to ActivitySource.StartActivity, which parents a span to the parent of the activity it is handed — its grandparent. With an incoming Server/Consumer span carrying a remote traceparent, every marten.connection span hung off the sending service's span instead of the one doing the work. It stayed hidden because it is invisible in the two easy cases: with no ambient activity, or with an ambient root, ParentId is null and ActivitySource falls back to Activity.Current, which is the right answer by accident. Diagnosed from the source by @bohdan-hukivskyi.Unit testing event-sourced handlers with StubEventStream is now documented (#5455, #5458, #5463). Also fixes StubEventStream(aggregate, StoreOptions), which built a second EventGraph so MapEventType aliases never reached it.
The pgvector docs no longer describe the ef_search cap that 9.37 removed (#5452, #5467), including the non-obvious case that a hybrid search asks for its CandidateDepth rather than its limit and so reaches pgvector's 1000-row ceiling at a limit above 250. Contributed by @arnelirobles.
JasperFx and JasperFx.Events 2.73.2; Weasel 9.32.0.
Eighteen commits, and almost all of them are Marten.PgVector correctness — found by auditing the search surface 9.36.0 had just shipped. Every one of
Eighteen commits, and almost all of them are Marten.PgVector correctness — found by auditing the search surface 9.36.0 had just shipped. Every one of these was silent: a plausible answer, in a plausible order, that was wrong.
| #5427 | Searches skipped Marten's default filters — soft-deleted rows returned, no subclass discriminator, and the tenant filter keyed on the store rather than the document |
| #5428 | Distance defaulted to Cosine rather than the metric the index declared, so an L2 index was searched by cosine and silently fell back to a sequential scan |
| #5419 | An indexed search was capped by hnsw.ef_search (default 40) — so it under-returned however large the limit |
| #5440 | Rows over a document hierarchy came back deserialized as T, never the concrete subtype |
| #5433 | A wrong-length query vector was cast to its own length rather than refused, so it errored in Postgres' words or answered empty |
| #5425 | Full text silently fell back to an unindexed whole-document to_tsvector when no index matched the regConfig |
VectorProjection wrote the wrong rows| #5420 | Ignored conjoined tenancy — one table keyed on id, so the later tenant's write replaced the earlier one's |
| #5439 | An inline projection wrote every event under the outer session's tenant, so ForTenant(...) appends landed in the wrong tenant |
| #5424 | Guid-only, collapsing to Guid.Empty on string-identified streams |
| #5422 | Page folding — a write and a retraction of one id in a single page left the row in the index |
The session's connection, transaction, command timeout, resilience pipeline and IMartenSessionLogger. A search on a session inside a caller-managed transaction now sees that session's uncommitted writes, the way Query<T>() does.
⚠️ #5438's HNSW scan settings are preserved, not weakened. They ride the same batch as the search rather than a transaction of their own — Postgres runs a batch's statements in an implicit transaction, so SET LOCAL still cannot leak onto a pooled connection.
VectorSearchAsync, VectorSearchWithScoresAsync and VectorProjectionSearchAsync gain CancellationToken overloads. Overloads rather than defaulted parameters: an optional argument is compiled into the caller, so widening a shipped signature breaks every assembly that has not been rebuilt.
ColumnWeights is refused, not ignored (#5446)HybridSearchOptions gained per-column weights for the text leg (#854, JasperFx 2.72.0). Marten weights at index time through WeightedFullTextIndex, so there is nothing a per-call weight could apply to — and it is refused by name rather than ignored, because a caller who weights a title column and silently gets an unweighted ranking has no way to find out.
#5379 — mt_last_modified no longer jumps by the server's UTC offset when a document is patched. now() at time zone 'utc' strips the offset, and the naive result is re-read in the session's TimeZone: on a UTC+2 database the stored instant was two hours in the past. ⚠️ Only patching was affected — an ordinary Store/SaveChanges takes the column default, which was always correct.
#5430 (thanks @erikshafer) covers the event half of the same defect, reached through stream compaction, which #5445 fixed and left untested — and its assertions run over two time zones in both directions, where a one-sided bound passes against a negative offset.
#5383 — database-scoped explorer reads, honest store-global reads, and two tenant-scoping defects.
On JasperFx 2.72.0.
Backfilled. This release shipped to NuGet on 2026-09-14 but was never tagged or released here; the notes below were written afterwards from the commit
Backfilled. This release shipped to NuGet on 2026-09-14 but was never tagged or released here; the notes below were written afterwards from the commits it contains.
Takes Marten.PgVector from a vector search that could not be indexed and could not be called portably, to one that shares the JasperFx.Events.Vectors contracts with Fisher and Polecat, can be served by an HNSW index, returns scores, and fuses with full text.
| #5413 | Marten.PgVector on the store-neutral vector contracts, with scored search |
| #5414 | VectorIndex — declare an HNSW index for a vector search |
| #5415 | Hybrid search: reciprocal rank fusion over the ts_rank leg and the vector leg |
| #5417 | Keep the pre-9.36 API working alongside the shared contracts |
#5413 deleted that package's own IEmbeddingProvider and DistanceFunction in favour of the shared ones, and removed the dead VectorOn / PgVectorOptions registry, which had no references anywhere including the tests. The Pgvector.Vector overload of VectorSearchAsync was kept and forwards, so the common call site is source-compatible.
Minor rather than major because the whole package family shares one version and Marten itself has no break in this range — a major bump would move every other package for an extension package's API.
The search surface shipped here was audited immediately afterwards, and ten silent defects came out of it — searches that skipped Marten's default filters and returned soft-deleted rows, a distance metric that defaulted to Cosine rather than the one the index declared, an indexed search capped at 40 rows by hnsw.ef_search, a VectorProjection that ignored conjoined tenancy, and more. Every one of them returned a plausible answer in a plausible order.
All of them are fixed in 9.37.0. If you are picking up Marten.PgVector for the first time, start there rather than here.
On the tag. This points at daeb27b5, not at the "Release 9.36.0" commit baf1fc3f. The publish dispatch from the release commit was cancelled, and the run that actually pushed the packages went from daeb27b5 — one commit later, carrying #5417. The tag names what shipped.
Marten 9.35.0 — Event Model naming, store identity, and idempotent archiving.
Marten 9.35.0 — Event Model naming, store identity, and idempotent archiving.
Three, all deliberate. None require a code change to adopt, but each changes what an existing application observes.
1. A store's derived Event Model is now named after your service, not "EventModel" (#5408).
The store-derived Event Model source fell back to the literal "EventModel" — the one default guaranteed to be wrong for every host. Wolverine names its derived model after JasperFxOptions.ServiceName, and so does Bobcat's spec assembly, so the common case (Wolverine + one Marten store) assembled two models out of the box and had to restate a name it had already declared.
The fallback is now the service name. An explicit name still wins:
services.AddMarten(opts =>
{
opts.Connection(connectionString);
opts.EventModelName = "Ledgers"; // optional; defaults to JasperFxOptions.ServiceName
});If you were relying on the model being called EventModel, set opts.EventModelName = "EventModel" explicitly.
2. The primary store's Subject now follows StoreName (#5409).
Subject was a literal marten://main that ignored StoreOptions.StoreName, while Identity had always been built from it — so naming a primary store moved one and left the other behind, and Subject is the one consumers key on. Both are now built from StoreName. Tooling that keys on store.Subject (CritterWatch's explorer reads and shard progression ids among them) will see marten://{storename} for a named store where it previously saw marten://main.
A store name is user-supplied text and a uri host is not, so names are sanitized: My Store would otherwise throw UriFormatException and a/b would silently parse its tail as a path. Ordinary names are unchanged.
3. Archiving an already-archived stream is a no-op instead of an error (#5403).
Under UseArchivedStreamPartitioning, a second archive of the same stream raised 23505 on mt_streams_archived_pkey. This also stalled async single-stream projections with IncludeArchivedEvents = true when they processed an Archived marker after an inline snapshot had already archived the stream. Repeated archiving now succeeds and does nothing.
A genuine collision is still reported: a different active stream reusing an archived stream id still fails, rather than being silently swallowed and losing its metadata.
StoreOptions.EventModelName. A host that called AddEventModel("Something", …) assembled two models: its own, and one the store contributed under the default name. The name is configured on StoreOptions and read lazily when the model is assembled, so AddEventModel may be called before or after AddMarten.BuildStoreOptions assigned StoreName both before and after the IConfigureMarten<T> chain, so a contribution that named the store was silently reverted to the marker type's name.is_archived is the list partition key on both mt_streams and mt_events, the archive function's new predicates also let PostgreSQL prune to the active partition, where the previous statements had to consider both.ImHashMap keyed by Frame, which does not override GetHashCode, meant statement order followed identity hash codes and varied per process. Every Event Model slice also now carries the store it came from, so two stores projecting a document of the same simple name leave a recorded disagreement rather than one silently winning (#836).Binary compatible with 9.34.0. The public AddMarten / AddMartenStore<T> surface is unchanged — verified by diffing the extracted signature list against the 9.34.0 tag. New configuration is additive on StoreOptions.
Native AOT support for event-sourced applications, and a round of integrity fixes to the diagnostics and monitoring surface — including one deliberate
Native AOT support for event-sourced applications, and a round of integrity fixes to the diagnostics and monitoring surface — including one deliberate behaviour change worth reading before upgrading.
Event-sourced applications now run under Native AOT.
Utf8JsonWriter instead of round-tripping through the consumer's serializer, removing a reflection-dependent path from AOT reads.Three fixes to the event store explorer and projection-status APIs. All three are cases where a monitoring read returned something misleading, or changed the system it was observing.
#5400 — Behaviour change. The four explorer read APIs no longer provision anything. They resolved their database through ITenancy.FindOrCreateDatabase, and two tenancy models take the "or create" half literally: ShardedTenancy assigned an unknown tenant to a shard and ran partition + per-tenant sequence DDL for it, and SingleServerMultiTenancy issued CREATE DATABASE for a database named after the id. A console polling a retired or mistyped tenant id therefore brought that tenant — or a whole database — into existence.
An unrecognized tenant id now throws UnknownTenantIdException, which is what StaticMultiTenancy and MasterTableTenancy already did, so the explorer answers the same way across every tenancy model. Adds the public ITenancy.TryFindDatabase, a read-only counterpart to FindOrCreateDatabase; it is a default interface member, so custom ITenancy implementations keep working unchanged. FindOrCreateDatabase itself is untouched — create-on-demand remains its documented job for real tenant traffic.
If you call GetRecentStreamsAsync, ReadStreamAsync, QueryByTagsAsync or GetProjectionStatusesAsync with a tenant id that may not exist, it now throws where it previously succeeded.
#5382 — GetProjectionStatusesAsync(ct) threw NotSupportedException on a database-per-tenant store, because a tenant-less call has no default tenant to open a session against. It now answers from the projection registry, so "is this projection still registered?" — the only store-agnostic way to ask, and what orphan detection is built on — works on the store shape that most needs it.
Note the original issue reported that both overloads threw. Only the tenant-less one did; passing a tenant id or a database identifier from AllDatabases() has always worked and reads real per-database progression.
#5396 — ShardStatus.State reports the state of a reachable daemon instead of always answering Unknown. Unknown now means what it is documented to mean: there is no daemon here to ask.
IVersioned marker interface. Thanks to @JurJean for the report and the original fix.[JsonStringEnumMemberName] / [EnumMember] names are honored in LINQ translation rather than the CLR member name being assumed.byte[], and its limits are documented. Thanks to @erdtsieck.ProjectionEventModelSource is registered from AddMarten and AddMartenStore<T>, so Event Model views resolve from a Marten store without manual wiring.PrefixSearch and the session-level full-text search shortcuts.GuidOptimisticConcurrencyCompliance in the shared compliance suite.#5382's tenant-less registry answer reports ProcessedSequence and EventStoreSequence as 0 because nothing was read — which a caller cannot distinguish from a shard that has genuinely never run. That ambiguity is deliberate for now; #5383 tracks the database-scoped overloads that read real progression per database.
Cut as a minor on public API surface: #5400 adds ITenancy.TryFindDatabase, and #5397 changes DI registration.
The concurrency and portability release: a shared-state race that broke parallel db-apply , a Native AOT query failure, and two cross-store divergence
The concurrency and portability release: a shared-state race that broke parallel db-apply, a Native AOT query failure, and two cross-store divergences settled in Marten's favour and against it respectively.
Dependencies move to JasperFx 2.67.0 and Weasel 9.31.1.
db-apply --parallel could die with "Collection was modified" (#5364)Applying schema changes to many databases concurrently intermittently failed on one of them with:
System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
at Marten.Events.Schema.PerTenantEventSequences.currentPartitionSuffixes()
at Weasel.Core.Migrations.DatabaseBase`1.assertValidIdentifiers(IEnumerable<ISchemaObject>)
Reported from a production deployment at 29 databases and --parallel 16, where it hit exactly one database per run.
One ManagedListPartitions instance is shared by every database in a store. It mutated its partition dictionary in place and published it through a ReadOnlyDictionary — which wraps rather than copies — so InitializeAsync's Clear()-and-refill ran while another database's worker was mid-enumeration. Fixed upstream in Weasel 9.31.1 (JasperFx/weasel#583) by publishing the registry as a snapshot swapped copy-on-write. Marten needs only the bump; there is no Marten code change.
Two things worth knowing if you hit this before upgrading:
The same Weasel fix covers Weasel.SqlServer's ManagedTenantPartitions, which had the identical shape.
where x.Status == captured threw BadLinqExpressionException wrapping PlatformNotSupportedException: Dynamic code generation is not supported on this platform, while the same comparison against a literal worked. C# lowers enum equality to a comparison of the underlying integers, so the value side always arrives as Convert(closure.captured, Int32) — a shape 9.32.1's reflective walk did not cover, which sent it to FastExpressionCompiler and therefore to Reflection.Emit.
Two changes, because the enum shape was the symptom rather than the cause. The reflective walk now handles conversions between an enum and anything sharing its integral representation, including the Nullable<T> forms — widening and narrowing are still left alone, so no value is ever reinterpreted. And ReduceToConstant no longer requires the ability to emit at all: where the platform cannot, shapes the walk declines fall back to the BCL expression interpreter instead of throwing. Without that second half, every expression shape Marten has not explicitly been taught was a latent AOT failure.
EventQuery.TagValues is now honored (#5365)The lossy name/value tag filter, on the composable query object. Previously you filtered by tags or by everything else: the dictionary form existed only on the non-composable QueryByTagsAsync, which is unpaged and has no TotalCount. Now a caller holding a tag name can AND it with the event type, window, stream, metadata and tenant filters and keep paging and a truthful total.
Note the semantics, which differ from the rich TagConditions form deliberately: entries AND (an event must carry every named tag at the given value), where TagConditions conditions OR. Supplying both spellings on one query is refused rather than resolved by a silent precedence rule.
Fixing the above exposed a divergence in the existing IEventStore.QueryByTagsAsync(IReadOnlyDictionary<string,string>, …) overload. It matched a tag name against the CLR type name only and compared the value case-sensitively, while Polecat accepted either spelling and compared case-insensitively — so the same dictionary answered differently depending on which store you asked, and guessing wrong was an ArgumentException at runtime.
Both that overload and the new TagValues path now resolve names through the shared matcher (CLR simple name or registered table suffix, case-insensitively) and compare values against the string form of the stored tag value, ordinal case-insensitively. Postgres renders a Guid lowercase through ::text while SQL Server renders it uppercase, so an operator's copy-pasted id no longer depends on which store answered.
If you relied on a tag name or value being matched case-sensitively, this query now matches more rows than it used to. An unregistered tag name is still an ArgumentException listing what is registered, never an empty answer.
CompactStreamAsync<T> infers the fold for an unregistered aggregate (#5366)Compaction refused any type without a registered aggregation projection, while Fisher and Polecat both inferred one. That made a compaction policy able to target only an aggregate the application already snapshots — a real constraint on the feature, invisible until runtime, and one nobody would infer from the API.
CompactStreamAsync<T> now builds the aggregator from T's own Create/Apply conventions when no projection is registered, which is what every other aggregation path in Marten already did. The typed overload names T outright, and that is the same declaration of intent a registration would be.
A type with no aggregation conventions at all is still refused, and deliberately so: compaction deletes the events it folds, so a silently empty snapshot would destroy the stream's history. The refusal message now names what is actually missing.
A bug-fix release, on JasperFx 2.66.0 and Weasel 9.31.0 . The headline is that Native AOT document reads worked for nobody before this; the rest is co
A bug-fix release, on JasperFx 2.66.0 and Weasel 9.31.0. The headline is that Native AOT document reads worked for nobody before this; the rest is correctness work on natural keys, the outbox, EF Core projections, and flat tables.
Several of these change observable behaviour. Each one is called out below.
An app published with PublishAot=true built and published analyzer-clean, then threw on the first document read. Writes — including schema creation — were fine. Three separate sites, all on the read path:
MissingMethodException: No parameterless constructor defined for type
'Marten.Internal.Sessions.QuerySession+StorageFinder`1[MyDocument]'
PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
at FastExpressionCompiler.ExpressionCompiler.CompileFast[R](...)
at Marten.Linq.Parsing.LinqInternalExtensions.ReduceToConstant(Expression)
QuerySession.StorageFor(Type) and CompiledQueryPlan.sortMembers() both went from a runtime Type back to a closed generic through MakeGenericType + Activator.CreateInstance, and ILC has no native code for an instantiation nothing constructs statically. The third — expression compilation for every non-literal value in a where clause, i.e. every where x.Member == captured — is Reflection.Emit, which native AOT does not have at all.
All three now capture the closed generic where the compiler has already emitted it. LoadAsync, LINQ, raw SQL, Include, patching and source-generated compiled queries all run from a native binary.
Two shapes still need a JIT, and both have a one-line workaround (documented in the AOT publishing guide): a compiled query with an enum-typed parameter, and a where clause whose value comes from a method call — hoist the call into a local first.
Marten also ships a new runtime gate, src/Marten.AotRuntimeSmoke, which publishes natively and runs every read path against a real PostgreSQL in CI. The existing Marten.AotSmoke is a build-time analyzer gate and was structurally blind to this class of defect: the offending code analyzes clean and throws one line later. If you publish AOT, do the same in your own pipeline — a clean publish is necessary, not sufficient.
The first run of the shared NaturalKeyCompliance suite against Marten found three genuine bugs in the [NaturalKey] lookup table.
⚠️ Behavior change. A second stream claiming a natural key a live stream already holds is now refused with
JasperFx.Events.DuplicateNaturalKeyException, carrying bothExistingStreamIdandClaimingStreamId. Previously the claim silently succeeded and repointed the lookup row at the newcomer. The refusal is in the SQL rather than a pre-flight read, so concurrent claimants serialize on the row lockON CONFLICTalready takes and the existing mapping is never repointed even momentarily. Re-asserting your own mapping is still idempotent, and a key held by an archived stream is still claimable.If two live streams in your database already share a key — which older Marten permitted, because the upsert repointed — the rebuild path deliberately stays last-writer-wins so a pre-existing data condition cannot turn into an unrecoverable daemon failure.
⚠️ Behavior change. An archived stream no longer resolves by its natural key.
ArchiveStreamleft the lookup row intact, so an archived stream stayed resolvable indefinitely. The flag is now read off themt_streamsrow the lookup query already joins, which covers the explicit call, theArchivedmarker event, and the daemon alike. No schema change and no migration of existing lookup tables.
A [NaturalKey] of primitive type string on a Guid-identity store now works. FindFetchPlan asserted string stream storage for any string TId and refused with "This Marten event store is configured to identify streams with Guids" before any planner saw it. Both that guard and its mirror image in NaturalKeyFetchPlanner now narrow to the store's own stream identity type. The ambiguous case is unchanged by design: on a string-identity store, FetchLatest<T, string>(value) still addresses the stream.
IMessageBatch.BeforeCommitAsync was called before the UpdateBatch was built and before a transaction was even open — so it fired for units of work that had not been attempted, let alone succeeded. A SaveChangesAsync that then failed (a stream-id collision, say) left the message batch enlisted and committed, with no rollback hook able to un-enlist it.
The batch is now enlisted as an ITransactionParticipant, invoked inside the transaction after every page has executed and immediately before the COMMIT. A unit of work that fails never reaches it. Hook ordering and the inside/outside-the-transaction visibility contract are unchanged; the before hook simply runs later in the transaction. This is the placement Polecat already used.
Registering an EfCoreSingleStreamProjection / EfCoreMultiStreamProjection made Marten build a DocumentMapping for the aggregate type and emit schema for it, even though EF Core owns that data in a DbContext-mapped table Marten never reads or writes. Two consequences, both fixed:
ApplyAllConfiguredChangesToDatabaseAsync() created an empty mt_doc_<tdoc> table nothing used — and in the normal AutoCreate.None shape, where schema is built by a migration pass that does not register the projection, AssertDatabaseMatchesConfigurationAsync() then reported that table as MISSING and a correctly-migrated database failed its own startup assertion.Id threw InvalidDocumentException from every full-schema operation, including the db-apply / db-patch CLI commands. EF Core is perfectly happy with a non-Id key; Marten's document conventions are not, and the type should never have reached them.Separately (#5329), rebuilding an EF Core projection tried to truncate that non-existent Marten document table. Teardown now clears the EF Core table the DbContext actually maps.
ℹ️ An
mt_doc_<tdoc>table created by an earlier version is left in place — Marten now skips schema generation for the type rather than dropping anything. Drop it by hand if you want it gone.
Increment(column) counts the creating event (#5342, #5341)⚠️ Behavior change.
IncrementMap.ToInsertExpressionreturned0, so the event that created the row was never counted and the column read N-1 after N events. Polecat, Fisher and the liftedWeasel.Storage.FlattenedDSL all insert1, and the maintainer ruling on #5341 is that 1 is correct and Marten was wrong. Rows created after this ships read one higher; existing rows are untouched. If downstream reporting compensated for the old value, it needs to stop.
Marten.Exceptions.ApplyEventException is now [Obsolete] (#5337). Since the async daemon moved to JasperFx.Events, apply failures throw JasperFx.Events.Daemon.ApplyEventException and nothing in Marten throws the Marten copy any longer. It still exists so existing catch blocks compile through 9.x; catch the JasperFx.Events type instead. It will be removed in Marten 10.
Marten's own six event exception types (UnknownEventTypeException, NonExistentStreamException, ExistingStreamIdCollisionException, EventDeserializationFailureException, StreamLockedException, DefaultTenantUsageDisabledException) now have canonical versions in JasperFx.Events 2.64.0 (#5346). Marten's copies remain the ones thrown and are aliased assembly-wide, so catching them by name is unaffected; code that wants the shared type fully-qualifies it.
Dependency adoptions: JasperFx 2.64.0 → 2.66.0 and Weasel 9.31.0 (#5343, #5347, #5357). Compliance enrollment continued through waves 13 and 14 (#5336, #5343). The #4821 closed-shape event-storage extraction was assessed complete and its codegen-era leftovers retired (#5339), along with local duplicates of types since lifted to shared packages (#5337). No public API was added or removed in this release.
🤖 Generated with Claude Code
Two coordinated feature waves against JasperFx.Events 2.63.0 , plus the compliance enrollments that keep Marten, Polecat, and Fisher synchronized.
Two coordinated feature waves against JasperFx.Events 2.63.0, plus the compliance enrollments that keep Marten, Polecat, and Fisher synchronized.
QueryEventsAsync / EventQuery now supports inclusive timestamp windows (TimestampFrom/TimestampTo), inclusive sequence windows (SequenceFloor/SequenceCeiling), multiple event type aliases (EventTypeNames, union with the single EventTypeName), and DCB tag conditions folded into the query (TagConditions — the TagTables and HStore modes both translate through the same SQL as QueryByTagsAsync). Results are contractually ordered sequence-ascending; TotalCount counts matches across all pages.
⚠️ Behavior change: a metadata filter (
CorrelationId,CausationId,UserName) against a store that has not enabled the corresponding capture column now throwsNotSupportedExceptionnaming the field — previously the filter was silently dropped and unfiltered results were returned as if filtered.
QueryStreamStates(tenantId?) exposes a real IQueryable<StreamState> over mt_streams — every public member translates in Where(), including AggregateType == typeof(X) (resolved through the stored type alias) and the new CompactedVersion watermark, so a compaction policy predicate like s.AggregateType == typeof(Order) && s.Version - s.CompactedVersion > 1000 && !s.IsArchived runs server-side. mt_streams gains an additive compacted_version bigint NOT NULL DEFAULT 0 column (Weasel migration; expect a one-time schema-touch burst on a first local test run against pre-existing schemas), and CompactStreamAsync records the watermark in the same unit of work — monotonically (greatest()), so a replayed lower-cutoff compaction can never move it backwards. Untranslatable members and a tenant scope on a non-conjoined store refuse by name, never silently match-all.
The JasperFx.Events event-query and stream-query commands are covered end-to-end against Marten (first CLI-execution tests in the repo). Compliance: EventQueryCompliance (41 facts) and StreamStateQueryCompliance (15) enrolled; the full compliance namespace runs 388/388 on net9.0 and net10.0.
Note for code importing both Marten and JasperFx.Events.Documents: extension-style ToListAsync over the stream queryable is ambiguous (CS0121) — call the JasperFx extensions explicitly.
🤖 Generated with Claude Code
A diagnostics-only patch release. No behavioural change to the write path.
A diagnostics-only patch release. No behavioural change to the write path.
When a batched write failed, Marten threw a MartenCommandException whose message rendered an empty command text. Reported from the field on an async projection:
Marten Command Failure:$ $ $ 42601: syntax error at end of input POSITION: 56
Error trying to build and apply changes to event subscription MyProjection:All
The $ $ $ is the message template interpolating a command that was always null. ReadNpgsqlCommand() looks for an NpgsqlCommand in exception.Data, and on the ExecuteBatchPagesAsync path there is no single command to put there — AutoClosingLifetime transformed the exception with nothing recorded at all, and WrapAndThrow(NpgsqlBatch, ...) recorded the batch under a key that nothing ever read.
The practical consequence: the offending SQL was unrecoverable from the exception and from the logs, for every async projection write. That is precisely the situation where hand-written SQL — a QueueSqlCommand, a custom IStorageOperation, a projection side effect — turns out to be malformed, and the one thing you need to see is the statement.
MartenCommandException now recovers the SQL from three sources, in order:
NpgsqlCommand it was handed;NpgsqlException.BatchCommand — Npgsql identifies the exact statement the server rejected when the failure came out of a batch execution;NpgsqlBatch recorded on the exception.The recovered statement is also exposed on a new MartenCommandException.CommandText property, so it can be read programmatically rather than scraped out of the message.
AutoClosingLifetime executes its batches inline rather than through handleCommandException, so it now records its batch. The other three connection lifetimes already routed through WrapAndThrow(NpgsqlBatch, ...) and only needed a reader for the key they were already writing.
Two guardrails worth knowing about: a batch renders at most five statements, so a 500-operation projection page cannot turn one failure into an unreadable log entry, and the whole resolution is wrapped in a catch — building a diagnostic must never replace the real failure.
Also released as 8.38.1 on the 8.x line.
A single-fix patch release that moves Marten to JasperFx 2.61.0 , picking up the source generator fix for JasperFx/jasperfx#733 . Weasel stays on 9.29
A single-fix patch release that moves Marten to JasperFx 2.61.0, picking up the source generator fix for JasperFx/jasperfx#733. Weasel stays on 9.29.0.
CreateA self-aggregating snapshot may declare its Create handler as an event-shaped constructor, public Foo(FooCreated e), instead of a named static Create. That works on its own. Adding a ShouldDelete method to the same aggregate switched the source generator to a different emitter — one that built its dispatch switch from named conventional methods only — so the constructor's event type got no case arm at all.
Nothing failed loudly. The generated code compiled, the constructor never ran, and the next Apply-only event built the aggregate through RuntimeHelpers.GetUninitializedObject, skipping every field initializer. The visible symptom was an ApplyEventException wrapping a NullReferenceException out of an Apply that appended to a collection property:
JasperFx.Events.Daemon.ApplyEventException: Failure to apply event #0 Id(...)
---> System.NullReferenceException: Object reference not set to an instance of an object.
at TempGuidAggregate.Apply(TagAdded @event)
The quieter outcome, where no Apply happens to dereference anything, is a silently blank aggregate.
Both documented workarounds — converting the constructor to a static Create, or registering the delete through DeleteEvent<T>() instead of ShouldDelete — become unnecessary on this release.
The same omission was also in the generated EventTypes property on every self-aggregating path, including the ones whose dispatch was already correct, so an aggregate with a constructor Create and no ShouldDelete gains its creating event in that list here too.
Covered by Bug_jasperfx_733_event_constructor_create_with_should_delete across inline, async and live aggregation, plus the delete arm itself. See #5322.
Every fix in this release removes a silent wrong answer — not a crash, not an exception, but a plausible-looking result that was wrong with nothing to
Every fix in this release removes a silent wrong answer — not a crash, not an exception, but a plausible-looking result that was wrong with nothing to tell you so. A stream folded into partial aggregates. Paging that returned arbitrary pages. Monitoring that went dark rather than broken. A search reading a key that did not exist. An index that was never created.
Plus one genuinely new feature: weighted full text search with relevance ranking.
ts_rank orderingA full text index used to concatenate its members into one flat vector, so a match in a title was exactly as relevant as a match in a long description. Now it can be weighted, and ranked:
opts.Schema.For<Achievement>().WeightedFullTextIndex(idx => idx
.Weighted(a => a.Title, TextSearchWeight.A)
.Weighted(a => a.Tagline, TextSearchWeight.B)
.Weighted(a => a.Description, TextSearchWeight.C));
var results = await session.Query<Achievement>()
.Where(a => a.WebStyleSearch(term))
.OrderByTextRank(term, TextSearchFunction.WebStyle)
.ToListAsync();The rank resolves the same tsvector the Where clause matched on, read from the index definition. That is the load-bearing design constraint rather than an implementation detail: a rank computed over a different vector than the filter matched on returns rows in an order that looks plausible and means nothing — a far quieter failure than returning the wrong rows.
Requires Weasel 9.29.0 (weasel#541), which made it possible to index an expression that is already a tsvector. See the full text documentation for the costs worth knowing first — GIN cannot order, so this is a post-filter sort; and adding weights to an existing index drops and recreates it.
Async projections folded one stream into several partial aggregates (#5305, originally #4085). When a stream's events disagreed about tenant_id, the daemon sliced it per tenant and applied the pieces over each other, so an Apply saw a document with every property at its default. Marten had set ForceSingleTenancy since the original fix, but TenantedEventSlicer honoured that flag on only one of its two overloads — and the async daemon reaches the other one. The flag was being set on precisely the path that could not read it. Fixed upstream in JasperFx 2.58.0.
OrderBy after a GroupJoin/SelectMany was dropped from the SQL (#5311). Silently, and with Skip/Take it was worse: OFFSET and LIMIT were emitted while the ordering was not, and unordered paging in PostgreSQL has no stable row order — so rows repeat across pages while others never appear. Where on a bare-side selector and Count over a join were wrong in the same surface and are fixed too.
A store with monitoring off dropped the monitoring columns another store had added (#5309). Two DocumentStores over one DatabaseSchemaName that disagreed about EnableExtendedProgressionTracking kept stripping each other's mt_event_progression columns — last writer wins, silent on both sides. A service beside a seeder or a reporting job is an ordinary arrangement, and neither was doing anything wrong. The shape of that table no longer depends on configuration at all.
Full text search rewrote JSON keys, not just the column (#5314, thanks @mlh758). A member whose serialized name contained data produced d.data ->> 'd.data' — a key that does not exist. Two quiet consequences: that member contributed nothing to the search, and the expression no longer matched the index, so the GIN index could not serve the query either.
A second full text index over different members was silently discarded (#5315). Index names derive from the table rather than from the members, so two FullTextIndex() calls collided by construction and the second was thrown away — never registered, never created, never searched. Ambiguity is now refused with an explanatory exception instead of resolved by declaration order.
A DCB boundary aggregate failed far from its cause (JasperFx 2.60.0). An identity-less [BoundaryAggregate] folded by Evolve(IEvent) got no evolver and no diagnostic, surfacing much later as FetchForWritingByTags<T> throwing "No source-generated dispatcher found" — naming neither the type nor the reason. Now it generates, and a [BoundaryAggregate] with nothing to fold events with is reported as JFXEVT007.
A DCB tag version was captured after the events were read (#5300). Two batched statements do not share a READ COMMITTED snapshot, so the version could reflect a concurrent append. Captured before the read now.
projection-run CLI command arrives with JasperFx 2.60.0 and needs no Marten change — every host referencing JasperFx.Events picks it up. It replays one projection over a stream slice or DCB tag match and prints per-event before/after state, writing nothing.Bug_5268's concurrent index test no longer depends on how much unrelated transaction load happens to be in flight (#5308), so validating a dependency bump by running the whole suite at once is trustworthy again.BuildSlicer now agrees with FetchAsyncPlan about global aggregates (#5307). Alignment rather than a fix — no reachable corruption depended on it, and the reasoning is written down at the call site so the question does not have to be reconstructed next time.OrderByFragment.Expressions changed from List<string> to List<ISqlFragment>. A public API break, deliberately taken: an ordering could not carry a parameter while the clause was a list of strings, which is why the older ngram ranking inlines its search term rather than binding it. Nothing outside Marten's own LINQ internals is likely to touch this type, but it is a compile break if you did.
Ambiguous full text index selection now throws. If a document has two full text indexes sharing a regConfig, Marten refuses the query rather than picking one. This converts a silent wrong answer into an exception — which is the point, and also the kind of thing that shows up in CI rather than code review. Give the indexes different regConfig values, or register a single index covering every member you want to search.
EnableExtendedProgressionTracking still defaults to false. It briefly defaulted to true during this development cycle and that was reversed before release: turning it on by default would silently start writing six columns for every existing store that never asked for monitoring. The opt-in is unchanged.
Dependency floors: JasperFx 2.60.0, Weasel 9.29.0.
Dependencies move to Weasel 9.27.0 and JasperFx 2.56.0 .
Dependencies move to Weasel 9.27.0 and JasperFx 2.56.0.
Several fixes in this release share a failure mode worth calling out on its own: the write succeeded, the read disagreed, and nothing anywhere reported an error. Two of them ran undetected in production for weeks.
[JsonPropertyName] (or [JsonProperty] under Newtonsoft) was patched at a path the serializer never reads. The patch reported success, Load and Query kept returning the old value, and the phantom node was erased by the next full save — so there was no durable evidence anything had gone wrong. Patch paths now resolve through the same member machinery the LINQ provider uses, so Where(x => x.Name == v) and Patch(...).Set(x => x.Name, v) agree about where a member lives by construction. The predicate overloads had the same gap and are fixed with it..Duplicate() column now refreshes that column in more of the cases that need it: an aliased member, a patch on a parent of the duplicated member, and the destinations of the patching API's own Duplicate and Rename operations. Left unfixed, the document and the index that exists to search it disagreed — Load returned the new value while Query filtered on the same member returned nothing.Events.BuildHStoreTagIndexConcurrently builds the hstore tag index without holding ACCESS EXCLUSIVE on mt_events for the duration. On an ordinary event table that is CREATE INDEX CONCURRENTLY; under Events.UseTenantPartitionedEvents, where PostgreSQL refuses CONCURRENTLY on a partitioned parent outright, Marten emits the per-partition sequence it does accept. Opt-in, because a concurrent build cannot run inside a transaction and so changes what db-patch and db-dump write out. The out-of-band route from 9.29.0 (Events.IgnoreIndex(EventGraph.HStoreTagIndexName)) still works and is still supported.mt_dcb_tag_version rows, invalidating a concurrent session that did have something to append. A boundary is now only enforced when the save actually appends. #5280 covers the repeated-fetch half: each row is asserted exactly once, oldest capture wins.Events.TagWith<T>(...) states once how an event is tagged and applies it wherever the event is built — ordinary appends, StartStream, aggregate handlers and bulk inserts alike — closing the gaps tag inference leaves open. Events.TagEventsBy(...) hands over a translator an application already owns instead of restating it one registration at a time.x.EnumArray.Contains(variable) resolved to the MemoryExtensions.Contains comparer overload rather than Enumerable.Contains, because enums do not implement IEquatable<T>; with ImplicitUsings on, most applications hit this by default.HashSet<TEnum> in a Contains filter now projects to something Npgsql can bind.MatchesJsonPath(sql, params object[]) overload threw for every call. It now works for any argument type, maps null onto DBNull, and reports a placeholder/parameter count mismatch with a message naming both counts instead of an IndexOutOfRangeException from inside the LINQ provider. Worth knowing: ^ is the placeholder character for that overload and is also a regex anchor, so it lands inside JSONPath literals by accident.min(seq_id) over a join, and PostgreSQL only rewrites MIN into an ordered index scan when the aggregate's input is a single relation. The probe therefore scanned every remaining row in the partition, on the one code path that exists precisely because the store is too large for the normal query to finish.Paused shard after a rollout.Data-loss and silent-failure fixes across the event append path, DCB tagging, and schema naming. Two of these lose or drop data with no error at all,
Data-loss and silent-failure fixes across the event append path, DCB tagging, and schema naming. Two of these lose or drop data with no error at all, so they are worth reading even if the rest is not relevant to you.
A retried commit could lose its events entirely (#5262). QuickAppendEventsOperationBase released its pooled parameter buffers in PostprocessAsync, but the resilience pipeline can re-execute the same NpgsqlBatch — and a released PooledList reports Count = 0, which Npgsql binds as the array length. The retry sent empty event arrays, mt_quick_append_events wrote nothing and raised nothing, and SaveChangesAsync returned success having committed the documents without the events. The rentals now belong to the unit of work.
Commits are no longer retried when the outcome is unknown (#5262). A commit carries event appends and is not idempotent, so replaying one that already committed server-side appends the events twice. SaveChangesAsync now runs through a separate write pipeline that retries only when the previous attempt is known to have left nothing behind — a transient error PostgreSQL reported itself, or a failure Marten's own post-processing raised and rolled back. A command timeout or a dropped connection now surfaces instead of being replayed.
This is deliberately not NpgsqlException.IsTransient, whose definition is built for idempotent work and includes the whole connection-exception class. ConfigurePolly and ExtendPolly now govern reads only; ConfigureWritePolly / ExtendWritePolly are new for the commit path. See Resiliency Policies.
Only the first DCB tag of a given type was stored (#5265). mt_quick_append_events carries one array per tag type, parallel to the events, so it had exactly one slot per (event, tag type). An event legitimately carrying two tags of one type lost the rest — and a lost tag is not an error to a DCB query, it is simply absent from the answer. The surplus is now written as ordinary tag rows. In DcbStorageMode.HStore, which cannot represent the case at all, this now throws rather than dropping silently.
Long primary key constraint names could collide outright. A document table's PK constraint was left on Weasel's pkey_{table}_{columns} default, which passes 63 characters for a document type of no great length once tenant_id joins the key. PostgreSQL truncates rather than rejecting, and the constraint's backing index is schema-scoped, so two document types whose names agree for long enough failed with 42P07 relation "pkey_..." already exists. Also fixed for the natural key table (#5271). No-op for any schema that was not already being truncated.
Multi-database tenancy ignored Command Timeout on shard connection strings (#5269). StoreOptions.CommandTimeout was only ever raised from StoreOptions.Connection(...), which multi-database setups never call — so every command ran at the 5 second default no matter what the shards said. The database being used now has a say. An explicitly set store-wide value still wins.
FetchForWritingByTags broke later full-schema operations (#5264). EventGraph.Build<TDoc>() resolved the id type through MappingFor, which registers a DocumentMapping as a side effect — so a pure [BoundaryAggregate], which has no identity by design, became a document type with no IdMember. ResetAllData, ApplyAllConfiguredChangesToDatabaseAsync and the db-patch / db-apply commands then threw.
EF Core projection rebuilds corrupted the DbContext (#5266). The daemon applies a range's slices through a 10-wide block, all sharing one storage instance — and the EF Core storage wraps a single non-thread-safe DbContext. Requires JasperFx 2.53.0, which lets a storage declare it cannot take concurrent slices.
Bulk-imported events now carry DCB tags in hstore mode (#5267), thanks to @erdtsieck. Previously every bulk-imported event landed untagged, so a store whose history arrived by migration had a consistency boundary that silently excluded most of it.
The DCB hstore tag index can be built out of band (#5268). Enabling hstore mode on an existing store built a GIN index under ACCESS EXCLUSIVE — a write outage rather than a migration, with no way around it under per-tenant partitioning. EventGraph.HStoreTagIndexName can now be passed to Events.IgnoreIndex so an operator builds it themselves; see the DCB docs for the procedure.
A lost append race under conjoined tenancy surfaced as a raw PostgresException (#5270). The exception transform recognised the stream-version guard index by an enumerated list of names, and partitioning gives each partition a differently-named child index. It was missing three, not one — including on the single-tenant configuration #3520 was meant to have fixed. Now matched by shape.
FetchForWriting aggregate cache now really does skip the snapshot load — IdentityMapDocumentStorage.LoadManyAsync was issuing an empty-id query even when the item map had satisfied everything (#5258, #5259, #5260).@erdtsieck for #5262, #5264, #5267, #5268 and #5269 — several of these were forensic reports of silent data loss with the mechanism worked out, which is how they got fixed this quickly. @Noblix for the #5265 repro.
Implement JasperFx's IDocumentCommitListener ( #679 ) by @jeremydmiller in #5261
Full Changelog: V9.27.0...V9.28.0
Full Changelog: V9.27.0...V9.28.0
No breaking changes and no migration. Everything new here is opt-in.
Adopts JasperFx 2.51.0 and the four capabilities it promotes into the shared Critter Stack contracts. One of them is a genuinely new Marten feature; the other three close gaps where Marten had the capability but not under the shared spelling.
FetchForWriting (#5251)An opt-in, node-local cache of aggregate snapshots that lets FetchForWriting skip loading the stored snapshot and read only the events after it — effectively an identity map for aggregates with a lifetime longer than a session. Off for every aggregate type; enabled per type, because the win is proportional to how often one stream is fetched for writing:
opts.Events.CacheAggregatesForWriting<Order>(sizeLimit: 1000);The cached snapshot is only ever a baseline. The stream version and every event after the cached version are still read on every call, and the optimistic concurrency assertion on append is untouched — so a stale entry costs a larger delta query, never a wrong aggregate and never a suppressed EventStreamUnexpectedMaxEventIdException. That is what makes a deliberately incoherent, node-local cache the right shape here, and why there is no distributed-cache option: a distributed cache would reintroduce exactly the round trip this exists to remove.
Both the Async and Inline lifecycles are supported, and they genuinely differ. An Inline snapshot is written in the same transaction as the events, so it is always exactly at the stream head — a hit needs an exact version match, and the entry is written back only after a successful commit, because the inline projection mutates the very instance FetchForWriting handed out.
IAggregateWriteCache and friends live in JasperFx.Events.Fetching, so one cache implementation serves Marten, Polecat and Fisher alike. No new package lands on core Marten: the default is backed by JasperFx.Core's existing LRU rather than Microsoft.Extensions.Caching.Memory.
Docs: Optimizing Performance → Caching Aggregate Snapshots for FetchForWriting.
IDocumentSessionOperations.PendingStreams (#5250)Store-agnostic code can now read the StreamActions a session has queued but not yet committed — for a listener or a pre-commit hook deciding something from the events the session is about to write — without naming a store.
⚠️ Worth knowing if you are implementing these contracts yourself: this is the same non-covariance trap as 9.26's Events accessor. Marten's PendingChanges.Streams() returns IList<StreamAction>, and IList<T> is not assignable to IReadOnlyList<T>, so the member bound to the interface's throwing default with no compile error anywhere. It now carries an explicit implementation, pinned by PendingStreamActionsCompliance.
In Marten this is the same collection as IDocumentSession.PendingChanges.Streams(), handed back as a live view rather than a copy.
BinaryEventAttribute — one lookup instead of two (#5248)Marten.Events.BinaryEventAttribute now derives from the promoted JasperFx.Events.BinaryEventAttribute, which 2.51.0 unsealed for exactly this. EventGraph.ResolveBinarySerializerFor drops back to a single attribute lookup. Entirely non-breaking — existing [BinaryEvent] usages compile and resolve unchanged.
DocumentComplianceConfig.StreamIdentity is now replayed by Marten's document compliance fixture rather than inferred, and two new shared suites are enrolled: PendingStreamActionsCompliance and AggregateWriteCacheCompliance (wave 10).
No breaking changes and no migration. Everything new here is opt-in.
Full changelog: V9.26.0...V9.27.0
Consumes JasperFx 2.50.0 , which promoted two things into the shared contracts. Both changes here are additive — existing code compiles and behaves id
Consumes JasperFx 2.50.0, which promoted two things into the shared contracts. Both changes here are additive — existing code compiles and behaves identically.
JasperFx.Events.Documents gained an Events accessor on its session contracts, so a consumer that opens its own session through IDocumentSessionFactory can now reach the event store without naming Marten:
| tier | member |
|---|---|
IDocumentReadOperations |
IQueryEventStore Events |
IDocumentSessionOperations |
IEventStoreOperations Events |
Marten implements both. This does not affect handlers taking a chain parameter — Wolverine already fills those. It matters for the two shapes where you decide when a session exists: a background or timer publisher that opens a session and appends, and a deliberate second read session opened alongside a chain's writing session.
⚠️ Worth knowing if you maintain a store or a session wrapper. C# interface implementation is not return-type covariant, so a session that already declares an Events property of its own event-store type does not satisfy the contract member — it binds to a default that throws, and the build still succeeds with zero errors. Marten's own IQuerySession.Events returns Marten.Events.IQueryEventStore, a subtype, which is exactly this case; both tiers needed an explicit implementation. The shared compliance suite is what catches it.
IEventBinarySerializer promoted to JasperFx.EventsBinary event serialization (#4515) was Marten-specific, so a consumer compiling one body of source against several Critter Stack stores needed a separate identical serializer per store. The two-method interface and [BinaryEvent] now live in JasperFx.Events, and one serializer serves every store.
Non-breaking, in both directions:
Marten.Events.IEventBinarySerializer still exists and now derives from the core interface, so existing implementations keep compiling and also satisfy the core type.EventGraph.UseBinarySerializer<T>, DefaultBinarySerializer, IEventStoreOptions and EventMapping.BinarySerializer widened to accept the core interface, so a store-agnostic serializer can be registered.[BinaryEvent] attributes are honored.EventMapping resolved its binary serializer in its constructor, and AddEventType<T>() builds mappings eagerly. So this threw "no IEventBinarySerializer was registered" out of what reads as a plain type registration:
opts.Events.AddEventType<SomeBinaryEvent>(); // [BinaryEvent]-marked
opts.Events.DefaultBinarySerializer = mySerializer; // too latewhile the same two lines in the opposite order worked. Resolution is lazy now, deferring the failure to first use — which is what the documentation always described. The guard is volatile, because it is read from the append path on many threads and a torn read would route a binary event down the JSON path silently rather than throwing.
DocumentSessionEventsCompliance and BinaryEventSerializationCompliance.events/binary-serialization.md, documents/sessions.md.Full changelog: 9.25.0...9.26.0
The window-step event loader skipped events and reported the projection as caught up ( #5239 , #5242 )
If you run async projections with an event type filter against a large store, read this one.
Marten's adaptive event loader falls back through three strategies when a fetch times out: Normal → SkipAhead → WindowStep. The final one scans the sequence in fixed 10,000-wide windows, and its SELECT is bounded by the window — but it computed the page ceiling against the full high-water mark. Since the daemon writes that ceiling as durable projection progress (EventRange(floor, page.Ceiling) → last_seq_id), every matching event between the window ceiling and the high-water mark was skipped and would never be loaded.
This was the ordinary path, not an edge case. The window is 10,000 sequence numbers wide, the batch size is 500, and the strategy exists precisely because matching events are sparse — so "returned fewer than BatchSize events", the branch that took the high-water mark, was the expected outcome. It is reached only after two consecutive statement timeouts, i.e. on exactly the large, busy stores the strategy was added for.
The observable result: a projection logged Falling back to WindowStep at Warning, then advanced its progression to the high-water mark having applied only the handful of events in the first window. No exception, no dead-letter row, the shard reporting itself caught up, and a read model permanently missing most of its data.
Two narrower skips in the same loop are fixed alongside it: skipped unknown/undeserializable events were never counted toward the batch (so a window filled partly by skips was misclassified), and the loop advanced on events added to the page rather than rows read, so a window whose rows were all skipped stepped over any matching events the LIMIT had truncated.
If a projection has already been affected, this fix does not backfill it — rebuild the affected projections.
Reported with a traced mechanism and an executed failing test by @arnelirobles, from barakoCMS.
CompactStreamAsync threw, and then silently did nothing (#5240)IDocumentStore's store-level CompactStreamAsync overloads resolved their target method by reflection against Marten.Events.IEventStoreOperations. #5153 lifted both overloads onto JasperFx.Events.IEventStoreOperations, and because Type.GetMethod does not walk base interfaces, the lookup returned null and the call threw a NullReferenceException.
Repairing only the lookup would not have been enough: the compaction request merely queues its operations, so the store-level overload — which owns its own session — was disposing without committing. It would have been a silent no-op. It now saves, and the CancellationToken it accepts is no longer discarded.
The surface had no test anywhere in the tree, which is how an interface move broke it with no compile error and no failing test. Two store-level compaction tests now cover it.
Reported by @arnelirobles.
CompactStreamAsync now rejects a stream identity mismatch (#5244, #5245)Compaction branches on the store's configured StreamIdentity rather than on which overload you called, and never asserted the two agreed. Calling CompactStreamAsync<T>(Guid) against a string-identified store therefore matched no stream and returned successfully having compacted nothing — no exception, and no way to tell a no-op from a completed compaction. The other three combinations failed with messages that named nothing actionable (Nullable object must have a value, a stream not found misdiagnosis, or a raw Npgsql null-parameter error).
All four now throw the message Marten already uses for this class of mistake: This Marten event store is configured to identify streams with Guids (or …with strings).
This is a behaviour change for anyone whose code was reaching the silent no-op. That call was never doing anything, so the throw is surfacing a pre-existing bug in the caller rather than introducing one.
Raises the JasperFx floor from 2.48.0 to 2.49.0, which adds LoadAsync<T>(object id) to JasperFx.Events.Documents.IDocumentReadOperations so store-agnostic code can load a document keyed by a strong-typed identifier.
No Marten API change: IQuerySession already declared this overload and QuerySession already implemented it, dispatching on the runtime type of the id — Marten is the store the new member was modeled on. Marten code is unaffected; the bump matters only if you also consume the JasperFx document abstraction directly.
Cross-tenant event rewrites under UseTenantPartitionedEvents
UseTenantPartitionedEvents (#5234)If you use Events.UseTenantPartitionedEvents together with event masking or stream compaction, read this one.
Under per-tenant event partitioning each tenant draws from its own sequence, so seq_id is not unique across tenants — seq_id = 1 exists in every tenant's partition. Three operations that rewrite mt_events keyed their WHERE on seq_id alone, so while the read side was correctly scoped by ForTenant(...), the write escaped it:
Compacted<T>, sitting in their stream.Nothing threw in either case. All three operations now carry the tenant predicate.
Damage already written by an earlier version cannot be reversed by this fix — restore the affected tenants from a backup or archival storage. Stores that never enabled UseTenantPartitionedEvents are unaffected, because a single global sequence makes seq_id store-unique there.
Reported with executed failing tests by @arnelirobles.
The EF Core integration's placeholder connection was released only on the success path of BeforeCommitAsync. A projection that threw, an optimistic concurrency failure, or a throw inside the commit hook each stranded a pooled connection — and because an inline multi-stream projection builds its storage for events it will not even process, a workload that merely had one registered leaked on every failed save. Measured at 20 saves: 41 stranded backends before, 2 after. Reported by @markotny.
Select() (#5233)Count(predicate) inside a projection is now re-bound per invocation of an ICompiledQuery instead of being frozen at plan time. A projection Marten cannot translate to SQL, which is applied by a delegate compiled once per plan, now throws InvalidCompiledQueryException at plan time when it reads a value off the compiled query instance, rather than silently returning the first invocation's results forever.
IEventDatabase.StoreDeadLetterEventAsync wrapped its whole body in an empty catch. A failed write dropped the only record that a projection skipped an event, with no exception and no log line; a wrong storage argument made the method a silent no-op. Failures are now logged at Error with the projection, shard, sequence and tenant, and a wrong argument throws.
Count(predicate) translated inside Select() projections (#5223)x.Lines.Count(line => line.IsActive) in a projection is now computed by PostgreSQL rather than by deserializing the whole document on the client. Same translation Where() has used since 9.14.1. This also fixes bare boolean predicates and && compounds in the Where() form — Where(x => x.Lines.Count(l => l.IsActive) == 3) previously threw.
StoreOptions.Projections.FetchPlanners is now public, so an application can supply its own IFetchPlanner to take over FetchForWriting() / FetchLatest() for the aggregate types it recognizes.
FetchForWriting metrics (#5227)OpenTelemetryOptions.TrackFetchForWritingMetrics() enables a marten.fetch_for_writing.events_replayed histogram, tagged by aggregate type and fetch plan, so the cost of Live / Async / Inline is directly comparable.
Both contributed by @erdtsieck.
Store-agnostic document abstractions
Marten now implements the JasperFx.Events.Documents contract added in JasperFx 2.47.0, the shared document session surface behind the Wolverine aggregate-handler unification (wolverine#3907).
| Marten | JasperFx contract |
|---|---|
IQuerySession |
IDocumentReadOperations |
IDocumentOperations |
IDocumentWriteOperations |
IDocumentSession |
IDocumentSessionOperations |
IDocumentStore |
IDocumentSessionFactory<IDocumentSession, IQuerySession> |
MartenLinqQueryProvider |
IDocumentQueryExecutor |
Marten already had every operation with matching signatures, so this is additive — existing code is unaffected. Marten passes all 42 tests in the shared document storage compliance suite.
#5210 — UseListenNotifyForEventAppends corrupted inline projections in the same transaction. NotifyEventAppendedOperation was marked NoDataReturnedCall but emitted select pg_notify(...), which returns a one-row result set. OperationPage.ApplyCallbacksAsync never advances the reader past a NoDataReturnedCall, so every later operation in the batch read the wrong result set — surfacing as spurious ConcurrencyExceptions and wrong document versions, far from the cause. Now uses the DO $$ BEGIN PERFORM pg_notify(...); END $$ form, which fires the identical notification and returns nothing.
HardDeleteWhere<T> could not remove already soft-deleted rows. It inherited the soft-delete exclusion filter, so a retention or purge sweep returned cleanly while leaving behind exactly the rows it existed to remove. HardDelete by id was unaffected, which made the API look correct when spot-checked.
#5159 — UseOptimisticConcurrency and UseNumericRevisions were asymmetric. UseNumericRevisions(true) cleared the competing Guid version metadata; UseOptimisticConcurrency(true) did not clear the numeric revision metadata. The order of the two fluent calls therefore decided whether the configuration worked or threw at bootstrap. Both orders are now last-wins.
The bootstrap guard is narrowed rather than dropped, and now reports which case it is:
IRevisioned, ILongVersioned, a long [Version] member) still throws, naming the member — honoring the override would leave that property permanently unmapped#5131 — StreamPagedByCursor<T> published typeof(void) as its OpenAPI response type, so endpoints returning it advertised a 200 with no schema at all. Now publishes a new CursorPagedResult<T>, mirroring what StreamPaged<T> already does with PagedResult<T>.
#5213 — an audit test over the NoDataReturnedCall invariant. Reflects over every implementation and asserts its SQL cannot return a result set, so the class of bug behind #5210 goes red at build time rather than surfacing as a version mismatch three operations later.
#5212 — regression pins ported from Polecat, covering DeleteWhere not re-stamping mt_deleted_at on already-deleted rows.
#5217 — documented Npgsql's Max Auto Prepare connection-string knob, including measurements showing no resolvable effect on read latency, and the reasons Marten does not enable prepared statements: generic plans displacing parameter-aware ones (which matters most under conjoined multi-tenancy, where a skewed tenant_id sits in nearly every predicate), per-connection plan memory, and runtime DDL invalidating cached plans.
Marten.AspNetCore.CursorPagedResult<T>IQuerySession, IDocumentOperations, IDocumentSession and IDocumentStore now implement the corresponding JasperFx.Events.Documents contractsBoth are additive.
The opt-in LINQ query plan cache proposed in #5013 / #5018 was closed rather than merged. Benchmarking showed the cost it removes — LINQ parsing plus SQL generation — is 1.2 μs on the fastest realistic Marten query, against 28–56 μs of run-to-run variance on that same query. On a warm cache hit it allocated more than no cache at all, and in a conjoined multi-tenant store it cached nothing while paying two full LINQ parses per query. Details on #5018.
Full Changelog: V9.22.6...V9.23.0
A fix-and-adoption release: a masking-rule fix, a broadened event-store compliance net, a CI overhaul, and the JasperFx 2.46.0 / Weasel 9.24.0 depende
A fix-and-adoption release: a masking-rule fix, a broadened event-store compliance net, a CI overhaul, and the JasperFx 2.46.0 / Weasel 9.24.0 dependency adoptions.
ApplyEventDataMasking used to stop at the first masking rule whose event type matched, so an event enrolled in two rules (say, one masking a name and another masking an address) only ever had the first applied. All matching rules now compose: each rule runs in registration order against the output of the previous one.
Marten's event-store behavior is now enrolled in the shared JasperFx.Events.ComplianceTests suite for: stream compacting and event data masking (wave 6), projection rebuild/catch-up and dead letters (wave 7), conjoined event tenancy and subscriptions (wave 8), plus StrongTypedIdentityCompliance (#5144). These pin Marten's behavior to the same contract Polecat and future stores are held to.
The monolithic CI test run is split into one job per test project running under Bobcat's supervisor, so a flaky suite no longer poisons the whole gate and failures name the project that produced them. Also removes stray ITestOutputHelper/debug logger injections from tests (#5211).
db-apply/db-assert across physical databases (weasel#431/#442), per-fingerprint schema stamp keying (weasel#439), SQL Server CREATE DATABASE postcondition check (weasel#415), discovery progress reporting (weasel#432).Two source-generator and test-harness fixes that both surfaced on projections built through AddProjectionWithServices , plus the JasperFx 2.42.2 adopt
Two source-generator and test-harness fixes that both surfaced on projections built through AddProjectionWithServices, plus the JasperFx 2.42.2 adoption they ride on.
The bundled JasperFx.Events.SourceGenerator registers an EventProjection's discovered published document types (#4166) by writing into your partial class. It used to emit a parameterless constructor to do it, which failed two ways for exactly the projections that need dependencies injected.
It broke the build outright against a primary constructor. C# requires every other constructor to chain through the primary one, so this failed with CS8862 inside the generated <T>.TypeRegistration.g.cs:
public partial class MyProjection(ILogger<MyProjection> logger) : EventProjection
{
public override ValueTask ApplyAsync(IDocumentOperations operations, IEvent e, CancellationToken cancellation)
{
operations.Store(new Thing());
return new ValueTask();
}
}And where it did compile, it silently did nothing. A projection registered through AddProjectionWithServices is built by the container, which calls the dependency-taking constructor — so the generated parameterless one never ran and the published types went unregistered. That also left the projection's teardown targets unregistered, so a rebuild did not wipe its documents.
Registration now rides an override of ProjectionBase.PublishedTypes(), which does not care how the instance was constructed.
Affects 9.22.3 and 9.22.4. Earlier versions discovered published types syntactically, so only an explicit ops.Store<Doc>(x) produced a registration and the far more common ops.Store(x) produced none — which meant the constructor was rarely emitted at all.
One behavior change to be aware of: the generator used to skip registration entirely when your class already had an explicit parameterless constructor, a guard that existed only because you cannot add a second one. An override has no such conflict, so those projections now get their published types registered too. That is the intended #4166 behavior, but on upgrade it can newly provision document storage — and newly register teardown targets — for a projection that was quietly getting neither. If a projection writes into storage that must not be truncated on rebuild, set DeletePublishedTypesOnTeardown = false.
EventProjectionScenario no longer spends its wall clock asleep (#5195, in part)Almost none of a scenario's time was work. The harness wipes the event store and then starts the daemon, so the high-water agent's first look saw an empty store, read CaughtUp, and settled into SlowPollingTime — one second by default. Every append then raced a sleeping agent, and because the agent returns to CaughtUp after each batch drains, the cost recurred at every batch boundary. Since a boundary is how a scenario says "these appends must land in different daemon batches", the more precisely a test described its batching, the slower it got.
A scenario owns both the appends and the daemon that must notice them, so it now says so directly, through an in-process IDaemonWakeup — a semaphore release, no database round trip and no LISTEN/NOTIFY. Nothing about your store's polling configuration changes.
| batch boundaries | before | after |
|---|---|---|
| 1 | ~1290ms | ~300ms |
| 3 | ~3357ms | ~815ms |
A flat ~250ms per boundary remains, from a hard-coded poll delay in WaitForNonStaleDataAsync. That is the other half of #5195 and is still open.
JasperFx / JasperFx.Events 2.42.2. Adopting it also enrolls Marten in the strong-typed identity event-sourcing compliance suite that landed in 2.42.0 (IComplianceStoreRegistrar.RegisterValueType<T>()), taking the shared cross-store suite to 167 passing tests against Marten.
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →