Fix #4598: provision per-tenant event partition + sequence under sharded tenancy#4605
Merged
Merged
Conversation
…ded tenancy
Per-tenant event partitioning composes with sharded multi-tenancy in the docs
but didn't actually work in 9.4.0: runtime-provisioned sharded tenants got
their document LIST partitions but NOT their per-tenant
`mt_events_sequence_{suffix}` sequence. The first event append for such a
tenant failed with:
42P01: relation "{schema}.mt_events_sequence_<tenant>" does not exist
because quick-append calls `nextval(mt_events_sequence_<suffix>)` and the
sequence creation lived only inside `AddMartenManagedTenantsAsync`, which was
`DefaultTenancy`-guarded and therefore unreachable on a sharded store.
Three coordinated parts in one change:
1. **Bump JasperFx 2.5.0 → 2.8.0.** Picks up #413's
`IDynamicTenantSource<T>.AddTenantAsync(tenantId, ct) → string` auto-assign
provisioning overload that ShardedTenancy now implements (part 3).
2. **Wire the sequence provisioning into the sharded path.**
- Extract `AdvancedOperations.AddMartenManagedTenantsAsync`'s inline
sequence-creation loop into `PerTenantEventSequences.EnsureSequencesAsync`
so both call sites share one idempotent implementation. No behavior
change for `DefaultTenancy` callers.
- `ShardedTenancy.createPartitionsForTenant` calls the helper against the
assigned shard database (not the default) right after
`AddPartitionToAllTables`. Tenant id == partition suffix per the existing
1:1 assumption.
- `AdvancedOperations.AddMartenManagedTenantsAsync`'s DefaultTenancy guard
now routes sharded calls through `ShardedTenancy.AddTenantAsync` per
tenant (rejecting suffix-overrides since the sharded model assumes
tenant id == suffix). Master-table tenancy keeps throwing with a clearer
message pointing at the caller-supplied connection-string path.
3. **Implement `IDynamicTenantSource<string>` on `ShardedTenancy`** so the
store-agnostic JasperFx admin extensions work end-to-end:
- `AddTenantAsync(tenantId, databaseId)` wraps `AssignTenantAsync`.
- `AddTenantAsync(tenantId, ct) → string` (the auto-assign override)
runs the same `findOrAssignTenantDatabaseAsync` +
`createPartitionsForTenant` path that `Advanced.AddTenantToShardAsync`
drives and returns the resolved database id.
- `AddTenantToShardAsync` delegates to it — one code path.
- Disable/enable lifecycle is not implemented for sharded tenancy (no
`disabled` column in the assignment table); methods throw
`NotSupportedException` with a clear message until a consumer needs it.
4. **Conditional DI registration in `AddMarten(services, StoreOptions)`** so
that `IServiceProvider.GetServices<IDynamicTenantSource<string>>()`
surfaces the configured tenancy when (and only when) it is dynamic.
Mirrors the existing `IMasterTableMultiTenancy` registration pattern;
`DefaultTenancy` / `StaticMultiTenant` keep returning an empty enumerable
(the graceful-no-op shape the JasperFx admin extensions rely on).
## Tests
`src/MultiTenancyTests/sharded_tenancy_per_tenant_events_tests.cs` (new, 8
tests):
- **Headline regression** — `AddTenantToShardAsync(...)` + append succeeds
without the `42P01` (would throw on 9.4.0).
- Per-tenant `mt_events_sequence_<tenant>` lands in the assigned shard only
(not in the default DB, not in sibling shards).
- The event partition itself (`mt_events_<tenant>`) materialises on the
assigned shard after the first append.
- Explicit `AddTenantToShardAsync(tenantId, databaseId)` provisions the
sequence too (sibling code path).
- #413 auto-assign override returns the resolved database id and
the resulting append succeeds.
- #413 caller-supplied overload assigns to the named shard.
- DI registration: present when the tenancy is sharded, absent on default
tenancy (so `GetServices<IDynamicTenantSource<string>>()` stays empty for
the no-op case).
Regression sweep on net9.0:
- `sharded_tenancy_tests`: 12/12 PASS (the existing sharded coverage stays
green).
- `use_tenant_partitioned_events_*`: 32/33 PASS — the single failure
(`delete_projection_progress_with_tenant_id_drops_only_that_tenants_rows`)
is a pre-existing partition-setup race independent of this work.
Note: a related-but-separate bug surfaced during test development — the
runtime partition-attach on `mt_events` (Weasel's
`additivelyMigrateTablesForNewPartitions`) hits `42P16: cannot drop inherited
constraint mt_events_tenant_id_stream_id_fkey` when the parent schema was
applied via `ApplyAllConfiguredChangesToDatabaseAsync` BEFORE runtime
tenant provisioning. The tests therefore exercise the lazy-apply flow
(parent schema created on first append after `AddTenantToShardAsync`) which
matches typical production usage. The eager-apply path will need its own
fix (Marten or Weasel side) — out of scope for #4598.
Related: #413 (dynamic tenancy auto-assign abstraction, shipped in
2.8.0), CritterWatch#267 (form D test), CritterWatch#269.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The IDynamicTenantSource<string> registration added in the prior commit
probed `options.Tenancy` synchronously at AddMarten time. That getter
throws `InvalidOperationException("No tenancy is configured!")` when the
user is on the AddMarten() + UseNpgsqlDataSource() flow (tenancy is set
later by the data-source extension, not inside the StoreOptions configure
callback). Seven CoreTests.bootstrapping_with_service_collection_extensions
tests broke as a result.
Wrap the probe in a try/catch and treat "tenancy not yet configured" as
"not a dynamic source". Dynamic tenancies (MasterTableTenancy /
ShardedTenancy) are always set inside the StoreOptions configure callback
before AddMarten returns, so the not-yet-configured store is by
construction not dynamic — silently skipping the registration is correct.
Verified locally:
- bootstrapping_with_service_collection_extensions: 24/24 PASS
- sharded_tenancy_per_tenant_events_tests: 8/8 PASS (unchanged)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Jun 3, 2026
Soft-delete (Disable/Enable) tenant lifecycle on ShardedTenancy / IDynamicTenantSource<string>
#4607
Closed
jeremydmiller
added a commit
that referenced
this pull request
Jun 3, 2026
When ShardedTenancy started implementing IDynamicTenantSource<string> in #4605, three of the interface's lifecycle methods stayed throwing NotSupportedException because the assignment table had no `disabled` column to record the soft-delete state. CritterWatch#269 needs them implemented so its tenant-management UI can treat sharded and master-table tenancies uniformly via the store-agnostic `DynamicTenancyAdminExtensions`. ## What lands - **MartenTenantAssignmentTable**: Marten-side derivative of Weasel's `TenantAssignmentTable` that adds a `disabled boolean not null default false` column. Subclassed on the Marten side so existing pools pick up the column via Weasel's additive table-delta migration (legacy rows backfill to enabled) without requiring a coordinated Weasel release. - **ShardedTenancy** swaps in the Marten subclass via its `PoolFeatureSchema` yield. The IDynamicTenantSource<string> lifecycle methods are now real: - `DisableTenantAsync(tenantId)` flips `disabled = true` and evicts the in-memory cache entry. Idempotent for already-disabled / unknown. - `EnableTenantAsync(tenantId)` flips `disabled = false`. Idempotent. - `AllDisabledAsync()` enumerates rows where `disabled = true`. - **Resolution gates**: `FindDatabaseForTenantAsync`, `BuildDatabases`, and the under-lock check in `findOrAssignTenantDatabaseAsync` all filter `disabled = false`. The under-lock path further distinguishes "no assignment" (auto-assign) from "disabled assignment" (throw `UnknownTenantIdException`) so auto-assign cannot silently resurrect a soft-deleted tenant onto a different shard. Mirrors MasterTableTenancy's soft-delete semantics. - **Explicit re-assignment**: `AssignTenantAsync`'s UPSERT now also clears the `disabled` flag so caller-supplied re-assignment of a soft-deleted tenant reactivates it (explicit intent overrides soft-delete). Same applies to the caller-supplied `IDynamicTenantSource<string>.AddTenantAsync(tenantId, dbId)` overload that delegates to it. ## Tests `src/MultiTenancyTests/sharded_tenancy_soft_delete_tests.cs` — 10 tests: - Disabled tenant resolution throws `UnknownTenantIdException`. - `FindDatabaseForTenantAsync` returns null for disabled. - Auto-assign on a disabled tenant throws (not silent resurrection); the row keeps its original database_id and `disabled = true`. - `DisableTenantAsync` / `EnableTenantAsync` are idempotent (already-disabled / already-enabled / unknown). - `EnableTenantAsync` restores resolution and the tenant resolves to its original shard (re-enable doesn't relocate). - `AllDisabledAsync` returns exactly the disabled set (empty when none). - Explicit re-assignment via the caller-supplied AddTenantAsync overload reactivates a disabled tenant. - The same lifecycle works when accessed only through the `IDynamicTenantSource<string>` interface — the CritterWatch surface. Regression sweep on net9.0: full sharded test suite (existing sharded_tenancy_tests + the #4598 sharded_tenancy_per_tenant_events_tests + the new #4607 sharded_tenancy_soft_delete_tests) — **30/30 PASS**. Closes #4607. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Jun 3, 2026
This was referenced Jun 3, 2026
Merged
This was referenced Jun 11, 2026
This was referenced Jun 18, 2026
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 #4598.
The docs at martendb.io/events/multitenancy.html#per-tenant-event-partitioning claim per-tenant event partitioning composes with the Sharded Multi-Tenancy with Database Pooling model. In 9.4.0 it didn't: runtime-provisioned sharded tenants got the document LIST partitions but not the per-tenant
mt_events_sequence_{suffix}sequence. The first event append for such a tenant failed with:because quick-append calls
nextval(mt_events_sequence_<suffix>)and the sequence creation lived only insideAdvancedOperations.AddMartenManagedTenantsAsync—DefaultTenancy-guarded and unreachable on a sharded store.Three coordinated changes
1. Bump JasperFx 2.5.0 → 2.8.0
Picks up jasperfx#413's
IDynamicTenantSource<T>.AddTenantAsync(tenantId, ct) → stringauto-assign provisioning overload thatShardedTenancynow implements (part 3).2. Wire the sequence provisioning into the sharded path
AdvancedOperations.AddMartenManagedTenantsAsync's inline sequence-creation loop intoPerTenantEventSequences.EnsureSequencesAsyncso both call sites share one idempotentCREATE SEQUENCE IF NOT EXISTS …implementation.ShardedTenancy.createPartitionsForTenantcalls the helper against the assigned shard database (not the default) right afterAddPartitionToAllTables. Tenant id == partition suffix per the existing 1:1 assumption.AdvancedOperations.AddMartenManagedTenantsAsync'sDefaultTenancyguard now routes sharded calls throughShardedTenancy.AddTenantAsyncper tenant (rejecting suffix-overrides since the sharded model assumestenant id == suffix). Master-table tenancy keeps throwing, with a clearer message pointing at the caller-supplied connection-string path.3. Implement
IDynamicTenantSource<string>onShardedTenancySo the JasperFx 2.8.0 store-agnostic admin extensions work end-to-end without sniffing the concrete tenancy type:
AddTenantAsync(tenantId, databaseId)wrapsAssignTenantAsync.AddTenantAsync(tenantId, ct) → string(the auto-assign override) runs the samefindOrAssignTenantDatabaseAsync+createPartitionsForTenantpath thatAdvanced.AddTenantToShardAsyncdrives and returns the resolved database id.AddTenantToShardAsyncdelegates to it — one code path.disabledcolumn in the assignment table); those methods throwNotSupportedExceptionwith a clear message until a consumer needs them.4. Conditional DI registration in
AddMarten(services, StoreOptions)Per the issue addendum. So
IServiceProvider.GetServices<IDynamicTenantSource<string>>()surfaces the configured tenancy when (and only when) it's dynamic. Mirrors the existingIMasterTableMultiTenancyregistration pattern;DefaultTenancy/StaticMultiTenantkeep returning an empty enumerable — the graceful-no-op shape the JasperFx admin extensions rely on. Without this, store-agnostic consumers (CritterWatch) would either stay Marten-coupled or no-op.Tests
src/MultiTenancyTests/sharded_tenancy_per_tenant_events_tests.cs(new, 8 tests):append_event_for_runtime_provisioned_tenant_does_not_throw_42P01per_tenant_event_sequence_lives_in_the_assigned_shardper_tenant_event_partition_also_created_in_assigned_shardmt_events_<tenant>partition materialises on the assigned shard.explicit_AddTenantToShardAsync_provisions_the_event_sequence_tooIDynamicTenantSource_AddTenantAsync_returns_assigned_database_id_and_provisionsIDynamicTenantSource_caller_supplied_overload_assigns_to_named_shardIDynamicTenantSource_is_registered_in_the_container_when_tenancy_is_shardedIDynamicTenantSource_is_NOT_registered_when_tenancy_is_defaultRegression sweep (net9.0)
sharded_tenancy_tests: 12/12 PASSuse_tenant_partitioned_events_*: 32/33 PASS — the single fail (use_tenant_partitioned_events_admin_overrides.delete_projection_progress_with_tenant_id_drops_only_that_tenants_rows) is a pre-existing partition-setup race independent of this work.Note on a related-but-separate bug
While writing the tests I hit a second issue: the runtime partition-attach on
mt_events(Weasel'sadditivelyMigrateTablesForNewPartitions) fails with42P16: cannot drop inherited constraint mt_events_tenant_id_stream_id_fkeywhen the parent schema was applied viaApplyAllConfiguredChangesToDatabaseAsyncbefore runtime tenant provisioning. The tests therefore exercise the lazy-apply flow (parent schema is created on first append afterAddTenantToShardAsync) which matches typical production usage anyway. The eager-apply path will need its own fix (Marten or Weasel side); I treated it as out-of-scope for #4598 since it's distinct from the reported42P01sequence-missing failure.Related: jasperfx#413 (dynamic tenancy auto-assign abstraction, shipped in 2.8.0), CritterWatch#267, CritterWatch#269.
🤖 Generated with Claude Code