Skip to content

Fix #4598: provision per-tenant event partition + sequence under sharded tenancy#4605

Merged
jeremydmiller merged 2 commits into
masterfrom
fix/4598-sharded-per-tenant-events
Jun 2, 2026
Merged

Fix #4598: provision per-tenant event partition + sequence under sharded tenancy#4605
jeremydmiller merged 2 commits into
masterfrom
fix/4598-sharded-per-tenant-events

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

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:

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 AdvancedOperations.AddMartenManagedTenantsAsyncDefaultTenancy-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) → 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 CREATE SEQUENCE IF NOT EXISTS … implementation.
  • 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 JasperFx 2.8.0 store-agnostic admin extensions work end-to-end without sniffing the concrete tenancy type:

  • 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 on sharded tenancy yet (no disabled column in the assignment table); those methods throw NotSupportedException with 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 existing IMasterTableMultiTenancy registration pattern; DefaultTenancy / StaticMultiTenant keep 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):

Test What it pins
append_event_for_runtime_provisioned_tenant_does_not_throw_42P01 Headline regression — would throw on 9.4.0.
per_tenant_event_sequence_lives_in_the_assigned_shard Sequence lands in the assigned shard only (not in default DB, not in sibling shards).
per_tenant_event_partition_also_created_in_assigned_shard mt_events_<tenant> partition materialises on the assigned shard.
explicit_AddTenantToShardAsync_provisions_the_event_sequence_too Sibling code path (explicit target) hits the same provisioning.
IDynamicTenantSource_AddTenantAsync_returns_assigned_database_id_and_provisions #413 auto-assign override — returns the resolved id; append works.
IDynamicTenantSource_caller_supplied_overload_assigns_to_named_shard #413 caller-supplied overload routes to the named shard.
IDynamicTenantSource_is_registered_in_the_container_when_tenancy_is_sharded DI: present on a sharded store.
IDynamicTenantSource_is_NOT_registered_when_tenancy_is_default DI: absent on default tenancy (graceful no-op).

Regression sweep (net9.0)

  • sharded_tenancy_tests: 12/12 PASS
  • use_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's additivelyMigrateTablesForNewPartitions) fails with 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 is created on first append after AddTenantToShardAsync) 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 reported 42P01 sequence-missing failure.

Related: jasperfx#413 (dynamic tenancy auto-assign abstraction, shipped in 2.8.0), CritterWatch#267, CritterWatch#269.

🤖 Generated with Claude Code

jeremydmiller and others added 2 commits June 2, 2026 17:27
…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>
@jeremydmiller jeremydmiller merged commit cb477dc into master Jun 2, 2026
8 checks passed
@jeremydmiller jeremydmiller deleted the fix/4598-sharded-per-tenant-events branch June 2, 2026 23:12
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-tenant event partitioning does not provision event partition/sequence under sharded (database-per-tenant) tenancy in 9.4.0

1 participant