Database-driven tenant partition sweep (#4944)#4950
Merged
Conversation
AddPartitionToAllTables decided which tables needed a new tenant's list partition by walking the CALLING store's StoreOptions, so "all tables" really meant "all tables this store happens to know about". Any process that provisions tenants from a partially-registered store -- a conversion tool, an admin CLI, a seeding job, all of which typically register only the event types they need -- silently under-provisioned: the registry row and the event partitions landed, the tenant looked provisioned, and the damage only surfaced when the APPLICATION did its first document write into a partition that was never created (23514 no partition of relation). Schema migration never heals this either, by design: document partitions are excluded from the table delta (IgnorePartitionsInMigration, #4706), so partition creation is exclusively the provisioning path's job -- which makes it critical that the provisioning path is complete regardless of who calls it. Make the sweep database-driven: enumerate the tenant-list-partitioned parents straight from the PostgreSQL catalog and create the tenant's partition on every one of them, purely additively on top of the existing options-driven pass (CREATE TABLE IF NOT EXISTS ... PARTITION OF is idempotent). Provisioning tools no longer need full document-type registration to be correct. Scoped narrowly so a shared database is never violated -- all three filters are enforced in the catalog query itself: 1. SCHEMA -- only schemas the store owns (database.AllSchemaNames()). Foreign schemas in a shared database are never touched. 2. PARTITION SHAPE -- only LIST-partitioned parents with exactly one partition key column named tenant_id. This is what keeps the sweep off Marten's own non-tenant partitioning (archived-stream partitioning keys on is_archived; ByList() on a duplicated field keys on that field), off hash/range partitioned tables, and off tables that are themselves partitions of another parent. 3. EXTERNAL MANAGEMENT -- tables the calling store knows about and has marked ByExternallyManagedListPartitions() are subtracted. SweepPartitionedTablesFromDatabase opts back out to the old behavior. Found by @erdtsieck at 512 tenant databases; see #4943. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 14, 2026
Merged
This was referenced Jul 14, 2026
Merged
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4944.
Problem
AddPartitionToAllTablesdecided which tables needed a new tenant's list partition by walking the calling store'sStoreOptions:So "all tables" really meant "all tables this
StoreOptionshappens to know about". Any process that provisions tenants from a partially-registered store — a conversion tool, an admin CLI, a seeding job, all of which typically register only the event types they need — silently under-provisioned. The registry row and the event partitions landed, the tenant looked provisioned, and the damage only surfaced when the application did its first document write into a partition that was never created:23514 no partition of relation ....Schema migration never heals this either, by design: document partitions are excluded from the table delta (
IgnorePartitionsInMigration, #4706), so partition creation is exclusively the provisioning path's job — which is exactly why that path has to be complete regardless of who calls it.The workaround so far was "the provisioning tool must register every document type", which re-creates the schema knowledge in a second place and drifts as document types are added.
Mechanism
Rather than trusting the caller's registrations, ask the database what is actually there.
DatabaseScopedTenantPartitionsnow enumerates the tenant-list-partitioned parents straight out of the PostgreSQL catalog (pg_partitioned_table/pg_class/pg_inherits) and creates the tenant's partition on every one of them.This is layered purely additively on top of the existing options-driven pass —
CREATE TABLE IF NOT EXISTS <parent>_<suffix> PARTITION OF <parent>is idempotent, and tables the base pass already handled are skipped — so it is independent of which process happens to call it. Both provisioning entry points (AdvancedOperations.AddMartenManagedTenantsAsyncandShardedTenancy.createPartitionsForTenant) funnel through this one chokepoint, so both are fixed.Combined with the #4942 idempotent repair, this also heals the "document type added after the tenant was provisioned" drift case.
Only the parent's
Identifieris needed to emit the DDL, so the sweep never tries to reconstruct an unknown table's definition and never runs a table delta against it.How schema scoping is enforced
This is the dangerous part, and it is deliberately narrow — a shard database is routinely shared with other applications, and a sweep that grabbed every partitioned table it could see would start bolting Marten tenant partitions onto a stranger's tables. Three filters, all enforced in the catalog query itself:
database.AllSchemaNames(), i.e. the distinct schemas of the store's own registered objects, which always includes theDatabaseSchemaNamewheremt_tenant_partitionsitself lives). Foreign schemas in a shared database are never touched.partstrat = 'l') with exactly one partition key column (partnatts = 1) whose name istenant_id. This is what keeps the sweep off Marten's own non-tenant partitioning:UseArchivedStreamPartitioninglist-partitionsmt_events/mt_streamsonis_archived, andMultiTenantedWithPartitioning(x => x.ByList())on a duplicated field list-partitions on that field — neither has atenant_idkey, so neither is swept. Hash- and range-partitioned tables are excluded outright, as are tables that are themselves partitions of another parent.ByExternallyManagedListPartitions()is subtracted. "Marten, keep your hands off my partitions" is honored.SweepPartitionedTablesFromDatabase(defaulttrue) opts back out to the old, options-only behavior.Documented residual limitation
A document type registered into a non-default schema that the calling store does not register stays invisible to filter (1) — a store cannot own a schema it has never heard of. Single-schema stores (the overwhelming default, and the reporter's shape) are fully covered. This is called out in the XML docs on the sweep.
Scope note
Done entirely inside Marten — no Weasel change is required. Everything the sweep needs (
ListPartition,IPartition.WriteCreateStatement,Table,IDatabase.AllSchemaNames()) is already public API, andDatabaseScopedTenantPartitionsalready shadows bothAddPartitionToAllTablesoverloads. So this ships with 9.15.2 rather than gating on a Weasel release. Hoisting the enumeration primitive intoManagedListPartitionsfor other Weasel consumers remains available as a follow-up.Tests
src/TenantPartitionedEventsTests/Sharded/Bug_4944_database_driven_partition_sweep.cs. The whole point is that provisioning happens from a store with a deliberately incomplete registration — a test that provisions from a fully-registered store proves nothing:tool_store_with_no_document_types_still_provisions_every_partitioned_table— the reporter's exact shape. The app store registers and applies three tenant-partitioned document types; a tool store registering zero of them provisions the tenant; all three partitions must exist, and the application must then be able to write all three doc types (pre-fix:23514).partially_registered_store_still_provisions_the_types_it_does_not_know— a store registering a strict subset (1 of 3) must still provision all 3.never_touches_partitioned_tables_outside_the_stores_schemas_or_shape— the negative. Two decoys, both shaped to be swept if the filters were sloppy:foreign_app.their_ledger(the exact shape the sweep looks for — LIST on a singletenant_idcolumn — but in a schema the store does not own) andpublic.other_app_ledger(in a schema the store does own, but list-partitioned on a non-tenant column). Neither may gain a single partition, while the store's own tables are still swept.opting_out_of_the_sweep_restores_the_old_under_provisioning_behavior— pins the pre-Sharded tenancy: database-driven partition sweep — enumerate partitioned tables from the database (pg_inherits) instead of the calling store's StoreOptions #4944 behavior behind the toggle, and doubles as proof the other tests genuinely exercise the new sweep: with the sweep off, the tool store under-provisions exactly as reported.Results: TenantPartitionedEventsTests 236/236 (run twice), CoreTests partitioning 41/41, MultiTenancyTests 159/159.
Found by @erdtsieck at 512 tenant databases; see #4943.
🤖 Generated with Claude Code