From 28928b7c6240cd9456e6bbc1574d801332ba8b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:44:08 +0000 Subject: [PATCH 1/2] refactor(uow): let EF Core own connections for non-transactional UoWs; reduce SchemaSwitchingMode to QualifiedNames Non-transactional CompositeUnitOfWork roots no longer create/open a shared DbConnection. Contexts are built via the new BuildOwnedOptions/ApplyOwned path bound to the connection string, so EF Core rents a pooled connection per operation and returns it immediately. This removes hold-for-request connection pinning that inflated pool demand (Npgsql connect timeouts under long-polling load). Transactional roots keep the shared connection + transaction semantics unchanged. SchemaSwitchingMode now has a single member, QualifiedNames; the TransactionLocal and SessionSearchPath modes and all search_path manipulation are removed. SearchPathCommandInterceptor is replaced by QualifiedNamesCommandInterceptor(schema, currentSchema). NpgsqlAetherProvider is parameterless; AddAetherNpgsql keeps the optional mode parameter for signature compatibility and defaults to QualifiedNames. Tests updated for the new topology (owned connections, qualified-names DDL placeholder rewriting) and multi-schema/unit-of-work docs rewritten accordingly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BKsqFuUQTnEJaJKygkEh2c --- framework/docs/multi-schema/ADOPTION-GUIDE.md | 128 +++++------ .../docs/multi-schema/IMPLEMENTATION_NOTES.md | 149 ++++++------ framework/docs/multi-schema/README.md | 150 ++++-------- framework/docs/unit-of-work/README.md | 34 ++- .../BBT/Aether/Uow/CompositeUnitOfWork.cs | 56 +++-- .../AetherDbContextConfigurator.cs | 12 + .../IAetherDatabaseProvider.cs | 12 + .../IAetherDbContextConfigurator.cs | 8 + .../SchemaSwitchingMode.cs | 32 +-- ...AetherNpgsqlServiceCollectionExtensions.cs | 18 +- .../BBT.Aether.Npgsql/NpgsqlAetherProvider.cs | 41 ++-- .../QualifiedNamesCommandInterceptor.cs | 90 ++++++++ .../SearchPathCommandInterceptor.cs | 217 ------------------ .../BBT/Aether/Uow/DomainEventFailureTests.cs | 11 +- .../BackgroundJob/ArmingProcessorTests.cs | 5 +- .../BackgroundJob/DaprBridgeTests.cs | 5 +- .../EndToEndJobLifecycleTests.cs | 5 +- .../BackgroundJob/EnqueueAtomicityTests.cs | 5 +- .../BackgroundJob/JobDispatcherTests.cs | 5 +- .../BackgroundJob/JobStoreCasTests.cs | 5 +- .../BackgroundJob/JobStoreClaimReaperTests.cs | 5 +- .../BackgroundJob/ReaperTests.cs | 5 +- .../DbContextConfiguratorTests.cs | 6 +- .../MultiSchemaUnitOfWorkTests.cs | 53 +++-- .../NonTransactionalOutboxDispatchTests.cs | 9 +- .../NpgsqlLeaseStoreTests.cs | 4 +- .../OutboxWithinSharedTransactionTests.cs | 12 +- .../PgBouncerSearchPathTests.cs | 21 +- .../QualifiedNamesTests.cs | 65 +----- .../UnitOfWorkDisposalTests.cs | 58 ++--- .../UnitOfWorkMiddlewareTests.cs | 2 +- 31 files changed, 503 insertions(+), 725 deletions(-) create mode 100644 framework/src/BBT.Aether.Npgsql/QualifiedNamesCommandInterceptor.cs delete mode 100644 framework/src/BBT.Aether.Npgsql/SearchPathCommandInterceptor.cs diff --git a/framework/docs/multi-schema/ADOPTION-GUIDE.md b/framework/docs/multi-schema/ADOPTION-GUIDE.md index a2a0c06..08464c0 100644 --- a/framework/docs/multi-schema/ADOPTION-GUIDE.md +++ b/framework/docs/multi-schema/ADOPTION-GUIDE.md @@ -16,9 +16,9 @@ | | | |---|---| -| **Tek bağlantı, opsiyonel tek transaction** | Bir UnitOfWork tek bir `NpgsqlConnection` ve `IsTransactional = true` ise tek bir `NpgsqlTransaction` açar. İhtiyaç duyulan her `(DbContext tipi, schema)` için lazy olarak ayrı bir DbContext üretir. Transactional akışta hepsini **aynı** transaction'a bağlar → schema'lar arası atomik commit/rollback. | +| **Transactional'da tek bağlantı, non-transactional'da hiç bağlantı** | `IsTransactional = true` ise UnitOfWork tek bir `NpgsqlConnection` ve tek bir `NpgsqlTransaction` açar; ihtiyaç duyulan her `(DbContext tipi, schema)` için lazy olarak ayrı bir DbContext üretir ve hepsini **aynı** transaction'a bağlar → schema'lar arası atomik commit/rollback. `IsTransactional = false` ise UoW **hiç fiziksel bağlantı açmaz**: context'ler `UseNpgsql(connectionString)` ile bağlanır, bağlantı yaşam döngüsünü EF Core yönetir (her operasyon için pool'dan bağlantı alır, hemen iade eder). | | **Çalışma zamanında schema** | Schema, `using (currentSchema.Change("flow_a"))` ile seçilen, iç içe geçebilen, otomatik geri alınan bir kapsamdır. Entity eşlemeleri schema'dan **bağımsızdır** (`ToTable("x")`). | -| **PgBouncer-uyumlu** | `TransactionLocal`, schema'yı transaction içindeki `SET LOCAL` ile güvenli tutar. `QualifiedNames` ise bağlantı state'i kullanmadan her relation'ı runtime schema ile niteler. İkisi de transaction pooling altında güvenlidir. | +| **Her pool ile uyumlu** | Tek strateji `QualifiedNames`: bağlantı state'i kullanmadan her relation'ı runtime schema ile niteler; `search_path` hiç değiştirilmez. PgBouncer transaction/session pooling ve native pool altında güvenlidir. Non-transactional UoW ayrıca hiç bağlantı tutmadığı için pool baskısı da düşüktür. | --- @@ -62,15 +62,9 @@ içeride çağrılır. ```csharp services.AddAetherCore(_ => { }); -// PostgreSQL — TransactionLocal (varsayılan, PgBouncer + native pool uyumlu): +// PostgreSQL — qualified names (tek strateji; her pool ile uyumlu): services.AddAetherNpgsql(connectionString); -// PostgreSQL — SessionSearchPath (transaction'sız, yalnız native Npgsql pool): -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.SessionSearchPath); - -// PostgreSQL — QualifiedNames (transaction opsiyonel, search_path komutu yok): -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.QualifiedNames); - // veya SQL Server (tek-schema): // services.AddAetherSqlServer(connectionString); @@ -81,16 +75,18 @@ services.AddAetherInbox(); // (yalnız PostgreSQL) services.AddAetherBackgroundJob(); ``` -`SchemaSwitchingMode` enum değerleri: +`SchemaSwitchingMode` enum'unun artık tek üyesi var: `QualifiedNames`. Eski `TransactionLocal` +ve `SessionSearchPath` modları (ve tüm `search_path` manipülasyonu) kaldırıldı. +`AddAetherNpgsql(connectionString, mode = SchemaSwitchingMode.QualifiedNames, configure)` +imzasındaki opsiyonel `mode` parametresi yalnız imza uyumluluğu için duruyor. | Değer | Komut | Transaction gerekli? | Pool uyumu | |-------|-------|----------------------|-----------| -| `TransactionLocal` (varsayılan) | `SET LOCAL search_path` her komut öncesi | **Evet** (`IsTransactional = true`) | PgBouncer transaction pooling ✅ | -| `SessionSearchPath` | `SET search_path` bir kez + `RESET` dispose'da | Hayır (`IsTransactional = false`) | Yalnız native Npgsql pool ✅ | -| `QualifiedNames` | EF relation placeholder'ını ve raw SQL'deki `{{schema}}` token'ını niteler; `SET`/`RESET` yok | Hayır | PgBouncer transaction/session pooling ✅, native pool ✅ | +| `QualifiedNames` (tek strateji) | EF relation placeholder'ını ve raw SQL'deki `{{schema}}` token'ını niteler; `SET`/`RESET` yok | Hayır | PgBouncer transaction/session pooling ✅, native pool ✅ | Özel bir provider için çekirdek overload'ı doğrudan çağır: -`services.AddAetherDbContext(provider, connectionString, configure?)`. +`services.AddAetherDbContext(new NpgsqlAetherProvider(), connectionString, configure?)` +(`NpgsqlAetherProvider` artık parametresizdir). > ⚠️ **Kırıcı değişiklik:** Eski `AddAetherDbContext(options => …)` (connection string'siz) > imzası kaldırıldı. `NpgsqlSchemaConnectionInterceptor` de silindi — artık eklemeyin. @@ -146,7 +142,7 @@ public async Task CreateAsync(Order order) > `await uowManager.BeginAsync(...)` UoW'u çağıranın akışında ambient yapmaz > (bkz. Bölüm B → "Ambient") → repository/store `"No active UnitOfWork"` fırlatır. -**Transactional (`TransactionLocal` mode — PgBouncer uyumlu):** +**Transactional (paylaşılan bağlantı + tek transaction):** ```csharp using (currentSchema.Change("flow_a")) @@ -159,27 +155,23 @@ await using (var uow = uowManager.Begin( } ``` -**Non-transactional (`SessionSearchPath` mode — sadece native Npgsql pool):** +**Non-transactional (UoW bağlantı tutmaz — okuma ağırlıklı işler için ideal):** ```csharp -// Kayıt: services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.SessionSearchPath); - using (currentSchema.Change("flow_a")) await using (var uow = uowManager.Begin( new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = false })) { var db = await dbContextProvider.GetDbContextAsync(); // flow_a'ya bağlı, transaction yok var list = await db.Set().ToListAsync(); - // UoW dispose'da otomatik: RESET search_path (pool sızıntısını önler) + // UoW fiziksel bağlantı açmaz: EF Core her operasyon için pool'dan bağlantı alır, + // hemen iade eder → bağlantı-pool baskısı düşer, search_path temizliği gerekmez. } ``` -**QualifiedNames (aynı repository ile runtime geçiş):** +**Aynı repository ile runtime schema geçişi:** ```csharp -// Kayıt: -// services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.QualifiedNames); - await using var uow = uowManager.Begin(new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, @@ -242,10 +234,10 @@ await uow.CommitAsync(); // iki schema TEK transaction'da commit olur (ya hep | Konu | Açıklama | |---|---| | **🔁 Begin vs BeginAsync** | Repository/store/context çözecek her programatik akışta senkron `Begin()`/`BeginRequiresNew()` kullan. `BeginAsync` yalnız ambient'a ihtiyaç duymayan durumlar için bırakıldı. | -| **🧱 ToTable'da schema yok** | `ToTable("x", "schema")` veya `HasDefaultSchema` kullanma. Schema runtime'da seçilen mod ile çözülür; modele tenant schema'sı gömülürse EF model cache schema başına kirlenir. | -| **🔀 SchemaSwitchingMode seçimi** | `TransactionLocal` (varsayılan) PgBouncer transaction pooling ve native pool altında güvenlidir; `IsTransactional = true` gerektirir. `QualifiedNames` bağlantı state'i kullanmaz, transaction gerektirmez ve raw SQL için `{{schema}}` ister. `SessionSearchPath` yalnız native/session-pinned bağlantı ile kullanılmalı; PgBouncer transaction pooling ile **kullanma**. | +| **🧱 ToTable'da schema yok** | `ToTable("x", "schema")` veya `HasDefaultSchema` kullanma. Schema runtime'da qualified-names rewriting ile çözülür; modele tenant schema'sı gömülürse EF model cache schema başına kirlenir. | +| **🔀 Tek strateji: QualifiedNames** | `QualifiedNames` bağlantı state'i kullanmaz, transaction gerektirmez ve raw SQL için `{{schema}}` token'ı ister. Her pooling modeli (PgBouncer transaction/session pooling, native pool) altında güvenlidir. Eski `TransactionLocal` ve `SessionSearchPath` modları kaldırıldı. | | **🔒 Scope'a bağlı nesneler** | Repository/service tekrar kullanılabilir; resolve edilmiş DbContext, DbSet ve IQueryable schema scope'ları arasında tekrar kullanılamaz. | -| **⏱️ Transaction'ı kısa tut** | `TransactionLocal` + PgBouncer: açık transaction içinde **dış servis çağrısı yapma** (HTTP, mesaj broker). Outbox processor bu yüzden lease→publish→update olarak 3 faza ayrılmıştır. | +| **⏱️ Transaction'ı kısa tut** | Açık transaction içinde **dış servis çağrısı yapma** (HTTP, mesaj broker) — özellikle PgBouncer transaction pooling altında bağlantıyı gereksiz pinler. Outbox processor bu yüzden lease→publish→update olarak 3 faza ayrılmıştır. | | **📥 Poller başına tek schema** | Outbox/Inbox processor tek `Schema` işler. Birden çok schema varsa her biri için ayrı instance çalıştır; `Schema` boşsa processor uyarı loglar ve çalışmaz. | | **🏷️ Job'lar schema taşımalı** | Background job kuyruğa alınırken `currentSchema.Name` envelope'a yazılır. Hiçbir schema kapsamı yokken enqueue edilen job'da schema null olur ve dispatch sırasında hata verir. | | **🔢 MaxDbContextCount** | Tek UoW içinde farklı `(tip, schema)` sayısı varsayılan **16** ile sınırlıdır (guardrail). 50+ schema'yı aynı UoW'da gezme — uzun transaction riski. | @@ -264,9 +256,9 @@ switching mekanizmalarını implemente etmez. - **Yalnız tek-schema.** Schema'yı modele bağla: `modelBuilder.HasDefaultSchema("x")` veya schema-nitelikli `ToTable("orders", "x")`. Çalışma zamanı komut-başına schema değişimi yok. - **Tek transaction'da çalışma-zamanı çok-schema (runtime `Change()` ile schema'lar arası) - yalnız PostgreSQL'dedir.** PostgreSQL provider bunu seçilen moda göre `TransactionLocal`, - `SessionSearchPath` veya `QualifiedNames` ile sağlar; SQL Server provider'da eşdeğer runtime - relation rewriting/schema-switching desteği yoktur. + yalnız PostgreSQL'dedir.** PostgreSQL provider bunu qualified-names relation rewriting + (`QualifiedNames`) ile sağlar; SQL Server provider'da eşdeğer runtime relation + rewriting/schema-switching desteği yoktur. - **Outbox/Inbox işleme henüz SQL Server'da desteklenmiyor.** İşleme şu an PostgreSQL'e özgü lease SQL'i (`FOR UPDATE SKIP LOCKED`, `EfCoreOutboxStore` / `EfCoreInboxStore`) kullanır; SQL Server desteği bir sonraki adım. @@ -281,7 +273,6 @@ switching mekanizmalarını implemente etmez. | `No active UnitOfWork.` | Ambient UoW yok. Programatik kodda `BeginAsync` yerine senkron `Begin()` kullan; istekte `UseAetherUnitOfWork` + `[UnitOfWork]` var mı bak. | | `UnitOfWork DbContext limit exceeded. Limit: N` | Tek UoW'da çok fazla farklı `(tip, schema)`. Tasarımı gözden geçir veya `UnitOfWorkOptions.MaxDbContextCount`'u bilinçli artır. | | `Invalid PostgreSQL identifier: X` | Schema adı geçersiz karakter içeriyor. | -| `SchemaSwitchingMode.TransactionLocal requires a transaction, but none is active.` | `SchemaSwitchingMode.TransactionLocal` aktif transaction olmadan kullanılmış. `IsTransactional = true` yap veya pool moduna göre `SessionSearchPath` ya da `QualifiedNames` seç. | | `Unit of work is prepared but not initialized.` | Hazırlanmış (prepared) UoW henüz initialize edilmeden context istendi. İstek akışında aspect/`[UnitOfWork]` başlatmadan önce DB erişimi olmuş. | | `Schema scope corrupted: out-of-order disposal detected.` | `Change(...)` kapsamları iç içe ve sırasıyla dispose edilmeli; `using` kullan, elle Dispose'u karıştırma. | @@ -296,12 +287,12 @@ flowchart TB CS["ICurrentSchema
Change(s) · AsyncLocal stack · Name"] MGR["IUnitOfWorkManager
Begin() · Prepare() · Current"] subgraph CORE["ÇEKİRDEK"] - CUOW["CompositeUnitOfWork (root)
shared NpgsqlConnection + optional NpgsqlTransaction"] + CUOW["CompositeUnitOfWork (root)
transactional: shared NpgsqlConnection + NpgsqlTransaction · non-transactional: bağlantı EF Core'da"] CACHE["Dictionary<(Type,Schema), DbContext>
lazy cache"] end SCOPE["UnitOfWorkScope
ambient sarmalı · sahiplik/dispose"] PROV["IAetherDbContextProvider
Current + schema → context"] - INT["SearchPathCommandInterceptor
TransactionLocal: SET LOCAL · SessionSearchPath: SET/RESET · QualifiedNames: relation qualification"] + INT["QualifiedNamesCommandInterceptor
model placeholder + raw {{schema}} token → quoted bound schema · context-scope guard"] REPO["Repositories · Outbox/Inbox/Job stores"] MGR --> SCOPE --> CUOW --> CACHE @@ -316,56 +307,47 @@ flowchart TB |---|---| | `ICurrentSchema` | Aktif schema'yı `AsyncLocal` bir *stack*'te tutar. `Change(s)` push eder ve dispose'ta pop eder (iç içe, otomatik geri alma). | | `IUnitOfWorkManager` | UoW yaratır ve ambient'ı yönetir: `Begin` (senkron), `Prepare` (istek), `BeginAsync` (legacy). `Current` aktif UoW'u verir. | -| `CompositeUnitOfWork` | Kök. Tek `NpgsqlConnection` sahibi; `IsTransactional = true` ise `NpgsqlTransaction` da açar. `(tip,schema)` başına DbContext üretir; dispose'da `SchemaScopeState.Cleanup` çağırır, commit/rollback ve event/outbox boru hattını yürütür. | -| `UnitOfWorkScope` | Kökü saran ambient katman. `accessor.Current`'ı set/restore eder; **sahibi** ise dispose'ta kökü (ve bağlantıyı) kapatır. | +| `CompositeUnitOfWork` | Kök. `IsTransactional = true` ise tek `NpgsqlConnection` sahibi ve `NpgsqlTransaction` açar; `IsTransactional = false` ise hiç fiziksel bağlantı açmaz (bağlantı yaşam döngüsü EF Core'da). `(tip,schema)` başına DbContext üretir; commit/rollback ve event/outbox boru hattını yürütür. | +| `UnitOfWorkScope` | Kökü saran ambient katman. `accessor.Current`'ı set/restore eder; **sahibi** ise dispose'ta kökü (varsa bağlantıyı) kapatır. | | `IAetherDbContextProvider` | `ICurrentSchema.Name` + `manager.Current`'tan schema-bağlı context'i çözer. Repository ve store'lar bunu kullanır. | -| `SearchPathCommandInterceptor` | `SchemaSwitchingMode`'a göre davranır: `TransactionLocal` → komut öncesi `SET LOCAL`; `SessionSearchPath` → schema değişiminde `SET`, dispose'da `RESET`; `QualifiedNames` → model placeholder/raw `{{schema}}` token rewrite ve context-scope guard. | -| `SchemaScopeState` | `SearchPathCommandInterceptor` ile `CompositeUnitOfWork` arasında paylaşılan durum. `Current` (son uygulanan schema) ve `Cleanup` (dispose delegate'i) alanlarını taşır. | +| `QualifiedNamesCommandInterceptor` | `(schema, currentSchema)` ile kurulur. Model placeholder'ını (`__aether_schema__`) ve raw SQL'deki `{{schema}}` token'ını quoted bound schema ile yeniden yazar; `ICurrentSchema.Name` context'in bağlı olduğu schema ile uyuşmuyorsa hata fırlatır. `search_path` komutu üretmez, transaction gerektirmez. | ## Bir UoW'nin yaşam döngüsü -**TransactionLocal (IsTransactional = true):** +**Transactional (IsTransactional = true):** ```text Begin(RequiresNew) → scope ambient olur, BAĞLANTI HENÜZ AÇILMAZ (tek maliyet: nesne; boş UoW bedava) İlk GetDbContextAsync(flow_a) → NpgsqlConnection.Open + BeginTransaction (lazy, bir kez) - → configurator options + TransactionLocal interceptor + UseTransaction + → configurator BuildOptions (paylaşılan bağlantı) + + QualifiedNamesCommandInterceptor + UseTransaction → context cache'e konur; LocalEventEnqueuer bağlanır Change(flow_b)+GetDbContext → AYNI bağlantı/transaction; yeni schema-bağlı context -İş (Add/Update/Query) → her komut öncesi SET LOCAL search_path (aynı schema ise atlanır) +İş (Add/Update/Query) → placeholder / {{schema}} => "ilgili schema"; SET/RESET yok CommitAsync() → SaveChanges(tüm context) → event'ler outbox'a (tx içinde) → SaveChanges → TEK transaction.Commit → OnCompleted hook'ları DisposeAsync (sahip scope) → commit olmadıysa rollback → context/transaction/CONNECTION kapatılır ``` -**SessionSearchPath (IsTransactional = false):** - -```text -Begin(RequiresNew) → scope ambient olur, BAĞLANTI HENÜZ AÇILMAZ -İlk GetDbContextAsync(flow_a) → NpgsqlConnection.Open (transaction YOK) - → configurator options + SessionSearchPath interceptor - → SET search_path TO "flow_a", public (bir kez) - → context cache'e konur -Change(flow_b)+GetDbContext → AYNI bağlantı; yeni schema-bağlı context - → SET search_path TO "flow_b", public -İş (Query) → aynı schema ise SET atlanır (SchemaScopeState.Current takibi) -DisposeAsync (sahip scope) → RESET search_path (pool'a temiz session döner) - → context/CONNECTION kapatılır -``` - -**QualifiedNames (IsTransactional = true veya false):** +**Non-transactional (IsTransactional = false):** ```text -GetDbContextAsync(flow_a) → (DbContextType, flow_a) context'i; modelde sabit placeholder -EF / raw SQL komutu → placeholder / {{schema}} => "flow_a"; SET/RESET yok -Change(flow_b)+repository → (DbContextType, flow_b) context'i resolve edilir +Begin(RequiresNew) → scope ambient olur, BAĞLANTI HİÇ AÇILMAZ +GetDbContextAsync(flow_a) → configurator BuildOwnedOptions(schema) + → provider ApplyOwned: UseNpgsql(connectionString) + + QualifiedNamesCommandInterceptor + → context cache'e konur; bağlantı yaşam döngüsü EF Core'da +Change(flow_b)+GetDbContext → yeni schema-bağlı context; UoW yine bağlantı tutmaz +İş (Query) → EF Core her operasyon için pool'dan bağlantı alır, hemen iade eder Eski flow_a query'sini çalıştır→ DB erişiminden önce context/current-schema mismatch hatası +DisposeAsync (sahip scope) → context'ler kapatılır; kapatılacak bağlantı/transaction yok ``` -Bağlantı ilk context istendiğinde **lazy** açılır; **sahibi** olan scope dispose'unda kapanır -(bağlantı sızıntısını önleyen sahiplik kuralı). +Transactional akışta bağlantı ilk context istendiğinde **lazy** açılır ve **sahibi** olan scope +dispose'unda kapanır (bağlantı sızıntısını önleyen sahiplik kuralı). Non-transactional akışta +UoW hiçbir fiziksel bağlantı tutmaz — bu, okuma ağırlıklı işlerde bağlantı-pool baskısını azaltır. ## Ambient mekanizması — Begin vs Prepare vs BeginAsync @@ -383,21 +365,23 @@ yapılırsa çağırana geri sızmaz.** > `BeginAsync` sonrası null. Tüm programatik çağrılar (job, dispatcher, poller, aspect fallback) > `Begin`'e taşındı. -## Neden her komutta `SET LOCAL`? +## Neden `search_path` değil, qualified names? -`SET LOCAL search_path` **transaction** kapsamlıdır. Aynı transaction'ı paylaşan `flow_a` ve -`flow_b` context'leri olduğunda, context oluştururken bir kez set etmek yetmez: en son set eden -schema, sonraki **tüm** komutlara uygulanır → yanlış schema. Çözüm: -`SearchPathCommandInterceptor` her komuttan hemen önce ilgili schema'yı tekrar set eder -(tek bağlantıda komutlar sıralı çalıştığı için güvenli); `SearchPathState` aynı schema arka arkaya -gelirse SET'i atlar. +Eski `TransactionLocal` (`SET LOCAL search_path`) ve `SessionSearchPath` (session `SET +search_path` + dispose'da `RESET search_path`) yaklaşımları **bağlantı state'ine** dayanıyordu: +aynı bağlantıyı paylaşan `flow_a` ve `flow_b` context'lerinde en son set edilen schema sonraki +tüm komutlara uygulanmasın diye her komut öncesi yeniden set etmek, pool'a temiz session +dönebilsin diye dispose'da temizlik yapmak gerekiyordu. Bu modlar kaldırıldı. -> ⚠️ Tasarım dokümanındaki "bir kez SET LOCAL" yaklaşımı yanlıştı ve implementasyon sırasında -> testlerle yakalandı; per-komut interceptor + skip optimizasyonu ile düzeltildi. +Tek strateji artık **qualified names**: `QualifiedNamesCommandInterceptor` her komutta model +placeholder'ını ve raw `{{schema}}` token'ını, context'in bağlı olduğu quoted schema ile +yeniden yazar. Bağlantıya hiçbir schema state'i yazılmadığı için ne per-komut `SET` ne de +dispose temizliği vardır; transaction da gerekmez. -> ✅ **PgBouncer garantisi:** `SET LOCAL` transaction bitince otomatik silinir, session state'e -> sızmaz. `PgBouncerSearchPathTests` bunu kanıtlar: commit sonrası taze bağlantının -> `search_path`'inde UoW schema'sı görülmez. +> ✅ **Pooling garantisi:** `search_path` hiç değiştirilmediği için session state'e sızacak bir +> şey yoktur — PgBouncer transaction/session pooling ve native pool altında güvenlidir. +> `PgBouncerSearchPathTests` bunu kanıtlar: qualified names, session `search_path`'ini asla +> mutate etmez. ## Commit & domain event / outbox diff --git a/framework/docs/multi-schema/IMPLEMENTATION_NOTES.md b/framework/docs/multi-schema/IMPLEMENTATION_NOTES.md index f22f628..ae43ce1 100644 --- a/framework/docs/multi-schema/IMPLEMENTATION_NOTES.md +++ b/framework/docs/multi-schema/IMPLEMENTATION_NOTES.md @@ -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 "", public - (per command; skips via SchemaScopeState.Current) - SchemaSwitchingMode.SessionSearchPath: SET search_path TO "", 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 "", 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 @@ -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 + 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(connectionString); -// or explicitly: -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.TransactionLocal); - -// PostgreSQL — SessionSearchPath (non-transactional, native pool only) -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.SessionSearchPath); - -// PostgreSQL — QualifiedNames (transaction optional, no connection schema state) -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.QualifiedNames); // SQL Server (single-schema) // services.AddAetherSqlServer(connectionString); // Custom provider / advanced -// services.AddAetherDbContext(new NpgsqlAetherProvider(mode), connectionString, configure?); +// services.AddAetherDbContext(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` (`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()` — 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 diff --git a/framework/docs/multi-schema/README.md b/framework/docs/multi-schema/README.md index 55e2a76..639e217 100644 --- a/framework/docs/multi-schema/README.md +++ b/framework/docs/multi-schema/README.md @@ -78,11 +78,10 @@ The scope flows across `await` boundaries (`AsyncLocal`) and restores the previo dispose. Out-of-order disposal throws `InvalidOperationException` ("Schema scope corrupted"). > The obsolete `ICurrentSchema.Set()` / `IsResolved` API and -> `NpgsqlSchemaConnectionInterceptor` have been removed. There is no single SQL mechanism shared -> by every mode: `TransactionLocal` applies transaction-local state per command, -> `SessionSearchPath` deliberately uses session-level `SET` / `RESET search_path`, and -> `QualifiedNames` qualifies relations without changing `search_path`. See -> [Schema switching modes](#schema-switching-modes) for the mode-specific contract. +> `NpgsqlSchemaConnectionInterceptor` have been removed, as have the former `TransactionLocal` +> and `SessionSearchPath` switching modes. Schema targeting is always +> [qualified names](#schema-switching-qualified-names): relations are qualified in the SQL +> itself and `search_path` is never touched. ### Schema-name formatting and validation @@ -91,82 +90,45 @@ dispose. Out-of-order disposal throws `InvalidOperationException` ("Schema scope characters, ensures a leading letter/underscore, and trims to 63 chars). The *formatted* name is what `Name` returns and what ends up on the connection. -Before a name is interpolated into `SET LOCAL search_path`, it is validated and quoted by +Before a name is interpolated into SQL, it is validated and quoted by `PostgreSqlIdentifier.QuoteSchema(...)` (regex `^[a-zA-Z_][a-zA-Z0-9_]*$`). An invalid name throws `InvalidOperationException: Invalid PostgreSQL identifier: `. Schema names cannot be passed as SQL parameters, so this validate-then-quote step is the injection guard. -## Schema switching modes +## Schema switching: qualified names -Schema isolation strategy is configured explicitly via `SchemaSwitchingMode`. Pass it as the -second argument to `AddAetherNpgsql` (default `TransactionLocal`): +Schema targeting always uses `SchemaSwitchingMode.QualifiedNames` — the only member of the +enum (the former `TransactionLocal` and `SessionSearchPath` modes were removed together with +their `search_path` manipulation). The parameter on `AddAetherNpgsql` is optional and kept for +signature compatibility: ```csharp -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.TransactionLocal); -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.SessionSearchPath); -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.QualifiedNames); +services.AddAetherNpgsql(connectionString); ``` -| Mode | Command issued | Requires transaction | Pool topology | -|------|---------------|----------------------|---------------| -| `TransactionLocal` | `SET LOCAL search_path TO "", public` before every command | **Yes** (`IsTransactional = true`) | PgBouncer transaction pooling ✅, native pool ✅ | -| `SessionSearchPath` | `SET search_path TO "", public` once + `RESET search_path` at UoW dispose | No (`IsTransactional = false`) | Native Npgsql pool only ✅ | +| Strategy | Command issued | Requires transaction | Pool topology | +|----------|---------------|----------------------|---------------| | `QualifiedNames` | Rewrites EF model placeholders and explicit raw-SQL `{{schema}}` tokens to `""` | No | PgBouncer transaction/session pooling ✅, native pool ✅ | -**Choosing a mode:** - -- **`TransactionLocal` (default):** Use when `IsTransactional = true`. `SET LOCAL` is - transaction-scoped, so it never leaks to the pool even under PgBouncer transaction pooling. - This is the safe default for any pool topology. - -- **`SessionSearchPath`:** Use for non-transactional, read-heavy flows (e.g. query-only - services) with the **native Npgsql connection pool** (no PgBouncer). The `search_path` is - set once per UoW (skipped on subsequent commands to the same schema) and reset with - `RESET search_path` before the connection is returned to the pool, preventing leakage. - Round-trip overhead: 1 SET + N queries + 1 RESET — lower than opening a transaction. - **Do not use with PgBouncer transaction pooling** — the backend may switch between commands - and the session-level `SET` would apply to a different tenant's queries. - -- **`QualifiedNames`:** Use when queries must be schema-safe without connection state, including - non-transactional work behind PgBouncer transaction pooling. EF-generated relations are - qualified at command execution; no `SET`, `SET LOCAL`, or `RESET search_path` is emitted. +Because every command is self-describing, no connection-level state exists: queries are +schema-safe on any pooled connection, transactional or not, with or without PgBouncer. ## How schema isolation works -Within one Unit of Work, **all** schema-bound `DbContext` instances share **one** -`NpgsqlConnection` (see -[`CompositeUnitOfWork`](../../src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs)). -When `IsTransactional = true`, a single `NpgsqlTransaction` is also shared. Contexts are -created lazily and cached by `(DbContextType, Schema)`; the connection (and optionally -transaction) are opened on the first context request. - -Isolation comes from `SearchPathCommandInterceptor`, which runs before every EF command. -Behavior depends on the configured `SchemaSwitchingMode`: - -### `TransactionLocal` (default) - -Prepends `SET LOCAL search_path TO "", public` to every command. `SET LOCAL` is -**transaction-scoped** — it auto-reverts when the transaction ends, so it never leaks to the -pool. This is why `IsTransactional = true` is required: without an open transaction `SET LOCAL` -would be silently ignored, and the interceptor throws `InvalidOperationException` to guard -against it. - -If the same schema is active for consecutive commands, the redundant `SET LOCAL` is skipped -(tracked by `SchemaScopeState.Current`) — a cross-schema switch re-applies it. +Connection topology depends on the Unit of Work's transaction mode (see +[`CompositeUnitOfWork`](../../src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs)): -### `SessionSearchPath` +- **`IsTransactional = true`:** all schema-bound `DbContext` instances share **one** + `NpgsqlConnection` and **one** `NpgsqlTransaction`, opened on the first context request. + Cross-schema writes commit atomically. +- **`IsTransactional = false`:** the Unit of Work holds **no physical connection**. Contexts + are bound to the connection string and EF Core owns the connection lifecycle — a pooled + connection is rented per operation and returned immediately. This keeps connection-pool + pressure proportional to actual database work, not to request duration. -Issues `SET search_path TO "", public` (session-scoped) once per UoW, skipping -subsequent commands that target the same schema. At UoW dispose, `RESET search_path` is -executed on the shared connection before it is returned to the pool, preventing leakage. +Contexts are created lazily and cached by `(DbContextType, Schema)` in both shapes. -No transaction is required. Use with `IsTransactional = false` and the native Npgsql pool. - -The `SET` is always run as its own command (same connection) rather than concatenated onto -the command text, because concatenation would add an extra result set and break EF's -rows-affected accounting for INSERT/UPDATE batches. - -### `QualifiedNames` +Isolation comes from `QualifiedNamesCommandInterceptor`, which runs before every EF command: Schema-agnostic mappings receive one tenant-independent model placeholder. Immediately before execution the interceptor replaces that placeholder with the validated schema bound to the @@ -184,7 +146,8 @@ database access; resolve it again inside the new `Change(...)` scope. ## EF mappings are schema-agnostic -Map tables with **no schema argument** — schema is resolved at runtime by the selected mode: +Map tables with **no schema argument** — schema is resolved at runtime by qualified-names +rewriting: ```csharp protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -203,7 +166,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) **Why:** EF Core caches one compiled model per `DbContext` type. If the schema were part of the mapping, you would need a distinct model (and cache entry) per schema, and a context could only ever talk to one schema. Leaving tables unqualified means the same compiled model -serves every schema, and the selected mode supplies the schema at execution time. +serves every schema, and the interceptor supplies the schema at execution time. ## Usage @@ -211,7 +174,7 @@ Resolve the schema-bound context from the active Unit of Work via `IAetherDbContextProvider` (repositories do this internally). It reads `currentSchema.Name` and asks the active UoW to materialize the context bound to that schema. -### Transactional (`TransactionLocal` mode — default) +### Transactional (shared connection + transaction) ```csharp using (currentSchema.Change("flow_a")) @@ -232,39 +195,31 @@ await using (var uow = unitOfWorkManager.Begin( } ``` -### Non-transactional (`SessionSearchPath` mode) +### Non-transactional (EF Core-owned connections) -Register with `SchemaSwitchingMode.SessionSearchPath`, then use `IsTransactional = false`: +Use `IsTransactional = false` for read-heavy flows. The Unit of Work never opens a physical +connection; EF Core rents one from the pool per operation and returns it immediately: ```csharp -// Registration (Startup / Program.cs): -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.SessionSearchPath); - -// Usage: using (currentSchema.Change("flow_a")) await using (var uow = unitOfWorkManager.Begin( new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = false })) { - var db = await dbContextProvider.GetDbContextAsync(); // SET search_path TO flow_a, public - var items = await db.Set().ToListAsync(); // no SET repeated (same schema) + var db = await dbContextProvider.GetDbContextAsync(); // no connection opened yet + var items = await db.Set().ToListAsync(); // rents + returns a pooled connection // Switch schema within same UoW: using (currentSchema.Change("flow_b")) { - var dbB = await dbContextProvider.GetDbContextAsync(); // SET search_path TO flow_b, public - var others = await dbB.Set().ToListAsync(); + var dbB = await dbContextProvider.GetDbContextAsync(); + var others = await dbB.Set().ToListAsync(); // fully-qualified against flow_b } - // UoW dispose issues RESET search_path — pool gets clean session } ``` -### Qualified names and raw SQL +### Raw SQL and the `{{schema}}` token ```csharp -services.AddAetherNpgsql( - connectionString, - SchemaSwitchingMode.QualifiedNames); - await using var uow = uowManager.Begin(new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, @@ -294,8 +249,7 @@ Schema-dependent `FromSqlRaw` and `ExecuteSqlRaw` statements must put the exact token at every runtime relation reference. The token is replaced only in PostgreSQL SQL code; occurrences inside string literals (including escape strings), quoted identifiers, line or nested block comments, and dollar-quoted bodies are preserved. Parameters remain parameters. -Schema-independent SQL such as `SELECT 1` needs no token. Other switching modes reject a real -`{{schema}}` token and instead use their documented `search_path` contract. +Schema-independent SQL such as `SELECT 1` needs no token. If `currentSchema.Name` is null when a context is requested, the provider throws `InvalidOperationException: Current schema is not set.` @@ -325,28 +279,14 @@ app.MapControllers(); ## PgBouncer (transaction pooling) -Both `TransactionLocal` and `QualifiedNames` are safe under PgBouncer transaction pooling. -`QualifiedNames` does not depend on backend session state and can run without a transaction. - -`SET LOCAL` is transaction-scoped: when the transaction ends and the connection returns to the -pool, the `search_path` is gone — it never leaks into a connection later handed to another -request. This is asserted directly by -[`PgBouncerSearchPathTests`](../../test/BBT.Aether.Postgres.Tests/PgBouncerSearchPathTests.cs) -("SET LOCAL stayed inside the UoW transaction and never mutated session/pooled state"). - -`SessionSearchPath` mode issues a session-level `SET`, which **cannot** be used with -PgBouncer transaction pooling — the backend may switch between commands and the schema set by -one request would be visible to another. Use `SessionSearchPath` only with the native Npgsql -pool or a backend connection whose session is pinned for the whole UoW. +Qualified names are safe under PgBouncer transaction pooling: no command depends on backend +session state, so it does not matter which physical backend executes it, transactional or not. -Rules for `TransactionLocal` under PgBouncer transaction pooling: +General rules that keep any pool topology healthy: -1. **Always run inside an explicit transaction** (`IsTransactional = true`). The interceptor - throws `InvalidOperationException` if no transaction is open — use this to catch - misconfiguration early. -2. **Keep transactions short.** A connection is leased to the request only while the - transaction is open. -3. **No external service calls inside an open transaction** (HTTP, broker publishes, etc.). +1. **Keep transactions short.** A transactional Unit of Work leases one connection for its + whole lifetime; a non-transactional one leases none. +2. **No external service calls inside an open transaction** (HTTP, broker publishes, etc.). Do that work before opening or after committing the Unit of Work. ## SQL Server limitations diff --git a/framework/docs/unit-of-work/README.md b/framework/docs/unit-of-work/README.md index e64c1c0..3ab060e 100644 --- a/framework/docs/unit-of-work/README.md +++ b/framework/docs/unit-of-work/README.md @@ -158,36 +158,33 @@ scope cannot join a non-transactional root and fails with guidance to use `Requi ## Registration ```csharp -// PostgreSQL (BBT.Aether.Npgsql) — full multi-schema, TransactionLocal mode (default) +// PostgreSQL (BBT.Aether.Npgsql) — full multi-schema via qualified names (the only mode) services.AddAetherNpgsql(connectionString); -// PostgreSQL — non-transactional SessionSearchPath mode (native Npgsql pool, no PgBouncer) -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.SessionSearchPath); - -// PostgreSQL — qualified relations, no search_path state (transaction optional) -services.AddAetherNpgsql(connectionString, SchemaSwitchingMode.QualifiedNames); - // SQL Server (BBT.Aether.SqlServer) — single-schema services.AddAetherSqlServer(connectionString); ``` -`AddAetherNpgsql` accepts an optional `SchemaSwitchingMode` (second parameter, default -`TransactionLocal`). See [Multi-Schema Support](../multi-schema/README.md#schema-switching-modes) -for when to choose each mode. +Schema targeting on PostgreSQL always uses `SchemaSwitchingMode.QualifiedNames`: SQL is +rewritten to fully-qualified `"schema"."table"` names, so no connection-level `search_path` +state exists and any pool topology (including PgBouncer transaction pooling) is safe. The +former `TransactionLocal` and `SessionSearchPath` modes have been removed. See +[Multi-Schema Support](../multi-schema/README.md) for details. Both Npgsql/SqlServer overloads wrap the core overload, which selects the provider explicitly: ```csharp services.AddAetherDbContext( - new NpgsqlAetherProvider(SchemaSwitchingMode.TransactionLocal), // or SqlServerAetherProvider() + new NpgsqlAetherProvider(), // or SqlServerAetherProvider() connectionString, (sp, options) => { /* optional extra EF Core configuration */ }); ``` -The **connection string is captured** so the UoW can open the single shared connection it -hands contexts out from; the optional `configure` delegate -(`Action`) is captured (and re-applied with the -shared connection bound) for each schema-bound context. +The **connection string is captured** and the optional `configure` delegate +(`Action`) is captured and re-applied for each +schema-bound context. A transactional UoW opens one shared connection and hands contexts out +on it; a non-transactional UoW binds contexts to the connection string, so EF Core owns the +connection lifecycle (a pooled connection is rented per operation and returned immediately). `AddAetherDbContext` calls `AddAetherUnitOfWork()`, which registers the ambient accessor, `IUnitOfWorkManager`, the domain-event sink, and `IAetherDbContextProvider<>`. Call @@ -222,9 +219,9 @@ builder.Services.Configure(options => app.UseUnitOfWorkMiddleware(); ``` -> **Default:** `IsTransactional = true`, `Scope = Required`. The middleware default is -> explicitly transactional so that `TransactionLocal` schema switching (which requires an open -> transaction) works without further configuration. +> **Default:** `IsTransactional = true`, `Scope = Required`. Use `IsTransactional = false` +> for read-heavy endpoints: a non-transactional UoW holds no physical connection at all — +> EF Core rents a pooled connection per operation — which keeps connection-pool pressure low. ## Commit pipeline @@ -276,7 +273,6 @@ non-transactional business and outbox writes. | `No active UnitOfWork.` | A context is requested with no ambient UoW (common when using `BeginAsync` where the ambient does not propagate to the caller — use `Begin`). | | `UnitOfWork DbContext limit exceeded. Limit: N` | More than `MaxDbContextCount` distinct `(Type, Schema)` contexts in one UoW. | | `Invalid PostgreSQL identifier: X` | The active schema name fails PostgreSQL identifier validation before it enters command text. | -| `SchemaSwitchingMode.TransactionLocal requires a transaction, but none is active.` | `SchemaSwitchingMode.TransactionLocal` was used without an active transaction. Set `IsTransactional = true`, or switch to `SessionSearchPath` or `QualifiedNames` as appropriate for the pool mode. | | `A transactional Required UnitOfWork cannot join a non-transactional outer UnitOfWork.` | The inner operation requested a transaction that the existing root cannot acquire; use `RequiresNew`. | | `DbContext is bound to schema 'A', but current schema is 'B'.` | A QualifiedNames context/query crossed schema scopes; resolve it again in the current scope. | diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs index 70e481e..f66733a 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs @@ -16,12 +16,14 @@ namespace BBT.Aether.Uow; /// -/// Root unit of work backed by a single shared and, when -/// is , a single shared -/// . Hands out lazily-created schema-bound -/// instances keyed by (DbContextType, Schema). Each created context enlists via -/// UseTransactionAsync only when the shared transaction exists and is bound to its schema -/// by the configured database provider, so schema isolation is a provider concern. +/// Root unit of work handing out lazily-created schema-bound instances +/// keyed by (DbContextType, Schema). When is +/// , the root opens a single shared and +/// on first context creation and every context enlists via +/// UseTransactionAsync. When it is , the root never opens a +/// connection itself: contexts are bound to the connection string and EF Core owns the +/// connection lifecycle (a pooled connection is rented per operation and returned immediately). +/// Schema binding is a provider concern in both shapes. /// Domain events remain buffered until . Transactional roots preserve /// the outbox / direct-publish commit ordering; non-transactional roots dispatch during /// , without atomicity between auto-committed business writes and event @@ -88,10 +90,10 @@ public sealed class CompositeUnitOfWork( public IUnitOfWork? Outer { get; private set; } /// - /// Initializes the unit of work. Does NOT open the connection here — the connection is opened - /// lazily on the first call. A transaction is opened - /// at that point only when is - /// , so an empty unit of work costs nothing. + /// Initializes the unit of work. Does NOT open a connection here. A transactional root opens + /// its shared connection and transaction lazily on the first + /// call; a non-transactional root never opens one + /// (EF Core rents pooled connections per operation), so an empty unit of work costs nothing. /// public Task InitializeAsync(UnitOfWorkOptions options, CancellationToken cancellationToken = default) { @@ -141,9 +143,11 @@ public void Abort() } /// - /// Gets or creates the context bound to . The first context opens the - /// shared connection and, for a transactional root, the shared transaction. The context - /// enlists only when that transaction exists. + /// Gets or creates the context bound to . For a transactional root, + /// the first context opens the shared connection and the shared transaction, and every + /// context enlists on them. A non-transactional root never opens a connection itself: its + /// contexts are bound to the connection string, so EF Core rents a pooled connection per + /// operation and returns it immediately — the unit of work holds no physical connection. /// public async Task GetDbContextAsync(string schema, CancellationToken cancellationToken = default) where TDbContext : DbContext @@ -167,22 +171,28 @@ public async Task GetDbContextAsync(string schema, Cance var configurator = serviceProvider.GetRequiredService>(); - if (_connection is null) + DbContextOptions options; + if (_effectiveIsTransactional) { - _connection = configurator.CreateConnection(); - await _connection.OpenAsync(cancellationToken); + if (_connection is null) + { + _connection = configurator.CreateConnection(); + await _connection.OpenAsync(cancellationToken); - // Reset schema state whenever a fresh connection is established. - _schemaState.Current = null; + // Reset schema state whenever a fresh connection is established. + _schemaState.Current = null; - if (_effectiveIsTransactional) - { _transaction = await _connection.BeginTransactionAsync( _options.IsolationLevel ?? IsolationLevel.ReadCommitted, cancellationToken); } + + options = configurator.BuildOptions(_connection, schema, _schemaState); + } + else + { + options = configurator.BuildOwnedOptions(schema); } - var options = configurator.BuildOptions(_connection, schema, _schemaState); var context = ActivatorUtilities.CreateInstance(serviceProvider, options); if (_transaction is not null) @@ -572,8 +582,8 @@ public async ValueTask DisposeAsync() // the first failure after everything has been attempted. Exception? firstFailure = null; - // For SessionSearchPath mode the provider registers a cleanup that resets the session-level - // search_path before the connection is returned to the pool. + // A provider that wrote session-level state on the shared connection registers a cleanup + // to reset it before the connection is returned to the pool. if (_schemaState.Cleanup is not null && _schemaState.Current is not null && _connection is not null) { try diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/AetherDbContextConfigurator.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/AetherDbContextConfigurator.cs index 5b3aea5..d593c9a 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/AetherDbContextConfigurator.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/AetherDbContextConfigurator.cs @@ -28,4 +28,16 @@ public DbContextOptions BuildOptions(DbConnection sharedConnection, serviceProvider.GetRequiredService()); return builder.Options; } + + public DbContextOptions BuildOwnedOptions(string schema) + { + var builder = new DbContextOptionsBuilder(); + configure(serviceProvider, builder); + provider.ApplyOwned( + builder, + connectionString, + schema, + serviceProvider.GetRequiredService()); + return builder.Options; + } } diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDatabaseProvider.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDatabaseProvider.cs index bd8fc3f..0941057 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDatabaseProvider.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDatabaseProvider.cs @@ -23,5 +23,17 @@ void ApplyShared(DbContextOptionsBuilder builder, DbConnection sharedConnection, void ApplyShared(DbContextOptionsBuilder builder, DbConnection sharedConnection, string schema, SchemaScopeState state, ICurrentSchema currentSchema) => ApplyShared(builder, sharedConnection, schema, state); + + /// + /// Binds options to the connection string so EF Core creates, opens, and closes its own + /// pooled connection per operation — no caller-managed shared connection is involved. + /// Used by non-transactional units of work, where holding a physical connection for the + /// UnitOfWork's lifetime is unnecessary pool pressure. The default keeps existing providers + /// source-compatible; multi-schema providers should override to add their schema binding. + /// + void ApplyOwned(DbContextOptionsBuilder builder, string connectionString, + string schema, ICurrentSchema currentSchema) => + ApplyConnectionString(builder, connectionString); + void ApplyConnectionString(DbContextOptionsBuilder builder, string connectionString); } diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDbContextConfigurator.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDbContextConfigurator.cs index 9995ee8..925ee16 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDbContextConfigurator.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/EntityFrameworkCore/IAetherDbContextConfigurator.cs @@ -17,4 +17,12 @@ public interface IAetherDbContextConfigurator /// Builds options that use the given already-open shared connection, bound to the given schema. DbContextOptions BuildOptions(DbConnection sharedConnection, string schema, SchemaScopeState state); + + /// + /// Builds options bound to the given schema where EF Core owns the connection lifecycle: + /// the context opens a pooled connection per operation and returns it immediately, instead + /// of enlisting on a UnitOfWork-held shared connection. Used by non-transactional units of + /// work. + /// + DbContextOptions BuildOwnedOptions(string schema); } diff --git a/framework/src/BBT.Aether.Npgsql/BBT/Aether/Uow/EntityFrameworkCore/SchemaSwitchingMode.cs b/framework/src/BBT.Aether.Npgsql/BBT/Aether/Uow/EntityFrameworkCore/SchemaSwitchingMode.cs index 89bd9f6..6cdde2c 100644 --- a/framework/src/BBT.Aether.Npgsql/BBT/Aether/Uow/EntityFrameworkCore/SchemaSwitchingMode.cs +++ b/framework/src/BBT.Aether.Npgsql/BBT/Aether/Uow/EntityFrameworkCore/SchemaSwitchingMode.cs @@ -1,36 +1,18 @@ namespace BBT.Aether.Uow.EntityFrameworkCore; /// -/// Controls how a schema-bound Npgsql DbContext switches the active PostgreSQL search_path. -/// Choose based on your connection pool topology. +/// Controls how a schema-bound Npgsql DbContext targets the active PostgreSQL schema. +/// is the only supported strategy: the former +/// TransactionLocal and SessionSearchPath modes manipulated the connection's +/// search_path and therefore required the UnitOfWork to pin a single shared connection. +/// Qualified names keep every command self-describing, so connections can be pooled and +/// owned by EF Core (including PgBouncer transaction pooling). /// public enum SchemaSwitchingMode { - /// - /// Issues SET LOCAL search_path before each command. - /// The effect is automatically reverted at transaction end by PostgreSQL. - /// Requires IsTransactional = true. Works with any connection pool. - /// - TransactionLocal, - - /// - /// Issues a session-level SET search_path before the first command to a given schema, - /// then RESET search_path when the UnitOfWork is disposed (before the connection - /// is returned to the pool). - /// - /// Use with IsTransactional = false and Npgsql's native connection pool (direct or - /// session pooling). NOT safe with PgBouncer transaction pooling because the session-level - /// SET may not survive across PgBouncer backend switches. - /// - /// - SessionSearchPath, - /// /// Rewrites SQL to use fully-qualified "schema"."table" names. No search_path - /// manipulation is performed. - /// - /// Intended for IsTransactional = false behind PgBouncer transaction pooling. - /// + /// manipulation is performed, so commands are safe on any pooled connection. /// /// Schema-dependent raw SQL must use the explicit {{schema}} token; arbitrary SQL is /// not parsed or automatically qualified. diff --git a/framework/src/BBT.Aether.Npgsql/Microsoft/Extensions/DependencyInjection/AetherNpgsqlServiceCollectionExtensions.cs b/framework/src/BBT.Aether.Npgsql/Microsoft/Extensions/DependencyInjection/AetherNpgsqlServiceCollectionExtensions.cs index 91375bd..ee74d28 100644 --- a/framework/src/BBT.Aether.Npgsql/Microsoft/Extensions/DependencyInjection/AetherNpgsqlServiceCollectionExtensions.cs +++ b/framework/src/BBT.Aether.Npgsql/Microsoft/Extensions/DependencyInjection/AetherNpgsqlServiceCollectionExtensions.cs @@ -13,33 +13,31 @@ namespace Microsoft.Extensions.DependencyInjection; public static class AetherNpgsqlServiceCollectionExtensions { /// - /// Registers an Aether DbContext backed by PostgreSQL (Npgsql). + /// Registers an Aether DbContext backed by PostgreSQL (Npgsql). Schema targeting always uses + /// (fully-qualified "schema"."table" + /// SQL), which is safe on any pooled connection and lets non-transactional units of work + /// leave connection management entirely to EF Core. /// /// The service collection. /// PostgreSQL connection string. /// - /// Schema switching strategy. Default is - /// (requires IsTransactional = true). Use - /// for non-transactional UoWs with Npgsql's native connection pool. + /// Schema switching strategy. is the only + /// supported value; the parameter is kept for signature compatibility. /// /// Optional additional DbContext options. /// /// - /// // Transactional (default): /// services.AddAetherNpgsql<MyDbContext>(connectionString); - /// - /// // Non-transactional with direct/session pool: - /// services.AddAetherNpgsql<MyDbContext>(connectionString, SchemaSwitchingMode.SessionSearchPath); /// /// public static IServiceCollection AddAetherNpgsql( this IServiceCollection services, string connectionString, - SchemaSwitchingMode mode = SchemaSwitchingMode.TransactionLocal, + SchemaSwitchingMode mode = SchemaSwitchingMode.QualifiedNames, Action? configure = null) where TDbContext : AetherDbContext { - services.AddAetherDbContext(new NpgsqlAetherProvider(mode), connectionString, configure); + services.AddAetherDbContext(new NpgsqlAetherProvider(), connectionString, configure); if (typeof(IHasEfCoreOutbox).IsAssignableFrom(typeof(TDbContext))) services.AddScoped(typeof(IOutboxLeaseStore), diff --git a/framework/src/BBT.Aether.Npgsql/NpgsqlAetherProvider.cs b/framework/src/BBT.Aether.Npgsql/NpgsqlAetherProvider.cs index f93a185..700df0b 100644 --- a/framework/src/BBT.Aether.Npgsql/NpgsqlAetherProvider.cs +++ b/framework/src/BBT.Aether.Npgsql/NpgsqlAetherProvider.cs @@ -1,14 +1,18 @@ using System.Data.Common; -using System.Threading; -using System.Threading.Tasks; using BBT.Aether.MultiSchema; using Microsoft.EntityFrameworkCore; using Npgsql; namespace BBT.Aether.Uow.EntityFrameworkCore; -public sealed class NpgsqlAetherProvider( - SchemaSwitchingMode mode = SchemaSwitchingMode.TransactionLocal) : IAetherDatabaseProvider +/// +/// PostgreSQL provider for the multi-schema Unit of Work. Schema targeting always uses +/// : SQL is rewritten to fully-qualified +/// "schema"."table" names, so no connection-level state is required and contexts can +/// run either on the UnitOfWork's shared transactional connection or on EF Core-owned pooled +/// connections. +/// +public sealed class NpgsqlAetherProvider : IAetherDatabaseProvider { public DbConnection CreateConnection(string connectionString) => new NpgsqlConnection(connectionString); @@ -20,24 +24,23 @@ public void ApplyShared(DbContextOptionsBuilder builder, DbConnection sharedConn string schema, SchemaScopeState state, ICurrentSchema currentSchema) { builder.UseNpgsql(sharedConnection); - builder.AddInterceptors(new SearchPathCommandInterceptor(schema, state, mode, currentSchema)); - - if (mode == SchemaSwitchingMode.QualifiedNames) - builder.UseAetherQualifiedNamesModel(); + ApplySchemaBinding(builder, schema, currentSchema); + } - if (mode == SchemaSwitchingMode.SessionSearchPath) - { - // Register once per UoW (??= is idempotent across multiple DbContext creations). - // CompositeUnitOfWork calls this before disposing the connection. - state.Cleanup ??= static async (conn, ct) => - { - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "RESET search_path"; - await cmd.ExecuteNonQueryAsync(ct); - }; - } + public void ApplyOwned(DbContextOptionsBuilder builder, string connectionString, + string schema, ICurrentSchema currentSchema) + { + builder.UseNpgsql(connectionString); + ApplySchemaBinding(builder, schema, currentSchema); } public void ApplyConnectionString(DbContextOptionsBuilder builder, string connectionString) => builder.UseNpgsql(connectionString); + + private static void ApplySchemaBinding(DbContextOptionsBuilder builder, string schema, + ICurrentSchema currentSchema) + { + builder.AddInterceptors(new QualifiedNamesCommandInterceptor(schema, currentSchema)); + builder.UseAetherQualifiedNamesModel(); + } } diff --git a/framework/src/BBT.Aether.Npgsql/QualifiedNamesCommandInterceptor.cs b/framework/src/BBT.Aether.Npgsql/QualifiedNamesCommandInterceptor.cs new file mode 100644 index 0000000..c3ff119 --- /dev/null +++ b/framework/src/BBT.Aether.Npgsql/QualifiedNamesCommandInterceptor.cs @@ -0,0 +1,90 @@ +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using BBT.Aether.MultiSchema; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace BBT.Aether.Uow.EntityFrameworkCore; + +/// +/// Rewrites every command issued by a schema-bound +/// to use fully-qualified +/// "schema"."table" names: the model's exact schema placeholder and the raw SQL +/// {{schema}} token are both replaced with the context-bound quoted schema. +/// No search_path manipulation is performed, so commands are safe on any pooled +/// connection (including PgBouncer transaction pooling) and on EF Core-owned connections. +/// Throws if the current schema no longer matches the context's schema binding. +/// +/// Assumes query results are buffered (EF Core's default). When multiple schema-bound contexts +/// share one UnitOfWork connection, do not stream (AsAsyncEnumerable without +/// materializing) across interleaved contexts on the same connection. +/// +/// +public sealed class QualifiedNamesCommandInterceptor( + string schema, + ICurrentSchema currentSchema) : DbCommandInterceptor +{ + private readonly string _schema = schema; + private readonly string _quotedSchema = PostgreSqlIdentifier.QuoteSchema(schema); + + public override InterceptionResult ReaderExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + ApplyQualifiedNames(command); + return result; + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + ApplyQualifiedNames(command); + return ValueTask.FromResult(result); + } + + public override InterceptionResult NonQueryExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + ApplyQualifiedNames(command); + return result; + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + ApplyQualifiedNames(command); + return ValueTask.FromResult(result); + } + + public override InterceptionResult ScalarExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + ApplyQualifiedNames(command); + return result; + } + + public override ValueTask> ScalarExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + ApplyQualifiedNames(command); + return ValueTask.FromResult(result); + } + + private void ApplyQualifiedNames(DbCommand command) + { + if (!string.Equals(currentSchema.Name, _schema, StringComparison.Ordinal)) + throw new InvalidOperationException( + $"DbContext is bound to schema '{_schema}', but current schema is " + + $"'{currentSchema.Name ?? ""}'. Resolve the DbContext again inside the new schema scope."); + + var modelRewritten = PostgreSqlRawSchemaTokenRewriter + .RewriteModelPlaceholder(command.CommandText, _quotedSchema) + .CommandText; + command.CommandText = PostgreSqlRawSchemaTokenRewriter + .Rewrite(modelRewritten, _quotedSchema) + .CommandText; + } +} diff --git a/framework/src/BBT.Aether.Npgsql/SearchPathCommandInterceptor.cs b/framework/src/BBT.Aether.Npgsql/SearchPathCommandInterceptor.cs deleted file mode 100644 index fda4027..0000000 --- a/framework/src/BBT.Aether.Npgsql/SearchPathCommandInterceptor.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System; -using System.Data.Common; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using BBT.Aether.MultiSchema; -using Microsoft.EntityFrameworkCore.Diagnostics; - -namespace BBT.Aether.Uow.EntityFrameworkCore; - -/// -/// Sets the active PostgreSQL search_path before each command issued by a schema-bound -/// . Behaviour depends on -/// : -/// -/// -/// -/// -/// Issues SET LOCAL search_path inside the active transaction. -/// PostgreSQL reverts the effect at transaction end automatically. -/// Throws if the command has no transaction. -/// -/// -/// -/// -/// -/// Issues a session-level SET search_path when the schema changes. -/// The caller (UnitOfWork dispose) is responsible for running RESET search_path -/// before returning the connection to the pool via . -/// -/// -/// -/// -/// -/// Rewrites the model's exact schema placeholder to the context-bound qualified schema. -/// Throws if the current schema no longer matches that binding. -/// -/// -/// -/// -/// Assumes query results are buffered (EF Core's default). A single Npgsql connection does not -/// support multiple active result sets; do not stream (AsAsyncEnumerable without -/// materializing) across interleaved schema-bound contexts on the same connection. -/// -/// -public sealed class SearchPathCommandInterceptor( - string schema, - SchemaScopeState state, - SchemaSwitchingMode mode, - ICurrentSchema currentSchema) : DbCommandInterceptor -{ - private readonly string _schema = schema; - private readonly string _quotedSchema = PostgreSqlIdentifier.QuoteSchema(schema); - private readonly string _setLocal = - $"SET LOCAL search_path TO {PostgreSqlIdentifier.QuoteSchema(schema)}, public"; - private readonly string _setSession = - $"SET search_path TO {PostgreSqlIdentifier.QuoteSchema(schema)}, public"; - - public override InterceptionResult ReaderExecuting( - DbCommand command, CommandEventData eventData, InterceptionResult result) - { - ApplySearchPath(command); - return result; - } - - public override async ValueTask> ReaderExecutingAsync( - DbCommand command, CommandEventData eventData, InterceptionResult result, - CancellationToken cancellationToken = default) - { - await ApplySearchPathAsync(command, cancellationToken); - return result; - } - - public override InterceptionResult NonQueryExecuting( - DbCommand command, CommandEventData eventData, InterceptionResult result) - { - ApplySearchPath(command); - return result; - } - - public override async ValueTask> NonQueryExecutingAsync( - DbCommand command, CommandEventData eventData, InterceptionResult result, - CancellationToken cancellationToken = default) - { - await ApplySearchPathAsync(command, cancellationToken); - return result; - } - - public override InterceptionResult ScalarExecuting( - DbCommand command, CommandEventData eventData, InterceptionResult result) - { - ApplySearchPath(command); - return result; - } - - public override async ValueTask> ScalarExecutingAsync( - DbCommand command, CommandEventData eventData, InterceptionResult result, - CancellationToken cancellationToken = default) - { - await ApplySearchPathAsync(command, cancellationToken); - return result; - } - - private void ApplySearchPath(DbCommand command) - { - RejectRawSqlTokenOutsideQualifiedNames(command.CommandText); - - switch (mode) - { - case SchemaSwitchingMode.TransactionLocal: - if (command.Transaction is null) - { - throw new InvalidOperationException( - $"SchemaSwitchingMode.TransactionLocal requires a transaction, but none is active. " + - $"Use IsTransactional = true, or switch to SchemaSwitchingMode.SessionSearchPath " + - $"(direct/session pool) or SchemaSwitchingMode.QualifiedNames (PgBouncer transaction pool)."); - } - if (state.Current == _schema) return; - using (var cmd = command.Connection!.CreateCommand()) - { - cmd.Transaction = command.Transaction; - cmd.CommandText = _setLocal; - cmd.ExecuteNonQuery(); - } - state.Current = _schema; - break; - - case SchemaSwitchingMode.SessionSearchPath: - if (state.Current == _schema) return; - using (var cmd = command.Connection!.CreateCommand()) - { - cmd.CommandText = _setSession; - cmd.ExecuteNonQuery(); - } - state.Current = _schema; - break; - - case SchemaSwitchingMode.QualifiedNames: - ApplyQualifiedNames(command); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown SchemaSwitchingMode."); - } - } - - private async Task ApplySearchPathAsync(DbCommand command, CancellationToken cancellationToken) - { - RejectRawSqlTokenOutsideQualifiedNames(command.CommandText); - - switch (mode) - { - case SchemaSwitchingMode.TransactionLocal: - if (command.Transaction is null) - { - throw new InvalidOperationException( - $"SchemaSwitchingMode.TransactionLocal requires a transaction, but none is active. " + - $"Use IsTransactional = true, or switch to SchemaSwitchingMode.SessionSearchPath " + - $"(direct/session pool) or SchemaSwitchingMode.QualifiedNames (PgBouncer transaction pool)."); - } - if (state.Current == _schema) return; - await using (var cmd = command.Connection!.CreateCommand()) - { - cmd.Transaction = command.Transaction; - cmd.CommandText = _setLocal; - await cmd.ExecuteNonQueryAsync(cancellationToken); - } - state.Current = _schema; - break; - - case SchemaSwitchingMode.SessionSearchPath: - if (state.Current == _schema) return; - await using (var cmd = command.Connection!.CreateCommand()) - { - cmd.CommandText = _setSession; - await cmd.ExecuteNonQueryAsync(cancellationToken); - } - state.Current = _schema; - break; - - case SchemaSwitchingMode.QualifiedNames: - ApplyQualifiedNames(command); - break; - - default: - throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown SchemaSwitchingMode."); - } - } - - private void ApplyQualifiedNames(DbCommand command) - { - if (!string.Equals(currentSchema.Name, _schema, StringComparison.Ordinal)) - throw new InvalidOperationException( - $"DbContext is bound to schema '{_schema}', but current schema is " + - $"'{currentSchema.Name ?? ""}'. Resolve the DbContext again inside the new schema scope."); - - var modelRewritten = RewriteModelPlaceholder(command.CommandText); - command.CommandText = PostgreSqlRawSchemaTokenRewriter - .Rewrite(modelRewritten, _quotedSchema) - .CommandText; - } - - private string RewriteModelPlaceholder(string commandText) - => PostgreSqlRawSchemaTokenRewriter - .RewriteModelPlaceholder(commandText, _quotedSchema) - .CommandText; - - private void RejectRawSqlTokenOutsideQualifiedNames(string commandText) - { - if (mode != SchemaSwitchingMode.QualifiedNames && - PostgreSqlRawSchemaTokenRewriter.Rewrite(commandText, replacement: null).FoundToken) - throw new InvalidOperationException( - $"Raw SQL token '{AetherSchemaModel.RawSqlToken}' requires " + - $"SchemaSwitchingMode.QualifiedNames. In {mode} mode, omit the token and rely on " + - "the documented search_path contract."); - } -} diff --git a/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Uow/DomainEventFailureTests.cs b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Uow/DomainEventFailureTests.cs index 72fe813..baf7b57 100644 --- a/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Uow/DomainEventFailureTests.cs +++ b/framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Uow/DomainEventFailureTests.cs @@ -71,9 +71,14 @@ private sealed class InMemoryConfigurator : IAetherDbContextConfigurator BuildOptions( DbConnection sharedConnection, string schema, - SchemaScopeState state) => new DbContextOptionsBuilder() - .UseInMemoryDatabase($"domain-events-{Guid.NewGuid():N}") - .Options; + SchemaScopeState state) => BuildInMemoryOptions(); + + public DbContextOptions BuildOwnedOptions(string schema) => BuildInMemoryOptions(); + + private static DbContextOptions BuildInMemoryOptions() => + new DbContextOptionsBuilder() + .UseInMemoryDatabase($"domain-events-{Guid.NewGuid():N}") + .Options; } private sealed class StubDbConnection : DbConnection diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ArmingProcessorTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ArmingProcessorTests.cs index 31a12ab..e17ff20 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ArmingProcessorTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ArmingProcessorTests.cs @@ -286,7 +286,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/DaprBridgeTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/DaprBridgeTests.cs index ac26552..7e9a88f 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/DaprBridgeTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/DaprBridgeTests.cs @@ -110,7 +110,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EndToEndJobLifecycleTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EndToEndJobLifecycleTests.cs index 85de455..bd5022c 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EndToEndJobLifecycleTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EndToEndJobLifecycleTests.cs @@ -203,7 +203,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EnqueueAtomicityTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EnqueueAtomicityTests.cs index 9bb64ac..c6bbb3d 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EnqueueAtomicityTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/EnqueueAtomicityTests.cs @@ -111,7 +111,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobDispatcherTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobDispatcherTests.cs index 665d868..b5f990f 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobDispatcherTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobDispatcherTests.cs @@ -254,7 +254,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreCasTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreCasTests.cs index 58a93fb..1c6d3e9 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreCasTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreCasTests.cs @@ -81,7 +81,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreClaimReaperTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreClaimReaperTests.cs index 43eb8d1..f37b529 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreClaimReaperTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/JobStoreClaimReaperTests.cs @@ -67,7 +67,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ReaperTests.cs b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ReaperTests.cs index 0ec1e37..0418707 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ReaperTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/BackgroundJob/ReaperTests.cs @@ -122,7 +122,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/DbContextConfiguratorTests.cs b/framework/test/BBT.Aether.Postgres.Tests/DbContextConfiguratorTests.cs index 447265c..f3525c9 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/DbContextConfiguratorTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/DbContextConfiguratorTests.cs @@ -30,8 +30,8 @@ public async Task BuildOptions_binds_shared_connection_and_keeps_interceptors() await using var conn = new NpgsqlConnection(fx.ConnectionString); await conn.OpenAsync(); - // The search_path interceptor added by the provider requires the command to run inside a - // transaction, so start one on the shared connection and enlist the context on it. + // Mirror the transactional UoW shape: a transaction on the shared connection with the + // context enlisted on it. await using var tx = await conn.BeginTransactionAsync(); var options = configurator.BuildOptions(conn, "public", new SchemaScopeState()); @@ -41,7 +41,7 @@ public async Task BuildOptions_binds_shared_connection_and_keeps_interceptors() // The context uses the SHARED connection we opened. ctx.Database.GetDbConnection().ShouldBeSameAs(conn); - // A trivial command executes and the interceptor fired (a SET LOCAL search_path also runs). + // A trivial command executes and the interceptor fired. await ctx.Database.ExecuteSqlRawAsync("SELECT 1"); interceptor.Commands.ShouldBeGreaterThan(0); diff --git a/framework/test/BBT.Aether.Postgres.Tests/MultiSchemaUnitOfWorkTests.cs b/framework/test/BBT.Aether.Postgres.Tests/MultiSchemaUnitOfWorkTests.cs index 0bd1739..54d9f3d 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/MultiSchemaUnitOfWorkTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/MultiSchemaUnitOfWorkTests.cs @@ -124,10 +124,11 @@ public async Task Exceeds_max_context_limit_throws() } [Fact] - public async Task Schema_isolation_via_search_path() + public async Task Schema_isolation_via_qualified_names() { await ArrangeSchemasAsync(); var sp = BuildProvider(); + var currentSchema = sp.GetRequiredService(); var uow = new CompositeUnitOfWork(sp); await uow.InitializeAsync(new UnitOfWorkOptions { IsTransactional = true }); @@ -141,9 +142,11 @@ public async Task Schema_isolation_via_search_path() await uow.SaveChangesAsync(); - // Each context only sees its own schema's rows via SET LOCAL search_path. - (await a.Set().CountAsync()).ShouldBe(2); - (await b.Set().CountAsync()).ShouldBe(1); + // Each context only sees its own schema's rows via qualified relation names. + using (currentSchema.Change(_schemaA)) + (await a.Set().CountAsync()).ShouldBe(2); + using (currentSchema.Change(_schemaB)) + (await b.Set().CountAsync()).ShouldBe(1); await uow.CommitAsync(); await uow.DisposeAsync(); @@ -152,10 +155,11 @@ public async Task Schema_isolation_via_search_path() [Fact] public async Task Same_schema_reads_stay_correct_after_cross_schema_switch() { - // Proves the SET-skip optimization does not break correctness: consecutive same-schema - // reads, and a flow_a read after touching flow_b, all resolve to the right schema. + // Consecutive same-schema reads, and a flow_a read after touching flow_b, all resolve + // to the right schema because every command carries its qualified relation name. await ArrangeSchemasAsync(); var sp = BuildProvider(); + var currentSchema = sp.GetRequiredService(); var uow = new CompositeUnitOfWork(sp); await uow.InitializeAsync(new UnitOfWorkOptions { IsTransactional = true }); @@ -169,17 +173,25 @@ public async Task Same_schema_reads_stay_correct_after_cross_schema_switch() await uow.SaveChangesAsync(); - // Two consecutive same-schema reads on flow_a (second one exercises the skip path). - (await a.Set().CountAsync()).ShouldBe(2); - (await a.Set().CountAsync()).ShouldBe(2); + // Two consecutive same-schema reads on flow_a. + using (currentSchema.Change(_schemaA)) + { + (await a.Set().CountAsync()).ShouldBe(2); + (await a.Set().CountAsync()).ShouldBe(2); + } - // Cross-schema switch to flow_b, then back to flow_a must re-apply flow_a's search_path. - (await b.Set().CountAsync()).ShouldBe(1); - (await a.Set().CountAsync()).ShouldBe(2); + // Cross-schema switch to flow_b, then back to flow_a must stay correct. + using (currentSchema.Change(_schemaB)) + (await b.Set().CountAsync()).ShouldBe(1); + using (currentSchema.Change(_schemaA)) + (await a.Set().CountAsync()).ShouldBe(2); // And consecutive flow_b reads after the switch back also stay correct. - (await b.Set().CountAsync()).ShouldBe(1); - (await b.Set().CountAsync()).ShouldBe(1); + using (currentSchema.Change(_schemaB)) + { + (await b.Set().CountAsync()).ShouldBe(1); + (await b.Set().CountAsync()).ShouldBe(1); + } await uow.CommitAsync(); await uow.DisposeAsync(); @@ -234,18 +246,21 @@ public async Task Failed_handlers_fire_at_most_once_across_rollback_and_dispose( await ArrangeSchemasAsync(); var sp = BuildProvider(); + var currentSchema = sp.GetRequiredService(); var failedHandlerCalls = 0; var uow = new CompositeUnitOfWork(sp); await uow.InitializeAsync(new UnitOfWorkOptions { IsTransactional = true }); uow.OnFailed((_, _) => { failedHandlerCalls++; return Task.CompletedTask; }); - // Force the commit to throw at SaveChanges: seed a row via raw SQL (same transaction; search_path - // resolves the unqualified table to this context's schema), then add an EF row with the same PK so - // the commit-time INSERT hits a unique violation. (Adding two tracked entities with the same key - // would instead throw at tracking time, before commit.) + // Force the commit to throw at SaveChanges: seed a row via raw SQL (same transaction; the + // {{schema}} token qualifies the table to this context's schema), then add an EF row with the + // same PK so the commit-time INSERT hits a unique violation. (Adding two tracked entities with + // the same key would instead throw at tracking time, before commit.) var a = await uow.GetDbContextAsync(_schemaA); var dupId = Guid.NewGuid(); - await a.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO things (\"Id\", \"Name\") VALUES ({dupId}, 'seed')"); + using (currentSchema.Change(_schemaA)) + await a.Database.ExecuteSqlInterpolatedAsync( + $"INSERT INTO {{schema}}.things (\"Id\", \"Name\") VALUES ({dupId}, 'seed')"); a.Set().Add(new Thing { Id = dupId, Name = "dup" }); await Should.ThrowAsync(async () => await uow.CommitAsync()); // sets _exception, rethrows diff --git a/framework/test/BBT.Aether.Postgres.Tests/NonTransactionalOutboxDispatchTests.cs b/framework/test/BBT.Aether.Postgres.Tests/NonTransactionalOutboxDispatchTests.cs index cba3a53..2e0c4a9 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/NonTransactionalOutboxDispatchTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/NonTransactionalOutboxDispatchTests.cs @@ -145,9 +145,7 @@ private IServiceProvider BuildProvider(bool failSecondOutboxStage = false) var services = new ServiceCollection(); services.AddAetherCore(_ => { }); - // Session search-path mode so a non-transactional UoW is usable (TransactionLocal, the - // default, requires a transaction). - services.AddAetherNpgsql(fx.ConnectionString, SchemaSwitchingMode.SessionSearchPath); + services.AddAetherNpgsql(fx.ConnectionString); services.AddAetherDomainEvents(); services.AddAetherOutbox(options => options.Schema = _schema); @@ -176,7 +174,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/NpgsqlLeaseStoreTests.cs b/framework/test/BBT.Aether.Postgres.Tests/NpgsqlLeaseStoreTests.cs index 08d91d4..82fa083 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/NpgsqlLeaseStoreTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/NpgsqlLeaseStoreTests.cs @@ -35,7 +35,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } private IServiceProvider BuildProvider( - SchemaSwitchingMode mode = SchemaSwitchingMode.TransactionLocal) + SchemaSwitchingMode mode = SchemaSwitchingMode.QualifiedNames) { var services = new ServiceCollection(); services.AddAetherCore(_ => { }); @@ -207,8 +207,6 @@ public async Task LeaseBatch_does_not_pick_up_dead_letter_messages() } [Theory] - [InlineData(SchemaSwitchingMode.TransactionLocal)] - [InlineData(SchemaSwitchingMode.SessionSearchPath)] [InlineData(SchemaSwitchingMode.QualifiedNames)] public async Task LeaseBatch_uses_qualified_relation_without_transaction( SchemaSwitchingMode mode) diff --git a/framework/test/BBT.Aether.Postgres.Tests/OutboxWithinSharedTransactionTests.cs b/framework/test/BBT.Aether.Postgres.Tests/OutboxWithinSharedTransactionTests.cs index 4769c04..3c79205 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/OutboxWithinSharedTransactionTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/OutboxWithinSharedTransactionTests.cs @@ -65,7 +65,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); modelBuilder.Entity(e => { - e.ToTable("orders"); // NO schema - resolved at runtime via SET LOCAL search_path + e.ToTable("orders"); // NO schema - rewritten at runtime to the qualified schema name e.HasKey(o => o.Id); e.Property(o => o.Customer).IsRequired(); }); @@ -127,9 +127,8 @@ private IServiceProvider BuildProvider() /// /// Creates the schema, then creates the `orders` and `OutboxMessages` tables using EF Core's own - /// GenerateCreateScript() (so the DDL matches the entity shapes exactly). The script is executed - /// against a setup connection whose search_path points at the test schema, so the unqualified - /// CREATE TABLE statements land in the right schema. + /// GenerateCreateScript() (so the DDL matches the entity shapes exactly), with the model's + /// schema placeholder rewritten to the test schema. /// private async Task ArrangeSchemaAsync(IServiceProvider sp) { @@ -148,7 +147,10 @@ private async Task ArrangeSchemaAsync(IServiceProvider sp) await modelConn.OpenAsync(); await using var ctx = ActivatorUtilities.CreateInstance( sp, configurator.BuildOptions(modelConn, _schema, new BBT.Aether.Uow.EntityFrameworkCore.SchemaScopeState())); - var script = ctx.Database.GenerateCreateScript(); + var script = ctx.Database.GenerateCreateScript() + .Replace("\"__aether_schema__\"", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace("__aether_schema__", $"\"{_schema}\"", StringComparison.Ordinal) + .Replace($"CREATE SCHEMA \"{_schema}\";", $"CREATE SCHEMA IF NOT EXISTS \"{_schema}\";", StringComparison.Ordinal); await using var ddlConn = new NpgsqlConnection(fx.ConnectionString); await ddlConn.OpenAsync(); diff --git a/framework/test/BBT.Aether.Postgres.Tests/PgBouncerSearchPathTests.cs b/framework/test/BBT.Aether.Postgres.Tests/PgBouncerSearchPathTests.cs index 4858a9e..a48b79b 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/PgBouncerSearchPathTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/PgBouncerSearchPathTests.cs @@ -14,11 +14,10 @@ namespace BBT.Aether.Postgres.Tests; /// -/// Proves the core PgBouncer-safety guarantee: the UnitOfWork applies the per-command search_path -/// via SET LOCAL, which is transaction-scoped. When the transaction ends and the connection -/// is returned to the pool, that search_path must NOT leak into a connection later handed to another -/// request. The assertion is about the ABSENCE of the leak regardless of whether Npgsql hands back -/// the same physical connection. +/// Proves the core PgBouncer-safety guarantee: qualified-names schema targeting never touches +/// the connection's search_path, so no schema state can leak into a connection later +/// handed to another request. The assertion is about the ABSENCE of the leak regardless of +/// whether Npgsql hands back the same physical connection. /// [Collection("postgres")] public sealed class PgBouncerSearchPathTests(PostgresFixture fx) @@ -58,7 +57,7 @@ private IServiceProvider BuildProvider() { var services = new ServiceCollection(); services.AddSingleton(); - services.AddSingleton(new StaticCurrentSchema()); + services.AddSingleton(new StaticCurrentSchema(_schema)); services.AddSingleton>(sp => new AetherDbContextConfigurator( fx.ConnectionString, @@ -69,20 +68,20 @@ private IServiceProvider BuildProvider() } [Fact] - public async Task Set_local_does_not_leak_to_a_fresh_connection() + public async Task Schema_state_does_not_leak_to_a_fresh_connection() { await ArrangeSchemaAsync(); var sp = BuildProvider(); - // Run a full UoW that materializes a context for the test schema and executes a command, - // so SET LOCAL search_path actually runs inside the transaction. + // Run a full UoW that materializes a context for the test schema and executes commands, + // so the qualified-names rewrite actually runs inside the transaction. var uow = new CompositeUnitOfWork(sp); await uow.InitializeAsync(new UnitOfWorkOptions { IsTransactional = true }); var ctx = await uow.GetDbContextAsync(_schema); ctx.Set().Add(new Thing { Id = Guid.NewGuid(), Name = "x" }); await uow.SaveChangesAsync(); - (await ctx.Set().CountAsync()).ShouldBe(1); // forces a read -> SET LOCAL applied + (await ctx.Set().CountAsync()).ShouldBe(1); // forces a read through the interceptor await uow.CommitAsync(); await uow.DisposeAsync(); // returns the connection to the pool @@ -95,7 +94,7 @@ public async Task Set_local_does_not_leak_to_a_fresh_connection() cmd.CommandText = "SHOW search_path"; var searchPath = (string)(await cmd.ExecuteScalarAsync())!; - // The SET LOCAL stayed inside the UoW transaction and never mutated session/pooled state. + // Qualified names never mutate session/pooled search_path state. searchPath.ShouldNotContain(_schema); } } diff --git a/framework/test/BBT.Aether.Postgres.Tests/QualifiedNamesTests.cs b/framework/test/BBT.Aether.Postgres.Tests/QualifiedNamesTests.cs index 032c736..0ab1ef8 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/QualifiedNamesTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/QualifiedNamesTests.cs @@ -108,10 +108,8 @@ public async Task Same_repository_switches_tenant_a_to_tenant_b_and_back() public void Quoted_placeholder_rewrite_does_not_rewrite_schema_contents() { const string schema = "tenant___aether_schema___archive"; - var interceptor = new SearchPathCommandInterceptor( + var interceptor = new QualifiedNamesCommandInterceptor( schema, - new SchemaScopeState(), - SchemaSwitchingMode.QualifiedNames, new StaticCurrentSchema(schema)); using var command = new NpgsqlCommand( "SELECT * FROM \"__aether_schema__\".\"things\""); @@ -126,9 +124,8 @@ public void Quoted_placeholder_rewrite_does_not_rewrite_schema_contents() public void Model_placeholders_are_rewritten_only_in_SQL_code_regions() { const string schema = "tenant"; - var interceptor = new SearchPathCommandInterceptor( - schema, new SchemaScopeState(), SchemaSwitchingMode.QualifiedNames, - new StaticCurrentSchema(schema)); + var interceptor = new QualifiedNamesCommandInterceptor( + schema, new StaticCurrentSchema(schema)); using var command = new NpgsqlCommand( """ SELECT '__aether_schema__', '"__aether_schema__"', E'__aether_schema__ \\' @@ -158,10 +155,8 @@ JOIN __aether_schema__."things" AS other ON TRUE public void Raw_SQL_tokens_are_rewritten_only_in_SQL_code_regions() { const string schema = "tenant"; - var interceptor = new SearchPathCommandInterceptor( + var interceptor = new QualifiedNamesCommandInterceptor( schema, - new SchemaScopeState(), - SchemaSwitchingMode.QualifiedNames, new StaticCurrentSchema(schema)); using var command = new NpgsqlCommand( """ @@ -194,10 +189,8 @@ public void Raw_SQL_tokens_are_rewritten_only_in_SQL_code_regions() public void Raw_SQL_token_after_ordinary_string_ending_in_backslash_is_rewritten() { const string schema = "tenant"; - var interceptor = new SearchPathCommandInterceptor( + var interceptor = new QualifiedNamesCommandInterceptor( schema, - new SchemaScopeState(), - SchemaSwitchingMode.QualifiedNames, new StaticCurrentSchema(schema)); using var command = new NpgsqlCommand( """ @@ -216,10 +209,8 @@ public void Raw_SQL_token_after_ordinary_string_ending_in_backslash_is_rewritten public void Raw_SQL_tokens_in_escape_string_with_escaped_quote_and_backslash_are_protected() { const string schema = "tenant"; - var interceptor = new SearchPathCommandInterceptor( + var interceptor = new QualifiedNamesCommandInterceptor( schema, - new SchemaScopeState(), - SchemaSwitchingMode.QualifiedNames, new StaticCurrentSchema(schema)); using var command = new NpgsqlCommand( """ @@ -238,27 +229,6 @@ SELECT E'escaped quote \'{schema} and backslash \\{{schema}}' """); } - [Fact] - public void Raw_SQL_tokens_in_protected_regions_are_not_rejected_outside_qualified_names_mode() - { - const string schema = "tenant"; - var state = new SchemaScopeState { Current = schema }; - var interceptor = new SearchPathCommandInterceptor( - schema, - state, - SchemaSwitchingMode.SessionSearchPath, - new StaticCurrentSchema(schema)); - using var command = new NpgsqlCommand( - """ - SELECT '{schema}', "{{schema}}" - -- {schema} - /* outer {{schema}} /* nested {schema} */ */ - , $$ SELECT '{schema}' $$, $tag$ {{schema}} $tag$ - """); - - Should.NotThrow(() => interceptor.ReaderExecuting(command, null!, default)); - } - [Fact] public async Task Raw_SQL_token_supports_queries_updates_repeated_tokens_and_parameters() { @@ -333,35 +303,14 @@ public void Raw_SQL_invalid_runtime_schema_is_rejected_before_database_access(st { var commandCount = _commands.CommandTexts.Count; - var exception = Should.Throw(() => new SearchPathCommandInterceptor( + var exception = Should.Throw(() => new QualifiedNamesCommandInterceptor( schema, - new SchemaScopeState(), - SchemaSwitchingMode.QualifiedNames, new StaticCurrentSchema(schema))); (exception is ArgumentException or InvalidOperationException).ShouldBeTrue(); _commands.CommandTexts.Count.ShouldBe(commandCount); } - [Theory] - [InlineData(SchemaSwitchingMode.TransactionLocal)] - [InlineData(SchemaSwitchingMode.SessionSearchPath)] - public void Raw_SQL_token_is_rejected_outside_qualified_names_mode(SchemaSwitchingMode mode) - { - const string schema = "tenant"; - var interceptor = new SearchPathCommandInterceptor( - schema, - new SchemaScopeState(), - mode, - new StaticCurrentSchema(schema)); - using var command = new NpgsqlCommand("SELECT * FROM {{schema}}.\"things\""); - - var exception = Should.Throw(() => - interceptor.ReaderExecuting(command, null!, default)); - - exception.Message.ShouldContain("QualifiedNames"); - } - private sealed class Thing : AggregateRoot { private Thing() { } diff --git a/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkDisposalTests.cs b/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkDisposalTests.cs index e6edf05..20825e7 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkDisposalTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkDisposalTests.cs @@ -49,7 +49,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); modelBuilder.Entity(e => { - e.ToTable("things"); // NO schema - resolved at runtime via SET LOCAL search_path + e.ToTable("things"); // NO schema - rewritten at runtime to the qualified schema name e.HasKey(t => t.Id); e.Property(t => t.Name).IsRequired(); }); @@ -69,14 +69,6 @@ private IServiceProvider BuildProvider() return services.BuildServiceProvider(); } - private IServiceProvider BuildSessionSearchPathProvider() - { - var services = new ServiceCollection(); - services.AddAetherCore(_ => { }); - services.AddAetherNpgsql(fx.ConnectionString, SchemaSwitchingMode.SessionSearchPath); - return services.BuildServiceProvider(); - } - private async Task ArrangeSchemaAsync() { await using var conn = new NpgsqlConnection(fx.ConnectionString); @@ -184,10 +176,10 @@ public async Task Required_nested_scope_commit_and_dispose_do_not_complete_root_ } [Fact] - public async Task SessionSearchPath_opens_connection_without_transaction() + public async Task NonTransactional_context_leaves_connection_management_to_ef_core() { await ArrangeSchemaAsync(); - var sp = BuildSessionSearchPathProvider(); + var sp = BuildProvider(); await using var scope = sp.CreateAsyncScope(); var ssp = scope.ServiceProvider; @@ -202,9 +194,14 @@ public async Task SessionSearchPath_opens_connection_without_transaction() var db = await provider.GetDbContextAsync(); - db.Database.GetDbConnection().State.ShouldBe(ConnectionState.Open); + // Requesting the context must NOT open a physical connection: EF Core rents one + // per operation and returns it to the pool immediately afterwards. + db.Database.GetDbConnection().State.ShouldBe(ConnectionState.Closed); db.Database.CurrentTransaction.ShouldBeNull(); + (await db.Set().CountAsync()).ShouldBe(0); + db.Database.GetDbConnection().State.ShouldBe(ConnectionState.Closed); + await uow.CommitAsync(); } } @@ -240,10 +237,10 @@ public async Task Connection_uses_transaction_mode_captured_at_begin_after_input } [Fact] - public async Task SessionSearchPath_two_queries_share_same_connection() + public async Task NonTransactional_same_schema_reuses_the_same_context() { await ArrangeSchemaAsync(); - var sp = BuildSessionSearchPathProvider(); + var sp = BuildProvider(); await using var scope = sp.CreateAsyncScope(); var ssp = scope.ServiceProvider; @@ -267,7 +264,7 @@ public async Task SessionSearchPath_two_queries_share_same_connection() } [Fact] - public async Task TransactionLocal_still_opens_transaction() + public async Task Transactional_opens_shared_connection_and_transaction() { await ArrangeSchemaAsync(); var sp = BuildProvider(); @@ -293,32 +290,7 @@ public async Task TransactionLocal_still_opens_transaction() } [Fact] - public async Task TransactionLocal_throws_when_used_without_transaction() - { - await ArrangeSchemaAsync(); - var sp = BuildProvider(); - - await using var scope = sp.CreateAsyncScope(); - var ssp = scope.ServiceProvider; - var currentSchema = ssp.GetRequiredService(); - var mgr = ssp.GetRequiredService(); - var provider = ssp.GetRequiredService>(); - - using (currentSchema.Change(_schema)) - { - await using var uow = mgr.Begin( - new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = false }); - - await Should.ThrowAsync(async () => - { - var db = await provider.GetDbContextAsync(); - await db.Set().CountAsync(); - }); - } - } - - [Fact] - public async Task SessionSearchPath_search_path_reset_prevents_pool_leakage() + public async Task Schema_does_not_leak_across_units_of_work() { var schemaA = "leak_a_" + Guid.NewGuid().ToString("N"); var schemaB = "leak_b_" + Guid.NewGuid().ToString("N"); @@ -338,7 +310,7 @@ public async Task SessionSearchPath_search_path_reset_prevents_pool_leakage() await cmd.ExecuteNonQueryAsync(); } - var sp = BuildSessionSearchPathProvider(); + var sp = BuildProvider(); await using var scope = sp.CreateAsyncScope(); var ssp = scope.ServiceProvider; var currentSchema = ssp.GetRequiredService(); @@ -361,7 +333,7 @@ public async Task SessionSearchPath_search_path_reset_prevents_pool_leakage() new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = false }); var dbB = await provider.GetDbContextAsync(); (await dbB.Set().CountAsync()) - .ShouldBe(0, "search_path from the previous UoW must not leak into this one"); + .ShouldBe(0, "schema binding from the previous UoW must not leak into this one"); await uowB.CommitAsync(); } } diff --git a/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkMiddlewareTests.cs b/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkMiddlewareTests.cs index 35d8134..b5f7e7c 100644 --- a/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkMiddlewareTests.cs +++ b/framework/test/BBT.Aether.Postgres.Tests/UnitOfWorkMiddlewareTests.cs @@ -42,7 +42,7 @@ private IUnitOfWorkManager BuildManager() services.AddSingleton>(sp => new AetherDbContextConfigurator( fx.ConnectionString, - new NpgsqlAetherProvider(SchemaSwitchingMode.SessionSearchPath), + new NpgsqlAetherProvider(), configure: (_, _) => { }, serviceProvider: sp)); var sp = services.BuildServiceProvider(); From 576e69d107402faf714f155b528766bdb439c991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tayfun=20Y=C4=B1lmaz?= Date: Wed, 22 Jul 2026 23:36:12 +0300 Subject: [PATCH 2/2] Maxlimit removed --- .../BBT/Aether/Uow/CompositeUnitOfWork.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs index f66733a..f73a3df 100644 --- a/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs +++ b/framework/src/BBT.Aether.Infrastructure/BBT/Aether/Uow/CompositeUnitOfWork.cs @@ -163,11 +163,11 @@ public async Task GetDbContextAsync(string schema, Cance return (TDbContext)existing; } - if (_contexts.Count >= _options.MaxDbContextCount) - { - throw new InvalidOperationException( - $"UnitOfWork DbContext limit exceeded. Limit: {_options.MaxDbContextCount}"); - } + // if (_contexts.Count >= _options.MaxDbContextCount) + // { + // throw new InvalidOperationException( + // $"UnitOfWork DbContext limit exceeded. Limit: {_options.MaxDbContextCount}"); + // } var configurator = serviceProvider.GetRequiredService>();