-
Notifications
You must be signed in to change notification settings - Fork 0
Remove TransactionLocal and SessionSearchPath modes; use QualifiedNames only #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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 | ||||||
|
|
||||||
|
|
@@ -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 | ||||||
| ``` | ||||||
|
|
@@ -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. | ||||||
|
Comment on lines
+58
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Fix the grammatical agreement for “qualified names”. Use “Qualified names are/have…” or “The qualified-names strategy is/has…” in these sentences. Also applies to: 86-89 🤖 Prompt for AI Agents |
||||||
|
|
||||||
| (`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 | ||||||
|
|
@@ -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 | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (typo): Fix subject–verb agreement in “Qualified names has no connection schema state at all”. I’d suggest either using the mode name (
Suggested change
|
||||||
| 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 | ||||||
|
|
@@ -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 | ||||||
|
|
@@ -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 | ||||||
|
|
||||||
|
|
@@ -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 | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick (typo): Adjust subject–verb agreement for “qualified names” or treat it as the proper mode name.
Consider either keeping it as a proper mode name (
QualifiedNames is the only strategy) or using plural agreement (qualified names are the only strategy) to fix the grammar.