Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 56 additions & 72 deletions framework/docs/multi-schema/ADOPTION-GUIDE.md

Large diffs are not rendered by default.

149 changes: 72 additions & 77 deletions framework/docs/multi-schema/IMPLEMENTATION_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Multi-Schema Implementation Notes

> These notes describe the **current** shared-connection, mode-aware schema implementation.
> Earlier revisions described a session-level `SET search_path` applied by an
> `NpgsqlSchemaConnectionInterceptor`, plus an `ICurrentSchema.Set()` / `IsResolved` accessor.
> Those are gone. See the corrected design below.
> These notes describe the **current** qualified-names schema implementation.
> Earlier revisions described the `TransactionLocal` and `SessionSearchPath` switching modes
> (`SET LOCAL search_path` / session `SET search_path` + `RESET search_path`), a session-level
> interceptor (`NpgsqlSchemaConnectionInterceptor`), plus an `ICurrentSchema.Set()` /
> `IsResolved` accessor. Those are gone: qualified names is the only strategy, and no
> `search_path` manipulation happens anywhere. See the corrected design below.

## Design at a glance

Expand All @@ -15,21 +17,18 @@
│ reads currentSchema.Name
active UnitOfWork (CompositeUnitOfWork)
├── ONE NpgsqlConnection
├── ONE NpgsqlTransaction (only when IsTransactional = true)
├── SchemaScopeState (shared; tracks Current schema + optional Cleanup delegate)
├── IsTransactional = true: ONE NpgsqlConnection + ONE NpgsqlTransaction
│ (opened lazily; every schema-bound context enlists
│ on the shared tx via UseTransactionAsync)
├── IsTransactional = false: NO physical connection held by the UoW —
│ contexts bind UseNpgsql(connectionString) and EF Core
│ owns the connection lifecycle (pooled per operation)
└── DbContext cache keyed by (DbContextType, Schema)
│ transactional UoW: each context enlists on the shared tx
│ (UseTransactionAsync)
SearchPathCommandInterceptor ──► mode-aware command handling
SchemaSwitchingMode.TransactionLocal: SET LOCAL search_path TO "<schema>", public
(per command; skips via SchemaScopeState.Current)
SchemaSwitchingMode.SessionSearchPath: SET search_path TO "<schema>", public
(once per UoW; skips if same schema)
+ RESET search_path on UoW dispose (via Cleanup)
SchemaSwitchingMode.QualifiedNames: rewrite model placeholder / raw {{schema}} token
(no search_path command)
QualifiedNamesCommandInterceptor ──► rewrite model placeholder / raw {{schema}} token
to the quoted bound schema (no search_path command);
throws if ICurrentSchema.Name differs from the
context's bound schema
PostgreSQL
```
Expand All @@ -41,40 +40,41 @@
is no mutable setter and no "is resolved" flag.
(`BBT.Aether.Core/BBT/Aether/MultiSchema/CurrentSchema.cs`)

2. **One connection, plus one transaction when requested, per Unit of Work.** All schema-bound
contexts in a UoW share a single `NpgsqlConnection`. When `IsTransactional = true`, they also
enlist in one shared `NpgsqlTransaction`, so cross-schema writes commit atomically. When
`IsTransactional = false`, no transaction is created: business writes and outbox writes cannot
be atomic across a process failure. Contexts are lazily created and cached by `(Type, Schema)`;
the connection is opened on the first `GetDbContextAsync`.
2. **Transactional UoWs share a connection; non-transactional UoWs hold none.** When
`IsTransactional = true`, all schema-bound contexts in a UoW share a single
`NpgsqlConnection` and enlist in one shared `NpgsqlTransaction`, so cross-schema writes
commit atomically; the connection is opened lazily on the first `GetDbContextAsync`. When
`IsTransactional = false`, `CompositeUnitOfWork.GetDbContextAsync` opens **no** physical
connection at all: contexts are built via
`IAetherDbContextConfigurator.BuildOwnedOptions(schema)` →
`IAetherDatabaseProvider.ApplyOwned(builder, connectionString, schema, currentSchema)`,
which binds `UseNpgsql(connectionString)` so EF Core owns the connection lifecycle (rents a
pooled connection per operation and returns it immediately). This reduces connection-pool
pressure for read-heavy/non-transactional work, but business writes and outbox writes
cannot be atomic across a process failure. Contexts are lazily created and cached by
`(Type, Schema)` in both cases.
(`BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs`)

3. **Mode-aware isolation via `SchemaSwitchingMode`.** Schema isolation is enforced by a
mode-aware `SearchPathCommandInterceptor` configured at registration time via
`AddAetherNpgsql(connectionString, mode)` (default `TransactionLocal`).

- `TransactionLocal`: Prefixes `SET LOCAL search_path TO "<schema>", public` before each
command. `SET LOCAL` is transaction-scoped — requires `IsTransactional = true`. A
`SchemaScopeState.Current` field skips the redundant `SET` when the connection already
has the right schema. Throws `InvalidOperationException` if no transaction is open
(guard against misconfiguration).
- `SessionSearchPath`: Issues `SET search_path` once per UoW, skipping repeats via
`SchemaScopeState.Current`. Registers a `SchemaScopeState.Cleanup` delegate
(`RESET search_path`) that `CompositeUnitOfWork.DisposeAsync` invokes before the
connection is returned to the pool, preventing session-state leakage. Does not require
a transaction (`IsTransactional = false`).
- `QualifiedNames`: Uses one tenant-independent model placeholder, then rewrites it to the
validated schema bound to the context immediately before execution. Schema-dependent
`FromSqlRaw`/`ExecuteSqlRaw` relations use the exact `{{schema}}` token. It emits no
`SET`, `SET LOCAL`, or `RESET search_path` and does not require a transaction.

(`.../Uow/EntityFrameworkCore/SearchPathCommandInterceptor.cs`, `SchemaScopeState.cs`,
`SchemaSwitchingMode.cs`)
3. **Qualified names is the only isolation strategy.** The former `TransactionLocal` and
`SessionSearchPath` switching modes were removed, along with all `search_path` manipulation
(`SET LOCAL search_path`, session `SET search_path`, `RESET search_path` cleanup).
`SchemaSwitchingMode` now has the single member `QualifiedNames`, and isolation is enforced
by `QualifiedNamesCommandInterceptor(schema, currentSchema)`:

- Uses one tenant-independent model placeholder, then rewrites it to the validated schema
bound to the context immediately before execution. Schema-dependent
`FromSqlRaw`/`ExecuteSqlRaw` relations use the exact `{{schema}}` token, rewritten to the
quoted bound schema.
- Throws if `ICurrentSchema.Name` does not match the context's bound schema (guard against
a context leaking across schema scopes).
- Emits no `SET`, `SET LOCAL`, or `RESET search_path` and requires no transaction.

(`BBT.Aether.Npgsql/QualifiedNamesCommandInterceptor.cs`,
`.../Uow/EntityFrameworkCore/SchemaSwitchingMode.cs`)

4. **Schema-agnostic mappings.** Entities use `ToTable("name")` with no schema, so EF Core
compiles one model per context type that serves every schema. Search-path modes resolve
unqualified relations through connection state; QualifiedNames gives them the constant
Aether placeholder and rewrites it per context.
compiles one model per context type that serves every schema. Unqualified relations map to
the constant Aether placeholder, which the interceptor rewrites per context.

5. **Provider-agnostic Infrastructure.** `BBT.Aether.Infrastructure` has no `Npgsql` dependency;
provider specifics are abstracted behind `IAetherDatabaseProvider`. PostgreSQL support lives in
Expand All @@ -83,9 +83,10 @@
`BBT.Aether.SqlServer` and is single-schema. The mechanism described in this document applies to
the Npgsql provider.

6. **PgBouncer-safe choices.** `TransactionLocal` never leaks because PostgreSQL reverts
`SET LOCAL` with the transaction. `QualifiedNames` has no connection schema state at all.
`SessionSearchPath` remains limited to a session-pinned/native connection.
6. **Safe under any pooling.** Qualified names has no connection schema state at all — nothing

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (typo): Fix subject–verb agreement in the phrase "Qualified names has".

Change the sentence to: "Qualified names have no connection schema state at all" to ensure correct subject–verb agreement and consistency with other references to qualified names.

Suggested change
6. **Safe under any pooling.** Qualified names has no connection schema state at all — nothing
6. **Safe under any pooling.** Qualified names have no connection schema state at all — nothing

is ever written to session or transaction state — so it is safe under PgBouncer transaction
or session pooling as well as the native Npgsql pool. Non-transactional UoWs additionally
hold no connection at all, so they cannot pin a pooled connection.

7. **Schema-bound object lifetime.** The UoW cache key is `(DbContextType, Schema)`, so one
repository/service instance can switch `flow_a -> flow_b -> flow_a`. Repositories resolve
Expand All @@ -95,40 +96,36 @@
## Wiring

```csharp
// PostgreSQL — TransactionLocal (default, PgBouncer-safe)
// PostgreSQL — qualified names (the only strategy; safe under any pooling)
services.AddAetherNpgsql<MyDbContext>(connectionString);
// or explicitly:
services.AddAetherNpgsql<MyDbContext>(connectionString, SchemaSwitchingMode.TransactionLocal);

// PostgreSQL — SessionSearchPath (non-transactional, native pool only)
services.AddAetherNpgsql<MyDbContext>(connectionString, SchemaSwitchingMode.SessionSearchPath);

// PostgreSQL — QualifiedNames (transaction optional, no connection schema state)
services.AddAetherNpgsql<MyDbContext>(connectionString, SchemaSwitchingMode.QualifiedNames);

// SQL Server (single-schema)
// services.AddAetherSqlServer<MyDbContext>(connectionString);

// Custom provider / advanced
// services.AddAetherDbContext<MyDbContext>(new NpgsqlAetherProvider(mode), connectionString, configure?);
// services.AddAetherDbContext<MyDbContext>(new NpgsqlAetherProvider(), connectionString, configure?);
```

`AddAetherNpgsql(connectionString, mode = SchemaSwitchingMode.QualifiedNames, configure)` still
accepts the optional `mode` parameter, but only for signature compatibility —
`SchemaSwitchingMode.QualifiedNames` is the sole member.

`AddAetherNpgsql` (built on `AddAetherDbContext`) registers:

- `IAetherDbContextConfigurator<TDbContext>` (`AetherDbContextConfigurator<>`) — captures the
connection string and the configure delegate; `BuildOptions(sharedConnection, schema, state)`
re-applies the configuration, binds to the shared connection via `UseNpgsql(connection)`, and
adds a `SearchPathCommandInterceptor(schema, state, mode, currentSchema)` per context.
connection string and the configure delegate. For transactional UoWs,
`BuildOptions(sharedConnection, schema, state)` re-applies the configuration, binds to the
shared connection via `UseNpgsql(connection)`, and adds a
`QualifiedNamesCommandInterceptor(schema, currentSchema)` per context. For non-transactional
UoWs, `BuildOwnedOptions(schema)` → `IAetherDatabaseProvider.ApplyOwned(builder,
connectionString, schema, currentSchema)` binds `UseNpgsql(connectionString)` instead, so EF
Core owns the connection lifecycle.
- The design-time/migrations `DbContext` registration (`AddDbContext`).
- `AddAetherUnitOfWork<TDbContext>()` — ambient accessor (`IAmbientUnitOfWorkAccessor`,
AsyncLocal singleton), `IUnitOfWorkManager` (scoped), the domain-event sink, and
`IAetherDbContextProvider<>` (scoped).

`NpgsqlAetherProvider.ApplyShared` also registers `SchemaScopeState.Cleanup` when mode is
`SessionSearchPath`: the cleanup delegate issues `RESET search_path` and is invoked once by
`CompositeUnitOfWork.DisposeAsync` before releasing the connection to the pool.

In QualifiedNames mode the provider adds the stable `AetherSchemaModelOptionsExtension`, so
The provider adds the stable `AetherSchemaModelOptionsExtension`, so
`AetherDbContext` maps unqualified relations under `AetherSchemaModel.Placeholder`. The model
cache marker never includes a tenant name. At command execution the interceptor checks that
`ICurrentSchema.Name` still matches its immutable context binding, rewrites the model
Expand All @@ -153,8 +150,7 @@ identifiers, comments, and dollar-quoted bodies are data and remain unchanged.
| `UnitOfWork DbContext limit exceeded. Limit: N` | More than `MaxDbContextCount` distinct `(Type, Schema)` contexts in one UoW (default 16). |
| `Invalid PostgreSQL identifier: X` | Schema name fails the identifier regex. |
| `Schema scope corrupted: out-of-order disposal detected.` | `Change(...)` scopes disposed out of order. |
| `DbContext is bound to schema 'A', but current schema is 'B'.` | A DbContext/DbSet/IQueryable resolved in one QualifiedNames scope was executed in another; resolve it again. |
| `Raw SQL token '{{schema}}' requires SchemaSwitchingMode.QualifiedNames.` | The explicit token was used with a search-path mode. |
| `DbContext is bound to schema 'A', but current schema is 'B'.` | A DbContext/DbSet/IQueryable resolved in one schema scope was executed in another; resolve it again. |

## Background processors

Expand All @@ -169,13 +165,12 @@ If `Schema` is null/empty they log a warning and no-op. Run one instance per sch
These integration tests in `framework/test/BBT.Aether.Postgres.Tests/` are the source of truth
for behavior:

- `MultiSchemaUnitOfWorkTests` — atomic cross-schema commit/rollback, isolation via search_path,
the SET-skip optimization, and the `MaxDbContextCount` guardrail.
- `PgBouncerSearchPathTests` — `SET LOCAL` (`TransactionLocal` mode) does not leak to a
fresh/pooled connection.
- `UnitOfWorkDisposalTests` — `SessionSearchPath` mode: connection without transaction, shared
context caching, `RESET search_path` at dispose (pool leakage prevention); `TransactionLocal`
mode: transaction is opened, throws correctly when used without `IsTransactional = true`.
- `MultiSchemaUnitOfWorkTests` — atomic cross-schema commit/rollback, schema isolation via
qualified names, and the `MaxDbContextCount` guardrail.
- `PgBouncerSearchPathTests` — qualified names never mutate the session `search_path`, so no
schema state can leak to a fresh/pooled connection.
- `UnitOfWorkDisposalTests` — non-transactional context leaves connection management to EF
Core (the UoW holds no physical connection); schema does not leak across units of work.
- `OutboxWithinSharedTransactionTests` — a domain event is written to the outbox inside the same
shared transaction as the business data (default `AlwaysUseOutbox`).
- `DbContextConfiguratorTests` — `BuildOptions` binds the shared connection and preserves
Expand Down
Loading
Loading