Conjoined EF Core multi-tenancy epic: ITenanted core, tenant partitioning, tenant registry, HTTP detection (GH-3465) - #3498
Merged
Conversation
…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>
…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>
This was referenced Jul 19, 2026
Merged
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.
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
ITenantedon an EF entity makes it conjoined-multi-tenant:tenant_idcolumn + sentinel default + index, a tenant-bound global query filter (named filter on EF 10), stamp-on-insert, andCrossTenantWriteExceptionguards on cross-tenant writes — viaAddDbContextWithWolverineManagedConjoinedTenancy<T>(). The filter closes over the model-timeDbContextand 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 theIDbContextBuilder<T>shape, so codegen frames, the outbox factory, and saga persistence work unchanged;FindAsyncrespects the filter, making saga loads tenant-scoped for free.Phase 2 (#3463) — opt-in Weasel-managed tenant partitioning
tenancy => tenancy.PartitionPerTenant():PARTITION BY LIST (tenant_id)per non-sagaITenantedtable viaManagedListPartitions+wolverine_tenant_partitionscontrol table, with Marten-style suffix bucketing.RANGE RIGHTover anint tenant_ordinalshadow column viaManagedTenantPartitions(weasel#362), ordinal stamped by the tenant interceptor from the pre-hydrated registry;AllowPartitionSharingfor bucketed ordinals.(tenant, id)PK exists only in the database (attached during Weasel table mapping); the EF model keeps the user's single key, soFindAsync/Attachcall shapes and saga loads are untouched. EF-side composite keys are unworkable regardless — key members must be non-null atAdd()time, before any interceptor can stamp them, and EF value-generates key members (a GUID string forTenantId) if you try.ITenantPartitioning(+Factory)lives inWolverine.RDBMS; implementations contributed byWolverine.Postgresql/Wolverine.SqlServer.IConjoinedTenantPartitions<T>(add tenant / shared-suffix bucket / drop).Phase 3 (#3464) — tenant registry + dynamic tenant source
The message store provisions
wolverine_tenantsfor 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 atSaveChangeswithUnknownTenantIdException, remove = registry delete + partition drop with data. RegisteringIDynamicTenantSource<string>is what lights up CritterWatch tenant management (CritterWatch#720 continues there).Phase 4 — tests + docs
Also in this PR
WolverineModelCacheKeyFactorykeys 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-dependentEfCoreTests.MultiTenancySQL Server failures are gone: full suite 176/176 locally.ValueGeneratedNeverby 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).Validation (local)
Full
wolverine.slnxRelease build (0 warnings),EfCoreTests.MultiTenancy176/176,EfCoreTestssaga batteries, PostgreSQL transport + NServiceBus interop, HTTP detection — all green against Weasel 9.18.1.Open naming questions (per #3465)
AddDbContextWithWolverineManagedConjoinedTenancy<T>— happy to rename before 6.21.CrossTenantWriteExceptionnaming.🤖 Generated with Claude Code
https://claude.ai/code/session_01BKamPNecC62LWBUh23yuPe