Skip to content

Marten 9.33.0

Choose a tag to compare

@jeremydmiller jeremydmiller released this 09 Sep 15:37
· 158 commits to master since this release
eb6263d

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:

  • Downgrading does not help. This was reported as a 9.31.2 → 9.32.1 regression, and it is not one. There is no code change behind it in either Marten or Weasel across that range; the race has been latent since Marten's multi-database partition work and 9.32.1 only shifted the timing. Rolling back buys better odds, not a fix.
  • Retrying the failed command is safe. The throw happens before the apply opens a connection, so a failed database is left untouched rather than half-migrated.

The same Weasel fix covers Weasel.SqlServer's ManagedTenantPartitions, which had the identical shape.

An enum compared to a captured variable failed under Native AOT (#5361)

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.

Behaviour change: dictionary tag queries accept either spelling, case-insensitively

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.

Full changelog

V9.32.1...V9.33.0