Skip to content

Conjoined EF Core multi-tenancy epic: ITenanted core, tenant partitioning, tenant registry, HTTP detection (GH-3465) - #3498

Merged
jeremydmiller merged 11 commits into
mainfrom
conjoined-efcore-tenancy-3462
Jul 19, 2026
Merged

Conjoined EF Core multi-tenancy epic: ITenanted core, tenant partitioning, tenant registry, HTTP detection (GH-3465)#3498
jeremydmiller merged 11 commits into
mainfrom
conjoined-efcore-tenancy-3462

Conversation

@jeremydmiller

@jeremydmiller jeremydmiller commented Jul 19, 2026

Copy link
Copy Markdown
Member

Closes #3462, closes #3463, closes #3464, closes #3497. Completes the Wolverine side of the conjoined tenancy epic #3465 in one PR (per review-bandwidth preference: the phases were validated locally suite-by-suite rather than through per-phase CI round-trips).

Upstream: JasperFx 2.30.0 (shared ITenanted, #531), Weasel 9.18.1 (weasel#383: EF schema-mapping customization hook + index case-quoting fix), Marten 9.16.1.

Phase 1 (#3462) — conjoined core

ITenanted on an EF entity makes it conjoined-multi-tenant: tenant_id column + sentinel default + index, a tenant-bound global query filter (named filter on EF 10), stamp-on-insert, and CrossTenantWriteException guards on cross-tenant writes — via AddDbContextWithWolverineManagedConjoinedTenancy<T>(). The filter closes over the model-time DbContext and is re-rooted by EF's funcletizer to the executing context instance, so per-context tenant binding works with EF's cached model and no user base class. ConjoinedDbContextBuilder<T> keeps the IDbContextBuilder<T> shape, so codegen frames, the outbox factory, and saga persistence work unchanged; FindAsync respects the filter, making saga loads tenant-scoped for free.

Phase 2 (#3463) — opt-in Weasel-managed tenant partitioning

tenancy => tenancy.PartitionPerTenant():

  • PostgreSQL: PARTITION BY LIST (tenant_id) per non-saga ITenanted table via ManagedListPartitions + wolverine_tenant_partitions control table, with Marten-style suffix bucketing.
  • SQL Server: RANGE RIGHT over an int tenant_ordinal shadow column via ManagedTenantPartitions (weasel#362), ordinal stamped by the tenant interceptor from the pre-hydrated registry; AllowPartitionSharing for bucketed ordinals.
  • The composite (tenant, id) PK exists only in the database (attached during Weasel table mapping); the EF model keeps the user's single key, so FindAsync/Attach call shapes and saga loads are untouched. EF-side composite keys are unworkable regardless — key members must be non-null at Add() time, before any interceptor can stamp them, and EF value-generates key members (a GUID string for TenantId) if you try.
  • Provider abstraction ITenantPartitioning(+Factory) lives in Wolverine.RDBMS; implementations contributed by Wolverine.Postgresql / Wolverine.SqlServer.
  • Management via IConjoinedTenantPartitions<T> (add tenant / shared-suffix bucket / drop).
  • Sagas are deliberately not partitioned in v1 (composite keys would change saga load frames — flagged as an open question on Epic: Conjoined multi-tenancy for EF Core with Weasel-managed tenant partitioning #3465).

Phase 3 (#3464) — tenant registry + dynamic tenant source

The message store provisions wolverine_tenants for conjoined registrations (DurabilitySettings.TenantRegistryRequired). ConjoinedTenantSource<T> : IDynamicTenantSource<string> — auto-assign add (registry insert + partition creation), explicit-connection add rejected with a descriptive error, disable/enable enforced at SaveChanges with UnknownTenantIdException, remove = registry delete + partition drop with data. Registering IDynamicTenantSource<string> is what lights up CritterWatch tenant management (CritterWatch#720 continues there).

Phase 4 — tests + docs

  • Compliance batteries (PG + SQL Server): conjoined core (9×2), partitioning (5×2), registry (3×2), all ported from Marten's conjoined semantics.
  • HTTP tenant-detection integration test: header/query-string detection pins the context, stamps writes, scopes reads with zero endpoint tenant-awareness.
  • Docs: conjoined tenancy + partitioning + registry sections with extracted samples on the EF Core multi-tenancy page.

Also in this PR

  • EfCoreTests.MultiTenancy: cross-test leak makes SQL Server suites write envelopes to bug2739_master (ordering-dependent) #3497 fix: WolverineModelCacheKeyFactory keys EF's model cache on (context type, wolverine schema, designTime) — two hosts sharing a DbContext type with different durability schemas no longer share an envelope-table mapping. The previously ordering-dependent EfCoreTests.MultiTenancy SQL Server failures are gone: full suite 176/176 locally.
  • Saga keys are ValueGeneratedNever by convention (unless explicitly configured): saga ids are app-assigned, and Weasel 9.18's faithful EF-model translation turned conventionally-mapped int/long saga keys into IDENTITY columns (weasel#382 analysis).
  • The NServiceBus interop regression under Weasel 9.18.0 is fixed upstream in 9.18.1 (index case-quoting, weasel#383).

Validation (local)

Full wolverine.slnx Release build (0 warnings), EfCoreTests.MultiTenancy 176/176, EfCoreTests saga batteries, PostgreSQL transport + NServiceBus interop, HTTP detection — all green against Weasel 9.18.1.

Open naming questions (per #3465)

  1. AddDbContextWithWolverineManagedConjoinedTenancy<T> — happy to rename before 6.21.
  2. CrossTenantWriteException naming.
  3. Sample app + announcement blog post are deliberately not in this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BKamPNecC62LWBUh23yuPe

jeremydmiller and others added 9 commits July 19, 2026 06:49
…onjoined tenancy epic (GH-3465)

JasperFx 2.30.0 promotes ITenanted into JasperFx.MultiTenancy (#531);
Weasel 9.18.0 brings SQL Server ManagedTenantPartitions parity (weasel#362).
Marten 9.16.1 re-points Marten.Metadata.ITenanted at the shared marker, which
required disambiguating one HTTP test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sagas (GH-3462)

ITenanted on an EF entity now makes it conjoined-multi-tenant through Wolverine:

- AddDbContextWithWolverineManagedConjoinedTenancy<T>() registration mode:
  single shared database (the app's message store), tenant-pinned DbContexts
- ConjoinedTenancyModelCustomizer maps TenantId -> tenant_id with the *DEFAULT*
  sentinel default, indexes it, and attaches a tenant global query filter. The
  filter calls ConjoinedTenancy.TenantIdOf(context) with the captured DbContext
  reference, which EF's funcletizer re-roots to the executing context instance,
  so the cached model still yields per-context tenant binding (named filter
  wolverine_conjoined_tenancy on EF Core 10)
- TenantStampingInterceptor stamps the ambient tenant on inserts and throws
  CrossTenantWriteException on cross-tenant inserts/updates/deletes
- ConjoinedDbContextBuilder<T> keeps the existing IDbContextBuilder<T> shape so
  the multi-tenanted codegen frames and DbContextOutboxFactory work unchanged;
  sagas implementing ITenanted get tenant-scoped loads because FindAsync
  respects the query filter (covered by the compliance battery)
- Compliance battery (PG + SQL Server) ported from Marten's conjoined
  semantics: sentinel, stamping, filtering, per-context filter binding,
  FindAsync scoping, cross-tenant write rejection, tenant-scoped sagas
- Docs section + samples on the EF Core multi-tenancy page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…by convention (weasel#382)

Weasel 9.18's EF-model translation now faithfully honors EF's value-generation
model (by design — its schema-comparison harness asserts parity with EF
migrations), which turns conventionally-mapped int/long saga keys into IDENTITY
columns and breaks Wolverine's application-assigned saga ids. The model was
always claiming identity; old Weasel just ignored it.

WolverineModelCustomizer now marks every Saga-derived entity's primary key as
ValueGenerated.Never unless the user explicitly configured value generation,
so any DDL generator (Weasel-managed migrations or EF migrations) produces a
plain column matching the actual saga semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t partitioning (GH-3463)

PartitionPerTenant() on the conjoined registration physically partitions every
non-saga ITenanted entity table:

- PostgreSQL: LIST partitions on tenant_id via Weasel ManagedListPartitions,
  control table wolverine_tenant_partitions in the durability schema, with
  Marten-style suffix bucketing
- SQL Server: RANGE RIGHT partitions over an int tenant_ordinal shadow column
  via Weasel ManagedTenantPartitions (new in Weasel 9.18 / weasel#362), ordinal
  registry table, AllowPartitionSharing for bucketed ordinals; the tenant
  interceptor stamps the ordinal from the pre-hydrated registry
- The composite (tenant, id) primary key lives ONLY in the database (attached
  during Weasel table mapping); the EF model keeps the user's own key so
  FindAsync/Attach call shapes and saga loads are unchanged. EF-side composite
  keys are unworkable anyway: key members must be non-null at Add() time,
  before any SaveChanges interceptor can stamp them
- IConjoinedTenantPartitions<T> service: AddTenantAsync (with optional shared
  partition suffix), DropTenantAsync, startup hydration
- Provider abstraction ITenantPartitioning(+Factory) in Wolverine.RDBMS,
  implementations contributed by Wolverine.Postgresql / Wolverine.SqlServer
- Requires the Weasel EfSchemaMappingCustomization hook (table customization +
  contributed control-table objects in the EF migration path), currently
  consumed from a locally-packed Weasel 9.18.1-conjoined.1

Partitioned battery green on both engines (5x2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EF caches the built model per context type, so two hosts in one process using
the same DbContext type with different Wolverine schemas silently shared
whichever envelope-table mapping was built first -- the second host wrote
envelopes into the first host's schema. Surfaced as the ordering-dependent
SQL Server failures in EfCoreTests.MultiTenancy (Bug_2739's unprovisioned
bug2739_master schema leaking into the multi-tenancy suites), reproducible on
a pristine main.

WolverineModelCacheKeyFactory keys the model cache on
(context type, wolverine schema, designTime) and is installed at every site
that installs the Wolverine model customizer. The previously-failing repro
combination now passes 34/34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ dynamic tenant source (GH-3464)

- The message store provisions its wolverine_tenants registry table for
  conjoined registrations (DurabilitySettings.TenantRegistryRequired, set by
  the EF integration at bootstrap) -- the authoritative tenant list exists in
  both plain-conjoined and partitioned-conjoined
- ConjoinedTenantSource<T> : IDynamicTenantSource<string>: auto-assign
  AddTenantAsync inserts the registry record (empty connection string = shared
  database) and creates the tenant's partitions when partitioning is enabled;
  explicit connection strings are rejected with a descriptive error;
  DisableTenantAsync/EnableTenantAsync flip the registry flag and are enforced
  at SaveChanges time with UnknownTenantIdException; RemoveTenantAsync deletes
  the record and, for partitioned tenants, drops the partition with its data.
  Registering IDynamicTenantSource<string> is what lights up CritterWatch's
  tenant management satellite handlers
- Registry battery green on PostgreSQL + SQL Server

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ration test (GH-3465)

Alba end-to-end proving HTTP tenant detection (request header + query string)
pins the conjoined DbContext, stamps writes, and scopes reads with zero
tenant-awareness in the endpoint code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tenant registry (GH-3465)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uoting fix, weasel#383)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeremydmiller jeremydmiller changed the title Conjoined EF Core multi-tenancy, Phase 1: ITenanted core + conjoined sagas (GH-3462) Conjoined EF Core multi-tenancy epic: ITenanted core, tenant partitioning, tenant registry, HTTP detection (GH-3465) Jul 19, 2026
jeremydmiller and others added 2 commits July 19, 2026 10:05
…tomizer

WolverineModelCustomizer always hard-required DatabaseSettings, but EF Core
hosts with no database-backed message persistence never registered it -- they
only worked by borrowing another host's cached model, the very cross-host model
leak GH-3497 fixed. With per-schema cache keys those hosts now build their own
model, so the customizer skips envelope-table mapping when no Wolverine
database settings exist (there is no message store, hence no envelope tables to
map). Resolution goes through the application service provider fallback like
EF's own GetService, but null-tolerant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…parity)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment