Marten 9.15.2
Marten 9.15.2
A patch release. Both fixes come out of the same 512-tenant-database production deployment, reported by @erdtsieck, and both turned out to be worse than the reports described.
Bulk event insert ran a full schema apply on every batch
The batch BulkInsertEventsAsync overloads opened with Storage.ApplyAllConfiguredChangesToDatabaseAsync() on every call.
That is not a cheap check. It calls Tenancy.BuildDatabases() and runs a full schema delta — partition introspection plus information_schema sweeps — across every database in the store. So a sharded store paid one apply per database, per batch. On the reporting deployment, each ~1,000-event batch was triggering 512 schema applies.
The measured effect: import throughput collapsed to ~17 events/s, against >3,000/s for the streaming overload. A 686k-event tenant projected to roughly 11 hours. The connection pool filled with ~370 backends whose last statement was Weasel's partition-introspection query, which fed directly into the server-wide connection pressure that deployment was already fighting.
That the streaming overload BulkInsertEventStreamAsync has no such call and is fine is the tell: the schema apply was never part of the contract. It was a leftover.
The apply is now:
- skipped entirely when the effective
AutoCreateisNone— it is a no-op there by contract, so all that remained was the introspection cost; and - otherwise run at most once per database the import actually touches, memoized on
IMartenDatabase.Identifier.
One subtlety worth recording, because it is the kind of thing that bites later: the memoized apply deliberately does not take a caller's CancellationToken. The first caller to arrive owns the single in-flight task that every concurrent caller for that database awaits — so binding that shared task to one caller's token would let a single cancelled batch fail sibling batches that were never cancelled. Each caller applies its own token at the await site instead. A schema apply is short and idempotent, so letting it run to completion is the cheaper trade.
Under AutoCreate.None, the event storage must already exist before import. That is the documented contract and it matches the streaming overload — but if you were previously relying on the per-call apply to create it for you under a non-None store, note the change.
The document bulk-insert path (BulkInsertAsync / BulkInsertDocumentsAsync) is unaffected. It routes through the ordinary per-feature EnsureStorageExistsAsync that Weasel already memoizes, not a full-store delta.
Tenant provisioning silently under-provisioned partitions
AddPartitionToAllTables, and the tenant-provisioning paths built on it, walked the calling store's StoreOptions to decide which tables needed a list partition for a new tenant.
So any tool or host that provisions tenants from a store which doesn't register every document type silently under-provisioned. Document types unknown to the caller never got their partitions — and the tenant then failed with a Postgres 23514 check-constraint violation on first write to the missing partition. Nothing failed at provisioning time; the damage surfaced later, somewhere else.
The workaround was "the provisioning tool must register all document types," which re-creates schema knowledge in a second place and drifts as document types are added.
The sweep is now database-driven: it enumerates tenant list-partitioned tables from the Postgres catalog, so a partially-registered store still provisions every partitioned table it finds.
Scoping is enforced inside the catalog query rather than filtered in memory afterward:
- Schema — the store's own
AllSchemaNames()only. Foreign partitioned tables in a shared database are never touched. - Partition shape — LIST strategy, exactly one key column, and that column named
tenant_id. This is the filter that matters most, and it is what keeps the sweep off Marten's own non-tenant list partitioning:UseArchivedStreamPartitioningkeysmt_eventsonis_archived, andByList()keys on its own field. Without it, a "helpful" sweep would start adding tenant partitions to tables partitioned on something else entirely. - External management — tables marked
ByExternallyManagedListPartitions()are subtracted.
Opt out with SweepPartitionedTablesFromDatabase (default on). No Weasel change was required.
Known limitation, and it is a real one: a document type registered into a schema the calling store has never heard of stays invisible to the schema filter — a store cannot own a schema it does not know exists. Single-schema stores (the default, and the reporting deployment's shape) are fully covered. Closing this properly would need a persisted table list alongside mt_tenant_partitions.
Everything in this release
| Issue | Pull request | Summary |
|---|---|---|
| #4946 | #4949 | Bulk event insert applies the schema once per database, not per call |
| #4944 | #4950 | Database-driven tenant partition sweep |
| — | #4951 | Release prep: 9.15.2 |
Full changelog: 9.15.1...9.15.2