Skip to content

Marten 9.42.0

Choose a tag to compare

@jeremydmiller jeremydmiller released this 29 Sep 23:31
· 79 commits to master since this release

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.

Migrations settle again on a long schema name

#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.

The apply-changes lock waits long enough to be useful

#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 schema

This 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.

⚠️ No write retry from 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.

A batched Async-snapshot 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.

Dependencies

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).

Housekeeping

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.