Skip to content

Closes #4607: Disable/Enable/AllDisabled lifecycle on ShardedTenancy#4608

Merged
jeremydmiller merged 1 commit into
masterfrom
feat/4607-sharded-soft-delete
Jun 3, 2026
Merged

Closes #4607: Disable/Enable/AllDisabled lifecycle on ShardedTenancy#4608
jeremydmiller merged 1 commit into
masterfrom
feat/4607-sharded-soft-delete

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Closes #4607. Follow-up explicitly invited by #4605.

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 and implements the three lifecycle methods for real:

  • DisableTenantAsync(tenantId) — flips disabled = true, evicts in-memory cache entry. Idempotent for already-disabled / unknown.
  • EnableTenantAsync(tenantId) — flips disabled = false. Idempotent.
  • AllDisabledAsync() — enumerates rows where disabled = true.

Resolution gatesFindDatabaseForTenantAsync, 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-assignmentAssignTenantAsync'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:

# Test
1 Disabled tenant resolution throws UnknownTenantIdException
2 FindDatabaseForTenantAsync returns null for disabled
3 Auto-assign on a disabled tenant throws (not silent resurrection); the row keeps its original database_id and disabled = true
4 DisableTenantAsync is idempotent (already-disabled / unknown)
5 EnableTenantAsync restores resolution to the same shard the tenant had before — re-enable doesn't relocate
6 EnableTenantAsync is idempotent
7 AllDisabledAsync returns exactly the disabled set
8 AllDisabledAsync is empty when none disabled
9 Explicit re-assignment via caller-supplied AddTenantAsync overload reactivates a disabled tenant
10 Full lifecycle works through the IDynamicTenantSource<string> interface — the CritterWatch surface

Regression sweep (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.

Related: #413 (IDynamicTenantSource<T> abstraction), #4605 (the "open an issue to request the soft-delete surface" note that this closes), CritterWatch#269 (handler refactor that consumes this).

🤖 Generated with Claude Code

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.

Soft-delete (Disable/Enable) tenant lifecycle on ShardedTenancy / IDynamicTenantSource<string>

1 participant