diff --git a/AGENTS.md b/AGENTS.md index 199138e7..5a5f7446 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ Rules that are easy to violate and cause real breakage or wrong choices: - **No AutoMapper** — do not introduce AutoMapper. All mapping is explicit via `Mapper<>` (application layer) or `BiDirectionMapper<>` (infrastructure layer). - **`.ConfigureAwait(false)`** — always use it in service and repository code. - **File-scoped namespaces** — `namespace Foo.Bar;` only; never block-scoped `namespace Foo.Bar { }`. +- **Query materialization across data-access packages** — a new data-access package (e.g. `CoreEx.Cosmos`, a future `CoreEx.MongoDb`) should not materialize queries via `IQueryable` extension methods named with a bare, generic-sounding verb (`ToListAsync`, `ToItemsResultAsync`, `ToMappedItemsAsync`, etc.) if another CoreEx package (or a third-party one, e.g. `Microsoft.EntityFrameworkCore`) already defines an identically-shaped one on the same receiver type. C# extension-method resolution has no precedence rule between two equally-applicable candidates from different namespaces — it is a hard `CS0121` ambiguous-call compile error in any file that imports both namespaces, not merely a style clash. The preferred fix is a package-owned query-wrapper type (e.g. `CoreEx.Cosmos`'s `CosmosDbQuery`, returned from `CosmosDbContainer.Query(...)` instead of a bare `IQueryable`) so materializers are instance methods on that type — a different receiver type structurally cannot collide, so plain names (`ToListAsync`, `ToItemsResultAsync`, ...) are safe there; see [`CoreEx.Cosmos/AGENTS.md`](./src/CoreEx.Cosmos/AGENTS.md#do-not) for the worked example. Only fall back to prefixing with the provider name (`ToCosmosListAsync`, `ToCosmosItemsResultAsync`, ...) if a package genuinely must expose `IQueryable` extensions directly instead of owning a wrapper type. --- diff --git a/CoreEx.Core.Test.Sequential.slnf b/CoreEx.Core.Test.Sequential.slnf index 56e10999..9fcee972 100644 --- a/CoreEx.Core.Test.Sequential.slnf +++ b/CoreEx.Core.Test.Sequential.slnf @@ -4,6 +4,7 @@ "projects": [ "tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj", "tests/CoreEx.Caching.Redis.Test.Unit/CoreEx.Caching.Redis.Test.Unit.csproj", + "tests/CoreEx.Cosmos.Test.Unit/CoreEx.Cosmos.Test.Unit.csproj", "tests/CoreEx.Database.SqlServer.Test.Unit/CoreEx.Database.SqlServer.Test.Unit.csproj", "tests/CoreEx.Database.Postgres.Test.Unit/CoreEx.Database.Postgres.Test.Unit.csproj" ] diff --git a/CoreEx.Core.slnf b/CoreEx.Core.slnf index 110eed53..2e6b7f01 100644 --- a/CoreEx.Core.slnf +++ b/CoreEx.Core.slnf @@ -7,6 +7,7 @@ "src/CoreEx.AspNetCore.NSwag/CoreEx.AspNetCore.NSwag.csproj", "src/CoreEx.Azure.Messaging.ServiceBus/CoreEx.Azure.Messaging.ServiceBus.csproj", "src/CoreEx.Caching.FusionCache/CoreEx.Caching.FusionCache.csproj", + "src/CoreEx.Cosmos/CoreEx.Cosmos.csproj", "src/CoreEx.Data/CoreEx.Data.csproj", "src/CoreEx.Data.GraphQL/CoreEx.Data.GraphQL.csproj", "src/CoreEx.Database/CoreEx.Database.csproj", @@ -23,6 +24,7 @@ "tests/CoreEx.AspNetCore.Test.Unit/CoreEx.AspNetCore.Test.Unit.csproj", "tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreEx.Azure.Messaging.ServiceBus.Test.Unit.csproj", "tests/CoreEx.Caching.Redis.Test.Unit/CoreEx.Caching.Redis.Test.Unit.csproj", + "tests/CoreEx.Cosmos.Test.Unit/CoreEx.Cosmos.Test.Unit.csproj", "tests/CoreEx.Data.Test.Unit/CoreEx.Data.Test.Unit.csproj", "tests/CoreEx.Data.GraphQL.Test.Unit/CoreEx.Data.GraphQL.Test.Unit.csproj", "tests/CoreEx.Database.Postgres.Test.Unit/CoreEx.Database.Postgres.Test.Unit.csproj", diff --git a/CoreEx.Samples.Build.slnf b/CoreEx.Samples.Build.slnf index 63dda375..e8a141bf 100644 --- a/CoreEx.Samples.Build.slnf +++ b/CoreEx.Samples.Build.slnf @@ -3,6 +3,11 @@ "path": "CoreEx.slnx", "projects": [ "samples/aspire/Contoso.Aspire/Contoso.Aspire.csproj", + "samples/src/Contoso.Customers.Api/Contoso.Customers.Api.csproj", + "samples/src/Contoso.Customers.Application/Contoso.Customers.Application.csproj", + "samples/src/Contoso.Customers.CodeGen/Contoso.Customers.CodeGen.csproj", + "samples/src/Contoso.Customers.Contracts/Contoso.Customers.Contracts.csproj", + "samples/src/Contoso.Customers.Infrastructure/Contoso.Customers.Infrastructure.csproj", "samples/src/Contoso.Order.Workflow.Client/Contoso.Order.Workflow.Client.csproj", "samples/src/Contoso.Order.Workflow.Worker/Contoso.Order.Workflow.Worker.csproj", "samples/src/Contoso.Order.Workflow.Workflow/Contoso.Order.Workflow.Workflow.csproj", @@ -28,6 +33,9 @@ "samples/src/Contoso.Shopping.Infrastructure/Contoso.Shopping.Infrastructure.csproj", "samples/src/Contoso.Shopping.Relay/Contoso.Shopping.Relay.csproj", "samples/src/Contoso.Shopping.Subscribe/Contoso.Shopping.Subscribe.csproj", + "samples/tests/Contoso.Customers.Test.Api/Contoso.Customers.Test.Api.csproj", + "samples/tests/Contoso.Customers.Test.Common/Contoso.Customers.Test.Common.csproj", + "samples/tests/Contoso.Customers.Test.Unit/Contoso.Customers.Test.Unit.csproj", "samples/tests/Contoso.E2E.Runner/Contoso.E2E.Runner.csproj", "samples/tests/Contoso.Orders.Test.Api/Contoso.Orders.Test.Api.csproj", "samples/tests/Contoso.Orders.Test.Common/Contoso.Orders.Test.Common.csproj", diff --git a/CoreEx.Samples.Test.slnf b/CoreEx.Samples.Test.slnf index 80eb6bd0..31a2cc0d 100644 --- a/CoreEx.Samples.Test.slnf +++ b/CoreEx.Samples.Test.slnf @@ -2,6 +2,8 @@ "solution": { "path": "CoreEx.slnx", "projects": [ + "samples/tests/Contoso.Customers.Test.Api/Contoso.Customers.Test.Api.csproj", + "samples/tests/Contoso.Customers.Test.Unit/Contoso.Customers.Test.Unit.csproj", "samples/tests/Contoso.Orders.Test.Api/Contoso.Orders.Test.Api.csproj", "samples/tests/Contoso.Orders.Test.Unit/Contoso.Orders.Test.Unit.csproj", "samples/tests/Contoso.Products.Test.Api/Contoso.Products.Test.Api.csproj", diff --git a/CoreEx.sln b/CoreEx.sln index af282529..5ae51acd 100644 --- a/CoreEx.sln +++ b/CoreEx.sln @@ -1,4 +1,5 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.8.11904.113 MinimumVisualStudioVersion = 10.0.40219.1 @@ -250,6 +251,36 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Data.GraphQL.Test.Un EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Shopping.Test.Relay", "samples\tests\Contoso.Shopping.Test.Relay\Contoso.Shopping.Test.Relay.csproj", "{F0782889-6889-4DF1-B025-06A92C0C4E11}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Cosmos", "src\CoreEx.Cosmos\CoreEx.Cosmos.csproj", "{4F62010F-145E-4E2D-92A2-C220E060F661}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoreEx.Cosmos.Test.Unit", "tests\CoreEx.Cosmos.Test.Unit\CoreEx.Cosmos.Test.Unit.csproj", "{423C6BF3-EE92-433B-920B-F5F063168F60}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Customers", "Customers", "{B3AB6B39-2911-9174-0680-BA3663BCEA26}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3DE0C8B7-B7FF-A272-13CB-2C0B8C361F5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Contracts", "samples\src\Contoso.Customers.Contracts\Contoso.Customers.Contracts.csproj", "{A75B8986-41D4-40CB-B32B-BC61C6DC8265}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Application", "samples\src\Contoso.Customers.Application\Contoso.Customers.Application.csproj", "{BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Infrastructure", "samples\src\Contoso.Customers.Infrastructure\Contoso.Customers.Infrastructure.csproj", "{E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{0EBCF41E-FE1D-C1B6-6D1C-8EB39D4921FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.CodeGen", "samples\src\Contoso.Customers.CodeGen\Contoso.Customers.CodeGen.csproj", "{AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{36A24C9E-2496-A023-30B1-0BC8D8B24E54}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Test.Unit", "samples\tests\Contoso.Customers.Test.Unit\Contoso.Customers.Test.Unit.csproj", "{5A056D15-2E67-4689-976F-FFA184B0CF68}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hosts", "hosts", "{6AE52727-2385-B6B6-F414-0FC24AF94819}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Api", "samples\src\Contoso.Customers.Api\Contoso.Customers.Api.csproj", "{1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Test.Common", "samples\tests\Contoso.Customers.Test.Common\Contoso.Customers.Test.Common.csproj", "{2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Contoso.Customers.Test.Api", "samples\tests\Contoso.Customers.Test.Api\Contoso.Customers.Test.Api.csproj", "{B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1184,6 +1215,126 @@ Global {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x64.Build.0 = Release|Any CPU {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x86.ActiveCfg = Release|Any CPU {F0782889-6889-4DF1-B025-06A92C0C4E11}.Release|x86.Build.0 = Release|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Debug|x64.ActiveCfg = Debug|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Debug|x64.Build.0 = Debug|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Debug|x86.ActiveCfg = Debug|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Debug|x86.Build.0 = Debug|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Release|Any CPU.Build.0 = Release|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Release|x64.ActiveCfg = Release|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Release|x64.Build.0 = Release|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Release|x86.ActiveCfg = Release|Any CPU + {4F62010F-145E-4E2D-92A2-C220E060F661}.Release|x86.Build.0 = Release|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Debug|x64.ActiveCfg = Debug|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Debug|x64.Build.0 = Debug|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Debug|x86.ActiveCfg = Debug|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Debug|x86.Build.0 = Debug|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Release|Any CPU.Build.0 = Release|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Release|x64.ActiveCfg = Release|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Release|x64.Build.0 = Release|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Release|x86.ActiveCfg = Release|Any CPU + {423C6BF3-EE92-433B-920B-F5F063168F60}.Release|x86.Build.0 = Release|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Debug|x64.ActiveCfg = Debug|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Debug|x64.Build.0 = Debug|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Debug|x86.ActiveCfg = Debug|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Debug|x86.Build.0 = Debug|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Release|Any CPU.Build.0 = Release|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Release|x64.ActiveCfg = Release|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Release|x64.Build.0 = Release|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Release|x86.ActiveCfg = Release|Any CPU + {A75B8986-41D4-40CB-B32B-BC61C6DC8265}.Release|x86.Build.0 = Release|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Debug|x64.Build.0 = Debug|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Debug|x86.Build.0 = Debug|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Release|Any CPU.Build.0 = Release|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Release|x64.ActiveCfg = Release|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Release|x64.Build.0 = Release|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Release|x86.ActiveCfg = Release|Any CPU + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2}.Release|x86.Build.0 = Release|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Debug|x64.Build.0 = Debug|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Debug|x86.Build.0 = Debug|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Release|Any CPU.Build.0 = Release|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Release|x64.ActiveCfg = Release|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Release|x64.Build.0 = Release|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Release|x86.ActiveCfg = Release|Any CPU + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266}.Release|x86.Build.0 = Release|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Debug|x64.ActiveCfg = Debug|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Debug|x64.Build.0 = Debug|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Debug|x86.Build.0 = Debug|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Release|Any CPU.Build.0 = Release|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Release|x64.ActiveCfg = Release|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Release|x64.Build.0 = Release|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Release|x86.ActiveCfg = Release|Any CPU + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2}.Release|x86.Build.0 = Release|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Debug|x64.Build.0 = Debug|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Debug|x86.Build.0 = Debug|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Release|Any CPU.Build.0 = Release|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Release|x64.ActiveCfg = Release|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Release|x64.Build.0 = Release|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Release|x86.ActiveCfg = Release|Any CPU + {5A056D15-2E67-4689-976F-FFA184B0CF68}.Release|x86.Build.0 = Release|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Debug|x64.ActiveCfg = Debug|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Debug|x64.Build.0 = Debug|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Debug|x86.ActiveCfg = Debug|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Debug|x86.Build.0 = Debug|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Release|Any CPU.Build.0 = Release|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Release|x64.ActiveCfg = Release|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Release|x64.Build.0 = Release|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Release|x86.ActiveCfg = Release|Any CPU + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03}.Release|x86.Build.0 = Release|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Debug|x64.ActiveCfg = Debug|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Debug|x64.Build.0 = Debug|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Debug|x86.ActiveCfg = Debug|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Debug|x86.Build.0 = Debug|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Release|Any CPU.Build.0 = Release|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Release|x64.ActiveCfg = Release|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Release|x64.Build.0 = Release|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Release|x86.ActiveCfg = Release|Any CPU + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F}.Release|x86.Build.0 = Release|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Debug|x64.Build.0 = Debug|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Debug|x86.Build.0 = Debug|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Release|Any CPU.Build.0 = Release|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Release|x64.ActiveCfg = Release|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Release|x64.Build.0 = Release|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Release|x86.ActiveCfg = Release|Any CPU + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1243,7 +1394,6 @@ Global {175C2EF4-9EBF-1E78-03BD-A1B569B79218} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} {2F4DF929-C4B2-4A11-E2A7-13E4EC355269} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} {60D956AA-3030-FB48-423C-F561A8157E30} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} - {F0782889-6889-4DF1-B025-06A92C0C4E11} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} = {A8732A47-07D4-8D47-C5B6-F97BD3E38958} {88D83B9E-144B-54B9-421D-13C133018F24} = {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} {D1281655-C259-3F9B-6488-B2410D6DF57F} = {FE53E0A4-0616-B5A4-61FB-B03BB5DC12C1} @@ -1283,5 +1433,21 @@ Global {46503D23-225E-1D7F-C8D5-60320D711B10} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {D1079BA3-A768-49D1-8628-1370681932FF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {831702B9-80AE-47D9-A0D5-012E9241A298} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {F0782889-6889-4DF1-B025-06A92C0C4E11} = {1EDEE56C-4E5B-5153-E988-38698733D7A4} + {4F62010F-145E-4E2D-92A2-C220E060F661} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {423C6BF3-EE92-433B-920B-F5F063168F60} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {B3AB6B39-2911-9174-0680-BA3663BCEA26} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {3DE0C8B7-B7FF-A272-13CB-2C0B8C361F5F} = {B3AB6B39-2911-9174-0680-BA3663BCEA26} + {A75B8986-41D4-40CB-B32B-BC61C6DC8265} = {3DE0C8B7-B7FF-A272-13CB-2C0B8C361F5F} + {BFFE06CA-3A08-4FF3-97B1-433DF407DBA2} = {3DE0C8B7-B7FF-A272-13CB-2C0B8C361F5F} + {E3CC4EC9-E1B5-43FC-ACAE-3860BD4E3266} = {3DE0C8B7-B7FF-A272-13CB-2C0B8C361F5F} + {0EBCF41E-FE1D-C1B6-6D1C-8EB39D4921FC} = {B3AB6B39-2911-9174-0680-BA3663BCEA26} + {AF3856DD-B11F-402E-9AD3-D0846EF2EFB2} = {0EBCF41E-FE1D-C1B6-6D1C-8EB39D4921FC} + {36A24C9E-2496-A023-30B1-0BC8D8B24E54} = {B3AB6B39-2911-9174-0680-BA3663BCEA26} + {5A056D15-2E67-4689-976F-FFA184B0CF68} = {36A24C9E-2496-A023-30B1-0BC8D8B24E54} + {6AE52727-2385-B6B6-F414-0FC24AF94819} = {B3AB6B39-2911-9174-0680-BA3663BCEA26} + {1EC2BA33-781A-49D7-BCD1-CCFFBE9F2E03} = {6AE52727-2385-B6B6-F414-0FC24AF94819} + {2197E3FB-BE43-495E-BDF6-0B9CDBDD356F} = {36A24C9E-2496-A023-30B1-0BC8D8B24E54} + {B9CFAE96-BF7B-4E5F-B59A-671C847FCDAA} = {36A24C9E-2496-A023-30B1-0BC8D8B24E54} EndGlobalSection EndGlobal diff --git a/CoreEx.slnx b/CoreEx.slnx index 081c709a..15b570cc 100644 --- a/CoreEx.slnx +++ b/CoreEx.slnx @@ -15,6 +15,23 @@ + + + + + + + + + + + + + + + + + @@ -124,6 +141,7 @@ + @@ -139,10 +157,12 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 353275d1..e2dd24c5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,11 +7,13 @@ + + @@ -75,8 +77,8 @@ - - + + diff --git a/docker-compose.yml b/docker-compose.yml index f88a7114..a4397688 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,4 +49,24 @@ services: DTS_TASK_HUB_NAMES: "default,order" ports: - "8080:8080" - - "8082:8082" \ No newline at end of file + - "8082:8082" + + # Used by CoreEx.Cosmos.Test.Unit. Note: under rootless Podman this image can be flaky with the default bridge + # network (its TLS certificate is issued for the IP(s) it discovers at startup, which may not match the + # container's mapped/published address) - if `podman compose up -d cosmos-emulator` doesn't come up cleanly, + # try re-running with `--privileged` or host networking. Clients also need to accept the emulator's + # self-signed certificate (e.g. CosmosClientOptions with a permissive ServerCertificateCustomValidationCallback) + # rather than relying on the emulator's own IP-override/cert-export workarounds. + cosmos-emulator: + image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest + environment: + # Caps the TOTAL number of containers the emulator can host across the whole account (not "partitions per container") - each container the test suite creates consumes one. The test suite currently + # creates 24 distinct containers (see `grep -rn "ContainerId = \"\|const string containerId = \"" tests/CoreEx.Cosmos.Test.Unit`); when this cap is hit, container creation fails with a generic, + # misleading "high demand"/503 ServiceUnavailable that looks like transient load but is actually deterministic - confirmed by reading the container list directly and seeing exactly this many + # already exist. Kept with real headroom above the current count for future test additions, rather than raised just enough to limp past today's number. + AZURE_COSMOS_EMULATOR_PARTITION_COUNT: "35" + AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: "false" + AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE: "127.0.0.1" + ports: + - "8081:8081" + - "10251-10254:10251-10254" \ No newline at end of file diff --git a/samples/docs/hosts-layer.md b/samples/docs/hosts-layer.md index 380b4b68..70e74ad6 100644 --- a/samples/docs/hosts-layer.md +++ b/samples/docs/hosts-layer.md @@ -197,6 +197,15 @@ app.MapHostedServices(); // Exposes pause/resume management endpoints. > The `Program.cs` for the Outbox Relay is intentionally minimal — no controllers, no OpenAPI document, no application-layer services. Its sole concern is shuttling committed outbox records to the broker reliably. +### Distributed tracing: why the relay's own span is not the originating trace's parent/child + +A relayed event's outgoing message keeps the **original producer's** W3C `traceparent`/`tracestate` untouched — `IEventFormatter.AddTracing` is idempotent and skips an event that already carries trace context, so a Subscriber's span correlates directly back to the request that raised the event (e.g. an API `PUT`), never to the relay. This is intentional, not a gap: a single relay poll can pull a batch of events raised by many causally-unrelated originating traces, so there is no one valid "parent" for the relay's own batch-level span — reparenting it into a single trace only when a batch happens to contain one trace would make the relay's visibility a runtime accident (present for batch-of-one in dev, silently gone for real multi-event batches in production). + +Instead, every relay (SQL Server/Postgres via `DatabaseOutboxRelayBase`, and Cosmos DB via `CosmosDbOutboxRelayProcessor`) emits **two** complementary signals per batch, both from [`CloudEventTracingExtensions`](../../src/CoreEx.Events/CloudEventTracingExtensions.cs): + +- **`LinkTraceContext`** — adds one [`ActivityLink`](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-instrumentation-walkthroughs#activity-and-activitylink) per distinct originating trace onto the relay's own batch-level span — the correct W3C/OTel mechanism for a many-to-one, causally-related-but-not-nested fan-in operation. +- **`EmitRelayMarkers`** — in addition, starts and immediately ends a small `outbox.relay.publish` marker `Activity` **per relayed event**, parented directly to that event's own originating `ActivityContext` (via `ActivitySource.StartActivity(name, kind, parentContext)`, not the relay's ambient activity) and tagged with `outbox.destination`/`outbox.event.id`/`outbox.event.type`. Each marker also carries a back-link to the batch-level relay span. This is what makes the relay hop deterministically visible from *within* the originating trace regardless of batch size — the piece that was previously missing from a PUT's own trace view. Markers are emitted on the dedicated `CoreEx.Events.Outbox.Relay` `ActivitySource`, registered via `WithCoreExEventsSources()` - named to sit alongside the batch-span sources `CoreEx.Database.Outbox.Relay` and `CoreEx.Cosmos.Outbox.Relay` as the `*.Outbox.Relay` family. + > **See also**: [`PostgresOutboxRelay`](../../src/CoreEx.Database.Postgres/PostgresOutboxRelay.cs) · [`SqlServerOutboxRelay`](../../src/CoreEx.Database.SqlServer/SqlServerOutboxRelay.cs) · [Transactional Outbox pattern](https://learn.microsoft.com/en-us/azure/architecture/best-practices/transactional-outbox-cosmos) · [`MapHostedServices`](../../src/CoreEx.AspNetCore/WebApis/WebApiServiceCollectionExtensions.cs) --- @@ -284,3 +293,25 @@ app.MapHostedServices(); // Exposes pause/resume management endpoints. `AddSubscribersUsing()` scans the assembly containing `T` and auto-registers every class decorated with `[Subscribe]`, so adding a new subscriber requires only creating the class — no `Program.cs` edits are needed. > **See also**: [`SubscribedBase`](../../src/CoreEx.Events/Subscribing/SubscribedBase.cs) · [`SubscribedBase`](../../src/CoreEx.Events/Subscribing/SubscribedBase.cs) · [`ErrorHandler`](../../src/CoreEx.Events/Subscribing/ErrorHandler.cs) · [`AddSubscribedManager`](../../src/CoreEx.Azure.Messaging.ServiceBus) · [Competing Consumers pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/competing-consumers) + +### Filtering Azure SDK background-polling telemetry noise + +The Azure SDK raises several of its own background/infrastructure activities that carry no business signal and are pure volume in a trace view: + +- `ServiceBusReceiver.RenewMessageLock`/`ServiceBusSessionReceiver.RenewSessionLock` — for session-enabled subscriptions (as above, via `WithSessionReceiver`), and for any long-running message + processing under the standard auto-lock-renewal window, these fire on a timer for as long as a lock is held — one pair per lock-renewal interval, for every concurrently held lock. +- `ServiceBusReceiver.Receive` — a `CLIENT`-kind span the `ServiceBusProcessor`/`ServiceBusSessionProcessor` background pump creates on *every* underlying receive poll, whether or not a message + comes back. It is not correlated to any specific message's trace context (that correlation is carried separately by `ServiceBusProcessor.ProcessMessage`/ + `ServiceBusSessionProcessor.ProcessSessionMessage`, which remain visible), so it typically shows up as several detail-less, single-span traces per delivered message. + +`WithCoreExServiceBusTelemetry()` (called from `Program.cs` wherever CoreEx OpenTelemetry tracing is configured, e.g. `builder.WithCoreExTelemetry().WithCoreExServiceBusTelemetry()`) drops all +three activities **by default** using a custom `Sampler`, so they never reach an exporter (Aspire dashboard, OTLP, etc.), while leaving every other activity's sampling behaviour untouched. Pass +`includeBackgroundPollingTelemetry: true` to restore them — useful when actively diagnosing lock-expiry/session-timeout behaviour, or receive-call latency/batch-size: + +```csharp +builder.WithCoreExTelemetry() + .WithCoreExServiceBusTelemetry(includeBackgroundPollingTelemetry: true) // Opt back in only while diagnosing lock-expiry/session-timeout or receive-poll behaviour. + .UseOtlpExporter(); +``` + +> **See also**: [`CoreExServiceBusExtensions.WithCoreExServiceBusTelemetry`](../../src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs) · [OpenTelemetry `Sampler`](https://learn.microsoft.com/en-us/dotnet/api/opentelemetry.trace.sampler) diff --git a/samples/src/Contoso.Customers.Api/Contoso.Customers.Api.csproj b/samples/src/Contoso.Customers.Api/Contoso.Customers.Api.csproj new file mode 100644 index 00000000..9dcd1a7f --- /dev/null +++ b/samples/src/Contoso.Customers.Api/Contoso.Customers.Api.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/samples/src/Contoso.Customers.Api/Controllers/CustomerController.cs b/samples/src/Contoso.Customers.Api/Controllers/CustomerController.cs new file mode 100644 index 00000000..df79d979 --- /dev/null +++ b/samples/src/Contoso.Customers.Api/Controllers/CustomerController.cs @@ -0,0 +1,39 @@ +namespace Contoso.Customers.Api.Controllers; + +[ApiController, Route("/api/customers"), OpenApiTag("Customers")] +public class CustomerController(WebApi webApi, ICustomerService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly ICustomerService _service = service.ThrowIfNull(); + + [HttpPost] + [Accepts] + [ProducesResponseType(201)] + [IdempotencyKey] + public Task PostAsync(CancellationToken cancellationToken = default) => _webApi.PostAsync(Request, (ro, ct) => + { + ro.WithLocationUri(c => new Uri($"/api/customers/{c.Id}", UriKind.Relative)); + return _service.CreateAsync(ro.Value, ct); + }, cancellationToken: cancellationToken); + + [HttpPut("{id}")] + [Accepts] + [ProducesResponseType(typeof(Customer), 200)] + [ProducesNotFoundProblem()] + public Task PutAsync(string id, CancellationToken cancellationToken = default) => _webApi.PutAsync(Request, (ro, ct) + => _service.UpdateAsync(ro.Value.Adjust(c => c.Id = id.Required()), ct), cancellationToken: cancellationToken); + + [HttpPatch("{id}")] + [Accepts(HttpNames.MergePatchJsonMediaTypeName)] + [ProducesResponseType(typeof(Customer), 200)] + [ProducesNotFoundProblem()] + public Task PatchAsync(string id, CancellationToken cancellationToken = default) => _webApi.PatchAsync(Request, + get: (ro, ct) => _service.GetAsync(id.Required(), ct), + put: (ro, ct) => _service.UpdateAsync(ro.Value.Adjust(c => c.Id = id.Required()), ct), + cancellationToken: cancellationToken); + + [HttpDelete("{id}")] + [ProducesResponseType(204)] + public Task DeleteAsync(string id, CancellationToken cancellationToken = default) => _webApi.DeleteAsync(Request, (_, ct) + => _service.DeleteAsync(id.Required(), ct), cancellationToken: cancellationToken); +} diff --git a/samples/src/Contoso.Customers.Api/Controllers/CustomerReadController.cs b/samples/src/Contoso.Customers.Api/Controllers/CustomerReadController.cs new file mode 100644 index 00000000..f0709dfe --- /dev/null +++ b/samples/src/Contoso.Customers.Api/Controllers/CustomerReadController.cs @@ -0,0 +1,22 @@ +namespace Contoso.Customers.Api.Controllers; + +[ApiController, Route("/api/customers"), OpenApiTag("Customers")] +public class CustomerReadController(WebApi webApi, ICustomerReadService service) : ControllerBase +{ + private readonly WebApi _webApi = webApi.ThrowIfNull(); + private readonly ICustomerReadService _service = service.ThrowIfNull(); + + [HttpGet("{id}"), HttpHead("{id}")] + [ProducesResponseType(typeof(Customer), 200)] + [ProducesNotFoundProblem()] + public Task GetAsync(string id, CancellationToken cancellationToken = default) => _webApi.GetAsync(Request, (_, ct) => _service.GetAsync(id.Required(), ct), cancellationToken: cancellationToken); + + [HttpGet] + [ProducesResponseType(typeof(CustomerLite[]), 200)] + [Query(supportsOrderBy: true), Paging(supportsCount: true)] + public Task QueryAsync(CancellationToken cancellationToken = default) => _webApi.GetAsync(Request, (ro, ct) => _service.QueryAsync(ro.QueryArgs, ro.PagingArgs, ct), cancellationToken: cancellationToken); + + [HttpGet("$query")] + [ProducesResponseType(typeof(JsonElement), 200)] + public Task QuerySchemaAsync(CancellationToken cancellationToken = default) => _webApi.GetAsync(Request, (ro, ct) => _service.QuerySchemaAsync(ct), cancellationToken: cancellationToken); +} diff --git a/samples/src/Contoso.Customers.Api/Controllers/ReferenceDataController.g.cs b/samples/src/Contoso.Customers.Api/Controllers/ReferenceDataController.g.cs new file mode 100644 index 00000000..818d28db --- /dev/null +++ b/samples/src/Contoso.Customers.Api/Controllers/ReferenceDataController.g.cs @@ -0,0 +1,35 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Api.Controllers; + +/// Represents the reference-data controller. +[ApiController, Route("/api/refdata")] +public partial class ReferenceDataController(CoreEx.AspNetCore.Mvc.WebApi webApi) : ControllerBase +{ + private readonly CoreEx.AspNetCore.Mvc.WebApi _webApi = webApi.ThrowIfNull(); + + [HttpGet("customer-types"), HttpHead("customer-types")] + [ProducesResponseType(typeof(CustomerType[]), 200)] + [Query(supportsOrderBy: true), Paging(supportsCount: true)] + public Task GetCustomerTypesAsync(CancellationToken cancellationToken) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.QueryAsync(ro.QueryArgs, ro.PagingArgs, ct), cancellationToken: cancellationToken); + + [HttpGet("contact-methods"), HttpHead("contact-methods")] + [ProducesResponseType(typeof(ContactMethod[]), 200)] + [Query(supportsOrderBy: true), Paging(supportsCount: true)] + public Task GetContactMethodsAsync(CancellationToken cancellationToken) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.QueryAsync(ro.QueryArgs, ro.PagingArgs, ct), cancellationToken: cancellationToken); + + [HttpGet] + [ProducesResponseType(typeof(ReferenceDataMultiDictionary), 200)] + public Task GetNamedAsync([FromQuery] string[] name, CancellationToken cancellationToken) + => _webApi.GetAsync(Request, (ro, ct) => ReferenceDataOrchestrator.Current.GetNamedAsync(name, ro.IsIncludeInactive, ct), cancellationToken: cancellationToken); +} + +#nullable restore diff --git a/samples/src/Contoso.Customers.Api/GlobalUsing.cs b/samples/src/Contoso.Customers.Api/GlobalUsing.cs new file mode 100644 index 00000000..47653192 --- /dev/null +++ b/samples/src/Contoso.Customers.Api/GlobalUsing.cs @@ -0,0 +1,21 @@ +global using Contoso.Customers.Application; +global using Contoso.Customers.Application.Interfaces; +global using Contoso.Customers.Contracts; +global using Contoso.Customers.Infrastructure.Repositories; +global using CoreEx; +global using CoreEx.AspNetCore.Mvc; +global using CoreEx.Cosmos; +global using CoreEx.Cosmos.Outbox; +global using CoreEx.Data.Json; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Http; +global using CoreEx.Json; +global using CoreEx.RefData; +global using CoreEx.Validation; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.Azure.Cosmos; +global using NSwag.Annotations; +global using System.Net; +global using System.Text.Json; diff --git a/samples/src/Contoso.Customers.Api/Program.cs b/samples/src/Contoso.Customers.Api/Program.cs new file mode 100644 index 00000000..8d5ebb6a --- /dev/null +++ b/samples/src/Contoso.Customers.Api/Program.cs @@ -0,0 +1,96 @@ +using OpenTelemetry; +using OpenTelemetry.Trace; +using ZiggyCreatures.Caching.Fusion; + +namespace Contoso.Customers.Api; + +public class Program +{ + private static void Main(string[] args) + { + // Create the web builder. + var builder = WebApplication.CreateBuilder(args); + + // Add CoreEx host settings. + builder.AddHostSettings(); + + // Add CoreEx services. + builder.Services + .AddPrecisionTimeProvider() + .AddExecutionContext() + .AddReferenceDataOrchestrator() + .AddMvcWebApi() + .AddHttpWebApi(); + + // Add all the dynamically registered services. + builder.Services.AddDynamicServicesUsing(); + + // Add caching services - in-memory (L1) only for this sample; no distributed (L2)/Redis, kept deliberately simple. + builder.Services.AddMemoryCache(); + builder.Services.AddFusionCache() + .WithRegisteredMemoryCache() + .WithSystemTextJsonSerializer(JsonDefaults.SerializerOptions); + + builder.Services + .AddFusionHybridCache() // Adds the CoreEx.Caching.IHybridCache for FusionCache. + .AddDefaultCacheKeyProvider() // Adds the default CoreEx.Caching.ICacheKeyProvider. + .AddHybridCacheIdempotencyProvider(); // Adds the CoreEx.Caching.Idempotency.IIdempotencyProvider. + + // Add the Cosmos DB client (Aspire) - in Development, configured to accept the local emulator's self-signed certificate and use Gateway mode, matching CosmosTestBase's own approach. + builder.AddAzureCosmosClient("Cosmos", configureClientOptions: o => + { + o.UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + if (builder.Environment.IsDevelopment()) + { + o.ConnectionMode = ConnectionMode.Gateway; + o.HttpClientFactory = () => new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }); + } + }); + + // Add the Cosmos repository and related outbox services. + builder.Services.AddCosmosDb("contoso"); + builder.Services + .AddEventFormatter() // Adds the EventFormatter to enable message formatting for publishing. + .AddCosmosDbEventPublisher() // Adds the CosmosDbEventPublisher/IEventPublisher + .AddCosmosDbUnitOfWork() // Adds the CosmosDbUnitOfWork/IUnitOfWork, matching AddPostgresUnitOfWork/AddSqlServerUnitOfWork's multi-register shape. + .AddCosmosDbHealthCheck(); // Adds the CosmosDbHealthCheck - Aspire's own AddAzureCosmosClient does not register one itself, unlike its Npgsql/SqlClient counterparts. + + // Post-configure all health-checks; adds the standard tags. + builder.Services.PostConfigureAllHealthChecks(); + + // Add the ASP.NET Core services. + builder.Services.AddControllers(); + + // Add the OpenAPI services. + builder.Services.AddOpenApiDocument(s => + { + s.Title = builder.Environment.ApplicationName; + s.AddCoreExConfiguration(); + }); + + // Add OpenTelemetry tracing. + builder.WithCoreExTelemetry() + .WithCoreExCosmosDbTelemetry() + .UseOtlpExporter(); + + // Build the application. + var app = builder.Build(); + + // Configure the pipeline/middleware (order is important). + app.UseCoreExExceptionHandler(); + app.UseHttpsRedirection(); + // app.UseAuthentication(); // TODO: register an authentication scheme (builder.Services.AddAuthentication(...)) then uncomment. + app.UseAuthorization(); + app.UseExecutionContext(); + app.UseIdempotencyKey(); + app.MapControllers(); + + app.UseOpenApi(); + app.UseSwaggerUi(); + app.MapHealthChecks(); // Secure by default: detailed endpoints are disabled unless explicitly enabled (HealthCheckOptions.AreDetailedEndpointsEnabled) and secured (detailedGroupConfigure, e.g. g => g.RequireAuthorization()); basic live/startup/ready checks stay anonymous for orchestrator probes. + + // Run the application. + app.Run(); + } +} diff --git a/samples/src/Contoso.Customers.Api/Properties/launchSettings.json b/samples/src/Contoso.Customers.Api/Properties/launchSettings.json new file mode 100644 index 00000000..3037c8e8 --- /dev/null +++ b/samples/src/Contoso.Customers.Api/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5320" + }, + "https": { + "commandName": "Project", + "launchUrl": "https://localhost:7320/swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7320;http://localhost:5320" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} diff --git a/samples/src/Contoso.Customers.Api/appsettings.Development.json b/samples/src/Contoso.Customers.Api/appsettings.Development.json new file mode 100644 index 00000000..093f80cd --- /dev/null +++ b/samples/src/Contoso.Customers.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Azure": "Warning", + "Microsoft": "Warning" + } + }, + "ConnectionStrings": { + "Cosmos": "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==" + } +} diff --git a/samples/src/Contoso.Customers.Api/appsettings.json b/samples/src/Contoso.Customers.Api/appsettings.json new file mode 100644 index 00000000..24b99788 --- /dev/null +++ b/samples/src/Contoso.Customers.Api/appsettings.json @@ -0,0 +1,18 @@ +{ + "CoreEx": { + "Host": { + "SolutionName": "Contoso", + "DomainName": "Customers" + }, + "Events": { + "Destination": "contoso" // Topic/queue name. + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/src/Contoso.Customers.Application/Contoso.Customers.Application.csproj b/samples/src/Contoso.Customers.Application/Contoso.Customers.Application.csproj new file mode 100644 index 00000000..1c7aab12 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Contoso.Customers.Application.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/src/Contoso.Customers.Application/CustomerReadService.cs b/samples/src/Contoso.Customers.Application/CustomerReadService.cs new file mode 100644 index 00000000..2a95b47f --- /dev/null +++ b/samples/src/Contoso.Customers.Application/CustomerReadService.cs @@ -0,0 +1,13 @@ +namespace Contoso.Customers.Application; + +[ScopedService] +public class CustomerReadService(ICustomerRepository repository) : ICustomerReadService +{ + private readonly ICustomerRepository _repository = repository.ThrowIfNull(); + + public Task GetAsync(string id, CancellationToken ct = default) => _repository.GetAsync(id, ct); + + public Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken ct = default) => _repository.QueryAsync(query, paging, ct); + + public Task QuerySchemaAsync(CancellationToken ct = default) => _repository.QuerySchemaAsync(ct); +} diff --git a/samples/src/Contoso.Customers.Application/CustomerService.cs b/samples/src/Contoso.Customers.Application/CustomerService.cs new file mode 100644 index 00000000..e23d1f93 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/CustomerService.cs @@ -0,0 +1,102 @@ +namespace Contoso.Customers.Application; + +[ScopedService] +public class CustomerService(IUnitOfWork unitOfWork, ICustomerRepository repository) : ICustomerService +{ + private readonly IUnitOfWork _unitOfWork = unitOfWork.ThrowIfNull(); + private readonly ICustomerRepository _repository = repository.ThrowIfNull(); + + public Task GetAsync(string id, CancellationToken ct = default) => _repository.GetAsync(id, ct); + + public async Task CreateAsync(Customer customer, CancellationToken ct = default) + { + customer.ThrowIfNull(); + + await CustomerValidator.Default.ValidateAndThrowAsync(customer, ct).ConfigureAwait(false); + + customer.Id = Runtime.NewId(); + customer.HasShopped = false; + + var created = await _unitOfWork.TransactionAsync(async tct => + { + var dr = await _repository.CreateAsync(customer, tct).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Created))); + }, ct).ConfigureAwait(false); + + // The ETag is not final until the unit-of-work's deferred batch has actually executed - see CoreEx.Cosmos.CosmosDbUnitOfWork.SynchronizeETag. + _unitOfWork.SynchronizeETag(created); + return created; + } + + public async Task UpdateAsync(Customer customer, CancellationToken ct = default) + { + customer.ThrowIfNull(); + customer.Id.ThrowIfNullOrEmpty(); + + await CustomerValidator.Default.ValidateAndThrowAsync(customer, ct).ConfigureAwait(false); + + var current = await _repository.GetAsync(customer.Id, ct).ConfigureAwait(false); + NotFoundException.ThrowIfDefault(current); + + // HasShopped is read-only from the caller's perspective - only MarkAsShoppedAsync (below) can ever set it, so always preserve the current value here. + customer.HasShopped = current.HasShopped; + + var updated = await _unitOfWork.TransactionAsync(async tct => + { + var dr = await _repository.UpdateAsync(customer, tct).ConfigureAwait(false); + return dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Updated))); + }, ct).ConfigureAwait(false); + + // The ETag is not final until the unit-of-work's deferred batch has actually executed - see CoreEx.Cosmos.CosmosDbUnitOfWork.SynchronizeETag. + _unitOfWork.SynchronizeETag(updated); + return updated; + } + + public async Task DeleteAsync(string id, CancellationToken ct = default) + { + var customer = await _repository.GetAsync(id, ct).ConfigureAwait(false); + if (customer is null) + return; + + if (customer.HasShopped) + throw new BusinessException("A customer that has already shopped cannot be deleted."); + + // Carries the just-read ETag into a conditional delete so a concurrent MarkAsShoppedAsync committing between the HasShopped check above and this delete is detected (as a ConcurrencyException) + // rather than silently allowing the delete to proceed against a now-stale "has not shopped" read. + try + { + await _unitOfWork.TransactionAsync(async tct => + { + var dr = await _repository.DeleteAsync(id, customer.ETag, tct).ConfigureAwait(false); + dr.WhereMutated(() => _unitOfWork.Events.Add(EventData.CreateEvent(EventAction.Deleted).WithKey(id))); + }, ct).ConfigureAwait(false); + } + catch (ConcurrencyException) + { + // Re-check to surface the precise business rule where that is indeed what changed concurrently; otherwise let the concurrency conflict bubble as-is. + var current = await _repository.GetAsync(id, ct).ConfigureAwait(false); + if (current is not null && current.HasShopped) + throw new BusinessException("A customer that has already shopped cannot be deleted."); + + throw; + } + } + + /// + /// Idempotent - a no-op where the customer is unknown or already flagged. Deliberately skips (this only ever flips one internal, system-owned flag; it is not + /// a caller-supplied payload that needs re-validating) - the same reasoning as an activate/deactivate-style state toggle. + public async Task MarkAsShoppedAsync(string id, CancellationToken ct = default) + { + var customer = await _repository.GetAsync(id, ct).ConfigureAwait(false); + if (customer is null || customer.HasShopped) + return; + + customer.HasShopped = true; + + await _unitOfWork.TransactionAsync(async tct => + { + var dr = await _repository.UpdateAsync(customer, tct).ConfigureAwait(false); + dr.WhereMutated(v => _unitOfWork.Events.Add(EventData.CreateEventWith(v, EventAction.Updated))); + }, ct).ConfigureAwait(false); + } +} diff --git a/samples/src/Contoso.Customers.Application/GlobalUsing.cs b/samples/src/Contoso.Customers.Application/GlobalUsing.cs new file mode 100644 index 00000000..5ca6dd81 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/GlobalUsing.cs @@ -0,0 +1,16 @@ +global using Contoso.Customers.Application; +global using Contoso.Customers.Application.Interfaces; +global using Contoso.Customers.Application.Repositories; +global using Contoso.Customers.Application.Validators; +global using Contoso.Customers.Contracts; +global using CoreEx; +global using CoreEx.Data; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.Validation; +global using System.Text.Json; diff --git a/samples/src/Contoso.Customers.Application/Interfaces/ICustomerReadService.cs b/samples/src/Contoso.Customers.Application/Interfaces/ICustomerReadService.cs new file mode 100644 index 00000000..7ab01252 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Interfaces/ICustomerReadService.cs @@ -0,0 +1,10 @@ +namespace Contoso.Customers.Application.Interfaces; + +public interface ICustomerReadService +{ + Task GetAsync(string id, CancellationToken ct = default); + + Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken ct = default); + + Task QuerySchemaAsync(CancellationToken ct = default); +} diff --git a/samples/src/Contoso.Customers.Application/Interfaces/ICustomerService.cs b/samples/src/Contoso.Customers.Application/Interfaces/ICustomerService.cs new file mode 100644 index 00000000..7bffd41a --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Interfaces/ICustomerService.cs @@ -0,0 +1,17 @@ +namespace Contoso.Customers.Application.Interfaces; + +public interface ICustomerService +{ + Task GetAsync(string id, CancellationToken ct = default); + + Task CreateAsync(Contracts.Customer customer, CancellationToken ct = default); + + Task UpdateAsync(Contracts.Customer customer, CancellationToken ct = default); + + Task DeleteAsync(string id, CancellationToken ct = default); + + /// + /// Flags the customer (idempotently) as having shopped, blocking any future delete. + /// + Task MarkAsShoppedAsync(string id, CancellationToken ct = default); +} diff --git a/samples/src/Contoso.Customers.Application/ReferenceDataService.g.cs b/samples/src/Contoso.Customers.Application/ReferenceDataService.g.cs new file mode 100644 index 00000000..687dc252 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/ReferenceDataService.g.cs @@ -0,0 +1,40 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Application; + +/// Provides the implementation. +[ScopedService] +public partial class ReferenceDataService(IReferenceDataRepository repository) : IReferenceDataProvider +{ + private readonly IReferenceDataRepository _repository = repository.ThrowIfNull(); + + /// + public IEnumerable<(Type, Type)> Types => + [ + (typeof(CustomerType), typeof(CustomerTypeCollection)), + (typeof(ContactMethod), typeof(ContactMethodCollection)), + ]; + + /// + public IEnumerable<(string, Type)> AlternateNames => + [ + ( "customer-types", typeof(CustomerType) ), + ( "contact-methods", typeof(ContactMethod) ) + ]; + + /// + public virtual async Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof(CustomerType) => await _repository.GetAllCustomerTypesAsync(cancellationToken).ConfigureAwait(false), + _ when type == typeof(ContactMethod) => await _repository.GetAllContactMethodsAsync(cancellationToken).ConfigureAwait(false), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; +} + +#nullable restore diff --git a/samples/src/Contoso.Customers.Application/Repositories/ICustomerRepository.cs b/samples/src/Contoso.Customers.Application/Repositories/ICustomerRepository.cs new file mode 100644 index 00000000..7f89c03a --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Repositories/ICustomerRepository.cs @@ -0,0 +1,16 @@ +namespace Contoso.Customers.Application.Repositories; + +public interface ICustomerRepository +{ + Task GetAsync(string id, CancellationToken ct = default); + + Task> CreateAsync(Contracts.Customer customer, CancellationToken ct = default); + + Task> UpdateAsync(Contracts.Customer customer, CancellationToken ct = default); + + Task DeleteAsync(string id, string? etag, CancellationToken ct = default); + + Task QuerySchemaAsync(CancellationToken ct = default); + + Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken ct = default); +} diff --git a/samples/src/Contoso.Customers.Application/Repositories/IReferenceDataRepository.g.cs b/samples/src/Contoso.Customers.Application/Repositories/IReferenceDataRepository.g.cs new file mode 100644 index 00000000..0d2a5d10 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Repositories/IReferenceDataRepository.g.cs @@ -0,0 +1,23 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Application.Repositories; + +/// Enables the underlying reference-data repository. +public partial interface IReferenceDataRepository +{ + /// Gets all items. + /// The . + Task GetAllCustomerTypesAsync(CancellationToken cancellationToken = default); + + /// Gets all items. + /// The . + Task GetAllContactMethodsAsync(CancellationToken cancellationToken = default); +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Customers.Application/Validators/AddressValidator.cs b/samples/src/Contoso.Customers.Application/Validators/AddressValidator.cs new file mode 100644 index 00000000..b622f863 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Validators/AddressValidator.cs @@ -0,0 +1,12 @@ +namespace Contoso.Customers.Application.Validators; + +public class AddressValidator : Validator +{ + public AddressValidator() + { + Property(a => a.Street1).Mandatory().MaximumLength(100); + Property(a => a.City).Mandatory().MaximumLength(100); + Property(a => a.PostCode).Mandatory().MaximumLength(20); + Property(a => a.State).Mandatory().MaximumLength(100); + } +} diff --git a/samples/src/Contoso.Customers.Application/Validators/CustomerValidator.cs b/samples/src/Contoso.Customers.Application/Validators/CustomerValidator.cs new file mode 100644 index 00000000..afab74c3 --- /dev/null +++ b/samples/src/Contoso.Customers.Application/Validators/CustomerValidator.cs @@ -0,0 +1,15 @@ +namespace Contoso.Customers.Application.Validators; + +public class CustomerValidator : Validator +{ + public CustomerValidator() + { + Property(c => c.FirstName).Mandatory().MaximumLength(100); + Property(c => c.LastName).Mandatory().MaximumLength(100); + Property(c => c.Email).Mandatory().MaximumLength(250); + Property(c => c.Phone).MaximumLength(50); + Property(c => c.CustomerType).Mandatory().IsValid(); + Property(c => c.ContactMethod).Mandatory().IsValid(); + Property(c => c.ShippingAddress).Entity(AddressValidator.Default); + } +} diff --git a/samples/src/Contoso.Customers.CodeGen/Contoso.Customers.CodeGen.csproj b/samples/src/Contoso.Customers.CodeGen/Contoso.Customers.CodeGen.csproj new file mode 100644 index 00000000..e2c5c520 --- /dev/null +++ b/samples/src/Contoso.Customers.CodeGen/Contoso.Customers.CodeGen.csproj @@ -0,0 +1,11 @@ + + + + Exe + + + + + + + diff --git a/samples/src/Contoso.Customers.CodeGen/Program.cs b/samples/src/Contoso.Customers.CodeGen/Program.cs new file mode 100644 index 00000000..50a35003 --- /dev/null +++ b/samples/src/Contoso.Customers.CodeGen/Program.cs @@ -0,0 +1 @@ +await CoreEx.CodeGen.CodeGenConsole.Create().RunAsync(args); diff --git a/samples/src/Contoso.Customers.CodeGen/ref-data.yaml b/samples/src/Contoso.Customers.CodeGen/ref-data.yaml new file mode 100644 index 00000000..8616c8d6 --- /dev/null +++ b/samples/src/Contoso.Customers.CodeGen/ref-data.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Avanade/CoreEx/refs/heads/main/schema/coreex-refdata.json +collectionSortOrder: Code +repository: Cosmos +entities: +- name: CustomerType +- name: ContactMethod diff --git a/samples/src/Contoso.Customers.Contracts/Address.cs b/samples/src/Contoso.Customers.Contracts/Address.cs new file mode 100644 index 00000000..cb252358 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/Address.cs @@ -0,0 +1,11 @@ +namespace Contoso.Customers.Contracts; + +[Contract] +public partial class Address +{ + public string? Street1 { get; set; } + public string? Street2 { get; set; } + public string? City { get; set; } + public string? PostCode { get; set; } + public string? State { get; set; } +} diff --git a/samples/src/Contoso.Customers.Contracts/ContactMethod.g.cs b/samples/src/Contoso.Customers.Contracts/ContactMethod.g.cs new file mode 100644 index 00000000..bdb4afc4 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/ContactMethod.g.cs @@ -0,0 +1,20 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Contracts; + +/// Represents the 'Contact Method' reference-data contract. +[ReferenceData] +public partial class ContactMethod : ReferenceData { } + +/// +/// Represents the 'ContactMethod' reference-data contract collection. +/// +public partial class ContactMethodCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Customers.Contracts/Contoso.Customers.Contracts.csproj b/samples/src/Contoso.Customers.Contracts/Contoso.Customers.Contracts.csproj new file mode 100644 index 00000000..31817534 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/Contoso.Customers.Contracts.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/src/Contoso.Customers.Contracts/Customer.cs b/samples/src/Contoso.Customers.Contracts/Customer.cs new file mode 100644 index 00000000..af8d8487 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/Customer.cs @@ -0,0 +1,11 @@ +namespace Contoso.Customers.Contracts; + +[Contract] +public partial class Customer : CustomerBase, IETag, IChangeLog +{ + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } + + [ReadOnly(true)] + public string? ETag { get; set; } +} diff --git a/samples/src/Contoso.Customers.Contracts/CustomerBase.cs b/samples/src/Contoso.Customers.Contracts/CustomerBase.cs new file mode 100644 index 00000000..70aff550 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/CustomerBase.cs @@ -0,0 +1,27 @@ +namespace Contoso.Customers.Contracts; + +[Contract] +public abstract partial class CustomerBase : IIdentifier +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? Email { get; set; } + + public string? Phone { get; set; } + + public Address? ShippingAddress { get; set; } + + [ReferenceData] + public partial string? CustomerTypeCode { get; set; } + + [ReferenceData] + public partial string? ContactMethodCode { get; set; } + + [ReadOnly(true)] + public bool HasShopped { get; set; } +} diff --git a/samples/src/Contoso.Customers.Contracts/CustomerLite.cs b/samples/src/Contoso.Customers.Contracts/CustomerLite.cs new file mode 100644 index 00000000..3013b3c9 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/CustomerLite.cs @@ -0,0 +1,20 @@ +namespace Contoso.Customers.Contracts; + +[Contract] +public partial class CustomerLite : IIdentifier +{ + [ReadOnly(true)] + public string? Id { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? Email { get; set; } + + [ReferenceData] + public partial string? CustomerTypeCode { get; set; } + + [ReadOnly(true)] + public ChangeLog? ChangeLog { get; set; } +} diff --git a/samples/src/Contoso.Customers.Contracts/CustomerType.g.cs b/samples/src/Contoso.Customers.Contracts/CustomerType.g.cs new file mode 100644 index 00000000..15b1f864 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/CustomerType.g.cs @@ -0,0 +1,20 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Contracts; + +/// Represents the 'Customer Type' reference-data contract. +[ReferenceData] +public partial class CustomerType : ReferenceData { } + +/// +/// Represents the 'CustomerType' reference-data contract collection. +/// +public partial class CustomerTypeCollection() : ReferenceDataCollection(ReferenceDataSortOrder.Code) { } + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Customers.Contracts/GlobalUsing.cs b/samples/src/Contoso.Customers.Contracts/GlobalUsing.cs new file mode 100644 index 00000000..b3b07bf1 --- /dev/null +++ b/samples/src/Contoso.Customers.Contracts/GlobalUsing.cs @@ -0,0 +1,5 @@ +global using CoreEx.Entities; +global using CoreEx.Localization; +global using CoreEx.RefData; +global using System.ComponentModel; +global using System.Text.Json.Serialization; diff --git a/samples/src/Contoso.Customers.Infrastructure/Contoso.Customers.Infrastructure.csproj b/samples/src/Contoso.Customers.Infrastructure/Contoso.Customers.Infrastructure.csproj new file mode 100644 index 00000000..c4763ae1 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Contoso.Customers.Infrastructure.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/samples/src/Contoso.Customers.Infrastructure/GlobalUsing.cs b/samples/src/Contoso.Customers.Infrastructure/GlobalUsing.cs new file mode 100644 index 00000000..541666ce --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/GlobalUsing.cs @@ -0,0 +1,17 @@ +global using Contoso.Customers.Application.Repositories; +global using Contoso.Customers.Infrastructure.Mapping; +global using CoreEx; +global using CoreEx.Cosmos; +global using CoreEx.Cosmos.Outbox; +global using CoreEx.Data; +global using CoreEx.Data.Querying; +global using CoreEx.DependencyInjection; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Mapping; +global using CoreEx.RefData; +global using Microsoft.Azure.Cosmos; +global using Microsoft.Extensions.Logging; +global using System.Text.Json; +global using PartitionKey = Microsoft.Azure.Cosmos.PartitionKey; diff --git a/samples/src/Contoso.Customers.Infrastructure/Mapping/AddressMapper.cs b/samples/src/Contoso.Customers.Infrastructure/Mapping/AddressMapper.cs new file mode 100644 index 00000000..b53dbe59 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Mapping/AddressMapper.cs @@ -0,0 +1,22 @@ +namespace Contoso.Customers.Infrastructure.Mapping; + +public class AddressMapper : BiDirectionMapper +{ + protected override Persistence.Address OnMap(Contracts.Address source) => new() + { + Street1 = source.Street1!, + Street2 = source.Street2, + City = source.City!, + PostCode = source.PostCode!, + State = source.State! + }; + + protected override Contracts.Address OnMap(Persistence.Address source) => new() + { + Street1 = source.Street1, + Street2 = source.Street2, + City = source.City, + PostCode = source.PostCode, + State = source.State + }; +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Mapping/ContactMethodMapper.g.cs b/samples/src/Contoso.Customers.Infrastructure/Mapping/ContactMethodMapper.g.cs new file mode 100644 index 00000000..844e119f --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Mapping/ContactMethodMapper.g.cs @@ -0,0 +1,38 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Infrastructure.Mapping; + +/// Provides mapping from to . +internal partial class ContactMethodMapper : Mapper +{ + /// + protected override Contracts.ContactMethod OnMap(Persistence.ContactMethod source) + { + var destination = new Contracts.ContactMethod + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + Description = source.Description, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + StartsOn = source.StartsOn, + EndsOn = source.EndsOn, + ETag = source.ETag + }; + + OnMapExtend(source, destination); + return destination; + } + + /// Provides the opportunity to extend the method. + partial void OnMapExtend(Persistence.ContactMethod source, Contracts.ContactMethod destination); +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Customers.Infrastructure/Mapping/CustomerMapper.cs b/samples/src/Contoso.Customers.Infrastructure/Mapping/CustomerMapper.cs new file mode 100644 index 00000000..3c14a500 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Mapping/CustomerMapper.cs @@ -0,0 +1,42 @@ +namespace Contoso.Customers.Infrastructure.Mapping; + +public class CustomerMapper : BiDirectionMapper +{ + // Unlike the EF-based sample domains, CosmosDbMappedContainer does not auto-copy Id/ETag/ChangeLog between the contract and persistence model itself - MapStandardFrom does that explicitly here + // (deliberately excludes PartitionKey; see below). + protected override Persistence.Customer OnMap(Contracts.Customer source) + { + var destination = new Persistence.Customer + { + FirstName = source.FirstName!, + LastName = source.LastName!, + Email = source.Email!, + Phone = source.Phone, + ShippingAddress = AddressMapper.To.Map(source.ShippingAddress), + CustomerTypeCode = source.CustomerType?.Code, + ContactMethodCode = source.ContactMethod?.Code, + HasShopped = source.HasShopped + }; + + destination.MapStandardFrom(source); + return destination; + } + + protected override Contracts.Customer OnMap(Persistence.Customer source) + { + var destination = new Contracts.Customer + { + FirstName = source.FirstName, + LastName = source.LastName, + Email = source.Email, + Phone = source.Phone, + ShippingAddress = AddressMapper.From.Map(source.ShippingAddress), + CustomerTypeCode = source.CustomerTypeCode, + ContactMethodCode = source.ContactMethodCode, + HasShopped = source.HasShopped + }; + + destination.MapStandardFrom(source); + return destination; + } +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Mapping/CustomerTypeMapper.g.cs b/samples/src/Contoso.Customers.Infrastructure/Mapping/CustomerTypeMapper.g.cs new file mode 100644 index 00000000..b845905c --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Mapping/CustomerTypeMapper.g.cs @@ -0,0 +1,38 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Infrastructure.Mapping; + +/// Provides mapping from to . +internal partial class CustomerTypeMapper : Mapper +{ + /// + protected override Contracts.CustomerType OnMap(Persistence.CustomerType source) + { + var destination = new Contracts.CustomerType + { + Id = source.Id!, + Code = source.Code, + Text = source.Text, + Description = source.Description, + SortOrder = source.SortOrder, + IsInactive = !source.IsActive, + StartsOn = source.StartsOn, + EndsOn = source.EndsOn, + ETag = source.ETag + }; + + OnMapExtend(source, destination); + return destination; + } + + /// Provides the opportunity to extend the method. + partial void OnMapExtend(Persistence.CustomerType source, Contracts.CustomerType destination); +} + +#nullable restore \ No newline at end of file diff --git a/samples/src/Contoso.Customers.Infrastructure/Persistence/Address.cs b/samples/src/Contoso.Customers.Infrastructure/Persistence/Address.cs new file mode 100644 index 00000000..9f2741ad --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Persistence/Address.cs @@ -0,0 +1,10 @@ +namespace Contoso.Customers.Infrastructure.Persistence; + +public class Address +{ + public string Street1 { get; set; } = default!; + public string? Street2 { get; set; } + public string City { get; set; } = default!; + public string PostCode { get; set; } = default!; + public string State { get; set; } = default!; +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Persistence/ContactMethod.g.cs b/samples/src/Contoso.Customers.Infrastructure/Persistence/ContactMethod.g.cs new file mode 100644 index 00000000..ba028559 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Persistence/ContactMethod.g.cs @@ -0,0 +1,18 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Infrastructure.Persistence; + +/// Cosmos persistence reference-data model representing the 'Contact Method'. +public partial class ContactMethod : CosmosDbReferenceDataModelBase, ITypeDiscriminator +{ + /// + public string? TypeDiscriminator { get; set; } +} + +#nullable restore diff --git a/samples/src/Contoso.Customers.Infrastructure/Persistence/Customer.cs b/samples/src/Contoso.Customers.Infrastructure/Persistence/Customer.cs new file mode 100644 index 00000000..45fd3ecd --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Persistence/Customer.cs @@ -0,0 +1,13 @@ +namespace Contoso.Customers.Infrastructure.Persistence; + +public class Customer : CosmosDbModelBase +{ + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string Email { get; set; } = default!; + public string? Phone { get; set; } + public Address? ShippingAddress { get; set; } + public string? CustomerTypeCode { get; set; } + public string? ContactMethodCode { get; set; } + public bool HasShopped { get; set; } +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Persistence/CustomerType.g.cs b/samples/src/Contoso.Customers.Infrastructure/Persistence/CustomerType.g.cs new file mode 100644 index 00000000..203dfa8c --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Persistence/CustomerType.g.cs @@ -0,0 +1,18 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Infrastructure.Persistence; + +/// Cosmos persistence reference-data model representing the 'Customer Type'. +public partial class CustomerType : CosmosDbReferenceDataModelBase, ITypeDiscriminator +{ + /// + public string? TypeDiscriminator { get; set; } +} + +#nullable restore diff --git a/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomerQueryArgsConfig.cs b/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomerQueryArgsConfig.cs new file mode 100644 index 00000000..fd50175d --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomerQueryArgsConfig.cs @@ -0,0 +1,20 @@ +namespace Contoso.Customers.Infrastructure.Repositories; + +/// +/// Provides the configuration for . +/// +public class CustomerQueryArgsConfig : QueryArgsConfig +{ + public CustomerQueryArgsConfig() + { + WithFilter(filter => filter + .AddField(nameof(Contracts.CustomerBase.FirstName), c => c.WithOperators(QueryFilterOperator.StringFunctions).AsUpperCase()) + .AddField(nameof(Contracts.CustomerBase.LastName), c => c.WithOperators(QueryFilterOperator.StringFunctions).AsUpperCase()) + .AddField(nameof(Contracts.CustomerBase.Email), c => c.WithOperators(QueryFilterOperator.EqualityOperators).AsUpperCase()) + .AddReferenceDataField(nameof(Contracts.CustomerBase.CustomerType), "CustomerTypeCode")); + + WithOrderBy(orderby => orderby + .AddField(nameof(Contracts.CustomerBase.LastName), c => c.WithDefault().WithAlwaysInclude()) + .AddField(nameof(Contracts.CustomerBase.FirstName))); + } +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomerRepository.cs b/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomerRepository.cs new file mode 100644 index 00000000..9033f3b8 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomerRepository.cs @@ -0,0 +1,35 @@ +namespace Contoso.Customers.Infrastructure.Repositories; + +[ScopedService] +public class CustomerRepository(CustomersCosmosDb cosmos) : ICustomerRepository +{ + private readonly CustomersCosmosDb _cosmos = cosmos.ThrowIfNull(); + + public Task GetAsync(string id, CancellationToken ct = default) => _cosmos.Customers.GetAsync(CompositeKey.Create(id), ct); + + public Task> CreateAsync(Contracts.Customer customer, CancellationToken ct = default) => _cosmos.Customers.CreateAsync(customer, ct); + + public Task> UpdateAsync(Contracts.Customer customer, CancellationToken ct = default) => _cosmos.Customers.UpdateAsync(customer, ct); + + public Task DeleteAsync(string id, string? etag, CancellationToken ct = default) => + _cosmos.Customers.DeleteAsync(string.IsNullOrEmpty(etag) ? new CosmosDbArgs() : new CosmosDbArgs { ItemRequestOptions = new ItemRequestOptions { IfMatchEtag = etag } }, CompositeKey.Create(id), ct); + + public Task QuerySchemaAsync(CancellationToken ct = default) => Task.FromResult(CustomerQueryArgsConfig.Default.ToJsonSchema()); + + public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken ct = default) + { + var parsed = CustomerQueryArgsConfig.Default.Parse(query).ThrowOnError(); + + return await _cosmos.Customers.Container + .Query(q => q.Where(parsed).OrderBy(parsed)) + .WithPaging(paging) + .ToMappedItemsResultAsync(m => new Contracts.CustomerLite + { + Id = m.Id, + FirstName = m.FirstName, + LastName = m.LastName, + Email = m.Email, + CustomerTypeCode = m.CustomerTypeCode + }, cancellationToken: ct); + } +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomersCosmosDb.cs b/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomersCosmosDb.cs new file mode 100644 index 00000000..4d358680 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Repositories/CustomersCosmosDb.cs @@ -0,0 +1,17 @@ +namespace Contoso.Customers.Infrastructure.Repositories; + +public class CustomersCosmosDb(CosmosClient client, string databaseId) : CosmosDb(client, databaseId, _options) +{ + private const string RefDataContainerId = "ref-data"; + private const string CustomersContainerId = "customers"; + + private static readonly CosmosDbOptions _options = new(); + + public CosmosDbContainer ContactMethods => Container(RefDataContainerId, o => o.WithTypeDiscriminator()); + + public CosmosDbContainer CustomerTypes => Container(RefDataContainerId, o => o.WithTypeDiscriminator()); + + // The Customers container is mapped to the Contracts.Customer model using the CustomerMapper to handle the mapping between the persistence model and the contract model as the default access. + public CosmosDbMappedContainer Customers + => Container(CustomersContainerId).ToMappedModel(new CustomerMapper()); +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Repositories/ReferenceDataRepository.cs b/samples/src/Contoso.Customers.Infrastructure/Repositories/ReferenceDataRepository.cs new file mode 100644 index 00000000..14184c89 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Repositories/ReferenceDataRepository.cs @@ -0,0 +1,6 @@ +namespace Contoso.Customers.Infrastructure.Repositories; + +public partial class ReferenceDataRepository(CustomersCosmosDb cosmos) +{ + private CustomersCosmosDb _cosmos = cosmos.ThrowIfNull(); +} diff --git a/samples/src/Contoso.Customers.Infrastructure/Repositories/ReferenceDataRepository.g.cs b/samples/src/Contoso.Customers.Infrastructure/Repositories/ReferenceDataRepository.g.cs new file mode 100644 index 00000000..4b39ffe1 --- /dev/null +++ b/samples/src/Contoso.Customers.Infrastructure/Repositories/ReferenceDataRepository.g.cs @@ -0,0 +1,24 @@ +// + +/* + * This file is automatically generated by 'Contoso.Customers.CodeGen'; any changes will be lost. + */ + +#nullable enable + +namespace Contoso.Customers.Infrastructure.Repositories; + +/// Provides the underlying reference-data repository. +[ScopedService] +public partial class ReferenceDataRepository : IReferenceDataRepository +{ + /// + public Task GetAllCustomerTypesAsync(CancellationToken cancellationToken = default) + => _cosmos.CustomerTypes.Query().ToMappedItemsAsync(CustomerTypeMapper.Map, cancellationToken); + + /// + public Task GetAllContactMethodsAsync(CancellationToken cancellationToken = default) + => _cosmos.ContactMethods.Query().ToMappedItemsAsync(ContactMethodMapper.Map, cancellationToken); +} + +#nullable restore diff --git a/samples/tests/Contoso.Customers.Test.Api/Contoso.Customers.Test.Api.csproj b/samples/tests/Contoso.Customers.Test.Api/Contoso.Customers.Test.Api.csproj new file mode 100644 index 00000000..248c889c --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/Contoso.Customers.Test.Api.csproj @@ -0,0 +1,31 @@ + + + + + true + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Create.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Create.cs new file mode 100644 index 00000000..8d05ca29 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Create.cs @@ -0,0 +1,111 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerMutateTests : WithApiTester +{ + [Test] + public void Create_Empty() + { + // Act/Assert. + Test.Http() + .Run(HttpMethod.Post, "/api/customers", new Customer()) + .AssertBadRequest() + .AssertErrors( + "First name is required.", + "Last name is required.", + "Email is required.", + "Customer type is required.", + "Contact method is required." + ); + } + + [Test] + public void Create_Bad_Data() + { + // Arrange. + var c = new Customer + { + FirstName = "Bart", + LastName = "Simpson", + Email = "bart.simpson@example.com", + CustomerTypeCode = "XX", + ContactMethodCode = "XX" + }; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Post, "/api/customers", c) + .AssertBadRequest() + .AssertErrors( + "Customer type is invalid.", + "Contact method is invalid." + ); + } + + [Test] + public void Create_Success() + { + // Arrange. + var c = new Customer + { + FirstName = "Marge", + LastName = "Simpson", + Email = "marge.simpson@example.com", + CustomerTypeCode = "IND", + ContactMethodCode = "EM" + }; + + // Act/Assert. + var r = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectCosmosDbOutboxEvents(e => e.AssertWithValue("contoso", "contoso.customers.customer.created.v1")) + .Run(HttpMethod.Post, "/api/customers", c) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/customers/{r!.Id}", UriKind.Relative)) + .Value!; + + r.HasShopped.Should().BeFalse(); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{r.Id}") + .AssertOK() + .AssertValue(r); + } + + [Test] + public void Create_WithShippingAddress() + { + // Arrange. + var c = new Customer + { + FirstName = "Lisa", + LastName = "Simpson", + Email = "lisa.simpson@example.com", + CustomerTypeCode = "IND", + ContactMethodCode = "SMS", + ShippingAddress = new Address { Street1 = "742 Evergreen Terrace", City = "Springfield", PostCode = "49007", State = "IL" } + }; + + // Act/Assert. + var r = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogCreated() + .ExpectCosmosDbOutboxEvents(e => e.AssertWithValue("contoso", "contoso.customers.customer.created.v1")) + .Run(HttpMethod.Post, "/api/customers", c) + .AssertCreated() + .AssertLocationHeader(r => new Uri($"/api/customers/{r!.Id}", UriKind.Relative)) + .Value!; + + r.ShippingAddress.Should().NotBeNull(); + r.ShippingAddress!.City.Should().Be("Springfield"); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{r.Id}") + .AssertOK() + .AssertValue(r); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Delete.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Delete.cs new file mode 100644 index 00000000..5df2f577 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Delete.cs @@ -0,0 +1,50 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerMutateTests : WithApiTester +{ + [Test] + public void Delete_NotFound() + { + // Arrange/Act/Assert. + Test.Http() + .Run(HttpMethod.Delete, "/api/customers/404") + .AssertNoContent(); + } + + [Test] + public void Delete_HasShopped() + { + // Arrange/Act/Assert. + Test.Http() + .Run(HttpMethod.Delete, $"/api/customers/{2.ToGuid()}") + .AssertBadRequest() + .AssertProblemDetails(p => p.Title.Should().Be("A customer that has already shopped cannot be deleted.")); + } + + [Test] + public void Delete_Success() + { + var id = 3.ToGuid().ToString(); + + // Arrange. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{id}") + .AssertOK(); + + // Act. + Test.Http() + .ExpectCosmosDbOutboxEvents(c => c.AssertMetadata("contoso", "contoso.customers.customer.deleted", id)) + .Run(HttpMethod.Delete, $"/api/customers/{id}") + .AssertNoContent(); + + // Assert idempotent. + Test.Http() + .Run(HttpMethod.Delete, $"/api/customers/{id}") + .AssertNoContent(); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{id}") + .AssertNotFound(); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Patch.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Patch.cs new file mode 100644 index 00000000..b577225a --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Patch.cs @@ -0,0 +1,96 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerMutateTests : WithApiTester +{ + [Test] + public void Patch_NotFound() + { + // Act/Assert. No If-Match needed - the get returns null and short-circuits before the ETag comparison. + Test.Http() + .Run(HttpMethod.Patch, "/api/customers/404", new { lastName = "Updated" }, requestModifier: r => r.WithMergePatchJsonContentType()) + .AssertNotFound(); + } + + [Test] + public void Patch_Concurrency() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{4.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Patch, $"/api/customers/{c.Id}", new { lastName = "Updated" }, requestModifier: r => r.WithIfMatch("AAAAAAAA").WithMergePatchJsonContentType()) + .AssertPreconditionFailed(); + } + + [Test] + public void Patch_Validation() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{4.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Patch, $"/api/customers/{c.Id}", new { customerType = "XX" }, requestModifier: r => r.WithIfMatch(c.ETag).WithMergePatchJsonContentType()) + .AssertBadRequest() + .AssertErrors("Customer type is invalid."); + } + + [Test] + public void Patch_Success() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{5.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. + var u = Test.Http() + .ExpectCosmosDbOutboxEvents(e => e.AssertWithValue("contoso", "contoso.customers.customer.updated.v1")) + .Run(HttpMethod.Patch, $"/api/customers/{c.Id}", new { lastName = "Patched" }, requestModifier: r => r.WithIfMatch(c.ETag).WithMergePatchJsonContentType()) + .AssertOK() + .Value!; + + u.LastName.Should().Be("Patched"); + u.ETag.Should().NotBe(c.ETag); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{c.Id}") + .AssertOK() + .AssertValue(u); + } + + [Test] + public void Patch_NoChanges() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{6.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. An empty merge patch has no changes - put() is never invoked, so no event is published and the ETag/ChangeLog stay untouched. + var u = Test.Http() + .ExpectNoCosmosDbOutboxEvents() + .Run(HttpMethod.Patch, $"/api/customers/{c.Id}", new { }, requestModifier: r => r.WithIfMatch(c.ETag).WithMergePatchJsonContentType()) + .AssertOK() + .Value!; + + u.ETag.Should().Be(c.ETag); + u.ChangeLog.Should().BeNull(); // Raw-seeded row (never went through Create/Update) - no changeLog was ever written, and this no-op patch must not add one either. + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{c.Id}") + .AssertOK() + .AssertValue(u); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Update.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Update.cs new file mode 100644 index 00000000..d7c1c6cc --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.Update.cs @@ -0,0 +1,68 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerMutateTests : WithApiTester +{ + [Test] + public void Update_NotFound() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{1.ToGuid()}") + .AssertOK() + .Value!; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Put, "/api/customers/404", c) + .AssertNotFound(); + } + + [Test] + public void Update_Concurrency() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{4.ToGuid()}") + .AssertOK() + .Value!; + + c.LastName += " Updated"; + + // Act/Assert. + Test.Http() + .Run(HttpMethod.Put, $"/api/customers/{c.Id}", c, requestModifier: r => r.WithIfMatch("AAAAAAAA")) + .AssertPreconditionFailed(); + } + + [Test] + public void Update_Success() + { + // Arrange. + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{1.ToGuid()}") + .AssertOK() + .Value!; + + c.LastName += " Updated"; + + // Act/Assert. + var u = Test.Http() + .ExpectIdentifier() + .ExpectETag() + .ExpectChangeLogUpdated() + .ExpectValue(c) + .ExpectCosmosDbOutboxEvents(e => e.AssertWithValue("contoso", "contoso.customers.customer.updated.v1")) + .Run(HttpMethod.Put, $"/api/customers/{c.Id}", c) + .AssertOK() + .Value!; + + u.LastName.Should().Be(c.LastName); + u.ETag.Should().NotBe(c.ETag); + + // Assert. + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{c.Id}") + .AssertOK() + .AssertValue(u); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.cs new file mode 100644 index 00000000..7bd802fd --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerMutateTests.cs @@ -0,0 +1,13 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerMutateTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.DatabaseSetUpAsync("mutate-data.seed.yaml").ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + + Test.UseExpectedCosmosDbOutboxPublisher(); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.Get.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.Get.cs new file mode 100644 index 00000000..1fba2cfd --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.Get.cs @@ -0,0 +1,56 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerReadTests : WithApiTester +{ + [Test] + public void Get_NotFound() + { + Test.Http() + .Run(HttpMethod.Get, "/api/customers/404") + .AssertNotFound(); + } + + [Test] + public void Get_Found() + { + var id = 16.ToGuid().ToString(); + + var c = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{id}") + .AssertOK() + .Value!; + + c.Id.Should().Be(id); + c.FirstName.Should().Be("Frank"); + c.LastName.Should().Be("Foster"); + c.Email.Should().Be("frank.foster@example.com"); + c.Phone.Should().Be("555-0100"); + c.ShippingAddress.Should().NotBeNull(); + c.ShippingAddress!.Street1.Should().Be("1 Test Street"); + c.ShippingAddress.City.Should().Be("Springfield"); + c.ShippingAddress.PostCode.Should().Be("49007"); + c.ShippingAddress.State.Should().Be("IL"); + c.CustomerTypeCode.Should().Be("IND"); + c.ContactMethodCode.Should().Be("EM"); + c.HasShopped.Should().BeFalse(); + c.ETag.Should().NotBeNullOrEmpty(); + c.ChangeLog.Should().BeNull(); // Raw-seeded row (never went through Create/Update), so no changeLog was ever written. + } + + [Test] + public void Get_Not_Modified() + { + var id = 11.ToGuid().ToString(); + + var r = Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{id}") + .AssertOK() + .Response; + + r.Headers.ETag.Should().NotBeNull(); + + Test.Http() + .Run(HttpMethod.Get, $"/api/customers/{id}", requestModifier: rm => rm.WithIfNoneMatch(r.Headers.ETag.Tag)) + .AssertNotModified(); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.Query.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.Query.cs new file mode 100644 index 00000000..ac09c1e7 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.Query.cs @@ -0,0 +1,133 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerReadTests : WithApiTester +{ + [Test] + public void Query_Schema() + { + Test.Http() + .Run(HttpMethod.Get, "/api/customers/$query") + .AssertOK() + .GetContent().Should().BeJson() + .ContainAll(["$.filter.fields.firstname", "$.filter.fields.lastname", "$.filter.fields.email", "$.filter.fields.customertype", "$.orderby.fields.lastname", "$.orderby.fields.firstname", "$.orderby.default"]); + } + + [Test] + public void Query_All() + { + // No $take specified - unlike the EF-backed domains (which default to PagingArgs.DefaultTake), CosmosDbQuery applies no paging at all when none is requested, so every seeded row comes back. + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(6); + } + + [Test] + public void Query_Paging() + { + // Default order (lastname asc): Anderson, Brown, Clarke, Davis, Edwards, Foster - skip 2, take 2 -> Clarke, Davis. + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$skip=2&$take=2&$count=true") + .AssertOK(); + + r.Value.Should().NotBeNull().And.HaveCount(2); + r.Value!.Select(c => c.LastName).Should().ContainInOrder("Clarke", "Davis"); + + r.Response.Headers.Should().ContainKey("X-Paging-Skip").WhoseValue.Should().ContainSingle().Which.Should().Be("2"); + r.Response.Headers.Should().ContainKey("X-Paging-Take").WhoseValue.Should().ContainSingle().Which.Should().Be("2"); + r.Response.Headers.Should().ContainKey("X-Paging-Total-Count").WhoseValue.Should().ContainSingle().Which.Should().Be("6"); + } + + [Test] + public void Query_FilterByLastName_StartsWith() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$filter=startswith(lastname, 'And')") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(1).And.OnlyContain(c => c.LastName == "Anderson"); + } + + [Test] + public void Query_FilterByLastName_EqualityNotSupported() + { + // LastName only supports StringFunctions (startswith/contains/endswith) - no 'eq' - an unsupported operator is a 400, not a silently-ignored filter. + Test.Http() + .Run(HttpMethod.Get, "/api/customers?$filter=lastname eq 'Brown'") + .AssertBadRequest(); + } + + [Test] + public void Query_FilterByFirstName_Contains() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$filter=contains(firstname, 'lice')") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(2) + .And.OnlyContain(c => c.FirstName == "Alice") + .And.BeInAscendingOrder(c => c.LastName); + } + + [Test] + public void Query_FilterByEmail_Eq() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$filter=email eq 'bob.brown@example.com'") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(1).And.OnlyContain(c => c.LastName == "Brown"); + } + + [Test] + public void Query_FilterByCustomerType_Eq() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$filter=customertype eq 'ORG'") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(2).And.OnlyContain(c => c.CustomerTypeCode == "ORG"); + } + + [Test] + public void Query_FilterByCustomerType_Invalid() + { + Test.Http() + .Run(HttpMethod.Get, "/api/customers?$filter=customertype eq 'ZZ'") + .AssertBadRequest(); + } + + [Test] + public void Query_OrderBy_LastName_Desc() + { + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$orderby=lastname desc") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(6).And.BeInDescendingOrder(c => c.LastName); + } + + [Test] + public void Query_OrderBy_FirstName() + { + // LastName is configured WithAlwaysInclude(), so this is a two-property "ORDER BY firstName, lastName" under the hood - requires the composite index DatabaseSetUp configures on "customers". + var r = Test.Http() + .Run(HttpMethod.Get, "/api/customers?$orderby=firstname") + .AssertOK() + .Value; + + r.Should().NotBeNull().And.HaveCount(6); + r!.Select(c => c.FirstName).Should().ContainInOrder("Alice", "Alice", "Bob", "Charlie", "Diana", "Frank"); + + // Tie-break: the always-included LastName keeps its own (ascending) direction regardless of what FirstName's direction was requested as, so Anderson sorts before Edwards. + r[0].LastName.Should().Be("Anderson"); + r[1].LastName.Should().Be("Edwards"); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.cs b/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.cs new file mode 100644 index 00000000..af797267 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/CustomerReadTests.cs @@ -0,0 +1,11 @@ +namespace Contoso.Customers.Test.Api; + +public partial class CustomerReadTests : WithApiTester +{ + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + await Test.DatabaseSetUpAsync("read-data.seed.yaml").ConfigureAwait(false); + await Test.ClearFusionCacheAsync().ConfigureAwait(false); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/DatabaseSetUp.cs b/samples/tests/Contoso.Customers.Test.Api/DatabaseSetUp.cs new file mode 100644 index 00000000..dce06148 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/DatabaseSetUp.cs @@ -0,0 +1,48 @@ +namespace Contoso.Customers.Test.Api; + +internal static class DatabaseSetUp +{ + /// The - the is resolved from its running host's own registration (see + /// ), guaranteeing this always seeds the exact database/containers the API host itself reads from. + /// Zero or more embedded YAML resource names (see ) imported via + /// . + public static async Task DatabaseSetUpAsync(this TesterBase tester, params string[] resourceFileNames) + { + var database = await tester.GetCosmosDatabaseAsync().ConfigureAwait(false); + + // Replace or create "customers" container used by the API. The partition key is assumed to be "/partitionKey" for all containers, which is a common pattern for Cosmos DB. + // CustomerQueryArgsConfig's "LastName" order-by field is configured WithAlwaysInclude() (always appended, in its own default ascending direction, regardless of what the caller actually + // requested) - so ordering by "FirstName" always produces a two-property ORDER BY (e.g. "firstName DESC, lastName ASC"), which Cosmos DB rejects outright unless a matching composite index + // exists. Both direction combinations actually reachable via the API ($orderby=firstname[,desc]) are indexed here so CustomerReadTests' query tests can exercise both. + var customersContainerProperties = new ContainerProperties("customers", "/partitionKey"); + customersContainerProperties.IndexingPolicy.CompositeIndexes.Add( + [ + new() { Path = "/firstName", Order = CompositePathSortOrder.Ascending }, + new() { Path = "/lastName", Order = CompositePathSortOrder.Ascending } + ]); + customersContainerProperties.IndexingPolicy.CompositeIndexes.Add( + [ + new() { Path = "/firstName", Order = CompositePathSortOrder.Descending }, + new() { Path = "/lastName", Order = CompositePathSortOrder.Ascending } + ]); + + await database.ReplaceOrCreateContainerAsync(customersContainerProperties).ConfigureAwait(false); + + // Seed any known precondition rows (e.g. mutate-data.seed.yaml) directly into the freshly reset container - no TModel typing involved, raw JSON straight through. + foreach (var fileName in resourceFileNames) + { + var customerJdr = JsonDataReader.ParseYaml(fileName, new JsonDataReaderOptions(JsonPropertyNamingConvention.CamelCase)); + await CosmosDbBatch.ImportBatchAsync(database, customerJdr).ConfigureAwait(false); + } + + // Replace or create "ref-data" container used by the API. Reuse the "test" configured reference data and import. A unique key policy on "/typeDiscriminator" and "/code" enforces + // (per logical partition) that no two documents share the same discriminator/code combination - the closest Cosmos DB equivalent to a unique index. + var refDataContainerProperties = new ContainerProperties("ref-data", "/partitionKey"); + refDataContainerProperties.UniqueKeyPolicy.UniqueKeys.Add(new UniqueKey { Paths = { "/typeDiscriminator", "/code" } }); + + await database.ReplaceOrCreateContainerAsync(refDataContainerProperties).ConfigureAwait(false); + + var jdr = JsonDataReader.ParseYaml("ref-data.seed.yaml", JsonDataReaderOptions.CreateForReferenceData(JsonPropertyNamingConvention.CamelCase)); + await CosmosDbBatch.ImportDiscriminatedBatchAsync(database, jdr).ConfigureAwait(false); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/GlobalUsing.cs b/samples/tests/Contoso.Customers.Test.Api/GlobalUsing.cs new file mode 100644 index 00000000..fddb1732 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/GlobalUsing.cs @@ -0,0 +1,16 @@ +global using Contoso.Customers.Contracts; +global using CoreEx; +global using CoreEx.Data.Json; +global using CoreEx.Http.Abstractions; +global using CoreEx.Cosmos.Extended; +global using AwesomeAssertions; +global using Microsoft.Azure.Cosmos; +global using NUnit.Framework; +global using System.Collections.ObjectModel; +global using System.Net; +global using System.Net.Http; +global using System.Text.Json; +global using UnitTestEx; +global using UnitTestEx.Abstractions; +global using UnitTestEx.Expectations; +global using TestData = Contoso.Customers.Test.Common.TestData; diff --git a/samples/tests/Contoso.Customers.Test.Api/HostTests.cs b/samples/tests/Contoso.Customers.Test.Api/HostTests.cs new file mode 100644 index 00000000..08c7fcdf --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/HostTests.cs @@ -0,0 +1,66 @@ +namespace Contoso.Customers.Test.Api; + +public partial class HostTests : WithApiTester +{ + [OneTimeSetUp] + public Task OneTimeSetUpAsync() => Test.DatabaseSetUpAsync(); + + [Test] + public void Swagger_UI() + { + // Hit swagger and assert redirect. + Test.Http() + .Run(HttpMethod.Get, "/swagger") + .Assert(HttpStatusCode.Found) + .AssertLocationHeader(new Uri("/swagger/index.html", UriKind.Relative)); + + // Go to redirected URL and assert basic content. + Test.Http() + .Run(HttpMethod.Get, "/swagger/index.html") + .Assert(HttpStatusCode.OK) + .GetContent().Should().Contain("Swagger UI"); + } + + [Test] + public void Swagger_Json() + { + Test.Http() + .Run(HttpMethod.Get, "/swagger/v1/swagger.json") + .Assert(HttpStatusCode.OK) + .AssertContentTypeJson() + .GetContent().Should().BeJson() + .ContainAll(["$.openapi", "$.info", "$.paths"]) + .HavePath("$.info.title").GetValue().Should().Be("Contoso.Customers.Api"); + } + + [TestCase("/health/live")] + [TestCase("/health/startup")] + [TestCase("/health/ready")] + public void Health(string path) + { + Test.Http() + .Run(HttpMethod.Get, path) + .Response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable); + } + + [TestCase("/health/live/detailed", true)] + [TestCase("/health/startup/detailed", false)] + [TestCase("/health/ready/detailed", false)] + public void Health_Detailed(string path, bool minimal) + { + // Both registered against HealthCheckTags.StartUpAndReadyOnly (not Live), matching AddHostedService's own convention - liveness reflects the process, not downstream dependencies. + string[] paths = ["$.entries.reference-data-orchestrator", "$.entries.cosmos-database"]; + + var r = Test.Http() + .Run(HttpMethod.Get, path) + .AssertContentTypeJson(); + + r.Response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.ServiceUnavailable); + + var json = r.GetContent().Should().BeJson(); + if (minimal) + json.NotContainAny(paths); + else + json.ContainAll(paths); + } +} diff --git a/samples/tests/Contoso.Customers.Test.Api/appsettings.unittest.json b/samples/tests/Contoso.Customers.Test.Api/appsettings.unittest.json new file mode 100644 index 00000000..8b2b0bcc --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Api/appsettings.unittest.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Microsoft": "Information", + "Npgsql": "Information", + "ZiggyCreatures": "Warning", + "StackExchange": "Warning" + } + }, + "CoreEx.AspNetCore.HealthChecks": { + "AreDetailedEndpointsEnabled": true + } +} diff --git a/samples/tests/Contoso.Customers.Test.Common/Contoso.Customers.Test.Common.csproj b/samples/tests/Contoso.Customers.Test.Common/Contoso.Customers.Test.Common.csproj new file mode 100644 index 00000000..ab86e201 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Common/Contoso.Customers.Test.Common.csproj @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + diff --git a/samples/tests/Contoso.Customers.Test.Common/Data/mutate-data.seed.yaml b/samples/tests/Contoso.Customers.Test.Common/Data/mutate-data.seed.yaml new file mode 100644 index 00000000..6f036592 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Common/Data/mutate-data.seed.yaml @@ -0,0 +1,10 @@ +# Cosmos 'customers' container precondition rows for CustomerMutateTests - raw camelCase document shape (matches Contoso.Customers.Infrastructure.Persistence.Customer's JSON property names), imported +# directly via CosmosDbBatch.ImportBatchAsync (no TModel typing involved). No 'partitionKey' property on any row - CustomersCosmosDb configures neither WithPartitionKey nor WithFixedPartitionKey, so every +# document resolves to PartitionKey.None (the simplest possible container shape - deliberate, see the PartitionKey.None work). +customers: +- { id: ^1, firstName: Existing, lastName: Customer, email: existing.customer@example.com, customerTypeCode: IND, contactMethodCode: EM, hasShopped: false } +- { id: ^2, firstName: Loyal, lastName: Shopper, email: loyal.shopper@example.com, customerTypeCode: IND, contactMethodCode: EM, hasShopped: true } +- { id: ^3, firstName: ToBe, lastName: Deleted, email: tobe.deleted@example.com, customerTypeCode: ORG, contactMethodCode: PH, hasShopped: false } +- { id: ^4, firstName: Concurrency, lastName: Test, email: concurrency.test@example.com, customerTypeCode: IND, contactMethodCode: SMS, hasShopped: false } +- { id: ^5, firstName: ToBe, lastName: PrePatch, email: tobe.patched@example.com, customerTypeCode: IND, contactMethodCode: EM, hasShopped: false } +- { id: ^6, firstName: NoChanges, lastName: Patch, email: nochanges.patch@example.com, customerTypeCode: IND, contactMethodCode: EM, hasShopped: false } diff --git a/samples/tests/Contoso.Customers.Test.Common/Data/read-data.seed.yaml b/samples/tests/Contoso.Customers.Test.Common/Data/read-data.seed.yaml new file mode 100644 index 00000000..19f64141 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Common/Data/read-data.seed.yaml @@ -0,0 +1,11 @@ +# Cosmos 'customers' container rows for CustomerReadTests (Get + Query) - raw camelCase document shape (matches Contoso.Customers.Infrastructure.Persistence.Customer's JSON property names), imported +# directly via CosmosDbBatch.ImportBatchAsync. Read-only fixture - every test here only reads, so all rows are freely shareable across tests. No 'partitionKey' property on any row (PartitionKey.None - +# see mutate-data.seed.yaml's equivalent note). Distinct leading letters on lastName support startswith/endswith/contains filter + ordering assertions; two rows share firstName "Alice" to prove +# multi-match filtering and the LastName tie-break order. +customers: +- { id: ^11, firstName: Alice, lastName: Anderson, email: alice.anderson@example.com, customerTypeCode: IND, contactMethodCode: EM, hasShopped: false } +- { id: ^12, firstName: Bob, lastName: Brown, email: bob.brown@example.com, customerTypeCode: IND, contactMethodCode: PH, hasShopped: true } +- { id: ^13, firstName: Charlie, lastName: Clarke, email: charlie.clarke@example.com, customerTypeCode: ORG, contactMethodCode: SMS, hasShopped: false } +- { id: ^14, firstName: Diana, lastName: Davis, email: diana.davis@example.com, customerTypeCode: ORG, contactMethodCode: EM, hasShopped: true } +- { id: ^15, firstName: Alice, lastName: Edwards, email: alice.edwards@example.com, customerTypeCode: IND, contactMethodCode: PO, hasShopped: false } +- { id: ^16, firstName: Frank, lastName: Foster, email: frank.foster@example.com, customerTypeCode: IND, contactMethodCode: EM, phone: "555-0100", hasShopped: false, shippingAddress: { street1: "1 Test Street", city: Springfield, postCode: "49007", state: IL } } diff --git a/samples/tests/Contoso.Customers.Test.Common/Data/ref-data.seed.yaml b/samples/tests/Contoso.Customers.Test.Common/Data/ref-data.seed.yaml new file mode 100644 index 00000000..b2c49604 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Common/Data/ref-data.seed.yaml @@ -0,0 +1,11 @@ +# Reference-data seed fixture, in the same $^TypeName-grouped shorthand convention used across the relational domains (e.g. Contoso.Products.Database/Data/ref-data.seed.yaml) - just code/text pairs, +# no id/isActive/sortOrder/typeDiscriminator per row. +ref-data: +- $^CustomerType: + - IND: Individual + - ORG: Organisation +- $^ContactMethod: + - EM: Email + - PH: Phone + - SMS: SMS + - PO: Postal mail diff --git a/samples/tests/Contoso.Customers.Test.Common/TestData.cs b/samples/tests/Contoso.Customers.Test.Common/TestData.cs new file mode 100644 index 00000000..47b34586 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Common/TestData.cs @@ -0,0 +1,6 @@ +namespace Contoso.Customers.Test.Common; + +/// +/// Marker class for test data used across multiple test projects in the 'Contoso.Customers' sample. +/// +public sealed class TestData { } diff --git a/samples/tests/Contoso.Customers.Test.Unit/Contoso.Customers.Test.Unit.csproj b/samples/tests/Contoso.Customers.Test.Unit/Contoso.Customers.Test.Unit.csproj new file mode 100644 index 00000000..5686d3e2 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Unit/Contoso.Customers.Test.Unit.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/tests/Contoso.Customers.Test.Unit/EntryPoint.cs b/samples/tests/Contoso.Customers.Test.Unit/EntryPoint.cs new file mode 100644 index 00000000..6f07ef9d --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Unit/EntryPoint.cs @@ -0,0 +1,30 @@ +using CoreEx.Data.Json; + +namespace Contoso.Customers.Test.Unit; + +public class EntryPoint +{ + public static void ConfigureApplication(IHostApplicationBuilder builder) + { + // Configure the minimum services required for the execution context and reference data orchestrator; caching will be in-memory for the unit tests. + builder.Services.AddExecutionContext(); + builder.Services.AddMemoryCache(); + builder.Services.AddReferenceDataOrchestrator(); + + // Reuse the "test" configured reference data. + var jdr = JsonDataReader.ParseYaml("ref-data.seed.yaml", JsonDataReaderOptions.CreateForReferenceData(JsonPropertyNamingConvention.CamelCase)); + builder.Services.AddSingleton(new ReferenceDataServiceDecorator(jdr)); + + } + + // TODO: temporary hard-coded stand-in values only, pending the real Cosmos-backed seed data fixture (see CustomersCosmosDb/ReferenceDataRepository) - replace once that seed data exists. + public class ReferenceDataServiceDecorator(JsonDataReader jdr) : ReferenceDataService(Mock.Of()) + { + public override Task GetAsync(Type type, CancellationToken cancellationToken = default) => type switch + { + _ when type == typeof(CustomerType) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("ref-data.$^CustomerType")!), + _ when type == typeof(ContactMethod) => Task.FromResult((IReferenceDataCollection)jdr.Deserialize("ref-data.$^ContactMethod")!), + _ => throw new InvalidOperationException($"Type {type.FullName} is not a known {nameof(IReferenceData)}.") + }; + } +} diff --git a/samples/tests/Contoso.Customers.Test.Unit/GlobalUsing.cs b/samples/tests/Contoso.Customers.Test.Unit/GlobalUsing.cs new file mode 100644 index 00000000..c53c8f4e --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Unit/GlobalUsing.cs @@ -0,0 +1,15 @@ +global using Contoso.Customers.Contracts; +global using Contoso.Customers.Application; +global using Contoso.Customers.Application.Validators; +global using Contoso.Customers.Application.Repositories; +global using CoreEx; +global using CoreEx.RefData; +global using CoreEx.RefData.Abstractions; +global using CoreEx.Results; +global using CoreEx.UnitTesting; +global using CoreEx.Validation; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Moq; +global using UnitTestEx; +global using ExecutionContext = CoreEx.ExecutionContext; diff --git a/samples/tests/Contoso.Customers.Test.Unit/Validators/AddressValidatorTests.cs b/samples/tests/Contoso.Customers.Test.Unit/Validators/AddressValidatorTests.cs new file mode 100644 index 00000000..dbf32144 --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Unit/Validators/AddressValidatorTests.cs @@ -0,0 +1,22 @@ +namespace Contoso.Customers.Test.Unit.Validators; + +public class AddressValidatorTests : WithGenericTester +{ + [Test] + public void Empty_Required() => Test.Scoped(test => + { + var a = new Address(); + AddressValidator.Default.AssertErrors(a, + ("street1", "Street1 is required."), + ("city", "City is required."), + ("postCode", "Post code is required."), + ("state", "State is required.")); + }); + + [Test] + public void Success() => Test.Scoped(test => + { + var a = new Address { Street1 = "1 Main Street", City = "Springfield", PostCode = "12345", State = "IL" }; + AddressValidator.Default.AssertSuccess(a); + }); +} diff --git a/samples/tests/Contoso.Customers.Test.Unit/Validators/CustomerValidatorTests.cs b/samples/tests/Contoso.Customers.Test.Unit/Validators/CustomerValidatorTests.cs new file mode 100644 index 00000000..a2c6e2dc --- /dev/null +++ b/samples/tests/Contoso.Customers.Test.Unit/Validators/CustomerValidatorTests.cs @@ -0,0 +1,52 @@ +namespace Contoso.Customers.Test.Unit.Validators; + +public class CustomerValidatorTests : WithGenericTester +{ + [Test] + public void Empty_Required() => Test.Scoped(test => + { + var c = new Customer(); + CustomerValidator.Default.AssertErrors(c, + ("firstName", "First name is required."), + ("lastName", "Last name is required."), + ("email", "Email is required."), + ("customerType", "Customer type is required."), + ("contactMethod", "Contact method is required.")); + }); + + [Test] + public void Invalid_ReferenceData() => Test.Scoped(test => + { + var c = new Customer { FirstName = "Homer", LastName = "Simpson", Email = "homer@example.com", CustomerTypeCode = "XX", ContactMethodCode = "XX" }; + CustomerValidator.Default.AssertErrors(c, + ("customerType", "Customer type is invalid."), + ("contactMethod", "Contact method is invalid.")); + }); + + [Test] + public void ShippingAddress_Invalid() => Test.Scoped(test => + { + var c = new Customer + { + FirstName = "Homer", + LastName = "Simpson", + Email = "homer@example.com", + CustomerTypeCode = "IND", + ContactMethodCode = "EM", + ShippingAddress = new Address() + }; + + CustomerValidator.Default.AssertErrors(c, + ("shippingAddress.street1", "Street1 is required."), + ("shippingAddress.city", "City is required."), + ("shippingAddress.postCode", "Post code is required."), + ("shippingAddress.state", "State is required.")); + }); + + [Test] + public void Success() => Test.Scoped(test => + { + var c = new Customer { FirstName = "Homer", LastName = "Simpson", Email = "homer@example.com", CustomerTypeCode = "IND", ContactMethodCode = "EM" }; + CustomerValidator.Default.AssertSuccess(c); + }); +} diff --git a/samples/tests/Contoso.Orders.Test.Unit/GlobalUsing.cs b/samples/tests/Contoso.Orders.Test.Unit/GlobalUsing.cs index 8bbe7c96..b4afa165 100644 --- a/samples/tests/Contoso.Orders.Test.Unit/GlobalUsing.cs +++ b/samples/tests/Contoso.Orders.Test.Unit/GlobalUsing.cs @@ -5,7 +5,7 @@ global using CoreEx.RefData; global using CoreEx.RefData.Abstractions; global using CoreEx.UnitTesting; -global using CoreEx.UnitTesting.Data; +global using CoreEx.Data.Json; global using CoreEx.Validation; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs b/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs index 7b465b95..4a2f441e 100644 --- a/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs +++ b/samples/tests/Contoso.Products.Test.Unit/GlobalUsing.cs @@ -7,7 +7,7 @@ global using CoreEx.RefData.Abstractions; global using CoreEx.Results; global using CoreEx.UnitTesting; -global using CoreEx.UnitTesting.Data; +global using CoreEx.Data.Json; global using CoreEx.Validation; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/samples/tests/Contoso.Shopping.Test.Unit/GlobalUsing.cs b/samples/tests/Contoso.Shopping.Test.Unit/GlobalUsing.cs index 3eddee93..bddfaba3 100644 --- a/samples/tests/Contoso.Shopping.Test.Unit/GlobalUsing.cs +++ b/samples/tests/Contoso.Shopping.Test.Unit/GlobalUsing.cs @@ -11,7 +11,7 @@ global using CoreEx.RefData.Abstractions; global using CoreEx.Results; global using CoreEx.UnitTesting; -global using CoreEx.UnitTesting.Data; +global using CoreEx.Data.Json; global using CoreEx.Validation; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/schema/coreex-refdata.json b/schema/coreex-refdata.json index ac8e8780..178ff6d4 100644 --- a/schema/coreex-refdata.json +++ b/schema/coreex-refdata.json @@ -55,7 +55,8 @@ "title": "The default repository implementation.", "enum": [ "None", - "EntityFramework" + "EntityFramework", + "Cosmos" ] }, "entityFrameworkRepositoryName": { @@ -63,6 +64,16 @@ "title": "The default Entity Framework (EF) repository identifier/name.", "description": "This is the .NET Entity Framework (EF) repository identifier/name that should be used within the generated code (often a private field). Defaults to \u0060_ef\u0060." }, + "cosmosRepositoryName": { + "type": "string", + "title": "The default Cosmos repository identifier/name.", + "description": "This is the .NET Cosmos repository identifier/name that should be used within the generated code (often a private field). Defaults to \u0060_cosmos\u0060." + }, + "cosmosPersistenceModel": { + "type": "boolean", + "title": "Indicates whether the Cosmos persistence model should also be generated.", + "description": "Defaults to \u0060true\u0060." + }, "contractsProjectPath": { "type": "string", "title": "The relative path for the .NET contracts-related project.", @@ -165,7 +176,8 @@ "description": "Defaults to root \u0060{Repository}\u0060.", "enum": [ "None", - "EntityFramework" + "EntityFramework", + "Cosmos" ] }, "repositoryName": { @@ -178,6 +190,16 @@ "title": "The corresponding repository model name.", "description": "Defaults to \u0060{Name}\u0060 (assumes same)." }, + "modelPlural": { + "type": "string", + "title": "The pluralized reference-data model (persistence) name.", + "description": "Defaults to \u0060{Model}\u0060 with the last word pluralized." + }, + "cosmosPersistenceModel": { + "type": "boolean", + "title": "Indicates whether the Cosmos persistence model should also be generated.", + "description": "Defaults to root \u0060{CosmosPersistenceModel}\u0060." + }, "mapper": { "type": "string", "title": "The mapper name.", diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs index 39670502..acec6d70 100644 --- a/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.MergePatch.cs @@ -70,9 +70,10 @@ public async Task PatchWithResultAsync(HttpRequest request, Fun if (gv is null || gv is not IReadOnlyETag etag) return gv; - // Where there is etag support and it is null (assumes auto-generation) then generate; and finally compare etag for a match. - //ETag.Compare(gro.ETag, etag.ETag ?? ETag.Generate(gv, JsonSerializerOptions)); - if (gro.ETag != (etag.ETag ?? ETag.Generate(gv, JsonSerializerOptions))) + // Where there is etag support and it is null (assumes auto-generation) then generate; and finally compare etag for a match. gro.ETag was already parsed (quote-bookends + // stripped) from the If-Match header by WebApiOptionsBase's constructor, but etag.ETag (the get result's own value) has not been - for a provider whose native ETag is + // itself already quote-wrapped (e.g. Cosmos DB's raw "_etag" system property), comparing the two unnormalized would always fail, even for a genuinely matching ETag. + if (gro.ETag != ETag.ParseETag(etag.ETag ?? ETag.Generate(gv, JsonSerializerOptions))) return Result.ConcurrencyError(); return gv; diff --git a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs index 4638db02..4075fd02 100644 --- a/src/CoreEx.AspNetCore/Abstractions/WebApi.cs +++ b/src/CoreEx.AspNetCore/Abstractions/WebApi.cs @@ -105,8 +105,10 @@ private WebApiResult CreateContentForValue(WebApiOptionsBase options ? Entities.ETag.Generate(json, options.Request.QueryString.ToString()) : value is IReadOnlyETag vetag && vetag.ETag is not null ? vetag.ETag : ETag.Generate(json); - // Where the request is a GET or HEAD and the ETag matches then return a 304 Not Modified. - if (options.ETag is not null && (HttpMethods.IsGet(options.Request.Method) || HttpMethods.IsHead(options.Request.Method)) && options.ETag == getag) + // Where the request is a GET or HEAD and the ETag matches then return a 304 Not Modified. options.ETag was already parsed (quote-bookends stripped) from the If-None-Match header by + // WebApiOptionsBase's constructor, but getag (above) has not been - for a provider whose native ETag is itself already quote-wrapped (e.g. Cosmos DB's raw "_etag" system property), + // comparing the two unnormalized would never match, even for a genuinely unmodified resource. The response's own ETag header (below) is left as getag, unnormalized, unaffected by this. + if (options.ETag is not null && (HttpMethods.IsGet(options.Request.Method) || HttpMethods.IsHead(options.Request.Method)) && options.ETag == Entities.ETag.ParseETag(getag)) return new WebApiResult(options.Request.HttpContext.Response) { StatusCode = HttpStatusCode.NotModified, diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs b/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs index fc3efa33..42635f0c 100644 --- a/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs +++ b/src/CoreEx.Azure.Messaging.ServiceBus/CoreExServiceBusExtensions.OpenTelemetry.cs @@ -7,15 +7,68 @@ namespace OpenTelemetry.Trace; /// public static class CoreExServiceBusExtensions { + /// + /// Gets the ServiceBusReceiver.RenewMessageLock activity name, as emitted by the Azure SDK's DiagnosticProperty.RenewMessageLockActivityName. + /// + public const string RenewMessageLockActivityName = "ServiceBusReceiver.RenewMessageLock"; + + /// + /// Gets the ServiceBusSessionReceiver.RenewSessionLock activity name, as emitted by the Azure SDK's DiagnosticProperty.RenewSessionLockActivityName. + /// + public const string RenewSessionLockActivityName = "ServiceBusSessionReceiver.RenewSessionLock"; + + /// + /// Gets the ServiceBusReceiver.Receive activity name, as emitted by the Azure SDK's DiagnosticProperty.ReceiveActivityName. + /// + /// This is a CLIENT-kind span wrapping every underlying ServiceBusReceiver.ReceiveMessagesAsync call the ServiceBusProcessor/ServiceBusSessionProcessor + /// background pump makes - it fires unconditionally on every poll, whether or not a message is returned, and is not correlated to any specific message's trace context (that correlation is + /// carried instead by ServiceBusProcessor.ProcessMessage/ServiceBusSessionProcessor.ProcessSessionMessage). + public const string ReceiveActivityName = "ServiceBusReceiver.Receive"; + /// /// Enables CoreEx OpenTelemetry instrumentation. /// /// The . + /// Indicates whether to include the Azure SDK's own background polling spans (, + /// and ); defaults to (excluded). /// The to support fluent-style method-chaining. - public static OpenTelemetryBuilder WithCoreExServiceBusTelemetry(this OpenTelemetryBuilder builder) => builder.ThrowIfNull() + /// The two lock-renewal activities fire on a timer for the lifetime of every held message/session lock (see ServiceBusProcessorOptions.MaxAutoLockRenewalDuration), and + /// fires on every underlying receive poll regardless of whether a message comes back - none of these carry business signal, they are pure volume, so they + /// are dropped by default via a custom . Set to to restore them, e.g. when actively debugging + /// lock-expiry/session-timeout behaviour or receive-call latency/batch-size. + /// The dropping wraps / (the OpenTelemetry SDK's own default) for every other activity, so no other + /// sampling behaviour changes. Note this calls : if another CoreEx (or user) OpenTelemetry extension + /// also calls SetSampler after this one, it will silently override this filter - a known, accepted limitation since no other CoreEx extension currently sets a . + public static OpenTelemetryBuilder WithCoreExServiceBusTelemetry(this OpenTelemetryBuilder builder, bool includeBackgroundPollingTelemetry = false) => builder.ThrowIfNull() .WithCoreExEventsSources() - .WithTracing(t => t.AddInvokerAsSource() - .AddSource("Azure.Messaging.ServiceBus") - .AddSource("Azure.Messaging.ServiceBus.*")) + .WithTracing(t => + { + t.AddInvokerAsSource() + .AddSource("Azure.Messaging.ServiceBus") + .AddSource("Azure.Messaging.ServiceBus.*"); + + if (!includeBackgroundPollingTelemetry) + t.SetSampler(new ServiceBusBackgroundPollingFilteringSampler(new ParentBasedSampler(new AlwaysOnSampler()))); + }) .WithMetrics(m => m.AddMeter(ServiceBusMetrics.Meter.Name)); + + /// + /// Gets a value indicating whether is one of the Azure SDK's own background polling activities (, + /// or ) that drops by default. + /// + /// The activity name (see ) to check. + /// if is a background polling activity; otherwise, . + public static bool IsBackgroundPollingActivity(string? activityName) => activityName is RenewMessageLockActivityName or RenewSessionLockActivityName or ReceiveActivityName; + + /// + /// A that drops the Azure SDK's own background polling activities (see ), delegating every other activity to an + /// . + /// + /// The to delegate to for any activity that is not one of the filtered background polling names. + private sealed class ServiceBusBackgroundPollingFilteringSampler(Sampler innerSampler) : Sampler + { + /// + public override SamplingResult ShouldSample(in SamplingParameters samplingParameters) => + IsBackgroundPollingActivity(samplingParameters.Name) ? new SamplingResult(SamplingDecision.Drop) : innerSampler.ShouldSample(samplingParameters); + } } \ No newline at end of file diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs b/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs index c828e27e..5dc97d2e 100644 --- a/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs +++ b/src/CoreEx.Azure.Messaging.ServiceBus/GlobalUsing.cs @@ -19,6 +19,7 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Diagnostics.HealthChecks; global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; global using Polly; global using Polly.CircuitBreaker; global using Polly.Retry; diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/README.md b/src/CoreEx.Azure.Messaging.ServiceBus/README.md index 09326f1c..736713c0 100644 --- a/src/CoreEx.Azure.Messaging.ServiceBus/README.md +++ b/src/CoreEx.Azure.Messaging.ServiceBus/README.md @@ -10,7 +10,7 @@ **Subscribing** is layered: `ServiceBusSubscriberBase` extends `EventSubscriberBase` to accept a raw `ServiceBusReceivedMessage`, converting it to a `CloudEvent` before delegating upward. `ServiceBusSubscribedSubscriber` adds `SubscribedManager` dispatch so that `[Subscribe]`-decorated handlers are resolved automatically from the message subject and source. The `ServiceBusReceiver` and `ServiceBusSessionReceiver` classes wrap the SDK `ServiceBusProcessor` / `ServiceBusSessionProcessor` lifetimes, and `ServiceBusReceiverHostedService` integrates them with the .NET hosted-service model including pause/resume and health-check support. -Resiliency is provided out-of-the-box: `ServiceBusReceiverResiliency` supplies factory methods for a receiver-level circuit breaker and a per-message retry pipeline (via Polly), both of which are applied by default in `ServiceBusReceiverOptionsBase`. +Resiliency is provided out-of-the-box: `ServiceBusReceiverResiliency` supplies factory methods for a receiver-level circuit breaker and a per-message retry pipeline (via Polly), both of which are applied by default in `ServiceBusReceiverOptionsBase`. Both factories are now thin wrappers over `CoreEx.Hosting`'s generic `CircuitBreakerResiliency`/`RetryResiliency` (promoted there so `CoreEx.Cosmos`'s Change Feed Processor-based outbox relay shares the exact same self-pause/self-resume behaviour) - this package supplies only the Service-Bus-specific pause-reason/dead-letter-exclusion/retry-classification wiring. ## Key capabilities @@ -19,7 +19,7 @@ Resiliency is provided out-of-the-box: `ServiceBusReceiverResiliency` supplies f - 📥 **Subscriber dispatch**: `ServiceBusSubscribedSubscriber` uses `SubscribedManager` to route each received message to the correct `[Subscribe]`-decorated handler by subject and source. - 🔄 **Session support**: `ServiceBusSessionReceiver` wraps `ServiceBusSessionProcessor`; `ServiceBusSessionStrategy` controls how `EventData.PartitionKey` is mapped to a `SessionId` (none, as-is, or converted to a bounded partition ID). - 🔧 **Hosted-service lifecycle**: `ServiceBusReceiverHostedService` integrates the receiver with `IHostedService`, forwarding start/pause/resume/stop to the underlying processor and reporting degraded health during pause. -- 🛡 **Built-in resiliency**: `ServiceBusReceiverResiliency` provides a circuit-breaker pipeline (`ReceiverResiliency`) and a per-message retry pipeline (`MessageResiliency`) pre-wired into every `ServiceBusReceiverOptionsBase` instance. +- 🛡 **Built-in resiliency**: `ServiceBusReceiverResiliency` provides a circuit-breaker pipeline (`ReceiverResiliency`) and a per-message retry pipeline (`MessageResiliency`) pre-wired into every `ServiceBusReceiverOptionsBase` instance - both delegate to `CoreEx.Hosting`'s generic `CircuitBreakerResiliency`/`RetryResiliency`. - 📊 **OpenTelemetry metrics**: `ServiceBusMetrics` exposes a `CoreEx.Azure.Messaging.ServiceBus` meter with counters for sent, failed, completed, dead-lettered, and abandoned messages, plus a send-duration histogram; `ServiceBusReceiverInvoker` wraps receive operations in activity spans. - 📡 **Dependency injection helpers**: `CoreExServiceBusExtensions` (`AddAzureServiceBusPublisher`, `AddAzureServiceBusSubscribedSubscriber`, `AzureServiceBusReceiving()`) and `CoreExServiceBusExtensions.AddAzureServiceBusOpenTelemetry` wire everything into the DI container with a single fluent call each. @@ -35,7 +35,7 @@ Resiliency is provided out-of-the-box: `ServiceBusReceiverResiliency` supplies f | **[`ServiceBusReceiverHostedService`](./ServiceBusReceiverHostedService.cs)** | `IHostedService` adapter for any `ServiceBusReceiverBase`; supports pause/resume and reports degraded health while paused. | | **[`ServiceBusReceiverOptions`](./ServiceBusReceiverOptions.cs)** | Options for `ServiceBusReceiver`; factory methods `CreateForQueue` / `CreateForTopicSubscription`; defaults to `PeekLock`, `AutoCompleteMessages=false`, `MaxConcurrentCalls=1`. | | **[`ServiceBusSessionReceiverOptions`](./ServiceBusSessionReceiverOptions.cs)** | Session-specific options for `ServiceBusSessionReceiver`. | -| **[`ServiceBusReceiverResiliency`](./ServiceBusReceiverResiliency.cs)** | Factory for Polly `ResiliencePipeline` — `CreateReceiverCircuitBreakerResiliency` and `CreateMessageRetryResiliency`; applied by default in `ServiceBusReceiverOptionsBase`. | +| **[`ServiceBusReceiverResiliency`](./ServiceBusReceiverResiliency.cs)** | Factory for Polly `ResiliencePipeline` — `CreateReceiverCircuitBreakerResiliency` and `CreateMessageRetryResiliency`; applied by default in `ServiceBusReceiverOptionsBase`. Thin wrappers over `CoreEx.Hosting.CircuitBreakerResiliency`/`RetryResiliency`. | | **[`ServiceBusSessionStrategy`](./ServiceBusSessionStrategy.cs)** | Enum: `None`, `UsePartitionKeyAsIs`, `UsePartitionKeyConvertedToAnId`; controls how the publisher assigns a `SessionId` to each outbound message. | | [IServiceBusMessageActions](./IServiceBusMessageActions.cs) | Defines complete/abandon/dead-letter/defer actions for a received message; implemented by `ProcessMessageEventArgsActions` and `ProcessSessionMessageEventArgsActions` in the Abstractions folder. | | **[`ServiceBusMetrics`](./ServiceBusMetrics.cs)** | Static class exposing the `CoreEx.Azure.Messaging.ServiceBus` `Meter` with send/receive counters and a send-duration histogram. | @@ -53,6 +53,7 @@ Resiliency is provided out-of-the-box: `ServiceBusReceiverResiliency` supplies f - **[`CoreEx.Events.Publishing`](../CoreEx.Events/Publishing/README.md)** - `IDestinationProvider` and `DestinationEvent` used by `ServiceBusPublisher` during batched dispatch. - **[`CoreEx.Events.Subscribing`](../CoreEx.Events/Subscribing/README.md)** - `ErrorHandling`, `ErrorHandler`, and subscriber exception types consumed by the receiver pipeline. - **[`CoreEx.Database.Outbox`](../CoreEx.Database/Outbox/README.md)** - Outbox relay publisher that produces events later consumed by a `ServiceBusReceiver`-based relay host. +- **[`CoreEx.Hosting`](../CoreEx/Hosting/README.md)** - `CircuitBreakerResiliency`/`RetryResiliency` are the generic self-pausing/retry pipelines that `ServiceBusReceiverResiliency` now delegates to; originally implemented here, promoted for reuse by `CoreEx.Cosmos`'s outbox relay. ## AI Usage Guide diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs index 7c1a9352..511fbab5 100644 --- a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisher.cs @@ -12,6 +12,12 @@ namespace CoreEx.Azure.Messaging.ServiceBus; /// Where are required then the must be configured accordingly. public sealed class ServiceBusPublisher(ServiceBusClient serviceBusClient, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) : EventPublisherBase(destinationProvider, formatter, logger) { + /// + /// The default , built once and shared across every instance - since is typically registered scoped (a new instance per DI scope), + /// building a fresh per instance would be wasted, avoidable allocation on what can be a busy path. + /// + private static readonly ResiliencePipeline DefaultSendResiliency = ServiceBusPublisherResiliency.CreateSendRetryResiliency(); + private readonly ServiceBusClient _serviceBusClient = serviceBusClient.ThrowIfNull(); /// @@ -62,6 +68,13 @@ public sealed class ServiceBusPublisher(ServiceBusClient serviceBusClient, IDest /// a real partition key rather than relying on this fallback. public string NoPartitionKeySessionId { get; set; } = DefaultNoPartitionKeySessionId; + /// + /// Gets or sets the applied around each send within ; defaults to . + /// + /// Consider using to adjust the retry timing/attempts while keeping the same + /// transient-failure classification, rather than constructing a pipeline from scratch. + public ResiliencePipeline SendResiliency { get; set => field = value.ThrowIfNull(); } = DefaultSendResiliency; + /// protected async override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) { @@ -129,7 +142,32 @@ await Invoker.InvokeAsync(this, async (tracer, cancellationToken) => try { - await sender.SendMessagesAsync(batch, cancellationToken).ConfigureAwait(false); + // Transient send failures (throttling, a momentary service timeout, etc.) are retried silently within this pipeline; only a genuinely sustained/permanent failure propagates. + var ctx = ResilienceContextPool.Shared.Get(cancellationToken); + try + { + ctx.Properties.Set(ResilienceOwner.PropertyKey, this); + + var result = await SendResiliency.ExecuteAsync(static async (rc, state) => + { + try + { + await state.sender.SendMessagesAsync(state.batch, rc.CancellationToken).ConfigureAwait(false); + return Result.Success; + } + catch (Exception ex) + { + return Result.Fail(ex); + } + }, ctx, (sender, batch)).ConfigureAwait(false); + + result.ThrowOnError(); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + ServiceBusMetrics.MessagesSendSent.Add(batch.Count, [ new (ServiceBusMetrics.DestinationTagName, destination) ]); } catch (Exception) @@ -175,4 +213,4 @@ private string LogNoPartitionKeyFallback(ServiceBusMessage message, string fallb return fallbackValue; } -} \ No newline at end of file +} diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisherResiliency.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisherResiliency.cs new file mode 100644 index 00000000..1f96f2e3 --- /dev/null +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusPublisherResiliency.cs @@ -0,0 +1,20 @@ +namespace CoreEx.Azure.Messaging.ServiceBus; + +/// +/// Provides factory methods for creating standardized resilience pipelines for via . +/// +public static class ServiceBusPublisherResiliency +{ + /// + /// Creates a standardized with retry capabilities for transient send failures. + /// + /// The delay between retry attempts. + /// The maximum number of retry attempts. + /// The strategy. + /// A configured instance. + /// The retry strategy is configured to handle failures classified as transient by ; any other failure is not retried. + /// Delegates the actual retry mechanics to the generic, provider-agnostic (shared with, e.g., a Cosmos DB change feed processor host) - this method only + /// supplies the service-bus-specific classification/log-owner wiring. + public static ResiliencePipeline CreateSendRetryResiliency(TimeSpan? delay = null, int maxRetryAttempts = 3, DelayBackoffType backoffType = DelayBackoffType.Exponential) + => RetryResiliency.Create(result => result.Error is ServiceBusException sbex && ServiceBusErrorClassifier.IsTransient(sbex), owner => owner.Logger ?? NullLogger.Instance, delay, maxRetryAttempts, backoffType); +} diff --git a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs index 828cfe71..6e5aec0f 100644 --- a/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs +++ b/src/CoreEx.Azure.Messaging.ServiceBus/ServiceBusReceiverResiliency.cs @@ -22,77 +22,17 @@ public static class ServiceBusReceiverResiliency /// A configured instance. /// The circuit breaker strategy is configured to handle failures that are not of type . The breaker will open based on the specified minimum throughput, /// sampling duration, failure ratio, and break duration settings, and will log events at the warning level. - /// The default settings are: minimumThroughput = 5, samplingDuration = 30s, breakDuration = 15s, maxBreakDuration = 5m, failureRatio = 0.1 + /// The default settings are: minimumThroughput = 5, samplingDuration = 30s, breakDuration = 15s, maxBreakDuration = 5m, failureRatio = 0.1 + /// Delegates the actual circuit breaker mechanics to the generic, provider-agnostic (shared with, e.g., a Cosmos DB change feed processor host) - this + /// method only supplies the service-bus-specific pause/resume/log-owner/exclusion wiring. public static ResiliencePipeline CreateReceiverCircuitBreakerResiliency(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null, TimeSpan? maxBreakDuration = null, double failureRatio = 0.1) - { - int circuitBreakerOpens = 0; - - samplingDuration ??= TimeSpan.FromSeconds(30); - breakDuration ??= TimeSpan.FromSeconds(15); - maxBreakDuration ??= TimeSpan.FromMinutes(5); - - return new ResiliencePipelineBuilder() - .AddCircuitBreaker(new CircuitBreakerStrategyOptions() - { - ShouldHandle = args => ValueTask.FromResult(args.Outcome.Result.IsFailure && args.Outcome.Result.Error is not EventSubscriberDeadLetterException), - MinimumThroughput = minimumThroughput, - SamplingDuration = samplingDuration.Value, - FailureRatio = failureRatio, - BreakDurationGenerator = args => - { - // Exponential backoff on each open, similar to: 15s, 30s, 60s, ... with a cap at 5 minutes (the default). - var n = Interlocked.Increment(ref circuitBreakerOpens); - var seconds = Math.Min(breakDuration.Value.TotalSeconds * Math.Pow(2, n - 1), maxBreakDuration.Value.TotalSeconds); - return ValueTask.FromResult(TimeSpan.FromSeconds(seconds)); - }, - OnOpened = args => - { - // Breaker is open; pause the receiver. - var owner = GetOwner(args.Context); - if (owner.Logger.IsEnabled(LogLevel.Warning)) - owner.Logger.LogWarning("Service bus receiver circuit breaker has been tripped for {BreakDuration}ms due to unhandled errors; receiver will be paused.", args.BreakDuration.TotalMilliseconds); - - var pause = args.BreakDuration.Add(TimeSpan.FromMilliseconds(100)); // Add a small buffer to ensure the breaker has fully opened before resuming. - - _ = Task.Run(async () => - { - try - { - await owner.PauseAsync($"Service bus receiver circuit breaker has been tripped; will resume automatically at: {DateTimeOffset.UtcNow.Add(pause):R}.").ConfigureAwait(false); - await Task.Delay(pause).ConfigureAwait(false); - await owner.ResumeAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - // This pause/resume is the circuit breaker's own protective mechanism; a failure here must not be silently lost as an unobserved task exception. - if (owner.Logger.IsEnabled(LogLevel.Error)) - owner.Logger.LogError(ex, "Service bus receiver circuit breaker pause/resume failed; the receiver may not have been paused/resumed as expected."); - } - }); - - return ValueTask.CompletedTask; - }, - OnHalfOpened = args => - { - var owner = GetOwner(args.Context); - if (owner.Logger.IsEnabled(LogLevel.Information)) - owner.Logger.LogInformation("Service bus receiver circuit breaker is attempting to recover in a limited state; receiver has been resumed."); - - return ValueTask.CompletedTask; - }, - OnClosed = args => - { - var owner = GetOwner(args.Context); - if (owner.Logger.IsEnabled(LogLevel.Information)) - owner.Logger.LogInformation("Service bus receiver circuit breaker has fully recovered; receiver is running."); - - // Reset after recovery. - Interlocked.Exchange(ref circuitBreakerOpens, 0); - return ValueTask.CompletedTask; - } - }) - .Build(); - } + => CircuitBreakerResiliency.Create( + "Service bus receiver", + owner => owner.Logger, + (owner, pause, cancellationToken) => owner.PauseAsync($"Service bus receiver circuit breaker has been tripped; will resume automatically at: {DateTimeOffset.UtcNow.Add(pause):R}.", cancellationToken), + (owner, cancellationToken) => owner.ResumeAsync(cancellationToken), + result => result.Error is not EventSubscriberDeadLetterException, + minimumThroughput, samplingDuration, breakDuration, maxBreakDuration, failureRatio); /// /// Creates a standardized with retry capabilities for transient message processing errors. @@ -102,35 +42,19 @@ public static ResiliencePipeline CreateReceiverCircuitBreakerResiliency( /// The strategy. /// A configured instance. /// The retry strategy is configured to handle failures that are specifically of type . The retry attempts will be made with a specified delay (defaults to two seconds) and - /// backoff strategy, and the retry attempts will be logged at the information level. + /// backoff strategy, and the retry attempts will be logged at the information level. + /// Delegates the actual retry mechanics to the generic, provider-agnostic (shared with, e.g., a Cosmos DB change feed processor host) - this method only + /// supplies the service-bus-specific classification/log-owner wiring. public static ResiliencePipeline CreateMessageRetryResiliency(TimeSpan? delay = null, int maxRetryAttempts = 3, DelayBackoffType backoffType = DelayBackoffType.Exponential) - { - return new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions() - { - ShouldHandle = args => ValueTask.FromResult(args.Outcome.Result.IsFailure && args.Outcome.Result.Error is EventSubscriberRetryException), - Delay = delay ?? TimeSpan.FromSeconds(2), - MaxRetryAttempts = maxRetryAttempts, - BackoffType = backoffType, - OnRetry = args => - { - var owner = GetOwner(args.Context); - if (owner.Logger.IsEnabled(LogLevel.Information)) - owner.Logger.LogInformation("Service bus message retry attempt {AttemptCount} in {AttemptDelay}ms.", args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds); - - return ValueTask.CompletedTask; - } - }) - .Build(); - } + => RetryResiliency.Create(result => result.Error is EventSubscriberRetryException, owner => owner.Logger, delay, maxRetryAttempts, backoffType); /// - /// Gets the used to configure and manage resilience strategies for the . + /// Gets the used to configure and manage resilience strategies for the . /// - public static ResiliencePropertyKey ResiliencePropertyKey { get; } = new(nameof(ServiceBusReceiverBase)); + public static ResiliencePropertyKey ResiliencePropertyKey => ResilienceOwner.PropertyKey; /// /// Gets the owning/invoking from the . /// - public static ServiceBusReceiverBase GetOwner(ResilienceContext context) => context.Properties.GetValue(ResiliencePropertyKey, default!); + public static ServiceBusReceiverBase GetOwner(ResilienceContext context) => ResilienceOwner.GetOwner(context); } \ No newline at end of file diff --git a/src/CoreEx.CodeGen/RefData/Config/CodeGenConfig.cs b/src/CoreEx.CodeGen/RefData/Config/CodeGenConfig.cs index c9d518c7..7ca7a83b 100644 --- a/src/CoreEx.CodeGen/RefData/Config/CodeGenConfig.cs +++ b/src/CoreEx.CodeGen/RefData/Config/CodeGenConfig.cs @@ -63,7 +63,7 @@ public class CodeGenConfig : ConfigRootBase /// Gets or sets the default repository implementation. /// [JsonPropertyName("repository")] - [CodeGenProperty("Repository", Title = "The default repository implementation.", IsMandatory = true, Options = ["None", "EntityFramework"])] + [CodeGenProperty("Repository", Title = "The default repository implementation.", IsMandatory = true, Options = ["None", "EntityFramework", "Cosmos"])] public string? Repository { get; set; } /// @@ -73,6 +73,20 @@ public class CodeGenConfig : ConfigRootBase [CodeGenProperty("Repository", Title = "The default Entity Framework (EF) repository identifier/name.", IsImportant = true, Description = "This is the .NET Entity Framework (EF) repository identifier/name that should be used within the generated code (often a private field). Defaults to `_ef`.")] public string? EntityFrameworkRepositoryName { get; set; } + /// + /// Gets or sets the default Cosmos repository identifier/name. + /// + [JsonPropertyName("cosmosRepositoryName")] + [CodeGenProperty("Repository", Title = "The default Cosmos repository identifier/name.", IsImportant = true, Description = "This is the .NET Cosmos repository identifier/name that should be used within the generated code (often a private field). Defaults to `_cosmos`.")] + public string? CosmosRepositoryName { get; set; } + + /// + /// Gets or sets a value indicating whether the Cosmos persistence model should also be generated. + /// + [JsonPropertyName("cosmosPersistenceModel")] + [CodeGenProperty("Repository", Title = "Indicates whether the Cosmos persistence model should also be generated.", IsImportant = true, Description = "Defaults to `true`.")] + public bool? CosmosPersistenceModel { get; set; } + #endregion #region Paths @@ -147,6 +161,11 @@ public class CodeGenConfig : ConfigRootBase /// public List? EntitiesWithApi => Entities?.Where(x => !(x.ExcludeApi ?? false)).ToList(); + /// + /// Gets the list of configured entities that require a Cosmos persistence model to be generated. + /// + public List? CosmosPersistenceModels => Entities?.Where(x => x.CosmosPersistenceModel ?? false).ToList(); + #endregion /// @@ -199,6 +218,11 @@ public class CodeGenConfig : ConfigRootBase /// public string? DataMappingNamespace { get; set; } + /// + /// Gets or sets the .NET namespace for the generated data models code. + /// + public string? DataModelsNamespace { get; set; } + /// protected override async Task PrepareAsync() { @@ -264,10 +288,13 @@ protected override async Task PrepareAsync() DataRepositoriesNamespace = $"{DataDirectory.Name}.{DataRepositoriesPath}"; DataMappingNamespace = $"{DataDirectory.Name}.{DataMappingPath}"; + DataModelsNamespace = $"{DataDirectory.Name}.{DataModelsPath}"; // Default the domain name from the file path (2nd to last part) if not explicitly set. Domain = DefaultWhereNull(Domain, () => parts.Length >= 2 ? parts[^2] : null) ?? throw new CodeGenException(this, nameof(Domain), $"Could not be defaulted from the file path; please explicitly set the property in the configuration."); EntityFrameworkRepositoryName = DefaultWhereNull(EntityFrameworkRepositoryName, () => "_ef"); + CosmosRepositoryName = DefaultWhereNull(CosmosRepositoryName, () => "_cosmos"); + CosmosPersistenceModel = DefaultWhereNull(CosmosPersistenceModel, () => true); IdType = DefaultWhereNull(IdType, () => "String"); CollectionSortOrder = DefaultWhereNull(CollectionSortOrder, () => "Code"); Route = DefaultWhereNull(Route, () => "/api/refdata"); diff --git a/src/CoreEx.CodeGen/RefData/Config/EntityConfig.cs b/src/CoreEx.CodeGen/RefData/Config/EntityConfig.cs index 12601397..a178175f 100644 --- a/src/CoreEx.CodeGen/RefData/Config/EntityConfig.cs +++ b/src/CoreEx.CodeGen/RefData/Config/EntityConfig.cs @@ -64,7 +64,7 @@ public class EntityConfig : ConfigBase /// Gets or sets the repository implementation. /// [JsonPropertyName("repository")] - [CodeGenProperty("Repository", Title = "The repository implementation.", IsImportant = true, Options = ["None", "EntityFramework"], Description = "Defaults to root `{Repository}`.")] + [CodeGenProperty("Repository", Title = "The repository implementation.", IsImportant = true, Options = ["None", "EntityFramework", "Cosmos"], Description = "Defaults to root `{Repository}`.")] public string? Repository { get; set; } /// @@ -81,6 +81,20 @@ public class EntityConfig : ConfigBase [CodeGenProperty("Repository", Title = "The corresponding repository model name.", IsImportant = true, Description = "Defaults to `{Name}` (assumes same).")] public string? Model { get; set; } + /// + /// Gets or sets the pluralized entity name. + /// + [JsonPropertyName("modelPlural")] + [CodeGenProperty("Repository", Title = "The pluralized reference-data model (persistence) name.", IsImportant = true, Description = "Defaults to `{Model}` with the last word pluralized.")] + public string? ModelPlural { get; set; } + + /// + /// Gets or sets a value indicating whether the Cosmos persistence model should also be generated. + /// + [JsonPropertyName("cosmosPersistenceModel")] + [CodeGenProperty("Repository", Title = "Indicates whether the Cosmos persistence model should also be generated.", Description = "Defaults to root `{CosmosPersistenceModel}`.")] + public bool? CosmosPersistenceModel { get; set; } + #endregion #region Mapping @@ -138,6 +152,23 @@ public class EntityConfig : ConfigBase /// public string? Inherits { get; set; } + /// + /// Gets or sets the contract collection's base class name. + /// + /// Must agree with 's : CoreEx.RefData.ReferenceDataCollection<TRef> (single type parameter) only accepts a TRef implementing + /// IReferenceData<string>, so a non-String must instead use the two-type-parameter CoreEx.RefData.ReferenceDataCollection<TId, TRef> - otherwise + /// the generated collection fails to compile (CS0311) against its own entity's base. + public string? CollectionInherits { get; set; } + + /// + /// Gets the C# expression the generated mapper uses to convert the persistence model's Id to the contract's Id. + /// + /// A Cosmos DB document id is always a (see CosmosDbModelBase.Id) - independent of the configured - so a of + /// Cosmos with a non-String requires parsing the persistence model's string Id into the contract's actual ; a straight assignment + /// would otherwise fail to compile (e.g. assigning a to a -typed Id). Every other combination (including all EntityFramework-backed entities, whose + /// persistence model's Id column type is expected to already agree with ) is a direct assignment. + public string? MapperIdExpression { get; set; } + /// protected override async Task PrepareAsync() { @@ -158,12 +189,25 @@ protected override async Task PrepareAsync() return string.Concat(words); }); + ModelPlural = DefaultWhereNull(ModelPlural, () => + { + // Best guess by pluralizing the last word of the name. + var words = OnRamp.Utility.StringConverter.ToSentenceCase(Model!)!.Split(' ').ToList(); + words[^1] = OnRamp.Utility.StringConverter.ToPlural(words[^1]); + return string.Concat(words); + }); + RepositoryName = DefaultWhereNull(RepositoryName, () => Repository switch { "EntityFramework" => Root!.EntityFrameworkRepositoryName, + "Cosmos" => Root!.CosmosRepositoryName, _ => "??" }); + CosmosPersistenceModel = DefaultWhereNull(CosmosPersistenceModel, () => Root!.CosmosPersistenceModel); + if (CosmosPersistenceModel == true && Repository != "Cosmos") // If the repository is not Cosmos, then we cannot generate the persistence model. + CosmosPersistenceModel = false; + Route = DefaultWhereNull(Route, () => Root!.RouteConvention switch { "KebabCase" => OnRamp.Utility.StringConverter.ToKebabCase(Plural!), @@ -180,7 +224,25 @@ protected override async Task PrepareAsync() _ => $"ReferenceData<{Name}>" }; + CollectionInherits = IdType switch + { + "Int32" => $"ReferenceDataCollection", + "Int64" => $"ReferenceDataCollection", + "Guid" => $"ReferenceDataCollection", + _ => $"ReferenceDataCollection<{Name}>" + }; + + MapperIdExpression = Repository == "Cosmos" + ? IdType switch + { + "Guid" => "global::System.Guid.Parse(source.Id!)", + "Int32" => "int.Parse(source.Id!, global::System.Globalization.CultureInfo.InvariantCulture)", + "Int64" => "long.Parse(source.Id!, global::System.Globalization.CultureInfo.InvariantCulture)", + _ => "source.Id!" + } + : "source.Id!"; + // Load the properties configuration. Properties = await PrepareCollectionAsync(Properties).ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/src/CoreEx.CodeGen/RefData/Generators/CosmosPersistenceModelGenerator.cs b/src/CoreEx.CodeGen/RefData/Generators/CosmosPersistenceModelGenerator.cs new file mode 100644 index 00000000..18fb2a0c --- /dev/null +++ b/src/CoreEx.CodeGen/RefData/Generators/CosmosPersistenceModelGenerator.cs @@ -0,0 +1,10 @@ +namespace CoreEx.CodeGen.RefData.Generators; + +/// +/// Provides the Cosmos persistence model code-generator. +/// +public class CosmosPersistenceModelGenerator : CodeGeneratorBase +{ + /// + protected override IEnumerable SelectGenConfig(CodeGenConfig config) => config.CosmosPersistenceModels ?? []; +} diff --git a/src/CoreEx.CodeGen/RefData/Templates/Contract_cs.hbs b/src/CoreEx.CodeGen/RefData/Templates/Contract_cs.hbs index 9cfa3a4c..eb76f190 100644 --- a/src/CoreEx.CodeGen/RefData/Templates/Contract_cs.hbs +++ b/src/CoreEx.CodeGen/RefData/Templates/Contract_cs.hbs @@ -31,6 +31,6 @@ public partial class {{Name}} : {{Inherits}} {{#ifeq ContractProperties.Count 0} /// /// Represents the '{{Name}}' reference-data contract collection. /// -public partial class {{Name}}Collection() : ReferenceDataCollection<{{Name}}>(ReferenceDataSortOrder.{{CollectionSortOrder}}) { } +public partial class {{Name}}Collection() : {{CollectionInherits}}(ReferenceDataSortOrder.{{CollectionSortOrder}}) { } #nullable restore \ No newline at end of file diff --git a/src/CoreEx.CodeGen/RefData/Templates/CosmosPersistenceModel_cs.hbs b/src/CoreEx.CodeGen/RefData/Templates/CosmosPersistenceModel_cs.hbs new file mode 100644 index 00000000..d6c3df94 --- /dev/null +++ b/src/CoreEx.CodeGen/RefData/Templates/CosmosPersistenceModel_cs.hbs @@ -0,0 +1,23 @@ +// + +/* + * This file is automatically generated by '{{Root.CodeGenName}}'; any changes will be lost. + */ + +#nullable enable + +namespace {{Root.DataModelsNamespace}}; + +/// Cosmos persistence reference-data model representing the '{{Text}}'. +public partial class {{Model}} : CosmosDbReferenceDataModelBase, ITypeDiscriminator +{ + /// + public string? TypeDiscriminator { get; set; } +{{#each Properties}} + + /// Gets or sets the {{Text}}. + public {{Type}} {{Name}} { get; set; }{{#ifne DefaultValue null}} = {{DefaultValue}}{{/ifne}}; +{{/each}} +} + +#nullable restore diff --git a/src/CoreEx.CodeGen/RefData/Templates/Mapper_cs.hbs b/src/CoreEx.CodeGen/RefData/Templates/Mapper_cs.hbs index 00696048..45bec937 100644 --- a/src/CoreEx.CodeGen/RefData/Templates/Mapper_cs.hbs +++ b/src/CoreEx.CodeGen/RefData/Templates/Mapper_cs.hbs @@ -16,7 +16,7 @@ internal partial class {{Mapper}} : Mapper<{{Root.DataModelsPath}}.{{Model}}, Co { var destination = new Contracts.{{Name}} { - Id = source.Id!, + Id = {{MapperIdExpression}}, Code = source.Code, Text = source.Text, Description = source.Description, diff --git a/src/CoreEx.CodeGen/RefData/Templates/Repository_cs.hbs b/src/CoreEx.CodeGen/RefData/Templates/Repository_cs.hbs index eb95b1b8..d0d940ee 100644 --- a/src/CoreEx.CodeGen/RefData/Templates/Repository_cs.hbs +++ b/src/CoreEx.CodeGen/RefData/Templates/Repository_cs.hbs @@ -21,7 +21,10 @@ public partial class ReferenceDataRepository : IReferenceDataRepository {{#ifeq Repository 'EntityFramework'}} => {{RepositoryName}}.ThrowIfNull().Model<{{Root.DataModelsPath}}.{{Name}}>().Query().ToMappedItemsAsync<{{Root.DataModelsPath}}.{{Name}}, Contracts.{{Name}}Collection, Contracts.{{Name}}>({{Mapper}}.Map, cancellationToken); {{/ifeq}} + {{#ifeq Repository 'Cosmos'}} + => {{RepositoryName}}.{{ModelPlural}}.Query().ToMappedItemsAsync({{Mapper}}.Map, cancellationToken); + {{/ifeq}} {{/each}} } -#nullable restore \ No newline at end of file +#nullable restore diff --git a/src/CoreEx.CodeGen/Scripts/ref-data-script.yaml b/src/CoreEx.CodeGen/Scripts/ref-data-script.yaml index 04ca8a28..b0b87600 100644 --- a/src/CoreEx.CodeGen/Scripts/ref-data-script.yaml +++ b/src/CoreEx.CodeGen/Scripts/ref-data-script.yaml @@ -5,4 +5,5 @@ generators: - { type: 'CoreEx.CodeGen.RefData.Generators.RootGenerator, CoreEx.CodeGen', template: 'Service_cs', file: 'ReferenceDataService.g.cs', directory: '{{Root.ApplicationDirectory.FullName}}', text: ReferenceDataService generation } - { type: 'CoreEx.CodeGen.RefData.Generators.RootGenerator, CoreEx.CodeGen', template: 'IRepository_cs', file: 'IReferenceDataRepository.g.cs', directory: '{{Root.ApplicationDirectory.FullName}}/{{Root.DataRepositoriesPath}}', text: IReferenceDataRepository generation } - { type: 'CoreEx.CodeGen.RefData.Generators.RootGenerator, CoreEx.CodeGen', template: 'Repository_cs', file: 'ReferenceDataRepository.g.cs', directory: '{{Root.DataDirectory.FullName}}/{{Root.DataRepositoriesPath}}', text: ReferenceDataRepository generation } -- { type: 'CoreEx.CodeGen.RefData.Generators.MapperGenerator, CoreEx.CodeGen', template: 'Mapper_cs', file: '{{Name}}Mapper.g.cs', directory: '{{Root.DataDirectory.FullName}}/{{Root.DataMappingPath}}', text: Mapper(s) generation } \ No newline at end of file +- { type: 'CoreEx.CodeGen.RefData.Generators.MapperGenerator, CoreEx.CodeGen', template: 'Mapper_cs', file: '{{Name}}Mapper.g.cs', directory: '{{Root.DataDirectory.FullName}}/{{Root.DataMappingPath}}', text: Mapper(s) generation } +- { type: 'CoreEx.CodeGen.RefData.Generators.CosmosPersistenceModelGenerator, CoreEx.CodeGen', template: 'CosmosPersistenceModel_cs', file: '{{Name}}.g.cs', directory: '{{Root.DataDirectory.FullName}}/{{Root.DataModelsPath}}', text: Cosmos persistence model(s) generation } diff --git a/src/CoreEx.CodeGen/docs/CodeGeneration.md b/src/CoreEx.CodeGen/docs/CodeGeneration.md index f9939caf..f32905a6 100644 --- a/src/CoreEx.CodeGen/docs/CodeGeneration.md +++ b/src/CoreEx.CodeGen/docs/CodeGeneration.md @@ -37,8 +37,10 @@ Provides the configuration for the generated repository code. Property | Description -|- -**`repository`** | The default repository implementation. Valid options are: `None`, `EntityFramework`. [Mandatory] +**`repository`** | The default repository implementation. Valid options are: `None`, `EntityFramework`, `Cosmos`. [Mandatory] **`entityFrameworkRepositoryName`** | The default Entity Framework (EF) repository identifier/name.
† This is the .NET Entity Framework (EF) repository identifier/name that should be used within the generated code (often a private field). Defaults to `_ef`. +**`cosmosRepositoryName`** | The default Cosmos repository identifier/name.
† This is the .NET Cosmos repository identifier/name that should be used within the generated code (often a private field). Defaults to `_cosmos`. +**`cosmosPersistenceModel`** | Indicates whether the Cosmos persistence model should also be generated.
† Defaults to `true`. ## Paths Provides the configuration for the paths used in code generation. diff --git a/src/CoreEx.CodeGen/docs/Entity.md b/src/CoreEx.CodeGen/docs/Entity.md index eddcc85d..3ac84426 100644 --- a/src/CoreEx.CodeGen/docs/Entity.md +++ b/src/CoreEx.CodeGen/docs/Entity.md @@ -39,9 +39,11 @@ Provides the configuration for the generated repository code. Property | Description -|- -**`repository`** | The repository implementation. Valid options are: `None`, `EntityFramework`.
† Defaults to root `{Repository}`. +**`repository`** | The repository implementation. Valid options are: `None`, `EntityFramework`, `Cosmos`.
† Defaults to root `{Repository}`. **`repositoryName`** | The repository parameter name.
† This is the .NET repository parameter name that should be used within the generated code. Defaults from root `{Repository}` and related configuration. **`model`** | The corresponding repository model name.
† Defaults to `{Name}` (assumes same). +**`modelPlural`** | The pluralized reference-data model (persistence) name.
† Defaults to `{Model}` with the last word pluralized. +`cosmosPersistenceModel` | Indicates whether the Cosmos persistence model should also be generated.
† Defaults to root `{CosmosPersistenceModel}`. ## Mapping Provides the configuration for the generated mapping code. diff --git a/src/CoreEx.Cosmos/AGENTS.md b/src/CoreEx.Cosmos/AGENTS.md new file mode 100644 index 00000000..f71e92d6 --- /dev/null +++ b/src/CoreEx.Cosmos/AGENTS.md @@ -0,0 +1,212 @@ +# CoreEx.Cosmos — AI Usage Guide + +Azure Cosmos DB implementation of the CoreEx core CRUD + query access layer pattern (model-direct and contract-to-model), structurally mirroring `CoreEx.EntityFrameworkCore`'s `EfDb`/`EfDbModel`/`EfDbMappedModel` shape, plus a `TransactionalBatch`-based transactional outbox and a Change Feed Processor-based outbox relay. + +## Registration + +```csharp +// Program.cs (host builder) +builder.AddAzureCosmosClient("Cosmos"); // Aspire resource name; registers CosmosClient + health check + telemetry +builder.Services.AddCosmosDb("MyDatabaseId"); +``` + +`AddCosmosDb` does **not** register the `CosmosClient` itself — it is resolved from DI (registered separately via Aspire's `AddAzureCosmosClient`). No custom health check is registered either, since Aspire's client integration already provides one. + +## Container access + +```csharp +public class OrderRepository(ICosmosDb cosmosDb) +{ + private readonly CosmosDbContainer _orders = cosmosDb.Container("orders", o => o.WithPartitionKey(m => m.CustomerId)); + + public Task GetAsync(string id, string customerId, CancellationToken ct = default) + => _orders.GetAsync(CompositeKey.Create(id), customerId, ct); +} +``` + +- `ICosmosDb.Container(containerId, configure?)` is cached per `(containerId, TModel)` pair - **not** `containerId` alone, since a container may legitimately host more than one type-discriminated model (see "Multi-type containers" below); the `configure` action only runs the first time for a given pair. +- `TModel` must implement `IEntityKey` (for `EntityKey`/`CompositeKey`) — everything else (`IETag`, `IPartitionKey`, `ITenantId`, `ITypeDiscriminator`, `ILogicallyDeleted`) is duck-typed via `is` checks, exactly like `EfDbModelOptions`. Use `CosmosDbModelBase` as an optional convenience base implementing the common ones. +- **Deviation from `EfDbModel`**: `GetAsync`/`DeleteAsync` (and their `WithResultAsync`/`CosmosDbArgs`-taking counterparts) each come in **two overloads** - one taking a required raw partition key `string` (in addition to the `CompositeKey`), one taking none at all - because a Cosmos DB point-read/point-delete is fundamentally two-dimensional (`id` + partition key), unlike a relational primary-key lookup. It's a raw `string` (not the SDK's opaque `PartitionKey` struct) so it can also be supplied to a paired outbox-event write inside a `CosmosDbUnitOfWork` (see "Transactional Outbox" below), which has no model instance of its own to resolve one from for a delete-only transaction. Use the no-partition-key overload to fall back to `WithFixedPartitionKey` (where configured) — otherwise `PartitionKey.None` is used (the simplest possible container shape, no per-item partitioning at all). `CreateAsync`/`UpdateAsync`/`UpsertAsync` derive the partition key from the model via `WithPartitionKey`, `WithFixedPartitionKey`, or the model's own `IReadOnlyPartitionKey.PartitionKey` (in that precedence — configuration always wins, and a configured value that disagrees with a non-null model value throws rather than silently overriding it). + +## Error Mapping + +`CosmosDbInvoker` catches `CosmosException` and maps by `StatusCode` via `ICosmosDb.HandleCosmosException`: + +| `CosmosException.StatusCode` | CoreEx exception | +|---|---| +| `404 NotFound` | `NotFoundException` | +| `409 Conflict` | `DuplicateException` | +| `412 PreconditionFailed` | `ConcurrencyException` | + +`GetAsync`'s **throwing** overload additionally honours `CosmosDbArgs.NullOnNotFound` (default `true`) — a `404` returns `null` rather than throwing. The `WithResult` (ROP) overloads always return `Result.NotFoundError()` on a `404`, irrespective of `NullOnNotFound`. `DeleteAsync` (physical delete) is idempotent — a `404` is not an error and results in `DataResult.False`. + +## Optimistic concurrency + +`UpdateAsync` maps the model's `IETag.ETag` into `ItemRequestOptions.IfMatchEtag` when `CosmosDbArgs.AutoMapETag` is `true` (the default) **and** the caller has not already supplied their own `ItemRequestOptions` — Cosmos DB enforces the check server-side and returns a `412`, which converts to `ConcurrencyException`/`Result.ConcurrencyError` automatically via the table above. + +## Multi-type containers (type discriminator) + +```csharp +cosmosDb.Container("refdata", o => o.WithTypeDiscriminator()); +cosmosDb.Container("refdata", o => o.WithTypeDiscriminator()); +``` + +`TModel` implements `ITypeDiscriminator` directly (a flat document property, auto-stamped by `Model.PrepareCreate`/`PrepareUpdate` from `SchemaAttribute.Name`); `WithTypeDiscriminator()` adds a query-time `Where` filter so several business model types can safely share one container/partition — no envelope/wrapper type is used. + +## Time-to-live + +```csharp +cosmosDb.Container("orders", o => o + .WithPartitionKey(m => m.CustomerId) + .WithTimeToLive(m => m.Status == "Closed" ? 60 * 60 * 24 * 30 : null)); // 30 days for closed orders; no expiry otherwise +``` + +`WithTimeToLive(Func)` is applied automatically on `CreateAsync`/`UpdateAsync` (after `Model.PrepareCreate`/`PrepareUpdate` stamping, before persisting) and requires `TModel` to implement the **mutable** `ITimeToLive` (throws `NotSupportedException`, checked via `TimeToLiveSupport.IsMutable` — not just `.IsSupported`, since `ITimeToLive` needs a setter to write the computed value back onto the model; there is no separate Cosmos DB SDK request-option channel for `ttl` the way there is for a partition key). Not configuring it is the common case — a model's own `ITimeToLive.TimeToLive` value (if any) just serializes through unmodified. + +## Fixed partition key + +```csharp +cosmosDb.Container("lookups", o => o.WithFixedPartitionKey("shared")); + +// GetAsync/DeleteAsync's partitionKey parameter is now optional - omit it to use the fixed value. +var lookup = await cosmosDb.Container("lookups").GetAsync(CompositeKey.Create(id)); +``` + +`WithFixedPartitionKey(string?)` configures one constant partition key value for the whole container — suitable for small, bounded containers where a high-cardinality partition key isn't needed (see the `WithFixedPartitionKey` XML doc for the underlying Cosmos DB guidance). Unlike `WithPartitionKey(Func)` (which needs a model instance, so only ever helps `CreateAsync`/`UpdateAsync`), the fixed value is also the default used by `GetAsync`/`DeleteAsync`'s no-partition-key overload — it is the only mechanism that can default those. The two are mutually exclusive (`InvalidOperationException` if both are configured), and both take **`string?`**, not the Cosmos DB SDK's `PartitionKey` struct — that struct has no public way to extract its own value back out once constructed, which matters because a configured value must be written back onto `TModel` (where it implements the mutable `IPartitionKey`) before `CreateAsync`/`UpdateAsync`: Cosmos DB rejects a write where the document body's value at the partition-key path disagrees with the value supplied for the operation, so this write-back is required for correctness, not just convenience. A non-null value the model already carries that *disagrees* with the configured one throws `InvalidOperationException` rather than being silently overridden. + +## Querying and paging + +`CosmosDbContainer.Query(query?, args?)` returns a `CosmosDbQuery` — a dedicated wrapper type, not a bare `IQueryable`. Compose additional filtering/ordering via the `query` +delegate (standard LINQ); materialize via instance methods on the wrapper: + +```csharp +var page = await cosmosDb.Container("orders") + .Query(q => q.Where(m => m.Status == "Open")) + .WithPaging(PagingArgs.Create(skip: 0, take: 25, count: true)) + .ToItemsResultAsync(); +``` + +Paging uses `Skip`/`Take` (translated by the Cosmos DB LINQ provider to `OFFSET…LIMIT`) applied via `WithPaging(PagingArgs?)`; continuation-token-based paging is not currently supported. Every +`CosmosDbQuery` materializer (`ToListAsync`, `ToItemsResultAsync`, `SingleAsync`/`FirstAsync`-family, `ToMappedItemsAsync`, `ToMappedItemsResultAsync`, each with a `WithResultAsync` ROP +counterpart) routes through `CosmosDbInvoker` — the same structured logging + `CosmosException` mapping as every CRUD operation. Use `AsQueryable(args?)` for ad-hoc `IQueryable` composition +(e.g. within a repository method); pass `new CosmosDbArgs { BypassFilters = true }` to skip only the additive `WithFilter` registrations that were themselves opted in via `allowFilterBypass: true` - +the mandatory `WithTenantFilter`/`WithLogicalDeleteFilter`/`WithTypeDiscriminator` predicates and the automatic outbox-document exclusion are always applied by `ApplyFilters` regardless of this flag, +matching `CoreEx.EntityFrameworkCore`'s `EfDbModelOptions.ApplyFilters` (tenant is never bypassable there either); `CosmosDbQuery.AsQueryable` always calls `ApplyFilters` unconditionally, it +never itself skips the call based on `BypassFilters`. + +## Transactional Outbox + +`CosmosDbUnitOfWork` implements `IUnitOfWork` directly (not a Cosmos-specific sub-interface, so application services stay provider-agnostic). Enlisted Create/Update/Delete calls made inside `TransactionAsync` accumulate into one ambient `TransactionalBatch` - Cosmos DB's only atomic multi-operation primitive, atomic only within a single container/logical partition key - and execute once, at the end. `CosmosDbEventPublisher` (an `IEventPublisher`) enlists outbox event documents into the *same* batch, so the business mutation and its event are atomic without a separate outbox table: + +```csharp +// Program.cs (host builder) +builder.Services + .AddCosmosDb("MyDatabaseId") + .AddScoped() + .AddScoped(); + +// Application service +await unitOfWork.TransactionAsync(async ct => +{ + var created = await orders.CreateAsync(order, ct); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created)); +}); +``` + +Outbox documents are identified by a reserved `$outbox` `Id` prefix and auto-excluded from ordinary business queries against the same container - no opt-in filter required. A cross-container/cross-partition-key enlistment throws `InvalidOperationException` client-side, before any network call. There is no "read your own uncommitted writes" within a unit-of-work - nothing is persisted until the batch executes at the end, so a `Query()`/`GetAsync` call inside `TransactionAsync`'s `work` delegate cannot see an earlier write from the *same* unit-of-work. + +`IUnitOfWork.SynchronizeETag(CompositeKey, T)` resolves a mapped contract's true, server-assigned `ETag` after the batch commits (correlated by `CompositeKey`, not object reference, since the object passed here is the mapped *contract* published as an event, not the *model* actually mutated) - call it only after `TransactionAsync` has completed, never from inside `work`. + +## Outbox Relay + +`CosmosDbOutboxRelay` consumes outbox event documents via a Cosmos DB [Change Feed Processor](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/change-feed-processor) - push-based and SDK-managed, not a polling loop like the SQL Server/Postgres relay - decodes/publishes/cleans up each batch, and self-pauses/self-resumes via a circuit breaker on a sustained publish-failure ratio. Unlike `AddCosmosDbEventPublisher` (the write-side registration above), `AddCosmosDbOutboxRelayHostedService` registers **only** the relay itself - a genuine *destination* `IEventPublisher` (e.g. Azure Service Bus) must also be registered, matching the SQL Server/Postgres samples, or every batch throws as soon as the Change Feed Processor delivers it: + +```csharp +// Relay host Program.cs +builder.Services.AddCosmosDb("MyDatabaseId"); +builder.Services.AddAzureServiceBusPublisher(); // the destination IEventPublisher - required; never register CosmosDbEventPublisher for this role (see below). +builder.AddCosmosDbOutboxRelayHostedService("orders"); // one call per outbox-hosting container +builder.AddCosmosDbOutboxRelayHostedService("customers", servicesCount: 1); // lower-volume container, fewer concurrent instances +``` + +`CosmosDbEventPublisher` must never be registered as this destination publisher - it is the outbox *write-side* publisher and can only publish inside an active `CosmosDbUnitOfWork.TransactionAsync` scope, which the relay's per-batch scope never has. `CosmosDbOutboxRelayProcessor.ProcessBatchAsync` detects this misconfiguration up front and throws a clear, actionable `InvalidOperationException` naming the offending container, rather than surfacing `CosmosDbEventPublisher`'s deeper "no active transaction" error. + +Poison-message/dead-letter handling is **not yet implemented** - a permanently-failing outbox document is redelivered forever by the Change Feed Processor's own native backoff (confirmed empirically against the emulator), with no built-in give-up, and can starve delivery of other, unrelated outbox documents sharing the same physical partition-key-range/lease. Since a Change Feed Processor lease checkpoints strictly in order, this isn't a bounded delay - it blocks every later change in that lease indefinitely, until either the underlying cause is fixed (so the same, already-captured change eventually succeeds) or an operator intervenes at the lease/checkpoint level; deleting the live document does not help, since the change feed record being retried is an immutable snapshot, not a live read. To be designed as one shared pattern across the SQL Server/Postgres/Cosmos relays, not Cosmos-specific. + +**Detecting a stuck lease:** `cosmos.outbox.enqueue` continuing to climb while `cosmos.outbox.relay.publish` stays flat for the same container is the signal - the write side is unaffected by a stuck relay, so a sustained divergence between the two indicates a lease is blocked. `cosmos.outbox.relay.oldest_lag`/`newest_lag` are recorded on both a successful and a failed publish attempt, so they keep climbing (rather than going silent) for as long as a batch keeps failing - alert on a sustained rise in `cosmos.outbox.relay.oldest_lag`, not just on `cosmos.outbox.relay.publish.failed`, since a low failure count can still mean one lease has been stuck for a long time. + +## Multi-set queries + +```csharp +List? animals = null; +PlantItem? plant = null; + +await cosmosDb.SelectMultiSetAsync("zoo", new MultiSetOptions +{ + PartitionKey = zooId, + MultiSetArgs = + [ + new MultiSetCollArgs, AnimalItem>(r => animals = r, minimumRows: 1), // at least one AnimalItem expected + new MultiSetSingleArgs(r => plant = r, isMandatory: false) // PlantItem is optional + ] +}); + +// For full control (a custom CosmosDbArgs and/or a CancellationToken): +await cosmosDb.SelectMultiSetAsync("zoo", new MultiSetOptions +{ + PartitionKey = zooId, + Args = new CosmosDbArgs { QueryRequestOptions = new QueryRequestOptions { MaxItemCount = 100 } }, + MultiSetArgs = [new MultiSetCollArgs, AnimalItem>(r => animals = r, minimumRows: 1)] +}, cancellationToken); +``` + +`ICosmosDb.SelectMultiSetAsync` (`Extended` namespace) reads multiple, type-discriminator-keyed sets of documents from the same container/partition in a single round-trip - the Cosmos DB equivalent of `CoreEx.Database.Extended`'s positional/ordered multi-set queries. Unlike the relational equivalent, Cosmos DB has no notion of ordered result sets: a single query instead returns a mixed stream of documents, each demuxed to its corresponding `IMultiSetArgs` by matching a server-side filter on the type-discriminator property against each `IMultiSetArgs.ResolveTypeDiscriminator(cosmosDb, containerId)` (resolved from the target `TModel`'s own configured `CosmosDbModelOptions.EffectiveTypeDiscriminator` - the explicit `WithTypeDiscriminator(value)` override where configured, otherwise the same default `WithTypeDiscriminator()` itself falls back to - so a multi-set query always agrees with whatever value is actually persisted, even where an override is configured). Co-located outbox event documents (see "Transactional Outbox" below) are always excluded server-side via the same reserved `$outbox` `id`-prefix check used by the LINQ query path - no opt-in required. Each matching document is then also checked per-item (tenant/logical-delete/additive-filter, via `CosmosDbContainer.CheckModel`) before being handed to the `IMultiSetArgs.AddItem(TModel)` implementation - a model excluded by that check is silently dropped, exactly as a single-item `GetAsync` would exclude it. + +There is a single `SelectMultiSetAsync(containerId, MultiSetOptions, cancellationToken?)` overload, plus its `Result` (Railway-Oriented Programming) counterpart `SelectMultiSetWithResultAsync` (same signature, returning `Task`) - `SelectMultiSetAsync` is a thin `ThrowOnError()` wrapper over it, consistent with every other `CosmosDbContainer` operation's `WithResultAsync` pairing; use `SelectMultiSetWithResultAsync` when composing with other `Result`/`Result` pipeline steps rather than catching exceptions. `MultiSetOptions` bundles the per-call inputs (`PartitionKey`, `Args`, `MultiSetArgs`) into one record so a future capability addition doesn't require a breaking method-contract change. + +**`Result` is for errors, not exceptions**: `SelectMultiSetWithResultAsync` only returns `Result.IsFailure` for a genuine business/domain-level outcome - a `CosmosException` mapped by `ICosmosDb.HandleCosmosException`, or a `WithFilter` authorization-style denial surfaced from a per-item `CheckModel` check (see `CosmosDbContainerFilterTests` for the equivalent single-item pattern). `MinimumRows`/`MaximumRows` being violated, a malformed/undeserializable Cosmos DB response, an empty/duplicate-discriminator `MultiSetArgs`, a `QueryRequestOptions`/`PartitionKey` mismatch, or a missing `UseSystemTextJsonSerializerWithOptions` configuration are all `ArgumentException`/`InvalidOperationException`/`NotSupportedException` guard-clause/invariant violations, not business errors - they are always thrown directly, even from this `Result`-returning method (mirroring how `CosmosDbContainer{TModel}.DeleteWithResultAsync` still throws `InvalidOperationException` directly for its own ambiguous-logical-delete-configuration guard clause). + +`TModel` must implement `IReadOnlyTypeDiscriminator` - `MultiSetSingleArgs`/`MultiSetCollArgs` enforce this via a static constructor guard (`NotSupportedException` on first use of an incompatible `TModel`, not per-instance). Each supplied `IMultiSetArgs` must resolve to a *unique* `ResolveTypeDiscriminator` value within one call. + +`MinimumRows`/`MaximumRows` are enforced, and `InvokeResult()` is invoked, in the order the `IMultiSetArgs` were supplied - `StopOnNull` (a zero-match, optional set) short-circuits any subsequent `IMultiSetArgs` in that order, exactly as it does for the relational equivalent's positionally-subsequent result sets. `MaximumRows` is checked as each matching document streams in (fails fast, before the whole feed has been read); `MinimumRows` can only be checked once the whole feed has been consumed. + +The type-discriminator's underlying JSON property name is resolved **once per call** from the ambient `CosmosClientOptions.UseSystemTextJsonSerializerWithOptions.PropertyNamingPolicy` (e.g. `camelCase`) - the same naming policy that already governs how every other model property serializes to/from Cosmos DB. This requires the underlying `CosmosClient` to be configured with a `System.Text.Json`-based serializer (already a de facto requirement for this package - see `CosmosDbModelBase`'s reliance on `[JsonPropertyName]`); throws `NotSupportedException` if it isn't. An explicit per-model override of the type-discriminator's JSON property name (e.g. via `[JsonPropertyName]` on `TypeDiscriminator` itself) is **not** supported - every `TModel` within one multi-set call must rely on the one ambient naming policy. + +**Query-level tenant/logical-delete SQL optimization**: where a model's `CosmosDbModelOptions.WithTenantFilter()`/`WithLogicalDeleteFilter()` is configured, `IMultiSetArgs.BuildFilterClause` adds an additional, defensive server-side predicate for that model's subset of the query - e.g. `(NOT IS_DEFINED(c["tenantId"]) OR c["tenantId"] = @f0_tenantId)` / `(NOT IS_DEFINED(c["isDeleted"]) OR c["isDeleted"] = false)`. Each predicate is `IS_DEFINED`-guarded so a document that predates the property being added at all is never silently excluded purely for that reason - it still reaches the always-applied per-item `CheckModel` check (unaffected by this optimization). This is a pure RU/bandwidth optimization, not a correctness guarantee on its own: a model with neither configured relies solely on `CheckModel`, exactly as before. Note `CheckModel` itself throws `InvalidOperationException` for a model implementing `IReadOnlyTenantId` with a null/empty `TenantId` (tenant stamping is expected to always have occurred - see "Multi-tenancy" in the root `README.md`), so the tenant guard's practical benefit is avoiding the RU cost of transferring such a (should-never-happen) document before it fails that check, not silently tolerating it; the logical-delete guard, by contrast, genuinely represents "not deleted" for a legacy document that predates `IsDeleted` being added. + +**`QueryRequestOptions` precedence**: `MultiSetOptions.Args.QueryRequestOptions`, where supplied, takes precedence over one freshly built from `MultiSetOptions.PartitionKey`. If it already carries a `PartitionKey` and `MultiSetOptions.PartitionKey` is also supplied, the two must agree (`.Equals`) - a genuine mismatch throws `ArgumentException` rather than silently preferring one. Where it has no `PartitionKey` set, `MultiSetOptions.PartitionKey` is layered in via a shallow clone (never mutating the caller's own, potentially shared/cached, `CosmosDbArgs`) - every other property (`MaxItemCount`, `ConsistencyLevel`, etc.) passes through unchanged. + +## Batch import & container provisioning + +`CosmosDbBatch`/`CosmosDbContainerExtensions` (`Extended` namespace) operate on raw JSON/SDK types with no dependency on the rest of this package - useful for data seeding, bulk/one-off loads, and migrations, independent of any typed `CosmosDbContainer`: + +```csharp +// Provision/reset a container, then import raw JSON straight from a JsonDataReader (CoreEx.Data.Json). +var container = await database.ReplaceOrCreateContainerAsync("orders", "/customerId"); +var jdr = JsonDataReader.ParseYaml("orders.yaml"); +await container.ImportBatchAsync(jdr, "Orders"); + +// Or import every top-level key in a fixture as its own container id. +await database.ImportBatchAsync(jdr); +``` + +`ImportBatchAsync` calls `Container.CreateItemAsync` directly per item (no `CosmosDbContainer` involved), so none of the usual cross-cutting pipeline runs - no ETag/tenant/logical-delete/type-discriminator handling, no `Model.PrepareCreate` stamping, no outbox enlistment. The caller's JSON must already carry the correct partition-key property value and (where relevant) type-discriminator value. + +## Do Not + +- Do not construct `CosmosClient` directly in application code — resolve it from DI (Aspire's `AddAzureCosmosClient`). +- Do not assume `GetAsync`/`DeleteAsync` only need a `CompositeKey` — a partition key is always required for a Cosmos DB point operation at the SDK level; the no-partition-key overload falls back to `CosmosDbModelOptions.WithFixedPartitionKey`'s configured value where set, otherwise `PartitionKey.None` (the simplest possible container shape, not an error). +- Do not configure both `WithPartitionKey` and `WithFixedPartitionKey` on the same `CosmosDbModelOptions` — they are mutually exclusive and throw `InvalidOperationException` immediately if you try. `WithPartitionKey`'s function can only ever help `CreateAsync`/`UpdateAsync` (it needs a model instance); only `WithFixedPartitionKey` also defaults `GetAsync`/`DeleteAsync`. +- Do not mutate a caller-supplied `CosmosDbArgs.ItemRequestOptions` instance expecting `AutoMapETag` to still apply — `AutoMapETag` only synthesizes an `ItemRequestOptions` when the caller has not already supplied one; set `IfMatchEtag` explicitly if you need both. +- Do not use `CosmosDbMappedContainer.Query()` — it does not exist by design; query stays model-typed (use `CosmosDbContainer.Query()` plus `CosmosDbQuery.ToMappedItemsResultAsync`/`ToMappedItemsAsync`). +- Do not call `SynchronizeETag` from inside a `TransactionAsync` `work` delegate — the batch (and therefore the true server-assigned `ETag`) has not executed yet; it throws `InvalidOperationException`. +- Do not assume a `CosmosDbOutboxRelay`/relay hosted service durably handles a permanently-failing event — see "Outbox Relay" above; there is no dead-letter mechanism yet. +- Do not materialize a query by defining a bare, generic-sounding `IQueryable` extension method (`ToListAsync`, `ToItemsResultAsync`, etc.) — `CoreEx.EntityFrameworkCore.EfDbExtensions` already defines several identically-shaped ones, and C# extension-method resolution has no precedence rule between two equally-applicable candidates: it's a hard `CS0121` ambiguous-call compile error in any file that imports both namespaces, not just a style clash. Add materializers as instance methods on `CosmosDbQuery` (or an equivalent package-owned wrapper type) instead — a different receiver type cannot collide, so plain names (`ToListAsync`, `ToItemsResultAsync`, ...) are safe there. Only fall back to a `To{Provider}XxxAsync`-prefixed `IQueryable` extension if a package genuinely cannot own a wrapper type. +- Do not use `CosmosDbBatch.ImportBatchAsync` for regular application writes — it bypasses `CosmosDbContainer`'s entire cross-cutting pipeline (ETag/concurrency, tenant/logical-delete filtering, type-discriminator stamping, outbox enlistment). Use it only for data seeding, bulk/one-off loads, or migrations where that pipeline genuinely isn't wanted. +- Do not use `SelectMultiSetAsync` with a `TModel` that has an explicit `[JsonPropertyName]` override on its `TypeDiscriminator` property — the discriminator's JSON property name is resolved once per call from the ambient naming policy only; a per-model override is silently ignored (the filter/demux will not match). Do not assume `MinimumRows` is enforced incrementally like `MaximumRows` — it can only be checked once the entire feed has been consumed, since a matching document for a given `IMultiSetArgs` may still arrive later in the stream. + +## Further Reading + +- [README](./README.md) — full API reference including `CosmosDb`, `CosmosDbContainer`, `CosmosDbModelOptions`, `CosmosDbQuery`, `CosmosDbUnitOfWork`, and the `Outbox` sub-namespace. +- [CoreEx.EntityFrameworkCore](../CoreEx.EntityFrameworkCore/AGENTS.md) — the closest structural analogue (`EfDb`/`EfDbModel`/`EfDbMappedModel`). +- [CoreEx.Database.SqlServer](../CoreEx.Database.SqlServer/AGENTS.md) / [CoreEx.Database.Postgres](../CoreEx.Database.Postgres/AGENTS.md) — the relational sibling families; compare DI/registration conventions (`AddAzureCosmosClient` vs `AddSqlServerClient`/`AddAzureNpgsqlDataSource`) and outbox relay wiring (poll-loop vs Change Feed Processor), though metric names and trace-linking are shared unchanged. diff --git a/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj new file mode 100644 index 00000000..a85e4b3d --- /dev/null +++ b/src/CoreEx.Cosmos/CoreEx.Cosmos.csproj @@ -0,0 +1,18 @@ + + + CoreEx .NET Cosmos DB extensions. + Core .NET extensions and abstractions for the development of backend services. + coreex microservices domain-based event-driven railway-oriented reference-data cosmos cosmosdb nosql database + + + + + + + + + + + + + diff --git a/src/CoreEx.Cosmos/CoreExCosmosExtensions.DependencyInjection.cs b/src/CoreEx.Cosmos/CoreExCosmosExtensions.DependencyInjection.cs new file mode 100644 index 00000000..04444ec6 --- /dev/null +++ b/src/CoreEx.Cosmos/CoreExCosmosExtensions.DependencyInjection.cs @@ -0,0 +1,88 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure - this is by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides and related extensions. +/// +public static partial class CoreExCosmosExtensions +{ + /// + /// Adds a scoped service. + /// + /// The . + /// The identifier. + /// An optional action to configure the database instance. + /// The for fluent-style method-chaining. + /// The underlying is not registered by this method; it is expected to already be registered in the (typically via Aspire's + /// builder.AddAzureCosmosClient("Cosmos") called on the host builder, which also provides connection-string resolution and telemetry - but not a health check, unlike its + /// Npgsql/SqlClient counterparts; see ). + public static IServiceCollection AddCosmosDb(this IServiceCollection services, string databaseId, Action? configure = null) + => AddCosmosDb(services, databaseId, configure); + + /// + /// Adds a scoped service. + /// + /// The . + /// The . + /// The identifier. + /// An optional action to configure the database instance. + /// The for fluent-style method-chaining. + /// The underlying is not registered by this method; it is expected to already be registered in the (typically via Aspire's + /// builder.AddAzureCosmosClient("Cosmos") called on the host builder, which also provides connection-string resolution and telemetry - but not a health check, unlike its + /// Npgsql/SqlClient counterparts; see ). No custom health check is registered by this method itself either, for the same reason + /// (it needs no -specific behavior). + public static IServiceCollection AddCosmosDb(this IServiceCollection services, string databaseId, Action? configure = null) where TCosmosDb : class, ICosmosDb + { + databaseId.ThrowIfNull(); + + return services.ThrowIfNull().AddScoped(sp => + { + var db = ActivatorUtilities.CreateInstance(sp, databaseId); + configure?.Invoke(sp, db); + return db; + }).AddScoped(sp => sp.GetRequiredService()); + } + + /// + /// Adds a scoped service. + /// + /// The . + /// Indicates whether to also register as the service. + /// The for fluent-style method-chaining. + /// Mirrors AddPostgresUnitOfWork/AddSqlServerUnitOfWork's multi-register shape (concrete type plus, optionally, the abstraction) - but unlike those, + /// takes no generic TCosmosDb/TDatabase type parameter: 's constructor already depends on the tech-agnostic interface (not a + /// concrete -derived type), and / + /// already register whichever concrete type was used as , so resolving that directly here is sufficient. + /// The optional outbox constructor parameter is left to to auto-resolve - + /// where an is registered (e.g. via ), it is + /// wired in automatically; otherwise the unit-of-work simply has no outbox support ( is ). + public static IServiceCollection AddCosmosDbUnitOfWork(this IServiceCollection services, bool addAsIUnitOfWork = true) + { + services.ThrowIfNull().AddScoped(sp => + { + var cosmosDb = sp.GetRequiredService(); + return ActivatorUtilities.CreateInstance(sp, cosmosDb); + }); + + if (addAsIUnitOfWork) + services.AddScoped(sp => sp.GetRequiredService()); + + return services; + } + + /// + /// Adds a for the registered . + /// + /// The . + /// The health check name. + /// The for fluent-style method-chaining. + /// Aspire's own Cosmos DB client integration (Aspire.Microsoft.Azure.Cosmos) does not register a health check of its own, unlike its Npgsql/SqlClient counterparts - this fills that + /// gap; see . Registered against HealthCheckTags.StartUpAndReadyOnly (matching AddHostedService's own health-check registration convention), not + /// - liveness should reflect whether the process itself is running, not the reachability of a downstream dependency. + public static IServiceCollection AddCosmosDbHealthCheck(this IServiceCollection services, string name = "cosmos-database") + { + services.ThrowIfNull().AddHealthChecks().AddCheck(name.ThrowIfNullOrEmpty(), tags: HealthCheckTags.StartUpAndReadyOnly); + return services; + } +} diff --git a/src/CoreEx.Cosmos/CoreExCosmosExtensions.OpenTelemetry.cs b/src/CoreEx.Cosmos/CoreExCosmosExtensions.OpenTelemetry.cs new file mode 100644 index 00000000..5ee4434b --- /dev/null +++ b/src/CoreEx.Cosmos/CoreExCosmosExtensions.OpenTelemetry.cs @@ -0,0 +1,22 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace OpenTelemetry.Trace; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides standard extensions. +/// +public static class CoreExCosmosExtensions +{ + /// + /// Enables CoreEx OpenTelemetry instrumentation. + /// + /// The . + /// The to support fluent-style method-chaining. + /// Deliberately does not register / as tracing sources - both disable tracing + /// themselves (IsTracingDisabled) since CRUD/unit-of-work operations are high-frequency; registering their (never-emitted) source would be dead weight. Only + /// is registered, mirroring WithCoreExPostgresTelemetry/WithCoreExSqlServerTelemetry's relay-only tracing choice. + public static OpenTelemetryBuilder WithCoreExCosmosDbTelemetry(this OpenTelemetryBuilder builder) => builder.ThrowIfNull() + .WithCoreExEventsSources() // Included here as they are leveraged by the Cosmos DB Outbox capabilities. + .WithTracing(t => t.AddInvokerAsSource()) + .WithMetrics(m => m.AddMeter(CoreEx.Cosmos.CosmosMetrics.Meter.Name)); +} diff --git a/src/CoreEx.Cosmos/CosmosDb.cs b/src/CoreEx.Cosmos/CosmosDb.cs new file mode 100644 index 00000000..96c56b05 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDb.cs @@ -0,0 +1,96 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides the core Azure Cosmos DB functionality. +/// +/// The converts the following pre-determined values: +/// +/// (404) -> . +/// (409) -> . +/// (412) -> . +/// +/// The is not created/owned by ; it is expected to be resolved from dependency injection (typically registered via Aspire's +/// builder.AddAzureCosmosClient("Cosmos")) and shared across the application. +public class CosmosDb : ICosmosDb +{ + private readonly ConcurrentDictionary _containers = new(); + + // Keyed by (containerId, TModel) - see the identical rationale on CosmosDbOptions._models; a container legitimately hosts multiple distinct model types (type-discriminator sharing), so containerId + // alone would let the first TModel requested for a given containerId "win" the cache slot, with every other type sharing that containerId throwing InvalidCastException. + private readonly ConcurrentDictionary<(string ContainerId, Type ModelType), object> _modelContainers = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The identifier. + /// The optional (typically a singleton service or statically declared). + /// The optional . + /// The optional . + /// The optional . + public CosmosDb(CosmosClient client, string databaseId, CosmosDbOptions? options = null, CosmosDbInvoker? invoker = null, ExecutionContext? executionContext = null, ILogger? logger = null) + { + Client = client.ThrowIfNull(); + Database = Client.GetDatabase(databaseId.ThrowIfNull()); + Options = options ?? new CosmosDbOptions(); + Invoker = invoker ?? CosmosDbInvoker.Default; + ExecutionContext = executionContext ?? ExecutionContext.Current; + Logger = logger; + } + + /// + public CosmosClient Client { get; } + + /// + public Database Database { get; } + + /// + public CosmosDbInvoker Invoker { get; set => field = value.ThrowIfNull(); } + + /// + public CosmosDbArgs DbArgs => Options.Args; + + /// + public ExecutionContext ExecutionContext { get; } + + /// + public CosmosDbOptions Options { get; } + + /// + /// Gets the . + /// + protected ILogger? Logger { get; } + + /// + public CosmosDbTransaction? CurrentTransaction { get; private set; } + + /// + public void UseTransaction(CosmosDbTransaction? transaction) => CurrentTransaction = transaction; + + /// + public Container GetContainer(string containerId) => _containers.GetOrAdd(containerId.ThrowIfNull(), cid => Database.GetContainer(cid)); + + /// + /// Where a does not yet exist for this / combination, is + /// invoked exactly once against it (delegated to - see its remarks for why this is + /// deliberately once-only rather than being re-invoked by every new instance/scope that subsequently requests the same container/model). + public CosmosDbContainer Container(string containerId, Action>? configure = null) where TModel : class, IEntityKey, new() + => (CosmosDbContainer)_modelContainers.GetOrAdd((containerId.ThrowIfNull(), typeof(TModel)), + key => new CosmosDbContainer(this, GetContainer(key.ContainerId), Options.GetOrAddModelOptions(key.ContainerId, configure))); + + /// + public Exception? HandleCosmosException(CosmosException cex) => OnCosmosException(cex.ThrowIfNull()); + + /// + /// Provides the handling as a result of . + /// + /// The . + /// The converted where handled; otherwise, . + protected virtual Exception? OnCosmosException(CosmosException cex) => cex.StatusCode switch + { + HttpStatusCode.NotFound => new NotFoundException(null, cex), + HttpStatusCode.Conflict => new DuplicateException(null, cex), + HttpStatusCode.PreconditionFailed => new ConcurrencyException(null, cex), + _ => null + }; +} diff --git a/src/CoreEx.Cosmos/CosmosDbArgs.cs b/src/CoreEx.Cosmos/CosmosDbArgs.cs new file mode 100644 index 00000000..8753c6b7 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbArgs.cs @@ -0,0 +1,51 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides the / arguments. +/// +/// The is intended, and expected, to be immutable. Therefore, when implementing/extending, please ensure additional properties are enabled as such to ensure there are +/// not any unintended side-effects. +/// There is deliberately no static/default PartitionKey here; the partition key is derived per-model (see ) since a +/// instance is commonly shared/cached across callers and cannot itself close over one fixed value. +public record class CosmosDbArgs : IDataArgs +{ + /// + /// Indicates whether a 404 Not Found response for a Get operation results in a (rather than a ) for the throwing (non-WithResult) method overloads. + /// + /// Defaults to . The WithResult (ROP) overloads always return a failure on a 404 irrespective of this setting. + public bool NullOnNotFound { get; init; } = true; + + /// + /// Indicates whether the model's is automatically mapped into the ItemRequestOptions.IfMatchEtag for an Update operation to enable native Cosmos DB optimistic concurrency. + /// + /// Defaults to . Where the model does not implement this has no effect. + public bool AutoMapETag { get; init; } = true; + + /// + /// Indicates whether the data should be refreshed (re-selected) after a Create or Update operation. + /// + /// Defaults to . Given the Cosmos DB SDK already returns the persisted resource (see ) as part of a create/update/upsert response, this is + /// rarely required; it is provided primarily for structural/behavioral parity with other CoreEx data access layers. + public bool Refresh { get; init; } = false; + + /// + /// Gets or sets the applied to point operations (Get/Create/Update/Delete). + /// + public ItemRequestOptions? ItemRequestOptions { get; init; } + + /// + /// Gets or sets the applied to operations. + /// + public QueryRequestOptions? QueryRequestOptions { get; init; } + + /// + /// Indicates whether to bypass any filters configured via that were opted in to being bypassable (allowFilterBypass). + /// + /// Defaults to . Important: this never affects the built-in tenant (), logical-delete + /// () or type-discriminator () checks — those are applied + /// unconditionally, for both queries () and point operations (), regardless of this setting. + /// A caller that sets expecting a point GetAsync/DeleteAsync to surface a soft-deleted row or another tenant's row will still get a / + /// — only an additive registration explicitly marked allowFilterBypass: true is ever actually bypassed + /// by this flag. + public bool BypassFilters { get; init; } = false; +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.Create.cs b/src/CoreEx.Cosmos/CosmosDbContainer.Create.cs new file mode 100644 index 00000000..d2cddc57 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.Create.cs @@ -0,0 +1,85 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbContainer +{ + /// + /// Creates the . + /// + /// The model. + /// The . + /// The containing the created model. + public Task> CreateAsync(TModel model, CancellationToken cancellationToken = default) => CreateAsync(Args, model, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The model. + /// The . + /// The containing the created model. + public async Task> CreateAsync(CosmosDbArgs args, TModel model, CancellationToken cancellationToken = default) => (await CreateWithResultInternalAsync(args, model, nameof(CreateAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates the . + /// + /// The model. + /// The . + /// The containing the created model. + public Task>> CreateWithResultAsync(TModel model, CancellationToken cancellationToken = default) => CreateWithResultAsync(Args, model, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The model. + /// The . + /// The containing the created model. + public Task>> CreateWithResultAsync(CosmosDbArgs args, TModel model, CancellationToken cancellationToken = default) => CreateWithResultInternalAsync(args, model, nameof(CreateWithResultAsync), cancellationToken); + + /// + /// Creates the model (internal). + /// + private async Task>> CreateWithResultInternalAsync(CosmosDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + model.ThrowIfNull(); + + if (model is IReadOnlyLogicallyDeleted ld && ld.IsDeleted) + throw new InvalidOperationException($"Cannot create a model with a deleted state; {nameof(ILogicallyDeleted.IsDeleted)} must be false."); + + return await CosmosDb.Invoker.InvokeAsync(CosmosDb, args.ThrowIfNull(), async (_, args, cancellationToken) => + { + // Prepare the model (stamps ITenantId/ITypeDiscriminator/IChangeLog as applicable). + Model.PrepareCreate(model, CosmosDb.ExecutionContext); + + // Override with an explicit WithTypeDiscriminator value where configured (see CosmosDbModelOptions.ApplyTypeDiscriminator) - a no-op otherwise; must run after PrepareCreate above, + // which otherwise leaves its own default (Schema/type name) stamped instead. + Options.ApplyTypeDiscriminator(model); + + // Apply a computed time-to-live where configured (see CosmosDbModelOptions.WithTimeToLive) - a no-op otherwise. + Options.ApplyTimeToLive(model); + + // Check model is valid. + var r = CheckModel(args, model, OperationType.Create); + if (r.IsFailure) + return r.Bind(); + + var partitionKeyValue = Options.GetPartitionKeyValue(model); + var partitionKey = CosmosDbModelOptions.ToPartitionKey(partitionKeyValue); + + // Where an ambient CosmosDbUnitOfWork transaction is active, enlist (queue) rather than execute immediately - see CosmosDbUnitOfWork for the full deferred-execution/atomicity model. The model's + // ETag is not yet final at this point (the batch has not executed) - see IUnitOfWork.SynchronizeETag for how a caller resolves the true, persisted ETag once the unit-of-work has committed. + var txn = CosmosDb.CurrentTransaction; + if (txn is not null) + { + txn.Enlist(Container, partitionKey, partitionKeyValue, Options.GetKeyFromModel(model), b => b.CreateItem(model)); + return Result.Ok(new DataResult(model, true)); + } + + var response = await Container.CreateItemAsync(model, partitionKey, BuildItemRequestOptions(args), cancellationToken).ConfigureAwait(false); + + // Refresh as required (rarely needed given the SDK already returns the persisted resource). + var pr = await RefreshPostMutationAsync(args, response.Resource, partitionKey, memberName, cancellationToken).ConfigureAwait(false); + return pr.ThenAs(m => new DataResult(m, true)); + }, cancellationToken, memberName).ConfigureAwait(false); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.Delete.cs b/src/CoreEx.Cosmos/CosmosDbContainer.Delete.cs new file mode 100644 index 00000000..8c0c9b02 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.Delete.cs @@ -0,0 +1,200 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbContainer +{ + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A . + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known - see its remarks for + /// the full delete semantics (idempotency, logical delete, unit-of-work enlistment). + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteAsync(Args, key, cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// A . + /// A delete is considered idempotent (a 404 is not treated as an error) unless logical delete is active (see ), in which case a + /// missing document results in a equivalent (as a read-modify-write is required to logically delete). + /// Unless the has none of , logical delete, + /// registrations, or configured, a delete first performs a read (the same tenant-ownership/filter/type-discriminator checks + /// GetAsync/UpdateAsync apply) before deleting by key — a raw DeleteItemAsync call has no other way to enforce them, since Cosmos DB deletes purely by id + partition key with no + /// awareness of tenant/filter/type-discriminator concerns. Where none of those are configured and there is no active + /// , there is nothing for the pre-read to catch, so it is skipped entirely and the delete goes straight to Cosmos DB — the common case for a plain, key-based, high-volume + /// delete pays no extra read cost. + /// Inside an active , the pre-read is always forced, for a different reason: Cosmos DB's TransactionalBatch fails the whole batch if any + /// enlisted operation targets a non-existent item — unlike a standalone delete, it has no tolerance for a "not found" being a benign no-op — so existence must be confirmed before a delete is safely + /// enlisted alongside any other operations in the same unit-of-work. A useful consequence: the returned is known accurately and synchronously even inside a unit-of-work, + /// so a caller can use the same DataResult.WhereMutated(v => unitOfWork.Events.Add(...))-style pattern to decide whether to queue a "deleted" event, exactly as for the non-transactional path. + /// Since is a raw (unlike the SDK's opaque struct), it also flows through to a paired + /// outbox-event write inside an active , which otherwise has no model instance of its own to resolve one from for a delete-only + /// transaction. + public Task DeleteAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => DeleteAsync(Args, key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A . + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public async Task DeleteAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => (await DeleteWithResultInternalAsync(args, key, Options.GetPartitionKeyValue((string?)null), nameof(DeleteAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// A . + public async Task DeleteAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) + => (await DeleteWithResultInternalAsync(args, key, Options.GetPartitionKeyValue(partitionKey.ThrowIfNull()), nameof(DeleteAsync), cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A . + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public Task> DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(Args, key, cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// A . + public Task> DeleteWithResultAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => DeleteWithResultAsync(Args, key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A . + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public Task> DeleteWithResultAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => DeleteWithResultInternalAsync(args, key, Options.GetPartitionKeyValue((string?)null), nameof(DeleteWithResultAsync), cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// A . + public Task> DeleteWithResultAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) + => DeleteWithResultInternalAsync(args, key, Options.GetPartitionKeyValue(partitionKey.ThrowIfNull()), nameof(DeleteWithResultAsync), cancellationToken); + + /// + /// Deletes the model (internal). + /// + private async Task> DeleteWithResultInternalAsync(CosmosDbArgs args, CompositeKey key, string? partitionKeyValue, string memberName, CancellationToken cancellationToken = default) => await CosmosDb.Invoker.InvokeAsync(CosmosDb, args.ThrowIfNull(), async (_, args, cancellationToken) => + { + var partitionKey = CosmosDbModelOptions.ToPartitionKey(partitionKeyValue); + + // Logical delete (ambiguous exception) - a type-level configuration issue, not model-instance-dependent, so fail fast before fetching anything. + if (Options.LogicalDeleteSupport.IsReadOnly) + throw new InvalidOperationException($"The model implements {nameof(IReadOnlyLogicallyDeleted)} which is ambiguous for a delete operation; the model must implement {nameof(ILogicallyDeleted)} not {nameof(IReadOnlyLogicallyDeleted)}."); + + var id = Options.FormatIdentifier(key); + + async Task> PhysicalDeleteAsync() + { + // Where an ambient CosmosDbUnitOfWork transaction is active, enlist (queue) rather than execute immediately. By the time this is reached with a txn active, existence has always already been + // confirmed by the forced pre-read below (see the fast-path condition) - enlisting a DeleteItem for something already known to exist is safe; TransactionalBatch has no tolerance at all for + // deleting a non-existent item (it fails the whole batch, confirmed empirically), unlike the non-batch DeleteItemAsync call below. partitionKeyValue is the caller-supplied raw value (see + // DeleteAsync's partitionKey parameter) - unlike Create/Update, there is no model instance here to read one back from, so a Delete-only unit-of-work can only provide a paired outbox event + // write (see CosmosDbEventPublisher) with the partition key value when the caller explicitly supplies one. + var txn = CosmosDb.CurrentTransaction; + if (txn is not null) + { + var itemOptions = BuildItemRequestOptions(args); + var batchOptions = itemOptions is null ? null : new TransactionalBatchItemRequestOptions { IfMatchEtag = itemOptions.IfMatchEtag }; + txn.Enlist(Container, partitionKey, partitionKeyValue, key, b => b.DeleteItem(id, batchOptions)); + return Result.Ok(DataResult.True); + } + + try + { + await Container.DeleteItemAsync(id, partitionKey, BuildItemRequestOptions(args), cancellationToken).ConfigureAwait(false); + return Result.Ok(DataResult.True); + } + catch (CosmosException cex) when (cex.StatusCode == HttpStatusCode.NotFound) + { + // A delete is considered idempotent; a 'not found' is not an error. + return Result.Ok(DataResult.False); + } + } + + // Fast path: nothing is configured for CheckModel to check (no ITenantId/IReadOnlyTenantId support, no logical delete, no WithFilter registrations, no WithTypeDiscriminator configuration) AND + // there is no active CosmosDbUnitOfWork - go straight to Cosmos DB with no pre-read at all. This is the low-cost path for the common case of a plain, key-based physical delete. + // + // Inside an active unit-of-work, the pre-read is forced even when none of the above are configured, for an entirely different reason: TransactionalBatch is all-or-nothing, and deleting a + // non-existent item inside a batch fails the WHOLE batch (confirmed empirically) - unlike a standalone DeleteItemAsync call, which tolerates a 404 as an idempotent no-op. Without confirming + // existence first, a delete-of-something-already-gone would silently also discard any other legitimate Create/Update operations enlisted in the same unit-of-work. This pre-read is also what + // makes the returned DataResult.WasMutated accurate for a delete performed inside a unit-of-work (see PhysicalDeleteAsync above) - a caller can use the same DataResult.WhereMutated(...)-style + // pattern to decide whether to queue a "Deleted" event, exactly as it already does for the non-transactional path. + if (Options.LogicalDeleteSupport.IsNone && !Options.TenantSupport.IsSupported && !Options.HasFilters && !Options.IsTypeDiscriminatorFilterEnabled && CosmosDb.CurrentTransaction is null) + return await PhysicalDeleteAsync().ConfigureAwait(false); + + // Fetch first (via CheckModel) so tenant ownership and any configured WithFilter checks are enforced consistently with Get/Update for both a physical and a logical delete - a physical + // DeleteItemAsync/ReplaceItemAsync call has no other opportunity to apply them, as Cosmos DB deletes/replaces purely by id + partition key with no awareness of our tenant/filter concerns. + var gr = await GetWithResultInternalAsync(args, key, partitionKey, memberName, treatNullAsNotFound: false, cancellationToken).ConfigureAwait(false); + if (gr.IsFailure) + return gr.Bind(); + + if (gr.Value is null) + return Result.Ok(DataResult.False); + + var model = gr.Value; + + // Physical delete (tenant support and/or filters are configured, hence the pre-read above; still no logical delete). + if (Options.LogicalDeleteSupport.IsNone) + { + // A 'not found' here (a race between the fetch above and this call) is still idempotent, same as the fast path. + return await PhysicalDeleteAsync().ConfigureAwait(false); + } + + // Logical delete (read-modify-write); reuses the already-fetched/checked model. + ((ILogicallyDeleted)model).IsDeleted = true; + Model.PrepareUpdate(model, CosmosDb.ExecutionContext); + + // Override with an explicit WithTypeDiscriminator value where configured (see CosmosDbModelOptions.ApplyTypeDiscriminator) - a no-op otherwise; must run after PrepareUpdate above, + // which otherwise leaves its own default (Schema/type name) stamped instead. + Options.ApplyTypeDiscriminator(model); + + var options = BuildItemRequestOptions(args); + if (options is null && args.AutoMapETag && model is IReadOnlyETag etag && !string.IsNullOrEmpty(etag.ETag)) + options = new ItemRequestOptions { IfMatchEtag = etag.ETag }; + + // Where an ambient CosmosDbUnitOfWork transaction is active, enlist (queue) rather than execute immediately - see the equivalent comment in PhysicalDeleteAsync above; partitionKeyValue flows + // through the same way here too. + var logicalDeleteTxn = CosmosDb.CurrentTransaction; + if (logicalDeleteTxn is not null) + { + var batchOptions = options is null ? null : new TransactionalBatchItemRequestOptions { IfMatchEtag = options.IfMatchEtag }; + logicalDeleteTxn.Enlist(Container, partitionKey, partitionKeyValue, key, b => b.ReplaceItem(id, model, batchOptions)); + return Result.Ok(DataResult.True); + } + + await Container.ReplaceItemAsync(model, id, partitionKey, options, cancellationToken).ConfigureAwait(false); + return Result.Ok(DataResult.True); + }, cancellationToken, memberName).ConfigureAwait(false); +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.Get.cs b/src/CoreEx.Cosmos/CosmosDbContainer.Get.cs new file mode 100644 index 00000000..2f785526 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.Get.cs @@ -0,0 +1,108 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbContainer +{ + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The model where found; otherwise, (see ). + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(Args, key, cancellationToken); + + /// + /// Gets the model for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// The model where found; otherwise, (see ). + public Task GetAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => GetAsync(Args, key, partitionKey, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The . + /// The model where found; otherwise, (see ). + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public async Task GetAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => (await GetWithResultInternalAsync(args, key, Options.GetPartitionKey((string?)null), nameof(GetAsync), treatNullAsNotFound: !args.ThrowIfNull().NullOnNotFound, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the model for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// The model where found; otherwise, (see ). + public async Task GetAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) + => (await GetWithResultInternalAsync(args, key, Options.GetPartitionKey(partitionKey.ThrowIfNull()), nameof(GetAsync), treatNullAsNotFound: !args.ThrowIfNull().NullOnNotFound, cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The model. + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(Args, key, cancellationToken); + + /// + /// Gets the model for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// The model. + public Task> GetWithResultAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => GetWithResultAsync(Args, key, partitionKey, cancellationToken); + + /// + /// Gets the model for the specified . + /// + /// The . + /// The . + /// The . + /// The model. + /// No partition key value is supplied - falls back to 's configured value where set, otherwise + /// . Use where a per-item partition key value is known. + public async Task> GetWithResultAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + => (await GetWithResultInternalAsync(args, key, Options.GetPartitionKey((string?)null), nameof(GetWithResultAsync), treatNullAsNotFound: true, cancellationToken).ConfigureAwait(false)).ThenAs(v => v!); + + /// + /// Gets the model for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// The model. + public async Task> GetWithResultAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) + => (await GetWithResultInternalAsync(args, key, Options.GetPartitionKey(partitionKey.ThrowIfNull()), nameof(GetWithResultAsync), treatNullAsNotFound: true, cancellationToken).ConfigureAwait(false)).ThenAs(v => v!); + + /// + /// Gets the model (internal). + /// + private async Task> GetWithResultInternalAsync(CosmosDbArgs args, CompositeKey key, PartitionKey partitionKey, string memberName, bool treatNullAsNotFound, CancellationToken cancellationToken) + => await CosmosDb.Invoker.InvokeAsync(CosmosDb, args.ThrowIfNull(), async (_, args, cancellationToken) => + { + var id = Options.FormatIdentifier(key); + + try + { + var response = await Container.ReadItemAsync(id, partitionKey, BuildItemRequestOptions(args), cancellationToken).ConfigureAwait(false); + return CheckModel(args, response.Resource, OperationType.Get, treatNullAsNotFound); + } + catch (CosmosException cex) when (cex.StatusCode == HttpStatusCode.NotFound) + { + // A 'not found' for a Get is not necessarily an error; whether it results in a Result-level not-found error depends on the caller's intent (treatNullAsNotFound). + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + } + }, cancellationToken, memberName).ConfigureAwait(false); +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.Query.cs b/src/CoreEx.Cosmos/CosmosDbContainer.Query.cs new file mode 100644 index 00000000..775988ad --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.Query.cs @@ -0,0 +1,17 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbContainer +{ + /// + /// Creates a to compose (via ) and materialize a query against the underlying . + /// + /// The optional function to further compose the underlying (e.g. Where/OrderBy) prior to any -configured + /// filters being applied (see ). + /// The optional . + /// The . + /// Paging uses Skip/Take (translated by the Cosmos DB LINQ provider to OFFSET…LIMIT) via ; continuation-token-based + /// paging is not currently supported. + /// Every materializer (e.g. ToListAsync, ToItemsResultAsync) routes through for structured logging and + /// to CoreEx exception mapping, matching every other operation. + public CosmosDbQuery Query(Func, IQueryable>? query = null, CosmosDbArgs? args = null) => new(this, args ?? Args, query); +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.Update.cs b/src/CoreEx.Cosmos/CosmosDbContainer.Update.cs new file mode 100644 index 00000000..34035878 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.Update.cs @@ -0,0 +1,108 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbContainer +{ + /// + /// Updates the . + /// + /// The model. + /// The . + /// The containing the updated model. + public Task> UpdateAsync(TModel model, CancellationToken cancellationToken = default) => UpdateAsync(Args, model, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The model. + /// The . + /// The containing the updated model. + public async Task> UpdateAsync(CosmosDbArgs args, TModel model, CancellationToken cancellationToken = default) => (await UpdateWithResultInternalAsync(args, model, nameof(UpdateAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Updates the . + /// + /// The model. + /// The . + /// The containing the updated model. + public Task>> UpdateWithResultAsync(TModel model, CancellationToken cancellationToken = default) => UpdateWithResultAsync(Args, model, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The model. + /// The . + /// The containing the updated model. + public Task>> UpdateWithResultAsync(CosmosDbArgs args, TModel model, CancellationToken cancellationToken = default) => UpdateWithResultInternalAsync(args, model, nameof(UpdateWithResultAsync), cancellationToken); + + /// + /// Updates the model (internal). + /// + private async Task>> UpdateWithResultInternalAsync(CosmosDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + model.ThrowIfNull(); + + if (model is IReadOnlyLogicallyDeleted ld && ld.IsDeleted) + throw new InvalidOperationException($"Cannot update a model and set to the deleted state ({nameof(ILogicallyDeleted.IsDeleted)} must be false); use the delete operation to perform."); + + return await CosmosDb.Invoker.InvokeAsync(CosmosDb, args.ThrowIfNull(), async (_, args, cancellationToken) => + { + // Prepare the model (stamps ITenantId/ITypeDiscriminator/IChangeLog as applicable). + Model.PrepareUpdate(model, CosmosDb.ExecutionContext); + + // Override with an explicit WithTypeDiscriminator value where configured (see CosmosDbModelOptions.ApplyTypeDiscriminator) - a no-op otherwise; must run after PrepareUpdate above, + // which otherwise leaves its own default (Schema/type name) stamped instead. + Options.ApplyTypeDiscriminator(model); + + // Apply a computed time-to-live where configured (see CosmosDbModelOptions.WithTimeToLive) - a no-op otherwise. + Options.ApplyTimeToLive(model); + + // Check model is valid. + var r = CheckModel(args, model, OperationType.Update); + if (r.IsFailure) + return r.Bind(); + + var partitionKeyValue = Options.GetPartitionKeyValue(model); + var partitionKey = CosmosDbModelOptions.ToPartitionKey(partitionKeyValue); + var id = Options.FormatIdentifier(Options.GetKeyFromModel(model)); + + // The CheckModel call above only validates the incoming model, which is always self-consistent - Model.PrepareUpdate stamps its ITenantId/ITypeDiscriminator from the caller's own execution + // context/type before the check runs - so it cannot detect that the PERSISTED document at this id/partition actually belongs to a different tenant, is logically deleted, fails an additive + // WithFilter authorization rule, or belongs to a different configured type. Unless none of those are configured (mirrors the equivalent Delete fast-path condition exactly), the existing + // document must be read and validated first via CheckModel(OperationType.Get) - a blind ReplaceItemAsync/batch enlistment has no other way to enforce them, since Cosmos DB replaces purely by + // id + partition key with no awareness of these concerns. A missing document surfaces as the same Result.NotFoundError() a subsequent ReplaceItemAsync 404 would have produced anyway (unlike + // Delete, a missing document is not treated as an idempotent no-op for Update). Note this only reads to validate isolation - the request's own ETag (captured below from the incoming model, + // not this pre-read) is what still governs optimistic concurrency for the replace itself. + if (!Options.LogicalDeleteSupport.IsNone || Options.TenantSupport.IsSupported || Options.HasFilters || Options.IsTypeDiscriminatorFilterEnabled) + { + var er = await GetWithResultInternalAsync(args, Options.GetKeyFromModel(model), partitionKey, memberName, treatNullAsNotFound: true, cancellationToken).ConfigureAwait(false); + if (er.IsFailure) + return er.Bind(); + } + + // Cosmos DB's native If-Match optimistic concurrency is enforced server-side (returns a 412 directly), unlike a relational/EF detached-entity comparison; the CosmosDbInvoker maps a 412 to a + // ConcurrencyException/Result.ConcurrencyError automatically. Note: AutoMapETag only synthesizes an ItemRequestOptions when the caller has not already supplied one (args.ItemRequestOptions is + // null) so as to never mutate a caller-owned/shared ItemRequestOptions instance; where a caller supplies their own ItemRequestOptions they are expected to set IfMatchEtag themselves. + var options = BuildItemRequestOptions(args); + if (options is null && args.AutoMapETag && model is IReadOnlyETag etag && !string.IsNullOrEmpty(etag.ETag)) + options = new ItemRequestOptions { IfMatchEtag = etag.ETag }; + + // Where an ambient CosmosDbUnitOfWork transaction is active, enlist (queue) rather than execute immediately - see CosmosDbUnitOfWork for the full deferred-execution/atomicity model. The model's + // ETag is not yet final at this point (the batch has not executed) - see IUnitOfWork.SynchronizeETag for how a caller resolves the true, persisted ETag once the unit-of-work has committed. + var txn = CosmosDb.CurrentTransaction; + if (txn is not null) + { + var batchOptions = options is null ? null : new TransactionalBatchItemRequestOptions { IfMatchEtag = options.IfMatchEtag }; + txn.Enlist(Container, partitionKey, partitionKeyValue, Options.GetKeyFromModel(model), b => b.ReplaceItem(id, model, batchOptions)); + return Result.Ok(new DataResult(model, true)); + } + + var response = await Container.ReplaceItemAsync(model, id, partitionKey, options, cancellationToken).ConfigureAwait(false); + + // Refresh as required (rarely needed given the SDK already returns the persisted resource). + var pr = await RefreshPostMutationAsync(args, response.Resource, partitionKey, memberName, cancellationToken).ConfigureAwait(false); + return pr.ThenAs(m => new DataResult(m, true)); + }, cancellationToken, memberName).ConfigureAwait(false); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.Upsert.cs b/src/CoreEx.Cosmos/CosmosDbContainer.Upsert.cs new file mode 100644 index 00000000..9feb5ae3 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.Upsert.cs @@ -0,0 +1,86 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbContainer +{ + /// + /// Upserts the . + /// + /// The model. + /// The . + /// The containing the upserted model. + /// An upsert operation will attempt to update the model if it exists, and then create a new model if it does not (i.e. the update results in a ). Note: this is + /// not a single atomic operation (unlike the underlying Cosmos DB SDK's own UpsertItemAsync), as it is applied via the same + /// and pipelines (change-log stamping, ETag concurrency, tenant/logical-delete checks) to ensure consistent CoreEx semantics. + /// Inside an active , "attempt update, retry as create on Not Found" is not possible - a only enlists (queues) the operation and + /// cannot observe a 404 until the whole batch executes, at which point retrying is too late. A forced pre-read (mirroring 's equivalent + /// transactional fast-path) determines existence up-front instead, so the correct operation is enlisted the first and only time. + public Task> UpsertAsync(TModel model, CancellationToken cancellationToken = default) => UpsertAsync(Args, model, cancellationToken); + + /// + /// Upserts the . + /// + /// The . + /// The model. + /// The . + /// The containing the upserted model. + public async Task> UpsertAsync(CosmosDbArgs args, TModel model, CancellationToken cancellationToken = default) => (await UpsertWithResultInternalAsync(args, model, nameof(UpsertAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Upserts the . + /// + /// The model. + /// The . + /// The containing the upserted model. + public Task>> UpsertWithResultAsync(TModel model, CancellationToken cancellationToken = default) => UpsertWithResultAsync(Args, model, cancellationToken); + + /// + /// Upserts the . + /// + /// The . + /// The model. + /// The . + /// The containing the upserted model. + public Task>> UpsertWithResultAsync(CosmosDbArgs args, TModel model, CancellationToken cancellationToken = default) => UpsertWithResultInternalAsync(args, model, nameof(UpsertWithResultAsync), cancellationToken); + + /// + /// Upserts the model (internal). + /// + private async Task>> UpsertWithResultInternalAsync(CosmosDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + model.ThrowIfNull(); + + return await CosmosDb.Invoker.InvokeAsync(CosmosDb, args.ThrowIfNull(), (_, args, cancellationToken) => + { + // Inside an active CosmosDbUnitOfWork, Create/ReplaceItem are only enlisted (queued) into the TransactionalBatch - a 404 for a missing item cannot be observed until the whole batch executes, + // by which point it is too late to retry as a Create (the batch has already failed as a whole; see CosmosDbUnitOfWork's remarks). A forced pre-read (mirroring DeleteWithResultInternalAsync's + // equivalent transactional fast-path) determines existence up-front instead, so the correct operation - with its correct Create-vs-Update model stamping (IChangeLog Created vs Updated, etc.) + // - is enlisted the first and only time. + if (CosmosDb.CurrentTransaction is not null) + return UpsertWithinTransactionAsync(args, model, memberName, cancellationToken); + + return Result.GoAsync(() => UpdateWithResultAsync(args, model, cancellationToken)) + .OnFailureAsync(r => r.IsNotFoundError ? CreateWithResultAsync(args, model, cancellationToken) : r.AsTask()); + }, cancellationToken, memberName).ConfigureAwait(false); + } + + /// + /// Upserts the model within an active transaction (internal) - see 's remarks for why this cannot simply retry on a Not Found failure the way the non-transactional path does. + /// + private async Task>> UpsertWithinTransactionAsync(CosmosDbArgs args, TModel model, string memberName, CancellationToken cancellationToken) + { + // Stamp ITenantId/ITypeDiscriminator up-front - identically performed by both Model.PrepareCreate and Model.PrepareUpdate - so that a CosmosDbModelOptions.WithPartitionKey selector based + // on a stamped value (e.g. TenantId) resolves the correct partition key for this pre-read, rather than a null/stale one. This is idempotent: whichever branch below is subsequently chosen re-runs + // the full Model.PrepareCreate/PrepareUpdate (re-stamping the same tenant/type-discriminator values and additionally applying the correct Create-vs-Update change-log semantics). + Model.PrepareTenantId(model, CosmosDb.ExecutionContext); + Model.PrepareTypeDiscriminator(model); + Options.ApplyTypeDiscriminator(model); + + var gr = await GetWithResultInternalAsync(args, Options.GetKeyFromModel(model), Options.GetPartitionKey(model), memberName, treatNullAsNotFound: false, cancellationToken).ConfigureAwait(false); + if (gr.IsFailure) + return gr.Bind(); + + return gr.Value is null + ? await CreateWithResultInternalAsync(args, model, memberName, cancellationToken).ConfigureAwait(false) + : await UpdateWithResultInternalAsync(args, model, memberName, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbContainer.cs b/src/CoreEx.Cosmos/CosmosDbContainer.cs new file mode 100644 index 00000000..a0c9eb5a --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbContainer.cs @@ -0,0 +1,110 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides the extended -based Azure Cosmos DB container model functionality. +/// +/// The model . +public sealed partial class CosmosDbContainer where TModel : class, IEntityKey, new() +{ + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The underlying . + /// The . + internal CosmosDbContainer(ICosmosDb cosmosDb, Container container, CosmosDbModelOptions options) + { + CosmosDb = cosmosDb.ThrowIfNull(); + Container = container.ThrowIfNull(); + Options = options.ThrowIfNull(); + } + + /// + /// Gets the owning . + /// + public ICosmosDb CosmosDb { get; } + + /// + /// Gets the underlying . + /// + public Container Container { get; } + + /// + /// Gets the . + /// + public CosmosDbModelOptions Options { get; } + + /// + /// Gets the default . + /// + /// Uses the where specified; otherwise, the . + public CosmosDbArgs Args => Options.Args ?? CosmosDb.DbArgs; + + /// + /// Checks (ensures) that the is valid. + /// + /// The . + /// The model. + /// The . + /// Indicates whether to treat a model as a not found error. + /// The . + [return: NotNullIfNotNull(nameof(model))] + public Result CheckModel(CosmosDbArgs args, TModel? model, OperationType operationType, bool treatNullAsNotFound = false) + { + args.ThrowIfNull(); + + if (model is null) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + + // Check valid tenant where multi-tenancy is being used. + if (model is IReadOnlyTenantId tenant) + { + // TenantId is stamped automatically (see Model.PrepareCreate/PrepareUpdate) and is never caller-supplied; a null/empty value is an internal data-integrity/environment problem, not a bad request from the caller. + if (string.IsNullOrEmpty(tenant.TenantId)) + throw new InvalidOperationException($"The model's {nameof(ITenantId.TenantId)} is null or empty; {nameof(IReadOnlyTenantId)} requires tenant stamping to have occurred prior to this check."); + + if (tenant.TenantId != CosmosDb.ExecutionContext.TenantId) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + } + + // Check not logically deleted. + if (model is IReadOnlyLogicallyDeleted ld && ld.IsDeleted) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + + // Check the type discriminator agrees where configured (see CosmosDbModelOptions.WithTypeDiscriminator) - a shared multi-type container otherwise has no other point-operation defence + // against deserializing/deleting/replacing a same-id/partition document belonging to a different configured type; ApplyFilters already applies the equivalent check on the query path. + if (Options.IsTypeDiscriminatorMismatch(model)) + return treatNullAsNotFound ? Result.NotFoundError() : Result.Ok(null); + + // Check any additive developer-supplied filters (see CosmosDbModelOptions.WithFilter) - e.g. authorization. + return Options.CheckFilters(args, model, operationType); + } + + /// + /// Builds the for a point operation from the specified . + /// + private static ItemRequestOptions? BuildItemRequestOptions(CosmosDbArgs args) => args.ItemRequestOptions; + + /// + /// Refreshes the model post-mutation (as required). + /// + private async Task> RefreshPostMutationAsync(CosmosDbArgs args, TModel model, PartitionKey partitionKey, string memberName, CancellationToken cancellationToken) + { + // Refresh the model as requested. + if (args.Refresh) + return Result.Go((await GetWithResultInternalAsync(args, Options.GetKeyFromModel(model), partitionKey, memberName, treatNullAsNotFound: true, cancellationToken).ConfigureAwait(false)).ThenAs(v => v!)); + + // Return the current (already persisted/returned-by-the-SDK) model. + return Result.Ok(model); + } + + /// + /// Creates a that provides mapped CRUD operations + /// (Create, Read, Update and Delete). + /// + /// The mapped . + /// The . + /// The . + /// The . + public CosmosDbMappedContainer ToMappedModel(TBiDirectionMapper mapper) where T : class where TBiDirectionMapper : IBiDirectionMapper => new(this, mapper); +} diff --git a/src/CoreEx.Cosmos/CosmosDbMappedContainer.Create.cs b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Create.cs new file mode 100644 index 00000000..d59ab4af --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Create.cs @@ -0,0 +1,46 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbMappedContainer +{ + /// + /// Creates the . + /// + /// The value. + /// The . + /// The containing the created value. + public Task> CreateAsync(TValue value, CancellationToken cancellationToken = default) => CreateAsync(Container.Args, value, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The value. + /// The . + /// The containing the created value. + public async Task> CreateAsync(CosmosDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Container.CreateAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return new DataResult(Mapper.From.Map(r.Value), r.WasMutated); + } + + /// + /// Creates the . + /// + /// The value. + /// The . + /// The containing the created value. + public Task>> CreateWithResultAsync(TValue value, CancellationToken cancellationToken = default) => CreateWithResultAsync(Container.Args, value, cancellationToken); + + /// + /// Creates the . + /// + /// The . + /// The value. + /// The . + /// The containing the created value. + public async Task>> CreateWithResultAsync(CosmosDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Container.CreateWithResultAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return r.ThenAs(dr => new DataResult(Mapper.From.Map(dr.Value)!, dr.WasMutated)); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbMappedContainer.Delete.cs b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Delete.cs new file mode 100644 index 00000000..767a8016 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Delete.cs @@ -0,0 +1,78 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbMappedContainer +{ + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A . + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task DeleteAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteAsync(Container.Args, key, cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// A . + /// A delete is considered idempotent and as such no will be thrown. The returning is informational only. + public Task DeleteAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => DeleteAsync(Container.Args, key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A . + public Task DeleteAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteAsync(args, key, cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// A . + public Task DeleteAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => Container.DeleteAsync(args, key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// A . + public Task> DeleteWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => DeleteWithResultAsync(Container.Args, key, cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// A . + public Task> DeleteWithResultAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => DeleteWithResultAsync(Container.Args, key, partitionKey, cancellationToken); + + /// + /// Deletes the model for the specified . + /// + /// The . + /// The . + /// The . + /// A . + public Task> DeleteWithResultAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(args, key, cancellationToken); + + /// + /// Deletes the model for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// A . + public Task> DeleteWithResultAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => Container.DeleteWithResultAsync(args, key, partitionKey, cancellationToken); +} diff --git a/src/CoreEx.Cosmos/CosmosDbMappedContainer.Get.cs b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Get.cs new file mode 100644 index 00000000..3567fe92 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Get.cs @@ -0,0 +1,92 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbMappedContainer +{ + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The value where found; otherwise, . + public Task GetAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetAsync(Container.Args, key, cancellationToken); + + /// + /// Gets the value for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// The value where found; otherwise, . + public Task GetAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => GetAsync(Container.Args, key, partitionKey, cancellationToken); + + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The . + /// The value where found; otherwise, . + public async Task GetAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + { + var m = await Container.GetAsync(args, key, cancellationToken).ConfigureAwait(false); + return Mapper.From.Map(m); + } + + /// + /// Gets the value for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// The value where found; otherwise, . + public async Task GetAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) + { + var m = await Container.GetAsync(args, key, partitionKey, cancellationToken).ConfigureAwait(false); + return Mapper.From.Map(m); + } + + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The value. + public Task> GetWithResultAsync(CompositeKey key, CancellationToken cancellationToken = default) => GetWithResultAsync(Container.Args, key, cancellationToken); + + /// + /// Gets the value for the specified and . + /// + /// The . + /// The raw partition key value. + /// The . + /// The value. + public Task> GetWithResultAsync(CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) => GetWithResultAsync(Container.Args, key, partitionKey, cancellationToken); + + /// + /// Gets the value for the specified . + /// + /// The . + /// The . + /// The . + /// The value. + public async Task> GetWithResultAsync(CosmosDbArgs args, CompositeKey key, CancellationToken cancellationToken = default) + { + var r = await Container.GetWithResultAsync(args, key, cancellationToken).ConfigureAwait(false); + return r.IsSuccess ? Mapper.From.Map(r.Value) : r.Bind(); + } + + /// + /// Gets the value for the specified and . + /// + /// The . + /// The . + /// The raw partition key value. + /// The . + /// The value. + public async Task> GetWithResultAsync(CosmosDbArgs args, CompositeKey key, string partitionKey, CancellationToken cancellationToken = default) + { + var r = await Container.GetWithResultAsync(args, key, partitionKey, cancellationToken).ConfigureAwait(false); + return r.IsSuccess ? Mapper.From.Map(r.Value) : r.Bind(); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbMappedContainer.Update.cs b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Update.cs new file mode 100644 index 00000000..6e178a9b --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Update.cs @@ -0,0 +1,46 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbMappedContainer +{ + /// + /// Updates the . + /// + /// The value. + /// The . + /// The containing the updated value. + public Task> UpdateAsync(TValue value, CancellationToken cancellationToken = default) => UpdateAsync(Container.Args, value, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The value. + /// The . + /// The containing the updated value. + public async Task> UpdateAsync(CosmosDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Container.UpdateAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return new DataResult(Mapper.From.Map(r.Value), r.WasMutated); + } + + /// + /// Updates the . + /// + /// The value. + /// The . + /// The containing the updated value. + public Task>> UpdateWithResultAsync(TValue value, CancellationToken cancellationToken = default) => UpdateWithResultAsync(Container.Args, value, cancellationToken); + + /// + /// Updates the . + /// + /// The . + /// The value. + /// The . + /// The containing the updated value. + public async Task>> UpdateWithResultAsync(CosmosDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Container.UpdateWithResultAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return r.ThenAs(dr => new DataResult(Mapper.From.Map(dr.Value)!, dr.WasMutated)); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbMappedContainer.Upsert.cs b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Upsert.cs new file mode 100644 index 00000000..76f86e73 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbMappedContainer.Upsert.cs @@ -0,0 +1,50 @@ +namespace CoreEx.Cosmos; + +public partial class CosmosDbMappedContainer +{ + /// + /// Upserts the . + /// + /// The value. + /// The . + /// The containing the upserted value. + /// An upsert operation will attempt to update the model if it exists, and then create a new model if it does not (i.e. the update results in a ). Note: It is not a single atomic operation. + public Task> UpsertAsync(TValue value, CancellationToken cancellationToken = default) => UpsertAsync(Container.Args, value, cancellationToken); + + /// + /// Upserts the . + /// + /// The . + /// The value. + /// The . + /// The containing the upserted value. + /// An upsert operation will attempt to update the model if it exists, and then create a new model if it does not (i.e. the update results in a ). Note: It is not a single atomic operation. + public async Task> UpsertAsync(CosmosDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Container.UpsertAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return new DataResult(Mapper.From.Map(r.Value), r.WasMutated); + } + + /// + /// Upserts the . + /// + /// The value. + /// The . + /// The containing the upserted value. + /// An upsert operation will attempt to update the model if it exists, and then create a new model if it does not (i.e. the update results in a ). Note: It is not a single atomic operation. + public Task>> UpsertWithResultAsync(TValue value, CancellationToken cancellationToken = default) => UpsertWithResultAsync(Container.Args, value, cancellationToken); + + /// + /// Upserts the . + /// + /// The . + /// The value. + /// The . + /// The containing the upserted value. + /// An upsert operation will attempt to update the model if it exists, and then create a new model if it does not (i.e. the update results in a ). Note: It is not a single atomic operation. + public async Task>> UpsertWithResultAsync(CosmosDbArgs args, TValue value, CancellationToken cancellationToken = default) + { + var r = await Container.UpsertWithResultAsync(args, Mapper.To.Map(value), cancellationToken).ConfigureAwait(false); + return r.ThenAs(dr => new DataResult(Mapper.From.Map(dr.Value)!, dr.WasMutated)); + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbMappedContainer.cs b/src/CoreEx.Cosmos/CosmosDbMappedContainer.cs new file mode 100644 index 00000000..05c613d1 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbMappedContainer.cs @@ -0,0 +1,34 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides the extended -based mapped value to/from model functionality. +/// +/// The value . +/// The model . +/// The . +/// Note: the does not provide a Query method equivalent to +/// by design. This is because queries are tightly-coupled to the model; use directly plus +/// where applicable. +public partial class CosmosDbMappedContainer where TValue : class where TModel : class, IEntityKey, new() where TBiDirectionMapper : IBiDirectionMapper +{ + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + internal CosmosDbMappedContainer(CosmosDbContainer container, TBiDirectionMapper mapper) + { + Container = container.ThrowIfNull(); + Mapper = mapper.ThrowIfNull(); + } + + /// + /// Gets the underlying . + /// + public CosmosDbContainer Container { get; } + + /// + /// Gets the . + /// + public TBiDirectionMapper Mapper { get; } +} diff --git a/src/CoreEx.Cosmos/CosmosDbModelBase.cs b/src/CoreEx.Cosmos/CosmosDbModelBase.cs new file mode 100644 index 00000000..04459344 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbModelBase.cs @@ -0,0 +1,49 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides an optional convenience base class for a Cosmos DB model implementing the standard , , , and +/// capabilities using the corresponding Cosmos DB reserved system property names (id, _etag and ttl). +/// +/// Nothing within requires this base class; it only requires TModel : class, , new(), with everything else (, +/// , , , , ) duck-typed via is checks within +/// (exactly as EfDbModelOptions does today). Existing domain models that already implement these interfaces directly do not need this base class at all. +/// The JSON property name (partitionKey) is a sensible default only; a container's actual partition key path is an application/infrastructure choice made at container-creation +/// time, so implement directly (rather than deriving from this base class) where a different property name is required. +/// lives in core CoreEx.Data (alongside /), not CoreEx.Cosmos, since a future non-Cosmos NoSQL data-access +/// package (e.g. MongoDB, which has its own distinct TTL-index mechanism) can reuse the same storage-agnostic contract. +public abstract class CosmosDbModelBase : IIdentifier, IChangeLog, IETag, IPartitionKey, ITimeToLive +{ + /// + [JsonPropertyName("id")] + [JsonPropertyOrder(-999)] + public string Id { get; set; } = string.Empty; + + /// + /// Serialization omits this property entirely when () rather than writing a JSON null - a document with an + /// absent partition-key field resolves to , whereas one with an explicit JSON null value resolves to the distinct + /// - writing the latter for a model with no configured/model-supplied partition key value would mismatch the + /// resolved for the operation itself (see ) and the write would fail with a raw, undiagnosable BadRequest. + [JsonPropertyName("partitionKey")] + [JsonPropertyOrder(-998)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PartitionKey { get; set; } + + /// + [JsonPropertyName("changeLog")] + [JsonPropertyOrder(100000)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ChangeLog? ChangeLog { get; set; } + + /// + [JsonPropertyName("_etag")] + [JsonPropertyOrder(100001)] + public string? ETag { get; set; } + + /// + /// Serialization omits this property entirely when () rather than writing a JSON null - the Cosmos DB service/emulator + /// rejects an explicit "ttl": null on create/replace ("The input ttl 'null' is invalid..."). + [JsonPropertyName("ttl")] + [JsonPropertyOrder(100002)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? TimeToLive { get; set; } +} diff --git a/src/CoreEx.Cosmos/CosmosDbModelOptions.cs b/src/CoreEx.Cosmos/CosmosDbModelOptions.cs new file mode 100644 index 00000000..c4fc1da1 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbModelOptions.cs @@ -0,0 +1,577 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides options for the . +/// +/// The model . +public class CosmosDbModelOptions where TModel : class, IEntityKey, new() +{ + private readonly List<(Func, IQueryable> Filter, Func? NonQueryResult, bool AllowFilterBypass)> _filters = []; + private Func _getKey = m => m.EntityKey; + private Func _formatIdentifier = key => key.ToString() ?? string.Empty; + private Func? _getPartitionKey; + private string? _fixedPartitionKey; + private Func? _getTimeToLive; + private bool _tenantFilterEnabled; + private bool _logicalDeleteFilterEnabled; + private bool _typeDiscriminatorFilterEnabled; + private string? _typeDiscriminatorValue; + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport LogicalDeleteSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport TenantSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport TypeDiscriminatorSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport ETagSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport PartitionKeySupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + public FeatureSupport TimeToLiveSupport { get; } = FeatureSupport.Determine(); + + /// + /// Indicates whether and/or is supported for the . + /// + /// Used by to build its automatic outbox-document exclusion predicate directly against this interface (the common + /// case) - see its remarks for the full mechanism and rationale, and for the fallback used when this is not supported. Unrelated to + /// , which is a business-modeling concern, not an infrastructure one. + public FeatureSupport IdentifierSupport { get; } = FeatureSupport.Determine, IReadOnlyIdentifier>(); + + /// + /// Lazily resolves a fallback outbox-document exclusion predicate for a that does not implement / + /// (see ), by locating whichever property is actually mapped to the reserved Cosmos DB id JSON property - either explicitly via + /// (the same attribute itself uses), or, failing that, a conventionally-named public string Id property (case-insensitive) that has neither an explicit + /// (which would mean it is deliberately mapped to a different JSON name) nor a , covering a serializer configured with a + /// naming policy (e.g. JsonNamingPolicy.CamelCase) that maps it to id without requiring an explicit attribute. Cosmos DB requires every physical document to have an id regardless + /// of which CoreEx interfaces (if any) a model implements, so a using + /// /a composite without also implementing would otherwise silently receive no + /// outbox-document exclusion at all. + /// + /// A compiled equivalent to the case, or where no such property can be found (nothing further can be + /// done here - see remarks). + private static Expression>? ResolveOutboxIdExclusion() + { + var stringProperties = typeof(TModel).GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.PropertyType == typeof(string)); + + var property = stringProperties.FirstOrDefault(p => p.GetCustomAttribute()?.Name == "id") + ?? stringProperties.FirstOrDefault(p => string.Equals(p.Name, "Id", StringComparison.OrdinalIgnoreCase) + && p.GetCustomAttribute() is null && p.GetCustomAttribute() is null); + + if (property is null) + return null; + + var parameter = Expression.Parameter(typeof(TModel), "m"); + var idAccess = Expression.Property(parameter, property); + var startsWith = Expression.Call(idAccess, StartsWithMethod, Expression.Constant(CosmosDbOutboxEvent.OutboxKeyPrefix)); + return Expression.Lambda>(Expression.Not(startsWith), parameter); + } + + private static readonly MethodInfo StartsWithMethod = typeof(string).GetMethod(nameof(string.StartsWith), [typeof(string)])!; + + private static readonly Lazy>?> _outboxIdExclusion = new(ResolveOutboxIdExclusion); + + /// + /// Gets the default . + /// + public CosmosDbArgs? Args { get; private set; } + + /// + /// Sets (overrides) the default . + /// + /// The . + /// The to support fluent-style method-chaining. + public CosmosDbModelOptions WithArgs(CosmosDbArgs? args) + { + Args = args; + return this; + } + + /// + /// Sets (overrides) the function to get the for the . + /// + /// The function to get the key. + /// The to support fluent-style method-chaining. + /// Defaults to the . + public CosmosDbModelOptions WithGetKey(Func getKey) + { + _getKey = getKey.ThrowIfNull(); + return this; + } + + /// + /// Gets the from the . + /// + /// The model. + /// The . + public CompositeKey GetKeyFromModel(TModel model) => _getKey(model.ThrowIfNull()); + + /// + /// Sets (overrides) the function used by to derive the physical Cosmos DB document id from a . + /// + /// The function to format the identifier. + /// The to support fluent-style method-chaining. + public CosmosDbModelOptions WithFormatIdentifier(Func formatIdentifier) + { + _formatIdentifier = formatIdentifier.ThrowIfNull(); + return this; + } + + /// + /// Formats (derives) the physical Cosmos DB document id from the specified . + /// + /// The . + /// The physical Cosmos DB document id. + /// Defaults to . + public string FormatIdentifier(CompositeKey key) => _formatIdentifier(key); + + /// + /// Sets (overrides) the function to get the partition key value for a instance. + /// + /// The function to get the partition key value. + /// The to support fluent-style method-chaining. + /// Where not specified, and the implements (see ) with a non-empty value, the + /// is used by default; otherwise (no override configured, and either the does not support it at all, or its value is + /// empty), is used for a Create/Update - the simplest possible container shape (a single default logical partition, no per-item + /// partitioning at all) requires zero configuration and no / implementation on the model whatsoever. + /// Only single-level (v1) Cosmos DB partition keys are supported; hierarchical (multi-level) partition keys are not currently supported. + /// Because is invoked per instance, it can only ever apply to Create/Update (where a model instance exists) — it + /// provides no default for Get/Delete's point-operation partitionKey parameter (see ); use if a + /// default for those is also needed. Mutually exclusive with — configuring both throws . + /// This takes a raw ? (not the Cosmos DB SDK's struct) because the resolved value must, where the + /// implements the mutable , be written back onto the model before a Create/Update — Cosmos DB requires the document body's value at the partition-key + /// path to agree with the value supplied for the operation itself, and the SDK's struct exposes no public way to extract its underlying value back out + /// once constructed, so working in throughout (only converting to at the point of the actual SDK call) is what makes that + /// write-back possible at all. + public CosmosDbModelOptions WithPartitionKey(Func getPartitionKey) + { + if (_fixedPartitionKey is not null) + throw new InvalidOperationException($"{nameof(WithPartitionKey)} cannot be specified when {nameof(WithFixedPartitionKey)} has already been configured; the two are mutually exclusive."); + + _getPartitionKey = getPartitionKey.ThrowIfNull(); + return this; + } + + /// + /// Sets (overrides) a single, constant partition key value used for every item in the container. + /// + /// The fixed partition key value. + /// The to support fluent-style method-chaining. + /// Suitable for small, bounded containers where partitioning is not meaningful — Cosmos DB's own guidance is that a container which stays well under the 20 GB/10,000 RU/s per-logical-partition + /// limits, and typically requires only one or two physical partitions, does not need a high-cardinality partition key + /// (see Partitioning and horizontal scaling). + /// Unlike (which computes a value per instance, and therefore can only apply to Create/Update), this fixed value is + /// also used as the default for Get/Delete's partitionKey parameter when the caller does not supply one (see ) — it is the only + /// mechanism that can provide a default for those point operations. Mutually exclusive with — configuring both throws . + /// Where the also implements with a non-null value that differs from this fixed value, + /// throws rather than silently overriding it — configuration always wins, but a genuine mismatch between the two is far more likely to indicate a + /// configuration/logic error than routine, expected behaviour. For a Create/Update to actually succeed against a real Cosmos DB container, must implement + /// the mutable (see ) so this fixed value can be written back onto the model — Cosmos DB rejects a write where the document body's + /// value at the partition-key path disagrees with the value supplied for the operation. + public CosmosDbModelOptions WithFixedPartitionKey(string? partitionKey) + { + if (_getPartitionKey is not null) + throw new InvalidOperationException($"{nameof(WithFixedPartitionKey)} cannot be specified when {nameof(WithPartitionKey)} has already been configured; the two are mutually exclusive."); + + _fixedPartitionKey = partitionKey; + return this; + } + + /// + /// Gets the for the specified . + /// + /// The model. + /// The - where nothing is configured and the model itself has no (or an empty) + /// partition key value. + /// 's function or 's value (at most one of these can be configured — they are mutually exclusive) always wins over the + /// 's own . Where the model also implements with a non-null value that differs from the + /// configured result, this throws rather than silently overriding it — unlike a multi-tenant "wrong tenant" lookup (which is expected, routine behaviour), there + /// is no scenario where a differing partition key is a benign, expected outcome; it is far more likely to indicate that the configuration and the model have drifted out of sync. + /// Where an override is configured and implements the mutable , the resolved value is also written back onto the + /// — Cosmos DB requires the document body's value at the partition-key path to agree with the value supplied for the operation itself, so this write-back is required for a + /// Create/Update using a configured override to succeed at all, not merely a convenience. + public PartitionKey GetPartitionKey(TModel model) => ToPartitionKey(GetPartitionKeyValue(model)); + + /// + /// Gets the raw partition key value for the specified (see ). + /// + /// The model. + /// The raw partition key value; where nothing is configured and the model itself has no (or an empty) partition key value (translates to + /// - see /). + /// Exists (in addition to ) because the Cosmos DB SDK's struct exposes no public way to extract its own + /// value back out once constructed — a -paired outbox-event write (see ) needs the raw value to co-locate itself in the same + /// partition, so it is resolved and tracked once here rather than re-derived unreliably later. + internal string? GetPartitionKeyValue(TModel model) + { + model.ThrowIfNull(); + + var configured = _getPartitionKey is not null ? _getPartitionKey(model) : _fixedPartitionKey; + if (configured is not null) + { + if (PartitionKeySupport.IsSupported) + { + var modelValue = ((IReadOnlyPartitionKey)model).PartitionKey; + if (!string.IsNullOrEmpty(modelValue) && modelValue != configured) + throw new InvalidOperationException($"The model's {nameof(IReadOnlyPartitionKey.PartitionKey)} ('{modelValue}') does not match the configured partition key ('{configured}'); this likely represents a configuration or logic error (see {nameof(WithPartitionKey)}/{nameof(WithFixedPartitionKey)})."); + } + + if (PartitionKeySupport.IsMutable) + ((IPartitionKey)model).PartitionKey = configured; + + return configured; + } + + // No override configured; fall back to the model's own value where it supports IReadOnlyPartitionKey - otherwise (or where that value is itself empty), there genuinely is no partition key to + // use, which is not an error: it simply means the caller wants the simplest possible container shape (see ToPartitionKey - translates to PartitionKey.None). + if (PartitionKeySupport.IsSupported) + { + var modelValue = ((IReadOnlyPartitionKey)model).PartitionKey; + return string.IsNullOrEmpty(modelValue) ? null : modelValue; + } + + return null; + } + + /// + /// Resolves the raw partition key value to use for a point operation (Get/Delete) given an optional caller-supplied . + /// + /// The caller-supplied raw partition key value; where , falls back to 's value (where configured), otherwise + /// (translates to - see ). + /// The raw partition key value to use. + /// 's function cannot contribute here — a Get/Delete point operation has no instance to invoke it against, only a + /// ; only can provide a default for these operations. Exposed as a raw ? (not the SDK's opaque + /// struct, which cannot be decomposed back into its value once constructed) so a caller enlisting a Delete in a + /// can also supply this same resolved value to a paired outbox-event write, which has no model instance of its own to read one from. + internal string? GetPartitionKeyValue(string? partitionKey) => partitionKey ?? _fixedPartitionKey; + + /// + /// Resolves the to use for a point operation (Get/Delete) given an optional caller-supplied raw value. + /// + /// The caller-supplied raw partition key value; where , falls back to 's value (where + /// configured), otherwise . + /// The to use. + /// Unlike , there is no model to write back onto here — a Get/Delete reads/removes by key and has no document body to reconcile. + public PartitionKey GetPartitionKey(string? partitionKey) => ToPartitionKey(GetPartitionKeyValue(partitionKey)); + + /// + /// Converts a raw partition key (as resolved by or ) to its corresponding + /// . + /// + /// The raw partition key value. + /// where is ; otherwise, new (value). + /// (no partition key at all - the simplest possible container shape) is deliberately distinct from what the SDK's own + /// new PartitionKey((string?)null) would produce (an explicit JSON partition-key value, for a container that still has a defined partition key path) - this + /// method is the single place that distinction is made, so every caller resolving a possibly-absent partition key goes through it rather than constructing + /// directly from a nullable string. + internal static PartitionKey ToPartitionKey(string? value) => value is null ? PartitionKey.None : new PartitionKey(value); + + /// + /// Sets (overrides) the function to compute the for a instance (where is ). + /// + /// The function to compute the time-to-live (in seconds; indicates no expiry). + /// The to support fluent-style method-chaining. + /// Unlike (whose resolved value is passed directly as a separate Cosmos DB SDK call parameter), a computed time-to-live can only take effect by being written back + /// onto the instance itself — Cosmos DB's ttl is purely a document-body field, there is no separate request-option equivalent. This therefore requires the + /// to implement the mutable (not merely ); an unconfigured model with no override simply never expires via + /// this mechanism, which is the common case and requires no configuration at all. + /// Applied automatically on Create and Update (see ), after Model.PrepareCreate/PrepareUpdate stamping and before the model is + /// persisted — so may itself inspect other already-stamped properties (e.g. ) if useful. + public CosmosDbModelOptions WithTimeToLive(Func getTimeToLive) + { + if (!TimeToLiveSupport.IsMutable) + throw new NotSupportedException($"{nameof(WithTimeToLive)} is not supported; model must implement {nameof(ITimeToLive)} to enable."); + + _getTimeToLive = getTimeToLive.ThrowIfNull(); + return this; + } + + /// + /// Applies the -computed time-to-live (where configured) to the . + /// + /// The model. + /// A no-op where has not been configured — an implementing 's own value (if any) is otherwise left + /// untouched and simply serializes through as-is; there is nothing to "apply" in that case. + public void ApplyTimeToLive(TModel model) + { + if (_getTimeToLive is null) + return; + + ((ITimeToLive)model.ThrowIfNull()).TimeToLive = _getTimeToLive(model); + } + + /// + /// Adds a filter to be applied to all operations (get, create, update, delete, and query). + /// + /// The filter query to apply. + /// The optional to return for non-query operations when the filter excludes. + /// Indicates whether the filter can be bypassed via ; defaults to . + /// The to support fluent-style method-chaining. + /// This is the additive extension point for filters that are not one of the built-in // + /// concerns — for example, an authorization-related filter that restricts which documents a given caller may see or mutate. The enables a different result to be returned for + /// non-query operations when the filter excludes; for example, a could be returned for an authorization filter. Where a is not + /// specified then the specified is only applied for queries (see ) and has no effect on non-query operations (see ). + /// The can be used to bypass filters registered with set to . + /// Each filter is applied individually, in the order specified. + /// The is evaluated in two different contexts and must be expressible in both: against the real Cosmos DB LINQ query (translated to a Cosmos DB SQL query) for + /// , and against an in-memory, single-item (LINQ-to-Objects) for the non-query pre-check performed by + /// — this is intentional, avoiding a second round-trip to re-verify a model already in hand, but it means the predicate cannot use Cosmos-LINQ-only constructs that have + /// no meaning against an in-memory sequence. + /// A query-only (no — see ) is not supported for a used in a + /// CoreEx.Cosmos.Extended.CosmosDbMultiSetExtensions.SelectMultiSetAsync multi-set query — an arbitrary cannot be safely translated into that query's raw SQL text, so + /// doing so throws rather than silently returning documents an equivalent single-set query would have excluded. Supply a to make + /// the filter also enforced per-item (consistent with multi-set's own per-item CheckModel check) if it needs to be usable there. + public CosmosDbModelOptions WithFilter(Func, IQueryable> filter, Func? nonQueryResult = null, bool allowFilterBypass = false) + { + _filters.Add((filter.ThrowIfNull(), nonQueryResult, allowFilterBypass)); + return this; + } + + /// + /// Indicates whether any filters have been specified. + /// + public bool HasFilters => _filters.Count > 0; + + /// + /// Indicates whether any filter has been specified without a nonQueryResult (i.e. a filter that only affects and has no effect on + /// /non-query operations). + /// + /// Consumed by CoreEx.Cosmos.Extended.IMultiSetArgs.BuildFilterClause to guard against a multi-set query silently disagreeing with the equivalent : + /// unlike /, an arbitrary predicate cannot be safely translated into the raw SQL text a multi-set query + /// requires, so a with a query-only filter configured is not supported for multi-set use - see remarks for why registering a nonQueryResult + /// (making the filter also enforced per-item via , exactly as multi-set's own per-item CheckModel call already requires) is the supported alternative. + public bool HasQueryOnlyFilters => _filters.Any(f => f.NonQueryResult is null); + + /// + /// Checks the non-query filters against the . + /// + /// The . + /// The model. + /// The . + /// The of the filters check. + /// See for more information. Invoked internally by for the Get/Create/Update/Delete operations. + public Result CheckFilters(CosmosDbArgs args, TModel? model, OperationType operationType) + { + args.ThrowIfNull(); + + if (model is null || !HasFilters) + return Result.Ok(model); + + var q = new[] { model }.AsQueryable(); + + foreach (var (filter, nonQueryResult, allowFilterBypass) in _filters) + { + // Bypass filter where selected to do so and allowed. + if (args.BypassFilters && allowFilterBypass) + continue; + + // Apply the filter to the single model query; if no match, then carry on. + if (nonQueryResult is null || filter(q).Any()) + continue; + + // Match; so, return the non-query result (should be an error). + return nonQueryResult(model, operationType); + } + + return Result.Ok(model); + } + + /// + /// Adds a tenant () query-only filter (where is supported). + /// + /// The to support fluent-style method-chaining. + /// Non-query operations (GetAsync, etc.) always check the where supported (see CosmosDbContainer{TModel}.CheckModel) irrespective of + /// whether this filter has been configured; this only controls whether also applies the equivalent predicate (and, for Extended + /// multi-set queries, an equivalent defensive server-side SQL predicate - see ). + public CosmosDbModelOptions WithTenantFilter() + { + if (!TenantSupport.IsSupported) + throw new NotSupportedException($"{nameof(WithTenantFilter)} is not supported; model must implement {nameof(IReadOnlyTenantId)} to enable."); + + _tenantFilterEnabled = true; + return this; + } + + /// + /// Indicates whether the query-only filter has been configured. + /// + /// Consumed by CoreEx.Cosmos.Extended.CosmosDbMultiSetExtensions.SelectMultiSetAsync to add an equivalent, defensive (IS_DEFINED-guarded) server-side SQL predicate - it has no + /// bearing on the always-applied, per-item CosmosDbContainer{TModel}.CheckModel tenant check. + public bool IsTenantFilterEnabled => _tenantFilterEnabled; + + /// + /// Adds a logical delete () query-only filter (where is supported). + /// + /// The to support fluent-style method-chaining. + /// Non-query operations always check the state where supported irrespective of whether this filter has been configured; this only controls whether + /// also applies the equivalent predicate (and, for Extended multi-set queries, an equivalent defensive server-side SQL predicate - + /// see ). + public CosmosDbModelOptions WithLogicalDeleteFilter() + { + if (!LogicalDeleteSupport.IsSupported) + throw new NotSupportedException($"{nameof(WithLogicalDeleteFilter)} is not supported; model must implement {nameof(IReadOnlyLogicallyDeleted)} to enable."); + + _logicalDeleteFilterEnabled = true; + return this; + } + + /// + /// Indicates whether the query-only filter has been configured. + /// + /// Consumed by CoreEx.Cosmos.Extended.CosmosDbMultiSetExtensions.SelectMultiSetAsync to add an equivalent, defensive (IS_DEFINED-guarded) server-side SQL predicate - it has no + /// bearing on the always-applied, per-item CosmosDbContainer{TModel}.CheckModel logical-delete check. + public bool IsLogicalDeleteFilterEnabled => _logicalDeleteFilterEnabled; + + /// + /// Adds a type discriminator () query-only filter (where is supported), enabling several business model + /// types to safely share the same container/partition. + /// + /// The type discriminator value to filter on; defaults to the where specified, otherwise the name + /// (i.e. the same default resolution used by Model.PrepareTypeDiscriminator when stamping a model prior to create/update). + /// The to support fluent-style method-chaining. + /// An explicit override is enforced end-to-end: re-stamps it onto the model on every Create/Update/Upsert + /// after Model.PrepareCreate/PrepareUpdate/PrepareTypeDiscriminator have already stamped their own default (/type name) - without + /// this, a model persisted with an explicit override here would instead be written with the default discriminator, immediately fail 's check against + /// this configured value, and become invisible to this container's own queries/point reads. exposes this same resolved value (override or default) for a + /// multi-set query (see CoreEx.Cosmos.Extended.IMultiSetArgs) to demux against, so the two never disagree either. + public CosmosDbModelOptions WithTypeDiscriminator(string? typeDiscriminator = null) + { + if (!TypeDiscriminatorSupport.IsSupported) + throw new NotSupportedException($"{nameof(WithTypeDiscriminator)} is not supported; model must implement {nameof(IReadOnlyTypeDiscriminator)} to enable."); + + _typeDiscriminatorValue = string.IsNullOrEmpty(typeDiscriminator) ? ResolveDefaultTypeDiscriminator() : typeDiscriminator; + _typeDiscriminatorFilterEnabled = true; + return this; + } + + /// + /// Gets the effective type discriminator value for - the explicit override where configured, otherwise the same + /// schema/CLR-type-name default that Model.PrepareTypeDiscriminator stamps automatically (irrespective of whether has ever been called). + /// + /// Used by CoreEx.Cosmos.Extended.IMultiSetArgs<TModel> to resolve the value a multi-set query demuxes documents against - it must always agree with whatever value is actually + /// persisted on instances (see /), not merely the unconfigured default, + /// otherwise a model configured with an explicit override would never be found by its own multi-set query. + public string EffectiveTypeDiscriminator => _typeDiscriminatorValue ?? ResolveDefaultTypeDiscriminator(); + + /// + /// Resolves the default type discriminator value ( where specified, otherwise the name) - the same default resolution used by + /// Model.PrepareTypeDiscriminator when stamping a model prior to create/update. + /// + private static string ResolveDefaultTypeDiscriminator() => Schema.TryGetMetadata(out var metadata) ? (metadata.Name ?? typeof(TModel).Name) : typeof(TModel).Name; + + /// + /// Applies the configured value (where configured and supports the mutable ) to the + /// , overriding whatever default value Model.PrepareCreate/PrepareUpdate/PrepareTypeDiscriminator already stamped. + /// + /// The model. + /// Must be called on every Create/Update/Upsert path after Model.PrepareCreate/PrepareUpdate/PrepareTypeDiscriminator - see + /// remarks for why. A no-op where has not been configured, or where only supports the read-only + /// (nothing to write back to in that case) - the value already stamped by Model.PrepareCreate/PrepareUpdate/PrepareTypeDiscriminator (its own default resolution) is then left as-is. + public void ApplyTypeDiscriminator(TModel model) + { + if (!_typeDiscriminatorFilterEnabled || model.ThrowIfNull() is not ITypeDiscriminator td) + return; + + td.TypeDiscriminator = _typeDiscriminatorValue; + } + + /// + /// Determines whether the specified 's disagrees with the configured value. + /// + /// The model. + /// where is configured and the model's discriminator does not match; otherwise, (including where + /// was never configured, since there is then nothing to isolate against). + /// Used by to apply the same type-discriminator isolation to point Get/Delete/Update operations that + /// already applies to queries - without this, a shared multi-type container could deserialize, delete, or replace a same-id/partition document belonging to a different + /// configured type, silently bypassing the isolation promises. Unlike / (whose + /// equivalent CheckModel checks are unconditional whenever merely implements the relevant interface), this check is deliberately gated on + /// having been called - there is no ambient "expected type" to compare against otherwise (tenant compares to ; + /// logical-delete compares to a fixed ), so an ungated check could wrongly reject a document in a single-type container that never opted into discriminator isolation. + internal bool IsTypeDiscriminatorMismatch(TModel model) => _typeDiscriminatorFilterEnabled && model is IReadOnlyTypeDiscriminator td && td.TypeDiscriminator != _typeDiscriminatorValue; + + /// + /// Indicates whether the filter has been configured. + /// + /// Consumed by 's equivalent fast-path check (alongside // + /// ) to decide whether a plain, key-based delete can skip the pre-read CheckModel performs - see remarks for why, unlike + /// those two, this one genuinely gates CheckModel's own type-discriminator check too. + public bool IsTypeDiscriminatorFilterEnabled => _typeDiscriminatorFilterEnabled; + + /// + /// Applies the configured query-only filters (, , and any additive + /// registrations), plus an automatic outbox-document exclusion predicate, to the . + /// + /// The ; used only to check against any bypassable registrations. + /// The . + /// The resolved by the owning (see ); used only by the + /// predicate, where configured. + /// The filtered . + /// Whenever is supported, this always also excludes any documents that may be physically co-located in the same + /// container (a -paired outbox write has no other choice — Cosmos DB's TransactionalBatch only supports a single container, so a dedicated outbox container, + /// like a relational store's separate table, is not possible) — no WithXxx() opt-in call is needed, and this applies even to a container that has never itself been used with a + /// . This is intentional and safe unconditionally: no legitimate business key would ever start with , so the predicate + /// can never wrongly exclude real business data, and a business developer is never required to add or even be aware of any interface/discriminator solely to accommodate this — unlike an earlier design + /// considered and rejected, which would have reused for this purpose (conflating a genuine business-modeling decision with an unrelated infrastructure concern). + /// Uses the same cast-to-interface-in-a-LINQ-predicate shape already used above for //, + /// not a new or unproven LINQ pattern. + /// Where is not supported (a using a composite / without + /// also implementing ), the exclusion is not simply skipped: falls back to locating whichever property is actually + /// mapped to the reserved Cosmos DB id JSON property (via ) and applies the identical predicate against it directly, since Cosmos DB requires every + /// physical document to have an id regardless of which CoreEx interfaces a model implements. Only where no such property can be found at all (a working would + /// always have one, since Cosmos DB itself would otherwise reject every write) is the exclusion genuinely skipped. + public IQueryable ApplyFilters(CosmosDbArgs args, IQueryable query, ExecutionContext executionContext) + { + args.ThrowIfNull(); + query.ThrowIfNull(); + + if (IdentifierSupport.IsSupported) + query = query.Where(m => !((IReadOnlyIdentifier)m).Id!.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + else if (_outboxIdExclusion.Value is not null) + query = query.Where(_outboxIdExclusion.Value); + + if (_tenantFilterEnabled) + { + var tenantId = executionContext.ThrowIfNull().TenantId; + query = query.Where(m => ((IReadOnlyTenantId)m).TenantId == tenantId); + } + + if (_logicalDeleteFilterEnabled) + query = query.Where(m => !((IReadOnlyLogicallyDeleted)m).IsDeleted); + + if (_typeDiscriminatorFilterEnabled) + { + var discriminator = _typeDiscriminatorValue; + query = query.Where(m => ((IReadOnlyTypeDiscriminator)m).TypeDiscriminator == discriminator); + } + + if (HasFilters) + { + foreach (var (filter, _, allowFilterBypass) in _filters) + { + // Bypass filter where selected to do so and allowed. + if (args.BypassFilters && allowFilterBypass) + continue; + + query = filter(query); + } + } + + return query; + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbOptions.cs b/src/CoreEx.Cosmos/CosmosDbOptions.cs new file mode 100644 index 00000000..c3fffea3 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbOptions.cs @@ -0,0 +1,69 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides options for the . +/// +public class CosmosDbOptions +{ + // Keyed by (containerId, TModel) - not containerId alone - since a container is legitimately shared by multiple distinct model types (see CosmosDbModelOptions.WithTypeDiscriminator); + // keying by containerId alone would let the first TModel registered for a given containerId "win" the cache slot for the lifetime of this (typically singleton) instance, with every other type + // sharing that containerId throwing InvalidCastException when it tries to cast the cached entry back to its own CosmosDbModelOptions. + private readonly ConcurrentDictionary<(string ContainerId, Type ModelType), object> _models = new(); + + /// + /// Gets the default . + /// + public CosmosDbArgs Args { get; private set; } = new(); + + /// + /// Sets (overrides) the default . + /// + /// The . + /// The to support fluent-style method-chaining. + public CosmosDbOptions WithArgs(CosmosDbArgs args) + { + Args = args with { }; + return this; + } + + /// + /// Gets or adds the for the specified container . + /// + /// The model . + /// The identifier. + /// The optional action to configure a newly created ; see remarks. + /// The . + /// is invoked only the first time a is created for this / + /// combination - deliberately, since this is typically a long-lived singleton shared across every instance (e.g. one per request/scope) that calls + /// for the same . Were instead re-invoked against an + /// already-configured (and potentially already in-use) shared instance by every new scope, a callback appending state (e.g. ) would keep + /// accumulating duplicate registrations for as long as the process runs, and concurrent first-callers could race on mutating the same shared instance. 's + /// factory may itself run more than once under concurrent first-time access, but only ever against its own freshly-constructed (not-yet-published/not-yet-shared) candidate instance - exactly one of + /// which is ever actually stored and returned - so this remains safe without any additional locking. + public CosmosDbModelOptions GetOrAddModelOptions(string containerId, Action>? configure = null) where TModel : class, IEntityKey, new() + => (CosmosDbModelOptions)_models.GetOrAdd((containerId.ThrowIfNull(), typeof(TModel)), _ => + { + var options = new CosmosDbModelOptions(); + configure?.Invoke(options); + return options; + }); + + /// + /// Tries to get the for the specified container . + /// + /// The model . + /// The identifier. + /// The where found. + /// where the was found; otherwise, . + public bool TryGetModelOptions(string containerId, [NotNullWhen(true)] out CosmosDbModelOptions? modelOptions) where TModel : class, IEntityKey, new() + { + if (_models.TryGetValue((containerId.ThrowIfNull(), typeof(TModel)), out var mo)) + { + modelOptions = (CosmosDbModelOptions)mo; + return true; + } + + modelOptions = null; + return false; + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbQuery.cs b/src/CoreEx.Cosmos/CosmosDbQuery.cs new file mode 100644 index 00000000..2344900c --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbQuery.cs @@ -0,0 +1,487 @@ +namespace CoreEx.Cosmos; + +/// +/// Represents a composable query against a , together with its materializers. +/// +/// The model . +/// Instances are created internally by ; additional filtering/ordering is composed +/// using standard LINQ () via that method's query delegate parameter, or via for ad-hoc composition. +/// Every materializer on this type (, , etc.) routes through for +/// structured logging and to CoreEx exception mapping — the same as every CRUD operation. Being instance methods on this dedicated +/// wrapper type (rather than extensions), they also structurally cannot collide with another package's identically-named extensions +/// (e.g. CoreEx.EntityFrameworkCore.EfDbExtensions). +public class CosmosDbQuery where TModel : class, IEntityKey, new() +{ + private readonly Func, IQueryable>? _query; + private PagingArgs? _paging; + + /// + /// Initializes a new instance of the class. + /// + /// The owning . + /// The . + /// The optional query composition function. + internal CosmosDbQuery(CosmosDbContainer container, CosmosDbArgs args, Func, IQueryable>? query) + { + Container = container.ThrowIfNull(); + Args = args.ThrowIfNull(); + _query = query; + } + + /// + /// Gets the owning . + /// + public CosmosDbContainer Container { get; } + + /// + /// Gets the (as specified at, or defaulted during, construction). + /// + public CosmosDbArgs Args { get; } + + /// + /// Sets (overrides) the to be applied by the /-family materializers. + /// + /// The . + /// The to support fluent-style method-chaining. + /// Must not be set prior to calling a Single/First-style materializer (see remarks there), which apply their own internally-limited paging; doing so results in an + /// . + public CosmosDbQuery WithPaging(PagingArgs? paging) + { + _paging = paging; + return this; + } + + /// + /// Gets the composed, filtered . + /// + /// The optional (defaults to the specified at construction). + /// The . + /// Builds the base (using ' ), applies the query composition function supplied to + /// (if any), and then always applies the -configured + /// filters (see ) — mirroring CoreEx.EntityFrameworkCore.EfDbModel{TModel}.Query, this method never + /// itself short-circuits on ' ; ApplyFilters is the single place bypass decisions are made, per-registration, so the mandatory tenant/logical-delete/ + /// type-discriminator/outbox-exclusion predicates always apply regardless of this setting. + public IQueryable AsQueryable(CosmosDbArgs? args = null) + { + args ??= Args; + + IQueryable query = Container.Container.GetItemLinqQueryable(requestOptions: args.QueryRequestOptions); + query = _query is null ? query : _query(query); + + return Container.Options.ApplyFilters(args, query, Container.CosmosDb.ExecutionContext); + } + + /// + /// Creates a by fully draining the underlying . + /// + /// The . + /// The resulting . + public async Task> ToListAsync(CancellationToken cancellationToken = default) => (await ToListWithResultInternalAsync(nameof(ToListAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates a by fully draining the underlying . + /// + /// The . + /// The containing the resulting . + public Task>> ToListWithResultAsync(CancellationToken cancellationToken = default) => ToListWithResultInternalAsync(nameof(ToListWithResultAsync), cancellationToken); + + /// + /// Creates the list (internal). + /// + private Task>> ToListWithResultInternalAsync(string memberName, CancellationToken cancellationToken) + => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => Result.Ok(await DrainAsync(tracer, ApplyPagingIfSet(AsQueryable()), ct).ConfigureAwait(false)), cancellationToken, memberName); + + /// + /// Creates a by fully draining the underlying . + /// + /// The collection . + /// The . + /// The resulting . + public async Task ToCollectionAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() + => (await ToCollectionWithResultInternalAsync(nameof(ToCollectionAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates a by fully draining the underlying . + /// + /// The collection . + /// The . + /// The containing the resulting . + public Task> ToCollectionWithResultAsync(CancellationToken cancellationToken = default) where TColl : ICollection, new() + => ToCollectionWithResultInternalAsync(nameof(ToCollectionWithResultAsync), cancellationToken); + + /// + /// Creates the collection (internal). + /// + private Task> ToCollectionWithResultInternalAsync(string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() + => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => + { + var coll = new TColl(); + foreach (var item in await DrainAsync(tracer, ApplyPagingIfSet(AsQueryable()), ct).ConfigureAwait(false)) + coll.Add(item); + + return Result.Ok(coll); + }, cancellationToken, memberName); + + /// + /// Creates an applying the state (including with where requested). + /// + /// Indicates whether to perform the query automatically. + /// The . + /// The resulting . + /// Where was never called, defaults to (i.e. is applied) via the + /// shared extension - matching CoreEx.EntityFrameworkCore's own behavior; an unbounded result set requires + /// the caller to explicitly opt in via . + /// The query executes a separate SELECT VALUE COUNT(1)-equivalent request unit cost (before paging is applied) and is opt-in given the additional RU cost; + /// it has no effect unless paging with has been requested. + public async Task> ToItemsResultAsync(bool autoCount = true, CancellationToken cancellationToken = default) + => (await ToItemsResultWithResultInternalAsync(autoCount, nameof(ToItemsResultAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates an applying the state (including with where requested). + /// + /// Indicates whether to perform the query automatically. + /// The . + /// The containing the resulting . + public Task>> ToItemsResultWithResultAsync(bool autoCount = true, CancellationToken cancellationToken = default) + => ToItemsResultWithResultInternalAsync(autoCount, nameof(ToItemsResultWithResultAsync), cancellationToken); + + /// + /// Creates the (internal). + /// + private Task>> ToItemsResultWithResultInternalAsync(bool autoCount, string memberName, CancellationToken cancellationToken) + => Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => + { + var paging = _paging ?? PagingArgs.Create(); + var baseQuery = AsQueryable(); + var ir = new ItemsResult(paging) { Items = await DrainAsync(tracer, baseQuery.WithPaging(paging), ct).ConfigureAwait(false) }; + + if (autoCount) + await ir.WithTotalCountAsync(async ct2 => (long?)(await baseQuery.CountAsync(ct2).ConfigureAwait(false)).Resource, ct).ConfigureAwait(false); + + return Result.Ok(ir); + }, cancellationToken, memberName); + + /// + /// Returns the only element, throwing an exception if there is not exactly one. + /// + /// The . + /// The single resulting element. + /// Internally applies Skip(0).Take(2) before draining so that at most two items are ever fetched from Cosmos DB, rather than the whole result set, before the in-memory + /// check. must not have been set; doing so results in an + /// as the internally-applied paging is required to limit unnecessary data retrieval. + public async Task SingleAsync(CancellationToken cancellationToken = default) => (await SingleWithResultInternalAsync(nameof(SingleAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Returns the only element, throwing an exception if there is not exactly one. + /// + /// The . + /// The containing the single resulting element. + /// See for the internally-applied paging behavior. + public Task> SingleWithResultAsync(CancellationToken cancellationToken = default) => SingleWithResultInternalAsync(nameof(SingleWithResultAsync), cancellationToken); + + /// + /// Gets the single element (internal). + /// + private Task> SingleWithResultInternalAsync(string memberName, CancellationToken cancellationToken) + { + ThrowIfPagingSet(memberName); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => Result.Ok((await DrainAsync(tracer, AsQueryable().Skip(0).Take(2), ct).ConfigureAwait(false)).Single()), cancellationToken, memberName); + } + + /// + /// Returns the only element, or if there are no elements; throws an exception if there is more than one element. + /// + /// The . + /// The single resulting element, or . + /// See for the internally-applied paging behavior. + public async Task SingleOrDefaultAsync(CancellationToken cancellationToken = default) => (await SingleOrDefaultWithResultInternalAsync(nameof(SingleOrDefaultAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Returns the only element, or if there are no elements; throws an exception if there is more than one element. + /// + /// The . + /// The containing the single resulting element, or . + /// See for the internally-applied paging behavior. + public Task> SingleOrDefaultWithResultAsync(CancellationToken cancellationToken = default) => SingleOrDefaultWithResultInternalAsync(nameof(SingleOrDefaultWithResultAsync), cancellationToken); + + /// + /// Gets the single-or-default element (internal). + /// + private Task> SingleOrDefaultWithResultInternalAsync(string memberName, CancellationToken cancellationToken) + { + ThrowIfPagingSet(memberName); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => Result.Ok((await DrainAsync(tracer, AsQueryable().Skip(0).Take(2), ct).ConfigureAwait(false)).SingleOrDefault()), cancellationToken, memberName); + } + + /// + /// Returns the first element. + /// + /// The . + /// The first resulting element. + /// Internally applies Take(1) before draining so that at most one item is ever fetched from Cosmos DB, rather than the whole result set. must + /// not have been set; doing so results in an as the internally-applied paging is required to limit unnecessary data retrieval. + public async Task FirstAsync(CancellationToken cancellationToken = default) => (await FirstWithResultInternalAsync(nameof(FirstAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Returns the first element. + /// + /// The . + /// The containing the first resulting element. + /// See for the internally-applied paging behavior. + public Task> FirstWithResultAsync(CancellationToken cancellationToken = default) => FirstWithResultInternalAsync(nameof(FirstWithResultAsync), cancellationToken); + + /// + /// Gets the first element (internal). + /// + private Task> FirstWithResultInternalAsync(string memberName, CancellationToken cancellationToken) + { + ThrowIfPagingSet(memberName); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => Result.Ok((await DrainAsync(tracer, AsQueryable().Take(1), ct).ConfigureAwait(false)).First()), cancellationToken, memberName); + } + + /// + /// Returns the first element, or if there are no elements. + /// + /// The . + /// The first resulting element, or . + /// See for the internally-applied paging behavior. + public async Task FirstOrDefaultAsync(CancellationToken cancellationToken = default) => (await FirstOrDefaultWithResultInternalAsync(nameof(FirstOrDefaultAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Returns the first element, or if there are no elements. + /// + /// The . + /// The containing the first resulting element, or . + /// See for the internally-applied paging behavior. + public Task> FirstOrDefaultWithResultAsync(CancellationToken cancellationToken = default) => FirstOrDefaultWithResultInternalAsync(nameof(FirstOrDefaultWithResultAsync), cancellationToken); + + /// + /// Gets the first-or-default element (internal). + /// + private Task> FirstOrDefaultWithResultInternalAsync(string memberName, CancellationToken cancellationToken) + { + ThrowIfPagingSet(memberName); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => Result.Ok((await DrainAsync(tracer, AsQueryable().Take(1), ct).ConfigureAwait(false)).FirstOrDefault()), cancellationToken, memberName); + } + + /// + /// Creates a using the specified . + /// + /// The mapped item . + /// The mapping . + /// The . + /// The resulting . + public async Task> ToMappedItemsAsync(Func mapper, CancellationToken cancellationToken = default) + => (await ToMappedItemsWithResultInternalAsync(mapper, nameof(ToMappedItemsAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates a using the specified . + /// + /// The mapped item . + /// The mapping . + /// The . + /// The containing the resulting . + public Task>> ToMappedItemsWithResultAsync(Func mapper, CancellationToken cancellationToken = default) + => ToMappedItemsWithResultInternalAsync(mapper, nameof(ToMappedItemsWithResultAsync), cancellationToken); + + /// + /// Creates a using the specified . + /// + /// The mapped item . + /// The mapping . + /// The . + /// The resulting . + public Task> ToMappedItemsAsync(IMapper mapper, CancellationToken cancellationToken = default) where T : class + => ToMappedItemsAsync(source => mapper.ThrowIfNull().Map(source)!, cancellationToken); + + /// + /// Creates a using the specified . + /// + /// The mapped item . + /// The mapping . + /// The . + /// The containing the resulting . + public Task>> ToMappedItemsWithResultAsync(IMapper mapper, CancellationToken cancellationToken = default) where T : class + => ToMappedItemsWithResultAsync(source => mapper.ThrowIfNull().Map(source)!, cancellationToken); + + /// + /// Creates the mapped list (internal). + /// + private Task>> ToMappedItemsWithResultInternalAsync(Func mapper, string memberName, CancellationToken cancellationToken) + { + mapper.ThrowIfNull(); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) + => Result.Ok((await DrainAsync(tracer, ApplyPagingIfSet(AsQueryable()), ct).ConfigureAwait(false)).ConvertAll(item => mapper(item))), cancellationToken, memberName); + } + + /// + /// Creates a using the specified . + /// + /// The item collection . + /// The mapped item . + /// The mapping . + /// The . + /// The resulting . + public async Task ToMappedItemsAsync(Func mapper, CancellationToken cancellationToken = default) where TColl : ICollection, new() + => (await ToMappedItemsWithResultInternalAsync(mapper, nameof(ToMappedItemsAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates a using the specified . + /// + /// The item collection . + /// The mapped item . + /// The mapping . + /// The . + /// The containing the resulting . + public Task> ToMappedItemsWithResultAsync(Func mapper, CancellationToken cancellationToken = default) where TColl : ICollection, new() + => ToMappedItemsWithResultInternalAsync(mapper, nameof(ToMappedItemsWithResultAsync), cancellationToken); + + /// + /// Creates a using the specified . + /// + /// The item collection . + /// The mapped item . + /// The mapping . + /// The . + /// The resulting . + public Task ToMappedItemsAsync(IMapper mapper, CancellationToken cancellationToken = default) where TColl : ICollection, new() where T : class + => ToMappedItemsAsync(source => mapper.ThrowIfNull().Map(source)!, cancellationToken); + + /// + /// Creates a using the specified . + /// + /// The item collection . + /// The mapped item . + /// The mapping . + /// The . + /// The containing the resulting . + public Task> ToMappedItemsWithResultAsync(IMapper mapper, CancellationToken cancellationToken = default) where TColl : ICollection, new() where T : class + => ToMappedItemsWithResultAsync(source => mapper.ThrowIfNull().Map(source)!, cancellationToken); + + /// + /// Creates the mapped collection (internal). + /// + private Task> ToMappedItemsWithResultInternalAsync(Func mapper, string memberName, CancellationToken cancellationToken) where TColl : ICollection, new() + { + mapper.ThrowIfNull(); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => + { + var coll = new TColl(); + foreach (var item in await DrainAsync(tracer, ApplyPagingIfSet(AsQueryable()), ct).ConfigureAwait(false)) + coll.Add(mapper(item)); + + return Result.Ok(coll); + }, cancellationToken, memberName); + } + + /// + /// Creates an using the specified , applying the state (including with + /// where requested). + /// + /// The mapped item . + /// The mapping . + /// Indicates whether to perform the query automatically. + /// The . + /// The resulting . + /// See for the "no paging specified" and behavior. + public async Task> ToMappedItemsResultAsync(Func mapper, bool autoCount = true, CancellationToken cancellationToken = default) + => (await ToMappedItemsResultWithResultInternalAsync(mapper, autoCount, nameof(ToMappedItemsResultAsync), cancellationToken).ConfigureAwait(false)).Value; + + /// + /// Creates an using the specified , applying the state (including with + /// where requested). + /// + /// The mapped item . + /// The mapping . + /// Indicates whether to perform the query automatically. + /// The . + /// The containing the resulting . + public Task>> ToMappedItemsResultWithResultAsync(Func mapper, bool autoCount = true, CancellationToken cancellationToken = default) + => ToMappedItemsResultWithResultInternalAsync(mapper, autoCount, nameof(ToMappedItemsResultWithResultAsync), cancellationToken); + + /// + /// Creates an using the specified , applying the state (including with + /// where requested). + /// + /// The mapped item . + /// The mapping . + /// Indicates whether to perform the query automatically. + /// The . + /// The resulting . + public Task> ToMappedItemsResultAsync(IMapper mapper, bool autoCount = true, CancellationToken cancellationToken = default) where T : class + => ToMappedItemsResultAsync(source => mapper.ThrowIfNull().Map(source)!, autoCount, cancellationToken); + + /// + /// Creates an using the specified , applying the state (including with + /// where requested). + /// + /// The mapped item . + /// The mapping . + /// Indicates whether to perform the query automatically. + /// The . + /// The containing the resulting . + public Task>> ToMappedItemsResultWithResultAsync(IMapper mapper, bool autoCount = true, CancellationToken cancellationToken = default) where T : class + => ToMappedItemsResultWithResultAsync(source => mapper.ThrowIfNull().Map(source)!, autoCount, cancellationToken); + + /// + /// Creates the mapped (internal). + /// + private Task>> ToMappedItemsResultWithResultInternalAsync(Func mapper, bool autoCount, string memberName, CancellationToken cancellationToken) + { + mapper.ThrowIfNull(); + return Container.CosmosDb.Invoker.InvokeAsync(Container.CosmosDb, Args, async (tracer, _, ct) => + { + var paging = _paging ?? PagingArgs.Create(); + var baseQuery = AsQueryable(); + var ir = new ItemsResult(paging) { Items = (await DrainAsync(tracer, baseQuery.WithPaging(paging), ct).ConfigureAwait(false)).ConvertAll(item => mapper(item)) }; + + if (autoCount) + await ir.WithTotalCountAsync(async ct2 => (long?)(await baseQuery.CountAsync(ct2).ConfigureAwait(false)).Resource, ct).ConfigureAwait(false); + + return Result.Ok(ir); + }, cancellationToken, memberName); + } + + /// + /// Applies the state to the , only where it was explicitly set. + /// + /// Unlike the -returning materializers (/ + /// and overloads), which default an unset to (applying ) via the shared + /// extension - matching CoreEx.EntityFrameworkCore's own ItemsResult behavior - the plain list/collection + /// materializers (// and overloads) must not silently truncate + /// to just because was never called - that would diverge from CoreEx.EntityFrameworkCore's equivalent + /// IQueryable{TSource}.ToMappedItemsAsync, which is unbounded by default, and would silently truncate generated reference-data repositories to a page. Paging is therefore only applied here + /// where the caller explicitly called (including with , a no-op); leaving it unset means the whole result set is drained. + private IQueryable ApplyPagingIfSet(IQueryable queryable) => _paging is null ? queryable : queryable.WithPaging(_paging); + + /// + /// Guards against having been explicitly set prior to a Single/First-style materializer. + /// + private void ThrowIfPagingSet(string memberName) + { + if (_paging is not null) + throw new InvalidOperationException($"{nameof(PagingArgs)} must be null (see {nameof(WithPaging)}) before calling '{memberName}'; internally applied paging is used to limit unnecessary data retrieval."); + } + + /// + /// Creates a from a by fully draining the underlying . + /// + /// Where 's has enabled, the composed Cosmos DB SQL query is logged before execution. The + /// ToQueryDefinition conversion (LINQ-to-SQL translation) is only performed when debug logging is actually enabled, so there is no cost when it is not. + private static async Task> DrainAsync(InvokerTracer tracer, IQueryable queryable, CancellationToken cancellationToken) + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.LogContext($"Cosmos query: {queryable.ToQueryDefinition().QueryText}"); + + var items = new List(); + using var iterator = queryable.ToFeedIterator(); + + while (iterator.HasMoreResults) + { + var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + items.AddRange(response); + } + + return items; + } +} diff --git a/src/CoreEx.Cosmos/CosmosDbReferenceDataModelBase.cs b/src/CoreEx.Cosmos/CosmosDbReferenceDataModelBase.cs new file mode 100644 index 00000000..1a6b9c82 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbReferenceDataModelBase.cs @@ -0,0 +1,55 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides a convenience base class for reference data models implementing the common IReferenceData properties (extends ). +/// +/// Usage is purely optional; there is no other specific requirement for its use. +/// Does not implement IReferenceData by design, as it is not intended to support the base functionality. +public class CosmosDbReferenceDataModelBase : CosmosDbModelBase +{ + /// + /// Gets or sets the unique code. + /// + [JsonPropertyOrder(-899)] + public string Code { get; set; } = default!; + + /// + /// Gets or sets the text. + /// + [JsonPropertyOrder(-898)] + public string? Text { get; set; } + + /// + /// Gets or sets the description. + /// + [JsonPropertyOrder(-897)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public string? Description { get; set; } + + /// + /// Gets or sets the sort order. + /// + [JsonPropertyOrder(-896)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int SortOrder { get; set; } + + /// + /// Indicates whether the reference data is active. + /// + [JsonPropertyOrder(-895)] + public bool IsActive { get; set; } + + /// + /// Gets or sets the validity start . + /// + [JsonPropertyOrder(-894)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public DateTimeOffset? StartsOn { get; init; } + + /// + /// Gets or sets the validity end . + /// + [JsonPropertyOrder(-893)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public DateTimeOffset? EndsOn { get; init; } +} diff --git a/src/CoreEx.Cosmos/CosmosDbUnitOfWork.cs b/src/CoreEx.Cosmos/CosmosDbUnitOfWork.cs new file mode 100644 index 00000000..81acbf64 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosDbUnitOfWork.cs @@ -0,0 +1,90 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides the transactional implementation for , including support for a +/// transactional outbox via . +/// +/// The . +/// The optional (typically a ). +/// The optional used to orchestrate the functionality. +/// Implements the tech-agnostic directly — deliberately not a Cosmos-specific sub-interface — so application-layer services stay fully provider-agnostic. +/// Cosmos DB's only atomic multi-operation primitive () is atomic only within a single container and a single logical partition key — a hard Cosmos DB service +/// limit, not a design choice. This is surfaced as an explicit, enforced rule: the first Create/Update/Delete call made from within +/// binds the ambient to that container/partition key; any later operation targeting a different one throws immediately, client-side, +/// before any network call — reinforced by (not solely reliant on) Cosmos DB's own server-side all-or-nothing rejection of a genuinely mismatched batch. +/// Execution is deferred — enlisted operations are queued into the ambient , not sent immediately, and the batch executes once, at the end of the root +/// call. On failure (an exception, or an failure returned by the work), the accumulated batch is simply +/// discarded — nothing was ever sent to Cosmos DB, so there is nothing to roll back, unlike a relational unit-of-work's real rollback. A consequence of this: there is no "read your own uncommitted +/// writes" within a single unit-of-work — a Query()/GetAsync call inside the work cannot see an earlier write from the same unit-of-work, since nothing is actually persisted until +/// the final batch executes. This is a real, unavoidable divergence from a relational unit-of-work's immediate-execution-within-an-open-transaction model. +/// Nesting: a nested call flows into the same outer ambient batch (consistent with 's own +/// "flows an existing transaction" contract) rather than throwing. There is no Cosmos DB equivalent of a relational save point, so a nested failure discards the whole accumulated batch (root and +/// nested) — simpler than save-point rollback, and still fully safe, since nothing ever partially persists. +/// The actual transaction orchestration (begin/flow/execute, outbox publish, exception mapping, metrics) lives in , +/// invoked via — mirrors SqlServerUnitOfWork/SqlServerUnitOfWorkInvoker's split of responsibility exactly. +public sealed class CosmosDbUnitOfWork(ICosmosDb cosmosDb, IEventPublisher? outbox = null, CosmosDbUnitOfWorkInvoker? invoker = null) : IUnitOfWork +{ + /// + /// Gets the underlying . + /// + public ICosmosDb CosmosDb { get; } = cosmosDb.ThrowIfNull(); + + /// + /// Gets the optional to be used as a transactional outbox. + /// + public IEventPublisher? Outbox { get; } = outbox; + + /// + /// Gets the underlying used to orchestrate the functionality. + /// + public CosmosDbUnitOfWorkInvoker UnitOfWorkInvoker { get; } = invoker ?? CosmosDbUnitOfWorkInvoker.Default; + + /// + /// Gets or sets the most recently completed root in this scope, retained independently of the ambient (which is always + /// cleared once returns) specifically so can resolve against it + /// afterwards. Set by only. + /// + public CosmosDbTransaction? LastTransaction { get; internal set; } + + /// + /// The is required to enable. + public bool AreEventsSupported => Outbox is not null; + + /// + public IEventQueue Events => Outbox ?? throw new NotSupportedException($"A Transaction {nameof(Outbox)} has not been provided to enable {nameof(Events)}."); + + /// + public Task TransactionAsync(Func work, CancellationToken cancellationToken = default) => TransactionAsync(CosmosDb.DbArgs, work, cancellationToken); + + /// + public Task TransactionAsync(Func> work, CancellationToken cancellationToken = default) => TransactionAsync(CosmosDb.DbArgs, work, cancellationToken); + + /// + /// is not currently leveraged by the Cosmos DB implementation; provided only to satisfy 's advanced/configurable-scenario overload. + public Task TransactionAsync(IDataArgs args, Func work, CancellationToken cancellationToken = default) + => UnitOfWorkInvoker.InvokeAsync(this, (CosmosDbArgs)args, async (_, _, ct) => { await work(ct).ConfigureAwait(false); return true; }, cancellationToken); + + /// + /// is not currently leveraged by the Cosmos DB implementation; provided only to satisfy 's advanced/configurable-scenario overload. + public Task TransactionAsync(IDataArgs args, Func> work, CancellationToken cancellationToken = default) + => UnitOfWorkInvoker.InvokeAsync(this, (CosmosDbArgs)args, async (_, _, ct) => await work(ct).ConfigureAwait(false), cancellationToken); + + /// + /// Thrown where there is no completed transaction to synchronize from (i.e. called before any + /// in this scope has completed, or from within its work delegate before the batch has actually executed), or where was not part of the most recently completed + /// transaction's tracked mutations. + public void SynchronizeETag(CompositeKey key, T value) where T : IETag + { + value.ThrowIfNull(); + + var txn = LastTransaction ?? throw new InvalidOperationException($"{nameof(SynchronizeETag)} can only be called after a {nameof(TransactionAsync)} has completed; there is no completed transaction to synchronize from."); + + if (txn.Response is null) + throw new InvalidOperationException($"{nameof(SynchronizeETag)} can only be called after {nameof(TransactionAsync)} has completed successfully with at least one persisted operation - it cannot be called from within the {nameof(TransactionAsync)} work delegate itself, before the batch has executed."); + + if (!txn.TryGetOperationIndex(key, out var index)) + throw new InvalidOperationException($"The specified key was not part of the most recently completed {nameof(TransactionAsync)}'s tracked mutations; {nameof(SynchronizeETag)} cannot resolve an ETag for it."); + + value.ETag = txn.Response.GetOperationResultAtIndex(index).ETag; + } +} diff --git a/src/CoreEx.Cosmos/CosmosMetrics.cs b/src/CoreEx.Cosmos/CosmosMetrics.cs new file mode 100644 index 00000000..67b9c5a3 --- /dev/null +++ b/src/CoreEx.Cosmos/CosmosMetrics.cs @@ -0,0 +1,60 @@ +namespace CoreEx.Cosmos; + +/// +/// Provides the Azure Cosmos DB metrics. +/// +/// Naming is harmonized with SqlServerMetrics/PostgresMetrics (Outbox{Relay}Xxx) so the same metric concept has the same name across all three outbox relay implementations. +public static class CosmosMetrics +{ + /// + /// Gets the tag name used to identify which container a metric relates to; a single relay host can run against multiple containers (see AddCosmosDbOutboxRelayHostedService), so this distinguishes them. + /// + public const string ContainerTagName = "cosmos.container"; + + /// + /// Gets the meter used for the Cosmos DB outbox metrics. + /// + public static Meter Meter { get; } = new("CoreEx.Cosmos.Outbox"); + + /// + /// Gets the counter representing the total number of outbox event documents enqueued successfully. + /// + public static Counter OutboxEnqueued { get; } = Meter.CreateCounter("cosmos.outbox.enqueue", unit: "{message}", description: "Number of Cosmos DB outbox event documents enqueued successfully."); + + /// + /// Gets the counter representing the total number of outbox event documents (batch) relayed (published) successfully. + /// + public static Counter OutboxRelayPublished { get; } = Meter.CreateCounter("cosmos.outbox.relay.publish", unit: "{message}", description: "Number of Cosmos DB outbox event documents successfully published to their destination."); + + /// + /// Gets the counter representing the total number of outbox event documents (batch) that failed to relay (publish). + /// + /// Feeds the relay's circuit breaker - a sustained run of these is what trips it. + public static Counter OutboxRelayPublishFailed { get; } = Meter.CreateCounter("cosmos.outbox.relay.publish.failed", unit: "{message}", description: "Number of Cosmos DB outbox event documents that failed to publish to their destination."); + + /// + /// Gets the histogram that tracks the oldest lag duration (now - CloudEvent.Time of the oldest event in the batch), in milliseconds, of a Cosmos DB outbox relay batch attempt; i.e. + /// end-to-end relay lag. + /// + /// Recorded on both a successful and a failed publish attempt, so this keeps climbing (rather than going silent) for as long as a batch keeps failing - a container whose relay is + /// stuck behind a persistently-failing item is visible as an ever-increasing oldest lag, not an absent metric. + public static Histogram OutboxRelayOldestLagDuration { get; } = Meter.CreateHistogram("cosmos.outbox.relay.oldest_lag", unit: "ms", description: "Oldest lag duration (now - enqueued time of oldest event in batch) of Cosmos DB outbox relay."); + + /// + /// Gets the histogram that tracks the newest lag duration (now - CloudEvent.Time of the newest event in the batch), in milliseconds, of a Cosmos DB outbox relay batch attempt; i.e. + /// end-to-end relay lag. + /// + /// Recorded on both a successful and a failed publish attempt; see . + public static Histogram OutboxRelayNewestLagDuration { get; } = Meter.CreateHistogram("cosmos.outbox.relay.newest_lag", unit: "ms", description: "Newest lag duration (now - enqueued time of newest event in batch) of Cosmos DB outbox relay."); + + /// + /// Gets the counter representing the total number of outbox event documents successfully deleted after a successful publish. + /// + public static Counter OutboxRelayCleanupDeleted { get; } = Meter.CreateCounter("cosmos.outbox.relay.cleanup.deleted", unit: "{message}", description: "Number of Cosmos DB outbox event documents successfully deleted after a successful publish."); + + /// + /// Gets the counter representing the total number of outbox event documents that failed to delete after a successful publish. + /// + /// Never feeds the relay's circuit breaker - the event was already published, so the only consequence is the document sitting until its time-to-live expires; a bounded, self-healing cost, not lost work. + public static Counter OutboxRelayCleanupFailed { get; } = Meter.CreateCounter("cosmos.outbox.relay.cleanup.failed", unit: "{message}", description: "Number of Cosmos DB outbox event documents that failed to delete after a successful publish."); +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbBatch.cs b/src/CoreEx.Cosmos/Extended/CosmosDbBatch.cs new file mode 100644 index 00000000..6659595c --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbBatch.cs @@ -0,0 +1,148 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides Cosmos DB batch data-import extension methods over raw JSON, suitable for data seeding, bulk/one-off loads, and migrations alike. +/// +/// Operates on raw JSON (/) rather than any CoreEx.Cosmos model type. A caller controls the exact document shape directly - +/// including whatever property the container's partition key path points at, and any type-discriminator value for a container hosting multiple document "types" - the same way they would for +/// any other Cosmos document. Container.CreateItemAsync with no explicit partition key auto-extracts it from the item's own serialized shape (empirically confirmed against the emulator, +/// when the is configured with UseSystemTextJsonSerializerWithOptions) - so no partition-key handling is needed here at all, unlike a naive per-batch-partition-key +/// approach. +public static class CosmosDbBatch +{ + /// + /// Imports (creates) a batch of raw JSON into the . + /// + /// The . + /// The batch of items to create. + /// Indicates whether the items are created sequentially (order-based and slower) rather than in parallel (no order guarantees, faster); defaults to . + /// The . + /// Each item is created individually and is not transactional - a partial failure part-way through leaves the already-created items in place. + public static async Task ImportBatchAsync(this Container container, JsonArray items, bool sequential = false, CancellationToken cancellationToken = default) + { + container.ThrowIfNull(); + items.ThrowIfNull(); + + var work = items.Where(n => n is not null).Select(n => container.CreateItemAsync(n, cancellationToken: cancellationToken)); + + if (sequential) + { + foreach (var task in work) + { + await task.ConfigureAwait(false); + } + } + else + await Task.WhenAll(work).ConfigureAwait(false); + } + + /// + /// Imports (creates) a batch of named items from the into the . + /// + /// The . + /// The . + /// The qualified path to the array of items within the (see ) - e.g. a payload + /// with a grouped/nested structure such as Orders: [{ Order: [...] }] would use the path "Orders.Order". + /// Indicates whether the items are created sequentially rather than in parallel; defaults to . + /// The . + /// indicates that one or more items were found at and imported; otherwise, . + /// Each item is created individually and is not transactional - a partial failure part-way through leaves the already-created items in place. + public static async Task ImportBatchAsync(this Container container, JsonDataReader jsonDataReader, string path, bool sequential = false, CancellationToken cancellationToken = default) + { + if (!jsonDataReader.ThrowIfNull().TryCreateData(path.ThrowIfNullOrEmpty(), out var node) || node is not JsonArray array) + return false; + + await ImportBatchAsync(container, array, sequential, cancellationToken).ConfigureAwait(false); + return true; + } + + /// + /// Imports (creates) every top-level array found in the 's root object, treating each top-level property name as a within + /// . + /// + /// The . + /// The . + /// Indicates whether the items are created sequentially rather than in parallel; defaults to . + /// The . + /// A one-line whole-file convenience for a payload shaped flatly as ContainerA: [...], ContainerB: [...] - each top-level key names a container, and its array value is the list + /// of documents to import into it. For a payload with a grouped/nested structure instead, use the explicit + /// overload naming the exact path. + /// Each item is created individually and is not transactional - a partial failure part-way through leaves the already-created items in place. + public static async Task ImportBatchAsync(this Microsoft.Azure.Cosmos.Database database, JsonDataReader jsonDataReader, bool sequential = false, CancellationToken cancellationToken = default) + { + database.ThrowIfNull(); + jsonDataReader.ThrowIfNull(); + + // RootNode is the raw, unsubstituted tree - only used here to discover the top-level container-id keys. Each one is then re-resolved via TryCreateData so dynamic parameters + // (e.g. '^guid', '^1') are substituted the same way the explicit-path overload already does - walking RootNode's children directly would skip substitution entirely. + if (jsonDataReader.RootNode is not JsonObject root) + return; + + foreach (var containerId in root.Select(kvp => kvp.Key).ToList()) + { + await ImportBatchAsync(database.GetContainer(containerId), jsonDataReader, containerId, sequential, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Imports (creates) every top-level container's discriminated data found in the 's root object, treating each top-level property name as a + /// within , and each of its child property names as an value. + /// + /// The . + /// The . + /// Indicates whether the items are created sequentially rather than in parallel; defaults to . + /// The . + /// A one-line whole-file convenience for a payload shaped as ContainerA: [{ Person: [...] }, { Organization: [...] }], ContainerB: [...] - each top-level key names a + /// container, and its array value contains objects whose keys each name an value, with the corresponding array being the list of documents to import + /// into that container, stamped with the corresponding . + /// Each item is created individually and is not transactional - a partial failure part-way through leaves the already-created items in place. + public static async Task ImportDiscriminatedBatchAsync(this Microsoft.Azure.Cosmos.Database database, JsonDataReader jsonDataReader, bool sequential = false, CancellationToken cancellationToken = default) + { + database.ThrowIfNull(); + jsonDataReader.ThrowIfNull(); + + // RootNode is the raw, unsubstituted tree - only used here to discover the top-level container-id keys (and their child type-discriminator keys). Each is then re-resolved via + // TryCreateData so dynamic parameters (e.g. '^guid', '^1') are substituted the same way the explicit-path overload already does - walking RootNode's children directly would skip + // substitution entirely. + if (jsonDataReader.RootNode is not JsonObject root) + return; + + var typeDiscriminatorProperty = jsonDataReader.Options.ConvertPropertyName(nameof(ITypeDiscriminator.TypeDiscriminator))!; + var hadExistingTypeDiscriminatorProperty = jsonDataReader.Options.Properties.TryGetValue(typeDiscriminatorProperty, out var existingTypeDiscriminatorValue); + + try + { + foreach (var containerId in root.Select(kvp => kvp.Key).ToList()) + { + if (root[containerId] is not JsonArray containerArray) + continue; + + var container = database.GetContainer(containerId); + + // Only single-key objects (see also RootNodePreProcessor's identical convention for the '{ code: text }' shorthand) are treated as '$^TypeName'-style discriminator group markers - any + // other shape found in the same array (e.g. a flat, already-fully-formed document) is ignored here rather than silently misread as a bogus discriminator. + var discriminators = containerArray.OfType().Where(jo => jo.Count == 1).SelectMany(jo => jo.Select(kvp => kvp.Key)).Distinct().ToList(); + + foreach (var discriminator in discriminators) + { + // The discriminator key may be prefixed with '$' and/or '^' to signify additional behaviors as a JSON property name; strip these before use as the actual property value in the resulting document. + jsonDataReader.Options.Properties[typeDiscriminatorProperty] = discriminator.TrimStart('$', '^'); + + if (!jsonDataReader.TryCreateData($"{containerId}.{discriminator}", out var node) || node is not JsonArray array) + continue; + + await ImportBatchAsync(container, array, sequential, cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + // Options is caller-owned and may outlive this call (e.g. reused for further, unrelated seeding) - restore whatever the caller had before we mutated it (a prior value, or + // absence), rather than unconditionally removing the property and silently discarding a value the caller had already configured. + if (hadExistingTypeDiscriminatorProperty) + jsonDataReader.Options.Properties[typeDiscriminatorProperty] = existingTypeDiscriminatorValue; + else + jsonDataReader.Options.Properties.Remove(typeDiscriminatorProperty); + } + } +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbContainerExtensions.cs b/src/CoreEx.Cosmos/Extended/CosmosDbContainerExtensions.cs new file mode 100644 index 00000000..ef694f2b --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbContainerExtensions.cs @@ -0,0 +1,57 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides Cosmos DB container lifecycle extension methods, suitable for provisioning or resetting a database/container from code. +/// +/// Operates directly on the raw SDK types - there is no dependency on any other CoreEx.Cosmos type here at all, since resetting/creating a +/// container needs none of this package's model-driven behavior (partition-key computation, type-discriminator stamping, etc.). +public static class CosmosDbContainerExtensions +{ + /// + /// Deletes the with the specified where it exists; otherwise, does nothing. + /// + /// The . + /// The . + /// The . + public static async Task DeleteContainerIfExistsAsync(this Microsoft.Azure.Cosmos.Database database, string containerId, CancellationToken cancellationToken = default) + { + try + { + await database.ThrowIfNull().GetContainer(containerId.ThrowIfNullOrEmpty()).DeleteContainerAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (CosmosException cex) when (cex.StatusCode == System.Net.HttpStatusCode.NotFound) { /* Already gone - nothing to do. */ } + } + + /// + /// Deletes the described by where it exists, then creates it fresh. + /// + /// The . + /// The . + /// The throughput (RU/s); where not specified, the database's shared/default throughput applies. + /// The . + /// The newly-created . + /// The most common need when provisioning a container from code: start from a known-empty container with a specific partition key path (and, optionally, a unique key policy), rather + /// than accumulating state across runs or assuming a container already exists with the right shape. + public static async Task ReplaceOrCreateContainerAsync(this Microsoft.Azure.Cosmos.Database database, ContainerProperties containerProperties, int? throughput = null, CancellationToken cancellationToken = default) + { + database.ThrowIfNull(); + containerProperties.ThrowIfNull(); + + await database.DeleteContainerIfExistsAsync(containerProperties.Id, cancellationToken).ConfigureAwait(false); + + var response = await database.CreateContainerAsync(containerProperties, throughput, cancellationToken: cancellationToken).ConfigureAwait(false); + return response.Container; + } + + /// + /// Deletes the with the specified and where it exists, then creates it fresh. + /// + /// The . + /// The . + /// The partition key path (e.g. /partitionKey). + /// The throughput (RU/s); where not specified, the database's shared/default throughput applies. + /// The . + /// The newly-created . + public static Task ReplaceOrCreateContainerAsync(this Microsoft.Azure.Cosmos.Database database, string containerId, string partitionKeyPath, int? throughput = null, CancellationToken cancellationToken = default) + => ReplaceOrCreateContainerAsync(database, new ContainerProperties(containerId.ThrowIfNullOrEmpty(), partitionKeyPath.ThrowIfNullOrEmpty()), throughput, cancellationToken); +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbHealthCheck.cs b/src/CoreEx.Cosmos/Extended/CosmosDbHealthCheck.cs new file mode 100644 index 00000000..10926a9a --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbHealthCheck.cs @@ -0,0 +1,28 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides an for an , verifying its configured is reachable and exists. +/// +/// The . +/// Aspire's own Cosmos DB client integration (Aspire.Microsoft.Azure.Cosmos) does not register a health check of its own, unlike its Npgsql/SqlClient counterparts (which default to enabled, +/// only opting out via a DisableHealthChecks setting) - this fills that gap; see . +/// Performs a lightweight - cheap, and (unlike a check that only verified the +/// itself) also surfaces "the configured database doesn't exist" as an explicit, named health-check failure rather than an opaque error on first real request. +public sealed class CosmosDbHealthCheck(ICosmosDb cosmosDb) : IHealthCheck +{ + private readonly ICosmosDb _cosmosDb = cosmosDb.ThrowIfNull(); + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await _cosmosDb.Database.ReadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return HealthCheckResult.Healthy(); + } + catch (CosmosException cex) + { + return HealthCheckResult.Unhealthy($"Cosmos DB database '{_cosmosDb.Database.Id}' is not reachable: {cex.Message}", cex); + } + } +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbInvoker.cs b/src/CoreEx.Cosmos/Extended/CosmosDbInvoker.cs new file mode 100644 index 00000000..78d17d14 --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbInvoker.cs @@ -0,0 +1,224 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides the standard invoker functionality. +/// +/// Catches any unhandled and invokes to handle before bubbling up. +[InvokerName("CoreEx.Cosmos.CosmosDb")] +public class CosmosDbInvoker : InvokerBase +{ + private static CosmosDbInvoker? _default; + + /// + /// Gets the default instance. + /// + public static CosmosDbInvoker Default => ExecutionContext.GetService() ?? (_default ??= new CosmosDbInvoker()); + + /// + public override bool IsTracingDisabled => true; + + /// + protected override async Task OnInvokeAsync(InvokerTracer tracer, ICosmosDb cosmosDb, CosmosDbArgs args, Func> func, CancellationToken cancellationToken) + { + try + { + return await base.OnInvokeAsync(tracer, cosmosDb, args, func, cancellationToken).ConfigureAwait(false); + } + catch (CosmosException cex) + { + var hex = cosmosDb.HandleCosmosException(cex); + if (hex is not null) + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug(cex, "Cosmos exception converted to '{ExceptionType}': {Message}", hex.GetType().Name, hex.Message); + + // Where the result is an IResult (ROP) and the exception is considered an error then return as an IResult _failure_. + if (ExtendedException.TryConvertExceptionToResult(hex, out var res)) + return res; + + throw hex; + } + + throw; + } + } + + /// + /// Provides standardized Cosmos DB unit-of-work transaction handling for a , including nested-transaction flow-through and outbox/event publishing where supported. + /// + /// The result . + /// The . + /// The . + /// The work to be performed within the unit-of-work. + /// The action to emit outbox metrics (where applicable). + /// The . + /// The result of the . + /// This is intended to be used by to provide the -specific transaction handling, mirroring + /// DatabaseInvoker.OrchestrateUnitOfWorkTransactionAsync's shape while diverging in mechanics where Cosmos DB's genuinely differs from an ADO.NET transaction + /// (deferred, all-at-once execution rather than immediate per-statement execution with a later commit; no save-point equivalent for nesting; nothing to explicitly roll back on failure, since nothing is + /// ever sent to Cosmos DB before the batch executes). See 's own remarks for the full model. + /// (below) necessarily happens before the batch actually executes - it is what enlists the outbox event document into the + /// same atomic batch as the business mutation in the first place. This means a batch that fails to commit (e.g. a concurrency conflict) does so after publish already completed successfully; + /// is called in that case so a test-only capture (see EventPublisherDecorator) doesn't wrongly believe an event was published + /// when nothing was ever actually persisted. Where publish has not yet happened, a failure at any nesting level instead s only the events that level + /// itself added - mirroring DatabaseInvoker.OrchestrateUnitOfWorkTransactionAsync's eventStartCount bookkeeping - so a failure inside a nested + /// never discards events an enclosing/outer scope already queued before the nested call began. + /// A failure at any nesting level also marks the shared ambient ed. Since execution is deferred until the root call + /// ends, a nested failure cannot itself prevent a later root commit by simply not enlisting further operations - operations from before the failure are already enlisted. The root checks + /// before executing the batch, so the whole unit-of-work is still refused even where an enclosing/outer work delegate ignores a nested call's returned + /// failure and otherwise reports its own success - consistent with 's documented "a nested failure discards the whole accumulated batch" nesting model. + public static async Task OrchestrateUnitOfWorkTransactionAsync(InvokerTracer tracer, CosmosDbUnitOfWork unitOfWork, Func> work, Action? emitOutboxMetrics, CancellationToken cancellationToken) + { + var txn = unitOfWork.CosmosDb.CurrentTransaction; + var isRoot = txn is null; + if (isRoot) + { + txn = new CosmosDbTransaction(); + unitOfWork.CosmosDb.UseTransaction(txn); + } + + // Events queued by an OUTER/ancestor scope (before this nesting level's work even started) must never be discarded by a failure at THIS level alone - only the events this level itself added. + var eventStartCount = unitOfWork.Outbox?.Count ?? 0; + + // Tracks whether THIS invocation is the one that actually called Outbox.PublishAsync (only ever set within the isRoot branch, below) - see DiscardAsync's remarks for why this cannot rely on + // Outbox.HasBeenPublished, which is global to the (typically request-scoped, reused-across-calls) Outbox instance, not scoped to this invocation. + var publishedByThisInvocation = false; + + // Reusable discard logic for any failure detected at this nesting level. Marks the shared ambient CosmosDbTransaction as aborted (see CosmosDbTransaction.Abort's remarks) so the root refuses to + // commit even where an enclosing/outer work delegate ignores this level's returned failure and otherwise reports its own success, and rolls back/dequeues only the outbox events added at this + // level - Dequeue only functions pre-publish (publish only ever happens once, at the very end of the root's own commit step); where THIS invocation's own root already completed it, RollbackAsync + // undoes it instead. This deliberately checks publishedByThisInvocation rather than Outbox.HasBeenPublished: the latter is a one-way, publisher-lifetime flag (see IEventPublisher.HasBeenPublished) + // that stays true for as long as the same Outbox instance is reused across multiple, entirely independent OrchestrateUnitOfWorkTransactionAsync calls within one scope (e.g. a request-scoped + // CosmosDbUnitOfWork used for several sequential TransactionAsync calls) - relying on it here would wrongly invoke RollbackAsync for a later, unrelated failed invocation that never itself + // published anything, undoing an earlier invocation's genuinely successful and already-committed publish. Dequeue itself also refuses to run at all once HasBeenPublished is (globally) true - + // regardless of count - so it is only called when THIS invocation actually added events of its own to remove; a later, unrelated failed invocation that added none has nothing to dequeue and + // must not touch the Outbox at all. + async Task DiscardAsync() + { + txn!.Abort(); + + if (unitOfWork.Outbox is not null) + { + if (publishedByThisInvocation) + await unitOfWork.Outbox.RollbackAsync(cancellationToken).ConfigureAwait(false); + else + { + var addedByThisInvocation = Math.Max(0, unitOfWork.Outbox.Count - eventStartCount); + if (addedByThisInvocation > 0) + unitOfWork.Outbox.Dequeue(addedByThisInvocation); + } + } + } + + try + { + var result = await work().ConfigureAwait(false); + + // Nothing has been sent to Cosmos DB yet (deferred execution) - a failure simply discards the accumulated batch, no explicit rollback required. + if (result is IResult ir && ir.IsFailure) + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug("Unit-of-work transaction discarded due to error: {Error}", ir.Error?.Message); + + await DiscardAsync().ConfigureAwait(false); + return result; + } + + if (isRoot) + { + // A nested TransactionAsync failure that the enclosing work silently ignored (returned its own success despite it) must still discard the whole batch - see CosmosDbTransaction.Abort's remarks. + if (txn!.IsAborted) + throw new InvalidOperationException("The CosmosDbUnitOfWork's transaction was aborted by a nested TransactionAsync failure that was not returned/propagated by the enclosing work; the accumulated batch has been discarded and cannot be committed."); + + var outboxEnqueued = 0; + if (unitOfWork.AreEventsSupported && !unitOfWork.Events.IsEmpty) + { + outboxEnqueued = unitOfWork.Outbox!.Count; + await unitOfWork.Outbox!.PublishAsync(cancellationToken).ConfigureAwait(false); + publishedByThisInvocation = true; + } + + if (txn.HasOperations) + { + var response = await txn.ExecuteAsync(cancellationToken).ConfigureAwait(false); + if (response is not null && !response.IsSuccessStatusCode) + throw CreateBatchFailureException(response); + + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug("Unit-of-work transaction committed successfully."); + } + + if (outboxEnqueued > 0) + emitOutboxMetrics?.Invoke(outboxEnqueued); + } + + return result; + } + catch (CosmosException cex) + { + await DiscardAsync().ConfigureAwait(false); + + // Mirrors OnInvokeAsync's per-call exception mapping - a raw CosmosException can still surface directly from ExecuteAsync itself (e.g. a genuine transport/service failure), distinct from a + // "logical" failure already surfaced via the TransactionalBatchResponse and translated by CreateBatchFailureException below. + var hex = unitOfWork.CosmosDb.HandleCosmosException(cex); + if (hex is not null) + { + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Debug)) + tracer.Logger.LogDebug(cex, "Unit-of-work transaction discarded; Cosmos exception converted to '{ExceptionType}': {Message}", hex.GetType().Name, hex.Message); + + if (ExtendedException.TryConvertExceptionToResult(hex, out var hres)) + return hres; + + throw hex; + } + + throw; + } + catch (Exception ex) + { + await DiscardAsync().ConfigureAwait(false); + + if (tracer.Logger is not null && tracer.Logger.IsEnabled(LogLevel.Error)) + tracer.Logger.LogError(ex, "Unit-of-work transaction discarded due to an unexpected error: {Error}", ex.Message); + + if (ExtendedException.TryConvertExceptionToResult(ex, out var result)) + return result; + + throw; + } + finally + { + if (isRoot) + { + // Retained (independent of the ambient scope, which is always cleared here) so IUnitOfWork.SynchronizeETag can resolve against it after this call returns. + unitOfWork.LastTransaction = txn; + unitOfWork.CosmosDb.UseTransaction(null); + } + } + } + + /// + /// Builds a representative exception for a failed , using the same status-code-to-exception mapping as . + /// + private static Exception CreateBatchFailureException(TransactionalBatchResponse response) + { + for (var i = 0; i < response.Count; i++) + { + var opResult = response.GetOperationResultAtIndex(i); + + // A 'FailedDependency' operation did not itself fail - it was rolled back because another operation in the same batch did; skip to find the actual cause. + if (opResult.StatusCode is HttpStatusCode.OK or HttpStatusCode.Created or HttpStatusCode.NoContent or HttpStatusCode.FailedDependency) + continue; + + return opResult.StatusCode switch + { + HttpStatusCode.NotFound => new NotFoundException(), + HttpStatusCode.Conflict => new DuplicateException(), + HttpStatusCode.PreconditionFailed => new ConcurrencyException(), + _ => new InvalidOperationException($"The CosmosDbUnitOfWork's TransactionalBatch failed with status code '{response.StatusCode}' at operation index {i} (operation status '{opResult.StatusCode}'): {response.ErrorMessage}") + }; + } + + return new InvalidOperationException($"The CosmosDbUnitOfWork's TransactionalBatch failed with status code '{response.StatusCode}', but no specific failing operation could be identified."); + } +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbMultiSetExtensions.cs b/src/CoreEx.Cosmos/Extended/CosmosDbMultiSetExtensions.cs new file mode 100644 index 00000000..0e084804 --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbMultiSetExtensions.cs @@ -0,0 +1,237 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides multi-set query extension methods; enabling multiple, type-discriminator-keyed sets of documents to be read from the same container/partition in a single round-trip. +/// +/// See the remarks on (or its +/// exception-based counterpart) for the full mechanism. +public static class CosmosDbMultiSetExtensions +{ + /// + /// Executes a multi-set query with the specified against the specified . + /// + /// The . + /// The identifier. + /// The . + /// The . + /// See for the (Railway-Oriented Programming) counterpart of this method - + /// it shares the exact same mechanics and throws the exact same exceptions for the exact same conditions (see its remarks for the full breakdown of what does, and does not, become a + /// there); this method simply calls it and then s. + public static async Task SelectMultiSetAsync(this ICosmosDb cosmosDb, string containerId, MultiSetOptions options, CancellationToken cancellationToken = default) + => (await SelectMultiSetWithResultAsync(cosmosDb, containerId, options, cancellationToken).ConfigureAwait(false)).ThrowOnError(); + + /// + /// Executes a multi-set query with the specified against the specified , with a (Railway-Oriented Programming). + /// + /// The . + /// The identifier. + /// The . + /// The . + /// Each must resolve to a unique value. + /// Unlike the relational CoreEx.Database.Extended equivalent - where result sets are positional/ordered within a single multi-statement query - Cosmos DB has no equivalent construct; a single + /// query instead returns a mixed stream of documents from the same container/partition, each demuxed to its corresponding via a server-side WHERE ... IN (...) filter on + /// the type-discriminator property (see ), then further checked per-item (tenant/logical-delete/additive-filter - see + /// CosmosDbContainer{TModel}.CheckModel) before being handed to the corresponding . + /// The type-discriminator's underlying JSON property name is resolved once per call from the ambient configured via + /// (e.g. camelCase) - the same naming policy that already governs how + /// (and every other model property) is serialized to/from Cosmos DB. This requires the underlying to be configured with a -based serializer (the + /// de facto requirement for this package - see 's reliance on ); an explicit per-model override of the + /// type-discriminator's JSON property name (e.g. via ) is not supported - all TModels within a single multi-set call must + /// rely on the one ambient naming policy. + /// The number of specified has no relationship to the number of documents returned (unlike the relational equivalent's positional result sets) - each is matched + /// independently by its own value, and / are enforced, and + /// is invoked, in the order the were supplied - honoring to short-circuit + /// subsequent invocations, exactly as the relational equivalent does for its (positionally) subsequent result sets. + /// Any co-located documents (see 's equivalent automatic exclusion for the LINQ query path) are + /// always excluded server-side via the same reserved id-prefix check - no opt-in required. Similarly, where an individual 's + /// model has and/or configured, an equivalent - but defensively + /// IS_DEFINED-guarded - predicate is added for that model's subset of the query as a server-side (RU/bandwidth) optimization; see for the exact + /// predicate shape and why it never excludes a document purely for predating the property. This is additive to, not a replacement for, the always-applied per-item CheckModel check - a model with + /// neither configured still relies solely on that per-item check, exactly as before. A model with a registered without a nonQueryResult + /// (a query-only filter - see ) is not supported here at all: unlike the tenant/logical-delete filters, an arbitrary filter predicate cannot + /// be safely translated into this query's raw SQL text, so throws for it rather than risk silently returning documents + /// an equivalent would have excluded. + /// (see ), where supplied, takes precedence over one freshly constructed from ; + /// its own , if already set, must either agree with a non- or the latter must be omitted + /// () - a genuine mismatch between the two throws (an argument/guard-clause violation, not a ) rather than silently + /// preferring one. The caller's is never mutated (it is expected to be immutable/shareable - see 's own remarks); a partition key is layered in via a + /// shallow clone where required. + /// Only a genuine domain/business-level outcome results in here - specifically, a translated by + /// into an IExtendedException (e.g. a downstream mapped the same way every other CosmosDbContainer{TModel}.WithResultAsync operation maps it), or an + /// failure surfaced from a per-item CheckModel check - i.e. a 's own + /// nonQueryResult callback returning a genuine (e.g. an authorization denial), exactly as it would for a single-item GetAsync/GetWithResultAsync. + /// Everything else - an empty/duplicate-discriminator , a / mismatch, a + /// not configured with , a malformed/undeserializable Cosmos DB response, or + /// / being violated - is an // + /// guard-clause/invariant violation, not a business error, and is always thrown directly instead - even from this -returning method - exactly as it is from the exception-based + /// counterpart (this mirrors how, e.g., CosmosDbContainer{TModel}.DeleteWithResultAsync still throws + /// directly for its own ambiguous-logical-delete-configuration guard clause). is reserved here for genuinely expected/business-level + /// outcomes, not as a blanket substitute for exceptions. + public static Task SelectMultiSetWithResultAsync(this ICosmosDb cosmosDb, string containerId, MultiSetOptions options, CancellationToken cancellationToken = default) + => SelectMultiSetInternalAsync(cosmosDb.ThrowIfNull(), containerId.ThrowIfNullOrEmpty(), options.ThrowIfNull(), cancellationToken); + + /// + /// Executes a multi-set query with the specified (internal). + /// + private static async Task SelectMultiSetInternalAsync(ICosmosDb cosmosDb, string containerId, MultiSetOptions options, CancellationToken cancellationToken) + { + var multiSetList = options.MultiSetArgs?.ToList(); + if (multiSetList is null || multiSetList.Count == 0) + throw new ArgumentException($"At least one {nameof(IMultiSetArgs)} must be supplied.", $"{nameof(options)}.{nameof(MultiSetOptions.MultiSetArgs)}"); + + var byDiscriminator = new Dictionary(); + var discriminators = new Dictionary(); + foreach (var msa in multiSetList) + { + var discriminator = msa.ResolveTypeDiscriminator(cosmosDb, containerId); + discriminators[msa] = discriminator; + if (!byDiscriminator.TryAdd(discriminator, msa)) + throw new ArgumentException($"Multiple {nameof(IMultiSetArgs)} resolve to the same {nameof(IMultiSetArgs.ResolveTypeDiscriminator)} '{discriminator}'; each must be unique.", $"{nameof(options)}.{nameof(MultiSetOptions.MultiSetArgs)}"); + } + + var args = options.Args ?? cosmosDb.DbArgs; + + var jsonSerializerOptions = cosmosDb.Client.ClientOptions.UseSystemTextJsonSerializerWithOptions + ?? throw new NotSupportedException($"{nameof(SelectMultiSetAsync)} requires the underlying {nameof(CosmosClient)} to be configured with {nameof(CosmosClientOptions.UseSystemTextJsonSerializerWithOptions)}."); + + var discriminatorProperty = jsonSerializerOptions.PropertyNamingPolicy?.ConvertName(nameof(IReadOnlyTypeDiscriminator.TypeDiscriminator)) ?? nameof(IReadOnlyTypeDiscriminator.TypeDiscriminator); + var partitionKeyValue = options.PartitionKey is null ? PartitionKey.None : new PartitionKey(options.PartitionKey); + var requestOptions = ResolveQueryRequestOptions(args.QueryRequestOptions, options.PartitionKey, partitionKeyValue); + + return await cosmosDb.Invoker.InvokeAsync(cosmosDb, args, async (_, args, cancellationToken) => + { + // Build a per-discriminator predicate honoring any configured tenant/logical-delete query filter (see IMultiSetArgs.BuildFilterClause) - a model with neither configured falls back to a + // plain discriminator-equality predicate, so the overall query text is unchanged from before this optimization existed where no IMultiSetArgs configures either filter. + var extraParameters = new Dictionary(); + var typeClauses = new string[multiSetList.Count]; + var anyModelFilters = false; + + for (var i = 0; i < multiSetList.Count; i++) + { + var filterClause = multiSetList[i].BuildFilterClause(cosmosDb, containerId, jsonSerializerOptions, $"@f{i}", extraParameters); + typeClauses[i] = filterClause is null ? $"c[\"{discriminatorProperty}\"] = @p{i}" : $"(c[\"{discriminatorProperty}\"] = @p{i} AND {filterClause})"; + anyModelFilters |= filterClause is not null; + } + + var discriminatorPredicate = anyModelFilters + ? string.Join(" OR ", typeClauses) + : $"c[\"{discriminatorProperty}\"] IN ({string.Join(", ", Enumerable.Range(0, multiSetList.Count).Select(i => $"@p{i}"))})"; + + // Always exclude any co-located outbox event documents (see CosmosDbModelOptions.ApplyFilters's equivalent automatic exclusion for the LINQ query path) - no opt-in required. + var query = new QueryDefinition($"SELECT * FROM c WHERE NOT STARTSWITH(c.id, @outboxKeyPrefix) AND ({discriminatorPredicate})") + .WithParameter("@outboxKeyPrefix", CosmosDbOutboxEvent.OutboxKeyPrefix); + + for (var i = 0; i < multiSetList.Count; i++) + query = query.WithParameter($"@p{i}", discriminators[multiSetList[i]]); + + foreach (var (parameterName, value) in extraParameters) + query = query.WithParameter(parameterName, value); + + var counts = new Dictionary(); + using var iterator = cosmosDb.GetContainer(containerId).GetItemQueryStreamIterator(query, requestOptions: requestOptions); + + while (iterator.HasMoreResults) + { + using var response = await iterator.ReadNextAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using var doc = await JsonDocument.ParseAsync(response.Content, cancellationToken: cancellationToken).ConfigureAwait(false); + if (!doc.RootElement.TryGetProperty("Documents", out var documents) || documents.ValueKind != JsonValueKind.Array) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} response JSON 'Documents' property either not found in result or is not an array."); + + foreach (var item in documents.EnumerateArray()) + { + if (!item.TryGetProperty(discriminatorProperty, out var discriminatorElement) || discriminatorElement.ValueKind != JsonValueKind.String) + continue; // Not a discriminated document (e.g. another document shape sharing the container) - ignore. + + var discriminator = discriminatorElement.GetString(); + if (discriminator is null || !byDiscriminator.TryGetValue(discriminator, out var msa)) + continue; // Not one of the requested types - ignore. + + var model = item.Deserialize(msa.ModelType, jsonSerializerOptions) + ?? throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} failed to deserialize a document with {nameof(IMultiSetArgs.ResolveTypeDiscriminator)} '{discriminator}' into '{msa.ModelType.Name}'."); + + var addResult = msa.AddItem(cosmosDb, containerId, args, model); + if (addResult.IsFailure) + return (Result)addResult; + + if (!addResult.Value) + continue; // Silently excluded by the per-item check (e.g. wrong tenant, logically deleted) - must not count towards MinimumRows/MaximumRows/StopOnNull. + + var count = counts[msa] = counts.GetValueOrDefault(msa) + 1; + if (msa.MaximumRows.HasValue && count > msa.MaximumRows.Value) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} ({nameof(IMultiSetArgs.ResolveTypeDiscriminator)} '{discriminator}') has returned more items ({count}) than expected ({msa.MaximumRows.Value})."); + } + } + + // Validate minimum rows and invoke results, in the order the multi-set args were supplied - honoring StopOnNull to short-circuit subsequent invocations. + foreach (var msa in multiSetList) + { + var count = counts.GetValueOrDefault(msa); + if (count < msa.MinimumRows) + throw new InvalidOperationException($"{nameof(SelectMultiSetAsync)} ({nameof(IMultiSetArgs.ResolveTypeDiscriminator)} '{discriminators[msa]}') has returned less items ({count}) than expected ({msa.MinimumRows})."); + + if (count == 0 && msa.StopOnNull) + return Result.Success; + + msa.InvokeResult(); + } + + return Result.Success; + }, cancellationToken, nameof(SelectMultiSetAsync)).ConfigureAwait(false); + } + + /// + /// Resolves the to use, honoring a caller-supplied (see this type's remarks). + /// + private static QueryRequestOptions ResolveQueryRequestOptions(QueryRequestOptions? queryRequestOptions, string? partitionKey, PartitionKey partitionKeyValue) + { + if (queryRequestOptions is null) + return new QueryRequestOptions { PartitionKey = partitionKeyValue }; + + if (queryRequestOptions.PartitionKey is PartitionKey existing) + { + if (partitionKey is not null && !existing.Equals(partitionKeyValue)) + throw new ArgumentException( + $"The partition key '{partitionKey}' does not match the partition key already configured on {nameof(CosmosDbArgs)}.{nameof(CosmosDbArgs.QueryRequestOptions)}.{nameof(Microsoft.Azure.Cosmos.QueryRequestOptions.PartitionKey)}.", + nameof(partitionKey)); + + return queryRequestOptions; // Already set and consistent (or no explicit partitionKey override supplied) - the caller's QueryRequestOptions takes precedence, used as-is. + } + + // The caller's QueryRequestOptions did not itself specify a partition key - clone (never mutate the caller's own, potentially shared/cached, CosmosDbArgs) and layer in the resolved value. + return CloneWithPartitionKey(queryRequestOptions, partitionKeyValue); + } + + /// + /// Shallow-clones a , applying the resolved . + /// + /// The Cosmos DB SDK's has no public copy constructor/Clone() method, so each property is copied explicitly; is expected to be + /// immutable/shareable (see its own remarks), so the caller's instance is never mutated in place. + private static QueryRequestOptions CloneWithPartitionKey(QueryRequestOptions source, PartitionKey partitionKey) => new() + { + ResponseContinuationTokenLimitInKb = source.ResponseContinuationTokenLimitInKb, + EnableScanInQuery = source.EnableScanInQuery, + EnableLowPrecisionOrderBy = source.EnableLowPrecisionOrderBy, + EnableOptimisticDirectExecution = source.EnableOptimisticDirectExecution, + MaxBufferedItemCount = source.MaxBufferedItemCount, + MaxItemCount = source.MaxItemCount, + MaxConcurrency = source.MaxConcurrency, + PartitionKey = partitionKey, + PopulateIndexMetrics = source.PopulateIndexMetrics, + PopulateQueryAdvice = source.PopulateQueryAdvice, + ConsistencyLevel = source.ConsistencyLevel, + SessionToken = source.SessionToken, + DedicatedGatewayRequestOptions = source.DedicatedGatewayRequestOptions, + QueryTextMode = source.QueryTextMode, + FullTextScoreScope = source.FullTextScoreScope, + IfMatchEtag = source.IfMatchEtag, + IfNoneMatchEtag = source.IfNoneMatchEtag, + Properties = source.Properties, + AddRequestHeaders = source.AddRequestHeaders, + PriorityLevel = source.PriorityLevel, + CosmosThresholdOptions = source.CosmosThresholdOptions, + ExcludeRegions = source.ExcludeRegions, + AvailabilityStrategy = source.AvailabilityStrategy + }; +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbTransaction.cs b/src/CoreEx.Cosmos/Extended/CosmosDbTransaction.cs new file mode 100644 index 00000000..8a3dc45c --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbTransaction.cs @@ -0,0 +1,130 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Represents the ambient ("current"), single-container/single-partition-key scope of an active . +/// +/// Mirrors IDatabase.CurrentTransaction/UseTransaction — a plain mutable state holder on the shared, DI-scoped instance, set/cleared by +/// and read by 's Create/Update/Delete operations to transparently enlist instead of executing directly. +/// Cosmos DB's is atomic only within a single container and a single logical partition key (a hard service limit, not a design choice) — this type enforces that +/// by binding to the / of the first enlisted operation and throwing immediately, client-side, on any later operation that +/// targets a different container or partition key. +public sealed class CosmosDbTransaction +{ + private readonly Dictionary _operationIndexByKey = []; + private Container? _container; + private PartitionKey? _partitionKey; + private string? _partitionKeyValue; + private TransactionalBatch? _batch; + private int _operationCount; + + /// + /// Gets the number of operations enlisted so far. + /// + public int OperationCount => _operationCount; + + /// + /// Indicates whether at least one operation has been enlisted. + /// + public bool HasOperations => _batch is not null; + + /// + /// Indicates whether this transaction has been aborted (see ) by a failure detected at some nesting level, and must therefore never be committed - even where an enclosing/outer + /// work delegate ignores a nested call's returned failure and otherwise reports its own success. + /// + public bool IsAborted { get; private set; } + + /// + /// Marks this transaction as . + /// + /// Cosmos DB's has no relational save-point equivalent - a nested + /// failure (an failure returned by the nested work, or an exception converted to one) discards the whole accumulated batch (root and nested), per 's + /// documented nesting model. Since execution is deferred until the root call ends, that discard cannot be enforced by simply not enlisting further operations (earlier ones are already enlisted) - this + /// flag is checked before the root actually executes the batch, so the whole unit-of-work is refused even if the nested failure's was never propagated/checked by the enclosing work. + public void Abort() => IsAborted = true; + + /// + /// Gets the bound by the first enlisted operation (see ); where nothing has been enlisted yet. + /// + public Container? BoundContainer => _container; + + /// + /// Gets the raw partition key value bound by the first enlisted operation; where nothing has been enlisted yet, or the first enlisted model did not expose one + /// (see ). + /// + /// The Cosmos DB SDK's struct exposes no public way to extract its own value back out once constructed - this is tracked separately, as a raw + /// , specifically so a paired outbox-event write (see ) can reuse the exact same partition key value without needing to re-derive it. + public string? BoundPartitionKeyValue => _partitionKeyValue; + + /// + /// Gets the once the batch has been executed (see ); beforehand. + /// + public TransactionalBatchResponse? Response { get; private set; } + + /// + /// Enlists an operation into the ambient batch, binding it to the specified / if this is the first enlisted operation, or validating + /// against that binding otherwise. + /// + /// The the operation targets. + /// The the operation targets. + /// The raw partition key value, where known (see ); where the model does not expose one. + /// The identifying the model instance the operation mutates (used later by ). + /// The action which adds the actual operation (CreateItem/ReplaceItem/DeleteItem/etc.) to the . + /// The zero-based operation index (see ). + /// Thrown where / differs from the first enlisted operation's binding. + public int Enlist(Container container, PartitionKey partitionKey, string? partitionKeyValue, CompositeKey key, Action addOperation) + { + container.ThrowIfNull(); + addOperation.ThrowIfNull(); + + if (_batch is null) + { + _container = container; + _partitionKey = partitionKey; + _partitionKeyValue = partitionKeyValue; + _batch = container.CreateTransactionalBatch(partitionKey); + } + else if (!IsSameContainer(_container!, container) || !_partitionKey!.Value.Equals(partitionKey)) + throw new InvalidOperationException( + $"An operation targeting container '{container.Id}'/partition key '{partitionKey}' was attempted within the same CosmosDbUnitOfWork as an earlier operation targeting container " + + $"'{_container!.Id}'/partition key '{_partitionKey}'. Cosmos DB's TransactionalBatch is atomic only within a single container and a single logical partition key; all operations within " + + $"one unit-of-work must target the same container and partition key."); + + addOperation(_batch); + var index = _operationCount++; + _operationIndexByKey[key] = index; + return index; + } + + /// + /// Attempts to resolve the batch operation index for the specified (see ). + /// + /// The . + /// The resolved zero-based operation index, where found. + /// where was enlisted as part of this transaction; otherwise, . + public bool TryGetOperationIndex(CompositeKey key, out int index) => _operationIndexByKey.TryGetValue(key, out index); + + /// + /// Determines whether and represent the same logical container. + /// + /// Compares / rather than reference equality. In practice every enlisted here is resolved via ICosmosDb's own + /// per-containerId cache, so reference equality alone would already hold - but relying on that as an implicit invariant is fragile (e.g. a caller constructing a directly + /// from a /, bypassing that cache, would otherwise be wrongly treated as a different container to the one already bound). Comparing identifiers is just + /// as cheap and removes the hidden coupling. + private static bool IsSameContainer(Container x, Container y) => ReferenceEquals(x, y) || (x.Id == y.Id && x.Database.Id == y.Database.Id); + + /// + /// Executes the accumulated (where ); a no-op returning otherwise. + /// + /// The . + /// The ; where is . + /// This is the single round trip that actually sends every enlisted operation to Cosmos DB, atomically, all-or-nothing — nothing enlisted via + /// is sent to Cosmos DB before this executes. + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + if (_batch is null) + return null; + + Response = await _batch.ExecuteAsync(cancellationToken).ConfigureAwait(false); + return Response; + } +} diff --git a/src/CoreEx.Cosmos/Extended/CosmosDbUnitOfWorkInvoker.cs b/src/CoreEx.Cosmos/Extended/CosmosDbUnitOfWorkInvoker.cs new file mode 100644 index 00000000..4a52ea22 --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/CosmosDbUnitOfWorkInvoker.cs @@ -0,0 +1,29 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides the underlying invoker functionality. +/// +/// Implements transaction handling for a , including nested-transaction flow-through (no Cosmos DB equivalent of a relational save point - see +/// 's own remarks) and outbox/event publishing where supported, by delegating to - +/// mirrors SqlServerUnitOfWorkInvoker's shape exactly. +/// Note that the underlying implementation is not thread-safe. +[InvokerName("CoreEx.Cosmos.CosmosDbUnitOfWork")] +public class CosmosDbUnitOfWorkInvoker : InvokerBase +{ + private static CosmosDbUnitOfWorkInvoker? _default; + + /// + /// Gets the default instance. + /// + public static CosmosDbUnitOfWorkInvoker Default => ExecutionContext.GetService() ?? (_default ??= new CosmosDbUnitOfWorkInvoker()); + + /// + public override bool IsTracingDisabled => true; + + /// + protected async override Task OnInvokeAsync(InvokerTracer tracer, CosmosDbUnitOfWork unitOfWork, CosmosDbArgs args, Func> func, CancellationToken cancellationToken) + => await CosmosDbInvoker.OrchestrateUnitOfWorkTransactionAsync(tracer, unitOfWork, + () => base.OnInvokeAsync(tracer, unitOfWork, args, func, cancellationToken), + outboxEnqueued => CosmosMetrics.OutboxEnqueued.Add(outboxEnqueued), + cancellationToken).ConfigureAwait(false); +} diff --git a/src/CoreEx.Cosmos/Extended/IMultiSetArgs.cs b/src/CoreEx.Cosmos/Extended/IMultiSetArgs.cs new file mode 100644 index 00000000..ba9ffbd9 --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/IMultiSetArgs.cs @@ -0,0 +1,61 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Enables the multi-set arguments. +/// +/// Unlike the relational CoreEx.Database.Extended.IMultiSetArgs - where each result set is identified purely by its position within a single multi-statement query - a +/// Cosmos DB multi-set query has no equivalent notion of ordered result sets; instead, a single round-trip returns a mixed stream of documents from the same container/partition, each demuxed to +/// its corresponding via its resolved value (see ). +public interface IMultiSetArgs : IMultiSetArgsCore +{ + /// + /// Gets the model . + /// + Type ModelType { get; } + + /// + /// Resolves the value used to demux a document to this . + /// + /// The . + /// The identifier. + /// Resolved from the target 's own CosmosDbModelOptions{TModel}.EffectiveTypeDiscriminator - the explicit + /// override where configured, otherwise the same schema/CLR-type-name default Model.PrepareTypeDiscriminator stamps automatically - so this always agrees with whatever value is actually + /// persisted on documents of this type, never merely the unconfigured default. + string ResolveTypeDiscriminator(ICosmosDb cosmosDb, string containerId); + + /// + /// Adds the - having first been checked (see CosmosDbContainer{TModel}.CheckModel) for tenant/logical-delete/additive-filter eligibility - to the underlying result. + /// + /// The . + /// The identifier. + /// The . + /// The deserialized model. + /// The , whose indicates whether the was actually added () or was silently excluded by the + /// per-item check (). + /// Invoked once per matching document by the multi-set query engine (see CosmosDbMultiSetExtensions.SelectMultiSetAsync); a model that fails the per-item check (see + /// CosmosDbContainer{TModel}.CheckModel) is silently excluded (not added) where that check itself resolves to (e.g. wrong tenant, logically deleted) - exactly as a + /// single-item would exclude it - as opposed to a genuine (e.g. a + /// that itself fails), which is propagated rather than swallowed. The caller (CosmosDbMultiSetExtensions.SelectMultiSetInternalAsync) relies + /// on the returned - not merely - to decide whether to count the document towards / + /// /; a silently-excluded document must never count as a received row, otherwise a mandatory single-item read could + /// wrongly satisfy while never actually invoking its result callback. + Result AddItem(ICosmosDb cosmosDb, string containerId, CosmosDbArgs args, object model); + + /// + /// Builds an additional, defensive server-side SQL predicate (adding any required parameters into ) enforcing this 's 's + /// configured tenant/logical-delete query filters (see /), or + /// where neither is configured for this model. + /// + /// The . + /// The identifier. + /// The used to resolve the tenant/logical-delete JSON property names (mirroring how the type-discriminator property name + /// itself is resolved - see 's remarks). + /// The unique SQL parameter name prefix for this , avoiding collisions with other in the same query. + /// The dictionary any required SQL parameter values are added to. + /// This is purely a server-side (RU/bandwidth) optimization - the same tenant/logical-delete eligibility is always re-checked, unconditionally, per returned item via + /// (see CosmosDbContainer{TModel}.CheckModel); a document that somehow slips through this predicate is still excluded there. + /// Each predicate defensively lets through a document that predates the property being added at all (IS_DEFINED-guarded), so historical documents are not silently excluded by a filter + /// enabled after they were originally written - the same intent as , but expressed defensively since a multi-set query commonly spans many + /// document shapes, of varying vintage, within one long-lived container. + string? BuildFilterClause(ICosmosDb cosmosDb, string containerId, JsonSerializerOptions jsonSerializerOptions, string parameterPrefix, IDictionary parameters); +} diff --git a/src/CoreEx.Cosmos/Extended/IMultiSetArgsT.cs b/src/CoreEx.Cosmos/Extended/IMultiSetArgsT.cs new file mode 100644 index 00000000..b3f8ddc5 --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/IMultiSetArgsT.cs @@ -0,0 +1,75 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Enables the multi-set arguments for a specific . +/// +/// The model . +/// is resolved purely from ; resolves the +/// 's -configured EffectiveTypeDiscriminator (its explicit +/// override where configured, otherwise the same schema/CLR-name default Model.PrepareTypeDiscriminator stamps automatically), so both always agree on the same value actually persisted for a given +/// . A that does not implement cannot be used in a multi-set query; concrete implementations +/// (see /) enforce this via a static guard. +public interface IMultiSetArgs : IMultiSetArgs where TModel : class, IEntityKey, new() +{ + /// + Type IMultiSetArgs.ModelType => typeof(TModel); + + /// + string IMultiSetArgs.ResolveTypeDiscriminator(ICosmosDb cosmosDb, string containerId) => cosmosDb.ThrowIfNull().Container(containerId.ThrowIfNullOrEmpty()).Options.EffectiveTypeDiscriminator; + + /// + Result IMultiSetArgs.AddItem(ICosmosDb cosmosDb, string containerId, CosmosDbArgs args, object model) + { + var result = cosmosDb.ThrowIfNull().Container(containerId.ThrowIfNullOrEmpty()).CheckModel(args.ThrowIfNull(), (TModel)model.ThrowIfNull(), OperationType.Get); + if (result.IsFailure) + return (Result)result; + + if (result.Value is null) + return false; // Silently excluded by CheckModel (e.g. wrong tenant, logically deleted) - not added, and must not count as a received row. + + AddItem(result.Value); + return true; + } + + /// + string? IMultiSetArgs.BuildFilterClause(ICosmosDb cosmosDb, string containerId, JsonSerializerOptions jsonSerializerOptions, string parameterPrefix, IDictionary parameters) + { + var options = cosmosDb.ThrowIfNull().Container(containerId.ThrowIfNullOrEmpty()).Options; + + // A query-only WithFilter (no nonQueryResult) is applied server-side by CosmosDbQuery (see CosmosDbModelOptions.ApplyFilters) but is not enforced per-item by CheckFilters/CheckModel + // (by design - see WithFilter remarks), and cannot be safely translated into this multi-set query's raw SQL text (an arbitrary Func, IQueryable> has no such translation + // outside of a real Cosmos LINQ query). Rather than silently returning documents a normal query would have excluded, fail fast and point the caller at the supported alternative. + if (options.HasQueryOnlyFilters) + throw new NotSupportedException( + $"{typeof(TModel).Name} has one or more {nameof(CosmosDbModelOptions<>.WithFilter)} filter(s) registered without a 'nonQueryResult' (i.e. query-only filters); these cannot be " + + $"used with a multi-set query as they cannot be safely translated into its raw SQL text, and would otherwise silently disagree with an equivalent {nameof(CosmosDbQuery<>)}. Either " + + $"register the filter with a 'nonQueryResult' (making it also enforced per-item, consistent with multi-set's own per-item check), or do not use {typeof(TModel).Name} in a multi-set query."); + + if (!options.IsTenantFilterEnabled && !options.IsLogicalDeleteFilterEnabled) + return null; + + var clauses = new List(2); + + if (options.IsTenantFilterEnabled) + { + var property = jsonSerializerOptions.ThrowIfNull().PropertyNamingPolicy?.ConvertName(nameof(IReadOnlyTenantId.TenantId)) ?? nameof(IReadOnlyTenantId.TenantId); + var parameterName = $"{parameterPrefix}_tenantId"; + parameters[parameterName] = cosmosDb.ExecutionContext.ThrowIfNull().TenantId; + clauses.Add($"(NOT IS_DEFINED(c[\"{property}\"]) OR c[\"{property}\"] = {parameterName})"); + } + + if (options.IsLogicalDeleteFilterEnabled) + { + var property = jsonSerializerOptions.ThrowIfNull().PropertyNamingPolicy?.ConvertName(nameof(IReadOnlyLogicallyDeleted.IsDeleted)) ?? nameof(IReadOnlyLogicallyDeleted.IsDeleted); + clauses.Add($"(NOT IS_DEFINED(c[\"{property}\"]) OR c[\"{property}\"] = false)"); + } + + return string.Join(" AND ", clauses); + } + + /// + /// Adds the already tenant/logical-delete/additive-filter-checked to the underlying result. + /// + /// The model. + void AddItem(TModel model); +} diff --git a/src/CoreEx.Cosmos/Extended/MultiSetCollArgs.cs b/src/CoreEx.Cosmos/Extended/MultiSetCollArgs.cs new file mode 100644 index 00000000..fa6db15e --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/MultiSetCollArgs.cs @@ -0,0 +1,57 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides the multi-set arguments when expecting a collection of items. +/// +/// The collection . +/// The model . +public class MultiSetCollArgs : IMultiSetArgs + where TModel : class, IEntityKey, new() + where TColl : class, ICollection, new() +{ + private readonly Action _result; + private TColl? _coll; + + static MultiSetCollArgs() => TypeDiscriminatorGuard.Check(); + + /// + /// Initializes a new instance of the class. + /// + /// The action that will be invoked with the result. + /// The minimum number of rows allowed. + /// The maximum number of rows allowed. + /// Indicates whether to stop further multi-set result processing where the current result has resulted in a (i.e. no matching items). + public MultiSetCollArgs(Action result, int minimumRows = 0, int? maximumRows = null, bool stopOnNull = false) + { + if (maximumRows.HasValue && minimumRows > maximumRows.Value) + throw new ArgumentException("Min Rows is greater than Max Rows.", nameof(maximumRows)); + + _result = result.ThrowIfNull(); + MinimumRows = minimumRows; + MaximumRows = maximumRows; + StopOnNull = stopOnNull; + } + + /// + public int MinimumRows { get; } + + /// + public int? MaximumRows { get; } + + /// + public bool StopOnNull { get; set; } + + /// + public void AddItem(TModel model) + { + model.ThrowIfNull(); + (_coll ??= new TColl()).Add(model); + } + + /// + public void InvokeResult() + { + if (_coll is not null) + _result(_coll); + } +} diff --git a/src/CoreEx.Cosmos/Extended/MultiSetOptions.cs b/src/CoreEx.Cosmos/Extended/MultiSetOptions.cs new file mode 100644 index 00000000..1084f4db --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/MultiSetOptions.cs @@ -0,0 +1,28 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides the options for a multi-set query. +/// +/// Bundles the per-call inputs (, , ) that would otherwise need to be threaded through as separate, ever-growing method +/// parameters/overloads. A future capability addition (e.g. a query-level row-count cap) can be layered on as an additional property here without altering the method's signature or any existing call +/// site - i.e. without a breaking method-contract change. +public sealed record MultiSetOptions +{ + /// + /// Gets the raw partition key value. + /// + /// resolves to unless overridden by an already-configured, non- partition key on + /// 's (which takes precedence - see 's remarks). + public string? PartitionKey { get; init; } + + /// + /// Gets the . + /// + /// Defaults to the owning where not specified. + public CosmosDbArgs? Args { get; init; } + + /// + /// Gets the one or more . + /// + public IEnumerable MultiSetArgs { get; init; } = []; +} diff --git a/src/CoreEx.Cosmos/Extended/MultiSetSingleArgs.cs b/src/CoreEx.Cosmos/Extended/MultiSetSingleArgs.cs new file mode 100644 index 00000000..804ed80e --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/MultiSetSingleArgs.cs @@ -0,0 +1,40 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides the multi-set arguments when expecting a single item only. +/// +/// The model . +/// The action that will be invoked with the result. +/// Indicates whether the value is mandatory; defaults to . +/// Indicates whether to stop further multi-set result processing where the current result has resulted in a (i.e. no matching item). +public class MultiSetSingleArgs(Action result, bool isMandatory = true, bool stopOnNull = false) : IMultiSetArgs where TModel : class, IEntityKey, new() +{ + private readonly Action _result = result.ThrowIfNull(); + private TModel? _value; + + static MultiSetSingleArgs() => TypeDiscriminatorGuard.Check(); + + /// + /// Indicates whether the value is mandatory; i.e. a corresponding item must be read. + /// + public bool IsMandatory { get; set; } = isMandatory; + + /// + public int MinimumRows => IsMandatory ? 1 : 0; + + /// + public int? MaximumRows => 1; + + /// + public bool StopOnNull { get; set; } = stopOnNull; + + /// + public void AddItem(TModel model) => _value = model.ThrowIfNull(); + + /// + public void InvokeResult() + { + if (_value is not null) + _result(_value); + } +} diff --git a/src/CoreEx.Cosmos/Extended/TypeDiscriminatorGuard.cs b/src/CoreEx.Cosmos/Extended/TypeDiscriminatorGuard.cs new file mode 100644 index 00000000..c311cea8 --- /dev/null +++ b/src/CoreEx.Cosmos/Extended/TypeDiscriminatorGuard.cs @@ -0,0 +1,19 @@ +namespace CoreEx.Cosmos.Extended; + +/// +/// Provides the shared static guard used by / to ensure their TModel can participate in a multi-set query. +/// +internal static class TypeDiscriminatorGuard +{ + /// + /// Checks that implements (or the mutable ); throws where not supported. + /// + /// The model . + /// Mirrors the same check performed by - a multi-set query is fundamentally a discriminator-keyed demux of a single + /// container/partition, so a TModel unable to carry a type discriminator can never be used with it. Invoked once (from a static constructor) per closed generic TModel, not per instance. + public static void Check() where TModel : class, IEntityKey, new() + { + if (!FeatureSupport.Determine().IsSupported) + throw new NotSupportedException($"'{typeof(TModel).Name}' cannot be used within a multi-set query; the model must implement {nameof(IReadOnlyTypeDiscriminator)} to enable."); + } +} diff --git a/src/CoreEx.Cosmos/GlobalUsing.cs b/src/CoreEx.Cosmos/GlobalUsing.cs new file mode 100644 index 00000000..246fb3e9 --- /dev/null +++ b/src/CoreEx.Cosmos/GlobalUsing.cs @@ -0,0 +1,39 @@ +global using CoreEx; +global using CoreEx.Abstractions; +global using CoreEx.Cosmos; +global using CoreEx.Cosmos.Extended; +global using CoreEx.Cosmos.Outbox; +global using CoreEx.Data; +global using CoreEx.Data.Json; +global using CoreEx.Data.Models; +global using CoreEx.Entities; +global using CoreEx.Entities.Abstractions; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.HealthChecks; +global using CoreEx.Hosting; +global using CoreEx.Invokers; +global using CoreEx.Mapping; +global using CoreEx.Results; +global using CoreEx.Results.Abstractions; +global using CoreEx.Schemas; +global using Microsoft.Azure.Cosmos; +global using Microsoft.Azure.Cosmos.Linq; +// CoreEx.Data also declares a (differently-purposed, hash-partitioning) 'PartitionKey' type; alias to disambiguate in favour of the Cosmos SDK's struct throughout this package. +global using PartitionKey = Microsoft.Azure.Cosmos.PartitionKey; +// CoreEx.Data also declares the shared base 'IMultiSetArgs' type; alias to disambiguate in favour of this package's discriminator-keyed variant throughout. +global using IMultiSetArgs = CoreEx.Cosmos.Extended.IMultiSetArgs; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Logging; +global using Polly; +global using System.Collections.Concurrent; +global using System.Diagnostics.CodeAnalysis; +global using System.Diagnostics.Metrics; +global using System.Linq.Expressions; +global using System.Net; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Text.Json; +global using System.Text.Json.Nodes; +global using System.Text.Json.Serialization; diff --git a/src/CoreEx.Cosmos/ICosmosDb.cs b/src/CoreEx.Cosmos/ICosmosDb.cs new file mode 100644 index 00000000..f939839e --- /dev/null +++ b/src/CoreEx.Cosmos/ICosmosDb.cs @@ -0,0 +1,75 @@ +namespace CoreEx.Cosmos; + +/// +/// Enables the core Azure Cosmos DB access capabilities. +/// +public interface ICosmosDb +{ + /// + /// Gets the underlying . + /// + CosmosClient Client { get; } + + /// + /// Gets the . + /// + Database Database { get; } + + /// + /// Gets the . + /// + CosmosDbInvoker Invoker { get; } + + /// + /// Gets the default . + /// + CosmosDbArgs DbArgs { get; } + + /// + /// Gets the . + /// + ExecutionContext ExecutionContext { get; } + + /// + /// Gets the . + /// + CosmosDbOptions Options { get; } + + /// + /// Gets the ambient ("current") for an active , where one is in scope; otherwise, . + /// + /// Mirrors IDatabase.CurrentTransaction — 's Create/Update/Delete operations check this to transparently enlist into the ambient batch instead of + /// executing directly, exactly like a SQL repository enlists into an open ADO.NET transaction unchanged. Set via , only by . + CosmosDbTransaction? CurrentTransaction { get; } + + /// + /// Sets (or clears, where ) the ambient ("current") (see ). + /// + /// The ; to clear. + void UseTransaction(CosmosDbTransaction? transaction); + + /// + /// Gets (creates and caches) the underlying for the specified . + /// + /// The identifier. + /// The . + Container GetContainer(string containerId); + + /// + /// Gets (creates and caches) the for the specified . + /// + /// The model . + /// The identifier. + /// An optional action to configure the corresponding ; only invoked the first time the for the + /// is created (i.e. it is cached/reused on subsequent calls). + /// The . + CosmosDbContainer Container(string containerId, Action>? configure = null) where TModel : class, IEntityKey, new(); + + /// + /// Handles the converting to a corresponding CoreEx (where applicable). + /// + /// The . + /// The converted where handled; otherwise, indicating that the exception is unexpected and will continue to be thrown/bubbled as-is. + /// Provides an opportunity to inspect and convert the exception before it continues to bubble. + Exception? HandleCosmosException(CosmosException cex); +} diff --git a/src/CoreEx.Cosmos/Outbox/CoreExCosmosExtensions.Outbox.DependencyInjection.cs b/src/CoreEx.Cosmos/Outbox/CoreExCosmosExtensions.Outbox.DependencyInjection.cs new file mode 100644 index 00000000..9be19d57 --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CoreExCosmosExtensions.Outbox.DependencyInjection.cs @@ -0,0 +1,68 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace Microsoft.Extensions.Hosting; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides and related extensions. +/// +public static class CoreExCosmosOutboxExtensions +{ + /// + /// Adds singleton keyed service(s) (as per ) that will be executed as a hosted service (in the background), relaying + /// outbox event documents from the specified . + /// + /// The . + /// The monitored identifier - the container whose outbox event documents (written by a against the same container) are + /// to be relayed. Required, unlike the equivalent SQL Server/Postgres extensions, since Cosmos DB outbox documents are co-located per-container rather than centralized in one table. + /// The number of hosted services to start to enable concurrency of processing for this (each gets its own Change Feed Processor instance, sharing + /// the same lease container for coordination). Where not specified, attempts to get the value from configuration using 'CoreEx:Host:Services:CosmosOutboxRelay:{containerId}:ServicesCount' as the + /// key (namespaced by so different containers can have independent concurrency); otherwise, defaults to '4'. + /// The lease identifier; where not specified, defaults to "{containerId}-leases". + /// The keyed singleton and health check key prefix, and the basis for each instance's distinct Change Feed Processor instance name; where not specified, defaults to + /// "cosmos-outbox-relay-{containerId}-". + /// An optional action to configure each instance before its is built. + /// An optional action to configure each instance. + /// The for fluent-style method-chaining. + /// Uses the + /// to enable, matching the same pattern as the SQL Server/Postgres outbox relay registrations. + /// Call this once per container that hosts outbox documents, each with its own (e.g. more instances for a high-volume "orders" container, fewer for a low-volume + /// "customers" one). + /// This registers the / only - unlike the SQL Server/Postgres equivalents' samples, it does not register a + /// destination , since the choice of destination is host-specific. A destination (e.g. an Azure Service Bus one, via + /// CoreEx.Azure.Messaging.ServiceBus's AddAzureServiceBusPublisher) must also be registered, or every batch throws once the Change Feed Processor delivers it - see + /// remarks and the package AGENTS.md/README.md "Outbox Relay" + /// section for a worked example. (the outbox write-side publisher) must never be registered for this role. + public static IHostApplicationBuilder AddCosmosDbOutboxRelayHostedService(this IHostApplicationBuilder builder, string containerId, int? servicesCount = null, string? leaseContainerId = null, string? serviceKeyPrefix = null, + Action? configureOptions = null, Action? configure = null) + { + builder.ThrowIfNull(); + containerId.ThrowIfNullOrEmpty(); + + servicesCount ??= CoreEx.Abstractions.Internal.GetConfigurationValue($"CoreEx:Host:Services:CosmosOutboxRelay:{containerId}:ServicesCount", 4, builder.Configuration); + servicesCount.ThrowWhen(c => c <= 0 || c > 32); + + leaseContainerId ??= $"{containerId}-leases"; + serviceKeyPrefix ??= $"cosmos-outbox-relay-{containerId}-"; + + for (var i = 0; i < servicesCount; i++) + { + var instanceName = $"{serviceKeyPrefix}{i:00}"; + builder.Services.AddHostedService(instanceName, sp => + { + var options = new CosmosDbOutboxRelayOptions { ContainerId = containerId, LeaseContainerId = leaseContainerId, InstanceName = instanceName }; + configureOptions?.Invoke(sp, options); + + // A short-lived scope, used only to obtain the reusable Microsoft.Azure.Cosmos.Database SDK proxy - ICosmosDb itself is registered scoped, but CosmosDbOutboxRelay is built once and lives for + // the process lifetime, so it cannot capture a scoped ICosmosDb directly (a captive-dependency bug); Database, like Container/CosmosClient, is stable and safe to hold long-term. + using var scope = sp.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService().Database; + + var processor = ActivatorUtilities.CreateInstance(sp, containerId); + var relay = ActivatorUtilities.CreateInstance(sp, database, options, processor); + return ActivatorUtilities.CreateInstance(sp, relay); + }, configure); + } + + return builder; + } +} diff --git a/src/CoreEx.Cosmos/Outbox/CoreExCosmosExtensions.Outbox.EventPublisher.DependencyInjection.cs b/src/CoreEx.Cosmos/Outbox/CoreExCosmosExtensions.Outbox.EventPublisher.DependencyInjection.cs new file mode 100644 index 00000000..d71ff6af --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CoreExCosmosExtensions.Outbox.EventPublisher.DependencyInjection.cs @@ -0,0 +1,39 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure - this is by design. +namespace Microsoft.Extensions.DependencyInjection; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class CoreExCosmosExtensions +{ + /// + /// Adds a keyed scoped service. + /// + /// The . + /// An optional action to configure the instance. + /// Indicates whether to also register as the default (non-keyed) service. + /// The service key to use for the keyed registration. + /// The for fluent-style method-chaining. + /// See for more information related to the underlying + /// registration implementation - matches the same keyed-root-plus-resolvable-key convention AddPostgresOutboxPublisher/AddSqlServerOutboxPublisher already use, so the same + /// UseExpectedEventPublisher-based test spy/expectation machinery (CoreEx.UnitTesting) works unchanged for a Cosmos-backed outbox too. + public static IServiceCollection AddCosmosDbEventPublisher(this IServiceCollection services, Action? configure = null, bool addAsDefaultIEventPublisher = true, string serviceKey = CosmosDbEventPublisher.DefaultServiceKey) + => services.AddCosmosDbEventPublisher(configure, addAsDefaultIEventPublisher, serviceKey); + + /// + /// Adds a keyed scoped service. + /// + /// The . + /// The . + /// An optional action to configure the instance. + /// Indicates whether to also register as the default (non-keyed) service. + /// The service key to use for the keyed registration. + /// The for fluent-style method-chaining. + /// See for more information related to the underlying + /// registration implementation. + public static IServiceCollection AddCosmosDbEventPublisher(this IServiceCollection services, Action? configure = null, bool addAsDefaultIEventPublisher = true, string serviceKey = CosmosDbEventPublisher.DefaultServiceKey) where TOutbox : CosmosDbEventPublisher + => services.ThrowIfNull().AddEventPublisher(serviceKey, sp => + { + var outbox = ActivatorUtilities.CreateInstance(sp); + configure?.Invoke(sp, outbox); + return outbox; + }, addAsDefaultIEventPublisher); +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbEventPublisher.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbEventPublisher.cs new file mode 100644 index 00000000..45372fb6 --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbEventPublisher.cs @@ -0,0 +1,114 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Provides the Azure Cosmos DB to be used as a +/// transactional outbox, in conjunction with a . +/// +/// The . +/// The optional . +/// The optional . +/// The optional . +/// Unlike a relational outbox (a dedicated table, inserted into within the same database transaction), an outbox event document here is written into the same container/partition as the +/// paired business mutation, in the same — Cosmos DB's only atomic multi-operation primitive supports a single container only, so a dedicated outbox container is not +/// possible while preserving atomicity. See for the full orchestration, and +/// for how these co-located documents are automatically kept invisible to ordinary business queries. +public class CosmosDbEventPublisher(ICosmosDb cosmosDb, IDestinationProvider? destinationProvider = null, IEventFormatter? formatter = null, ILogger? logger = null) + : EventPublisherBase(destinationProvider, formatter, logger) +{ + /// + /// Gets the default service key used for the underlying registration. + /// + /// See related CoreExCosmosExtensions.AddCosmosDbEventPublisher(IServiceCollection, Action{IServiceProvider, CosmosDbEventPublisher}?, bool, string). + public const string DefaultServiceKey = "CosmosOutbox"; + + /// + /// Gets the default outbox event time-to-live, in seconds (7 days). + /// + /// See for the trade-off this default represents. + public const int DefaultOutboxTimeToLiveSeconds = 60 * 60 * 24 * 7; + + /// + /// Gets the . + /// + protected ICosmosDb CosmosDb { get; } = cosmosDb.ThrowIfNull(); + + /// + /// Gets or sets the time-to-live (in seconds) applied to every outbox event document (see ). + /// + /// Defaults to (7 days). This is a real trade-off, not a free safety net: without a relay consuming these documents (not part of this package), + /// they would otherwise accumulate indefinitely (storage/RU cost, forever); a TTL bounds that. But if a future relay outage or bug ever runs longer than this window, the affected events are gone + /// permanently — Cosmos DB physically deletes expired documents, with no recovery — which is in tension with "guaranteed at-least-once delivery". Tune this once the operational characteristics of + /// whatever relay eventually consumes these documents are known; it is not a fixed law. + public int OutboxTimeToLiveSeconds { get; set => field = value.ThrowIfLessThanOrEqualToZero(); } = DefaultOutboxTimeToLiveSeconds; + + // CosmosDbOutboxEvent always serializes its partition key under the fixed JSON property name "partitionKey" (matching CosmosDbModelBase's convention). That is only correct if the container's actual, + // physical partition-key path (a container-creation-time setting, wholly independent of any C# property/JsonPropertyName) is literally "/partitionKey" - e.g. a model implementing IPartitionKey + // directly with a different [JsonPropertyName] to match a container whose real path is "/tenantId" would silently produce an outbox document with no value at that path, and TransactionalBatch + // (which requires every enlisted operation to agree on the exact same partition key) would then fail with an undiagnosable BadRequest. Rather than let that happen silently, the first outbox publish + // against a given container validates (and thereafter caches, since a container's partition-key path is immutable for its lifetime) that its actual path is "/partitionKey", failing fast with a clear, + // actionable exception otherwise. Keyed by (Database.Id, Container.Id) rather than the CosmosDb instance, since this reflects a physical, permanent fact about the container itself, safely shared + // process-wide regardless of how many CosmosDb/CosmosDbEventPublisher instances (e.g. one per request) ever touch it. + private static readonly ConcurrentDictionary<(string DatabaseId, string ContainerId), bool> _validatedOutboxContainers = new(); + + /// + /// Thrown where there is no active + /// scope, where no business mutation has yet been enlisted within it (an outbox event has no container/partition key to bind to otherwise — see / + /// ), or where the bound container's actual partition-key path is not /partitionKey (see remarks). + /// always serializes its partition key under the fixed JSON property name partitionKey; this is only correct where the container's actual, + /// physical partition-key path is /partitionKey - see the container-level check performed here (once per container, cached thereafter) for what happens otherwise. + protected override async Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) + { + var txn = CosmosDb.CurrentTransaction + ?? throw new InvalidOperationException($"{nameof(CosmosDbEventPublisher)} can only publish within an active {nameof(CosmosDbUnitOfWork)} ({nameof(IUnitOfWork.TransactionAsync)}) scope."); + + if (!txn.HasOperations) + throw new InvalidOperationException($"{nameof(CosmosDbEventPublisher)} requires at least one business mutation to already be enlisted in the current unit-of-work; an outbox event document has no container/partition key to bind to otherwise."); + + var container = txn.BoundContainer!; + await EnsureOutboxPartitionKeyPathAsync(container, cancellationToken).ConfigureAwait(false); + + // BoundPartitionKeyValue is null where the enlisted business mutation's own partition key resolved to PartitionKey.None - a real, valid single logical partition (not an error; the simplest + // possible container shape), so the outbox event document is co-located there too, exactly the same as any other partition key value. + var partitionKeyValue = txn.BoundPartitionKeyValue; + var partitionKey = partitionKeyValue is null ? PartitionKey.None : new PartitionKey(partitionKeyValue); + + foreach (var de in events) + { + var outboxEvent = new CosmosDbOutboxEvent + { + Id = CompositeKey.Create(CosmosDbOutboxEvent.OutboxKeyPrefix, Runtime.NewGuid()).ToString()!, + PartitionKey = partitionKeyValue, + Destination = de.Destination, + Event = de.Event.EncodeToJsonElement(), + TimeToLive = OutboxTimeToLiveSeconds + }; + + txn.Enlist(container, partitionKey, partitionKeyValue, CompositeKey.Create(outboxEvent.Id), b => b.CreateItem(outboxEvent)); + } + } + + /// + /// Validates (once per container, then caches the result for the remaining process lifetime) that 's actual, physical partition-key path is /partitionKey - + /// see the remarks on / for why this matters and why caching is safe. + /// + private static async Task EnsureOutboxPartitionKeyPathAsync(Container container, CancellationToken cancellationToken) + { + var key = (container.Database.Id, container.Id); + if (_validatedOutboxContainers.ContainsKey(key)) + return; + + var response = await container.ReadContainerAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + var paths = response.Resource.PartitionKeyPaths; + + if (paths.Count != 1 || paths[0] != "/partitionKey") + { + throw new InvalidOperationException( + $"{nameof(CosmosDbEventPublisher)} requires container '{container.Id}' to use the partition key path '/partitionKey' (matching {nameof(CosmosDbOutboxEvent)}'s fixed JSON property " + + $"name), but it is actually configured with partition key path(s) '{string.Join(", ", paths)}'. The transactional outbox document has no way to carry a value at a different, " + + "application-chosen path, so a TransactionalBatch enlisting both the business mutation and the outbox event would fail. Either recreate the container with '/partitionKey' as its " + + $"partition key path, or do not use {nameof(CosmosDbEventPublisher)}/{nameof(CosmosDbUnitOfWork)} against this container."); + } + + _validatedOutboxContainers[key] = true; + } +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxEvent.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxEvent.cs new file mode 100644 index 00000000..6f66c7cb --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxEvent.cs @@ -0,0 +1,49 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Represents a single transactional-outbox event document, written atomically alongside its paired business mutation by and physically co-located in the same +/// container/partition (forced by 's single-container/single-partition-key atomicity constraint - see ). +/// +/// Recognized (and automatically excluded from ordinary business queries against the same container) via its reserved -prefixed - +/// see 's automatic outbox-exclusion filter. A future relay (not built by this package) can read exactly these documents by querying for the same prefix instead of +/// excluding it - the mirror image of the same mechanism. +/// Assumes the owning container's partition key path is /partitionKey, matching 's convention used throughout this package - a container using a different +/// path (e.g. a model implementing directly with a different ) is not supported; validates this +/// once per container (cached thereafter) and throws a clear rather than let a mismatched write fail with an undiagnosable Cosmos DB BadRequest. +public sealed class CosmosDbOutboxEvent : IIdentifier, IPartitionKey, ITimeToLive +{ + /// + /// Gets the reserved prefix used to identify an outbox event document (see 's + /// automatic exclusion predicate, and , which constructs every as ). + /// + /// No legitimate business key would ever start with this reserved, $-prefixed sentinel — chosen deliberately so the automatic exclusion filter can be unconditional (applied whenever + /// a business model's IdentifierSupport allows it, with no configuration step, and with no risk of ever wrongly excluding real business data). + public const string OutboxKeyPrefix = "$outbox"; + + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Serialization omits this property entirely when () rather than writing a JSON null - a document with an + /// absent partition-key field resolves to , whereas one with an explicit JSON null value resolves to the distinct + /// - writing the latter here would mismatch a bound to + /// (the paired business mutation's own partition key, where it has none configured) and the batch would fail with a raw, undiagnosable BadRequest. + [JsonPropertyName("partitionKey")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PartitionKey { get; set; } + + /// + /// Gets or sets the destination (i.e. topic/queue) the is to ultimately be published to. + /// + public string Destination { get; set; } = string.Empty; + + /// + /// Gets or sets the serialized JSON. + /// + public JsonElement Event { get; set; } + + /// + [JsonPropertyName("ttl")] + public int? TimeToLive { get; set; } +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelay.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelay.cs new file mode 100644 index 00000000..c8cfca2b --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelay.cs @@ -0,0 +1,261 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Owns the underlying Cosmos DB for a single monitored container, providing start/pause/resume/stop lifecycle management and circuit-breaker-protected batch processing via +/// . +/// +/// The Cosmos DB analogue of Azure Service Bus's ServiceBusReceiverBase - a push/callback-driven, SDK-managed processor, not a poll-on-a-timer loop. The semaphore-guarded start/pause/resume/stop +/// state machine below deliberately mirrors ServiceBusReceiverBase's (which cannot be shared directly - it lives in CoreEx.Azure.Messaging.ServiceBus, a package this one must not depend on). +/// Constructed with the raw SDK rather than deliberately - is registered scoped, and an instance of this class +/// is built once and lives for the process lifetime, so capturing a scoped service here would be a captive-dependency bug. The proxy, like a +/// or , is stable and safe to hold long-term. +/// The lease container () is auto-provisioned (via , +/// partitioned on /id - the Change Feed Processor's own lease-document convention) the first time is called; a fresh Cosmos DB database would otherwise have no +/// lease container yet, and throws attempting to acquire leases against a container that does not exist. Concurrent hosted-service instances sharing the same +/// (see 's servicesCount) each call this +/// independently at their own startup; CreateContainerIfNotExistsAsync is idempotent/safe for this, so no additional coordination is required. +public sealed class CosmosDbOutboxRelay : IAsyncDisposable +{ +#if NET9_0_OR_GREATER + private readonly Lock _syncLock = new(); +#else + private readonly object _syncLock = new(); +#endif + private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly Database _database; + private readonly ChangeFeedProcessor _processor; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + /// The . + public CosmosDbOutboxRelay(Database database, CosmosDbOutboxRelayOptions options, CosmosDbOutboxRelayProcessor processor, ILogger logger) + { + Options = options.ThrowIfNull(); + Processor = processor.ThrowIfNull(); + Logger = logger.ThrowIfNull(); + Resiliency = options.Resiliency ?? CosmosDbOutboxRelayResiliency.CreateRelayCircuitBreakerResiliency(); + + _database = database.ThrowIfNull(); + var container = database.GetContainer(options.ContainerId); + var leaseContainer = database.GetContainer(options.LeaseContainerId); + + var builder = container.GetChangeFeedProcessorBuilder($"outbox-relay-{options.ContainerId}", OnChangesAsync) + .WithInstanceName(options.InstanceName) + .WithLeaseContainer(leaseContainer) + .WithErrorNotification(OnErrorNotificationAsync); + + if (options.PollInterval is not null) + builder = builder.WithPollInterval(options.PollInterval.Value); + + if (options.BatchSize is not null) + builder = builder.WithMaxItems(options.BatchSize.Value); + + if (options.StartTime is not null) + builder = builder.WithStartTime(options.StartTime.Value); + + _processor = builder.Build(); + } + + /// + /// Gets the . + /// + public CosmosDbOutboxRelayOptions Options { get; } + + /// + /// Gets the . + /// + public CosmosDbOutboxRelayProcessor Processor { get; } + + /// + /// Gets the . + /// + public ILogger Logger { get; } + + /// + /// Gets the used to protect batch execution with a self-pausing/self-resuming circuit breaker. + /// + public ResiliencePipeline Resiliency { get; } + + /// + /// Gets the . + /// + public ServiceStatus Status { get; private set; } + + /// + /// Gets or sets the reason for the current (where applicable, e.g. a pause). + /// + public string? StatusReason { get; set; } + + /// + /// Starts the underlying . + /// + /// The . + /// Provisions (partitioned on /id) first, where it does not already exist - see this type's own remarks. + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!Status.CanStart) + return; + + LogStatusChange(Status = ServiceStatus.Starting); + await _database.CreateContainerIfNotExistsAsync(Options.LeaseContainerId, "/id", Options.LeaseContainerThroughput, cancellationToken: cancellationToken).ConfigureAwait(false); + await _processor.StartAsync().ConfigureAwait(false); + LogStatusChange(Status = ServiceStatus.Running); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Pauses the underlying (via - there is no dedicated pause API; validated empirically that stopping then later starting the + /// same processor instance resumes correctly from its last checkpoint). + /// + /// The reason for the pause. + /// The . + public async Task PauseAsync(string reason, CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!Status.CanPause) + return; + + StatusReason = reason; + LogStatusChange(Status = ServiceStatus.Pausing); + await _processor.StopAsync().ConfigureAwait(false); + LogStatusChange(Status = ServiceStatus.Paused); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Resumes the underlying . + /// + /// The . + public async Task ResumeAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!Status.CanResume) + return; + + LogStatusChange(Status = ServiceStatus.Resuming); + await _processor.StartAsync().ConfigureAwait(false); + LogStatusChange(Status = ServiceStatus.Running); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Stops the underlying . + /// + /// The . + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var wasInitializing = Status.IsInitializing; + LogStatusChange(Status = ServiceStatus.Stopping); + + if (!wasInitializing) + await _processor.StopAsync().ConfigureAwait(false); + + LogStatusChange(Status = ServiceStatus.Stopped); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Handles a batch of changes delivered by the , executing through and rethrowing on failure so the Change Feed + /// Processor's own native redelivery/backoff continues to apply on top of whatever the circuit breaker decides. + /// + private async Task OnChangesAsync(IReadOnlyCollection changes, CancellationToken cancellationToken) + { + var ctx = ResilienceContextPool.Shared.Get(cancellationToken); + try + { + ctx.Properties.Set(ResilienceOwner.PropertyKey, this); + + var result = await Resiliency.ExecuteAsync(static async (rc, state) => + { + try + { + await state.relay.Processor.ProcessBatchAsync(state.changes, rc.CancellationToken).ConfigureAwait(false); + return Result.Success; + } + catch (Exception ex) + { + return Result.Fail(ex); + } + }, ctx, (relay: this, changes)).ConfigureAwait(false); + + result.ThrowOnError(); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + /// + /// Handles a Change Feed Processor infrastructure-level error notification (e.g. lease acquisition issues) - distinct from a batch exception, which is handled by . + /// + private Task OnErrorNotificationAsync(string leaseToken, Exception error) + { + if (Logger.IsEnabled(LogLevel.Warning)) + Logger.LogWarning(error, "Cosmos DB change feed processor error for container '{ContainerId}', lease '{LeaseToken}': {Error}", Options.ContainerId, leaseToken, error.Message); + + return Task.CompletedTask; + } + + /// + /// Logs the status change. + /// + private void LogStatusChange(ServiceStatus status) + { + lock (_syncLock) + { + if (!status.IsPause) + StatusReason = null; + } + + if (Logger.IsEnabled(LogLevel.Debug)) + Logger.LogDebug("Cosmos DB outbox relay for container '{ContainerId}': {Status}.", Options.ContainerId, status); + } + + /// + /// Stops the underlying (via ) before releasing synchronization resources - disposing a started relay without an + /// explicit preceding would otherwise leave the Change Feed Processor running, with its callbacks racing against the disposed semaphore. Idempotent - + /// safe to call more than once (only the first call performs any work), and safe to call even where the relay was never started ( itself tolerates that, + /// skipping the processor call while still transitioning ). + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + await StopAsync().ConfigureAwait(false); + _semaphore.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayHostedService.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayHostedService.cs new file mode 100644 index 00000000..bd3f3c4a --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayHostedService.cs @@ -0,0 +1,48 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Provides the execution leveraging an underlying . +/// +/// A thin wrapper only - all the actual Change Feed Processor/circuit-breaker logic lives in , exactly mirroring how ServiceBusReceiverHostedService{TReceiver} +/// delegates to ServiceBusReceiverBase. +public sealed class CosmosDbOutboxRelayHostedService : HostedServiceBase +{ + private readonly CosmosDbOutboxRelay _relay; + + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + /// The . + public CosmosDbOutboxRelayHostedService(CosmosDbOutboxRelay relay, IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider, logger) + { + _relay = relay.ThrowIfNull(); + ArePauseAndResumeSupported = true; + } + + /// + protected override async Task OnStartAsync(CancellationToken cancellationToken) + { + await _relay.StartAsync(cancellationToken).ConfigureAwait(false); + return ServiceStatus.Running; + } + + /// + protected override Task OnPauseAsync(CancellationToken cancellationToken) => _relay.PauseAsync("Hosted service externally paused.", cancellationToken); + + /// + protected override Task OnResumeAsync(CancellationToken cancellationToken) => _relay.ResumeAsync(cancellationToken); + + /// + protected override Task OnStopAsync(CancellationToken cancellationToken) => _relay.StopAsync(cancellationToken); + + /// + protected override HealthCheckResult OnReportHealthStatus(Dictionary data) + { + if (_relay.StatusReason is not null) + data.Add("statusReason", _relay.StatusReason); + + return Status.IsPause ? HealthCheckResult.Degraded("Service is in a paused state.", null, data) : HealthCheckResult.Healthy(null, data); + } +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayInvoker.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayInvoker.cs new file mode 100644 index 00000000..ae4f11c2 --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayInvoker.cs @@ -0,0 +1,18 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Provides the standard invoker functionality. +/// +/// Deliberately has no overrides - tracing stays enabled (unlike , which disables it for high-frequency CRUD operations), mirroring +/// CoreEx.Database.Outbox's DatabaseOutboxRelayInvoker, which is also a bare with only an . Relay batch processing is +/// comparatively low-frequency and specifically where distributed-tracing visibility (see ) is most valuable. +[InvokerName("CoreEx.Cosmos.Outbox.Relay")] +public class CosmosDbOutboxRelayInvoker : InvokerBase +{ + private static CosmosDbOutboxRelayInvoker? _default; + + /// + /// Gets the default instance. + /// + public static CosmosDbOutboxRelayInvoker Default => ExecutionContext.GetService() ?? (_default ??= new CosmosDbOutboxRelayInvoker()); +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayOptions.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayOptions.cs new file mode 100644 index 00000000..e344a941 --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayOptions.cs @@ -0,0 +1,53 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Provides the configuration options for a . +/// +public sealed class CosmosDbOutboxRelayOptions +{ + /// + /// Gets or sets the monitored identifier. + /// + public required string ContainerId { get; init; } + + /// + /// Gets or sets the lease identifier. + /// + public required string LeaseContainerId { get; init; } + + /// + /// Gets or sets the manually provisioned throughput (RU/s) to request when the container does not already exist and is auto-provisioned by ; + /// where not specified, no throughput is requested (the account/database default applies - e.g. serverless, or a database-level shared throughput). + /// + /// Only consulted the first time a given is auto-provisioned - see 's constructor remarks. + public int? LeaseContainerThroughput { get; set; } + + /// + /// Gets or sets the Change Feed Processor instance name; must be distinct per concurrently-running instance for the same / pair. + /// + public required string InstanceName { get; init; } + + /// + /// Gets or sets the poll interval; where not specified, the Change Feed Processor default applies. + /// + public TimeSpan? PollInterval { get; set; } + + /// + /// Gets or sets the maximum number of items returned per batch; where not specified, the Change Feed Processor default applies. + /// + /// Named to match the equivalent SQL Server/Postgres outbox relay hosted service configuration (DatabaseOutboxRelayHostedServiceBase.BatchSize) rather than the underlying Change Feed + /// Processor SDK's own WithMaxItems terminology - this property still maps directly onto it. + public int? BatchSize { get; set; } + + /// + /// Gets or sets the start time; where not specified, the Change Feed Processor's own default applies (confirmed empirically to mean "from the beginning" for a brand-new lease with no prior checkpoint, so + /// a first-ever relay startup does not silently skip a pre-existing outbox backlog). + /// + public DateTime? StartTime { get; set; } + + /// + /// Gets or sets the used to protect batch execution with a self-pausing/self-resuming circuit breaker; where not specified, defaults to + /// 's own defaults. + /// + public ResiliencePipeline? Resiliency { get; set; } +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayProcessor.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayProcessor.cs new file mode 100644 index 00000000..0d557203 --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayProcessor.cs @@ -0,0 +1,154 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Provides the pure filter/decode/publish/cleanup-delete batch logic for a , with no knowledge of the underlying Change Feed Processor SDK - directly unit-testable by handing +/// it an of without any live Cosmos DB dependency for the publish path. +/// +/// The root - a new scope is created per call. +/// The identifier being relayed (used for the cleanup-delete container lookup and metric tagging). +/// The . +/// The optional ; defaults to . +/// Mirrors the role of DatabaseOutboxRelayBase's per-partition relay body (filter/decode → publish → cleanup), minus the claim/lease-partition machinery SQL needs and Cosmos DB's Change Feed +/// Processor already handles via its own checkpointing. +/// A fresh is created per batch to resolve and (both registered scoped) - the Change Feed Processor can invoke concurrent +/// batches for different leases, so nothing scoped can be safely captured once at construction; this also means no shared mutable publisher state exists across concurrent batches at all. +public class CosmosDbOutboxRelayProcessor(IServiceProvider serviceProvider, string containerId, ILogger logger, CosmosDbOutboxRelayInvoker? invoker = null) +{ + /// + /// Gets the root . + /// + protected IServiceProvider ServiceProvider { get; } = serviceProvider.ThrowIfNull(); + + /// + /// Gets the identifier being relayed. + /// + public string ContainerId { get; } = containerId.ThrowIfNullOrEmpty(); + + /// + /// Gets the . + /// + protected ILogger Logger { get; } = logger.ThrowIfNull(); + + /// + /// Gets the . + /// + protected CosmosDbOutboxRelayInvoker Invoker { get; } = invoker ?? CosmosDbOutboxRelayInvoker.Default; + + /// + /// Processes a single batch of changes as delivered by the Change Feed Processor. + /// + /// The changed documents (may include co-located business documents - only -prefixed ones are relayed). + /// The . + /// Any decode or publish failure is allowed to propagate - this is what feeds the owning 's circuit breaker and lets the Change Feed Processor's own + /// native redelivery/backoff continue. A cleanup-delete failure, by contrast, is always caught and never propagated (see remarks). + /// Thrown immediately, before anything is enlisted, where the resolved default is a - see remarks. + /// A cleanup-delete failure only ever occurs after a successful publish - the event has already reached its destination, so the only consequence of leaving the document behind is it sitting + /// until its expires (a bounded, self-healing storage/RU cost), not lost work. Letting such a failure propagate and pause the relay over an already-completed delivery would be + /// wrong, so it is caught, logged, and counted () instead. + /// The resolved default must be a genuine destination publisher (e.g. an Azure Service Bus registered via + /// CoreEx.Azure.Messaging.ServiceBus's AddAzureServiceBusPublisher) - nothing in this package registers one, since the choice of destination is host-specific; see the package + /// AGENTS.md/README.md "Outbox Relay" section for a worked registration example. is deliberately rejected up front rather than left to fail deeper + /// inside - it is the outbox write-side publisher (paired with ) and can only publish inside an + /// active scope, which the relay's own per-batch scope never has. + public virtual async Task ProcessBatchAsync(IReadOnlyCollection changes, CancellationToken cancellationToken) + { + var outboxDocs = changes.Where(c => c.Id is not null && c.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix, StringComparison.Ordinal)).ToList(); + if (outboxDocs.Count == 0) + return; + + var tag = new KeyValuePair(CosmosMetrics.ContainerTagName, ContainerId); + + await using var scope = ServiceProvider.CreateAsyncScope(); + var eventPublisher = scope.ServiceProvider.GetRequiredService(); + var cosmosDb = scope.ServiceProvider.GetRequiredService(); + + // Fail fast with an actionable message rather than letting this reach CosmosDbEventPublisher.OnPublishAsync, whose "no active transaction" exception does not explain that it is the wrong + // publisher role entirely - see the remarks above. + if (eventPublisher is CosmosDbEventPublisher) + throw new InvalidOperationException( + $"The default {nameof(IEventPublisher)} resolved for container '{ContainerId}' is a {nameof(CosmosDbEventPublisher)}, which is the outbox write-side publisher (used by {nameof(CosmosDbUnitOfWork)}) and cannot " + + $"also serve as this relay's destination {nameof(IEventPublisher)}. Register a genuine destination publisher instead (e.g. an Azure Service Bus {nameof(IEventPublisher)} via CoreEx.Azure.Messaging.ServiceBus's " + + "AddAzureServiceBusPublisher) - see the package AGENTS.md/README.md \"Outbox Relay\" section for a worked example."); + + eventPublisher.Add(outboxDocs.Select(d => new DestinationEvent(d.Destination, d.Event.DecodeToCloudEvent()))); + + try + { + await Invoker.InvokeAsync(this, async (tracer, ct) => + { + if (tracer.Activity is not null) + { + tracer.Activity.AddTag("outbox.container", ContainerId); + tracer.Activity.AddTag("outbox.events.count", outboxDocs.Count); + tracer.Activity.LinkTraceContext(eventPublisher.GetEvents().Select(de => de.Event)); + } + + await eventPublisher.PublishAsync(ct).ConfigureAwait(false); + + // Only now that the publish has actually succeeded, give every originating trace (e.g. the API request that raised the event) a deterministic, visible "relayed" marker - see + // EmitRelayMarkers remarks for why this exists alongside (not instead of) the batch-level link above. Emitting this before the publish would risk a false-positive "relayed" + // marker for an event whose publish subsequently throws (and is then retried as part of the whole batch's Change Feed Processor redelivery). + eventPublisher.GetEvents().EmitRelayMarkers(tracer.Activity); + }, cancellationToken).ConfigureAwait(false); + + CosmosMetrics.OutboxRelayPublished.Add(outboxDocs.Count, tag); + RecordLagMetrics(eventPublisher); + } + catch (Exception ex) + { + CosmosMetrics.OutboxRelayPublishFailed.Add(outboxDocs.Count, tag); + RecordLagMetrics(eventPublisher); + if (Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(ex, "Failed to publish {Count} outbox event(s) for container '{ContainerId}': {Error}", outboxDocs.Count, ContainerId, ex.Message); + + throw; + } + finally + { + eventPublisher.Reset(); + } + + var container = cosmosDb.Container(ContainerId); + await Task.WhenAll(outboxDocs.Select(d => DeleteOneAsync(container, d, tag, cancellationToken))).ConfigureAwait(false); + } + + /// + /// Records the oldest/newest relay lag for the current batch, on both a successful and a failed publish attempt - so the histogram keeps reporting (and growing) for as long as a batch keeps + /// failing, rather than going silent, which is a far more useful signal to alert on than an absent metric. + /// + /// Computed via min/max across the batch rather than by indexing the first/last queued event - unlike SQL Server/Postgres's claim + /// query (which returns rows pre-ordered by enqueue time), a single Change Feed Processor delivery can span multiple logical partition keys with no guaranteed overall time ordering between + /// them. + private static void RecordLagMetrics(IEventPublisher eventPublisher) + { + var times = eventPublisher.GetEvents().Select(de => de.Event.Time ?? default).ToList(); + var now = Runtime.UtcNow; + CosmosMetrics.OutboxRelayOldestLagDuration.Record((now - times.Min()).TotalMilliseconds); + CosmosMetrics.OutboxRelayNewestLagDuration.Record((now - times.Max()).TotalMilliseconds); + } + + /// + /// Deletes a single, already-published outbox event document; never throws (see remarks). + /// + private async Task DeleteOneAsync(CosmosDbContainer container, CosmosDbOutboxEvent doc, KeyValuePair tag, CancellationToken cancellationToken) + { + try + { + // doc.PartitionKey is the raw partition key value as persisted alongside the event (see CosmosDbOutboxEvent/CosmosDbEventPublisher) - null there is not a missing value, it is the model + // genuinely having resolved to PartitionKey.None (e.g. no WithPartitionKey/WithFixedPartitionKey configured), a real, valid single logical partition. The partitionKey-taking overload + // ThrowIfNull()s its argument, so it must not be used for a PartitionKey.None document; the no-partition-key overload below resolves PartitionKey.None itself instead. + if (doc.PartitionKey is null) + await container.DeleteAsync(CompositeKey.Create(doc.Id), cancellationToken).ConfigureAwait(false); + else + await container.DeleteAsync(CompositeKey.Create(doc.Id), doc.PartitionKey, cancellationToken).ConfigureAwait(false); + + CosmosMetrics.OutboxRelayCleanupDeleted.Add(1, tag); + } + catch (Exception ex) + { + CosmosMetrics.OutboxRelayCleanupFailed.Add(1, tag); + if (Logger.IsEnabled(LogLevel.Warning)) + Logger.LogWarning(ex, "Failed to delete outbox event document '{Id}' after successful publish for container '{ContainerId}'; it will be removed automatically once its time-to-live expires: {Error}", doc.Id, ContainerId, ex.Message); + } + } +} diff --git a/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayResiliency.cs b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayResiliency.cs new file mode 100644 index 00000000..b4309a87 --- /dev/null +++ b/src/CoreEx.Cosmos/Outbox/CosmosDbOutboxRelayResiliency.cs @@ -0,0 +1,40 @@ +namespace CoreEx.Cosmos.Outbox; + +/// +/// Provides the wiring for a . +/// +/// Near-verbatim mirror of CoreEx.Azure.Messaging.ServiceBus's ServiceBusReceiverResiliency - exactly the reuse was promoted into base +/// CoreEx to enable. Unlike the Service Bus receiver's breaker (which excludes a dead-letter-classified exception type from counting), there is no exclusion predicate here - this is a happy-path-only +/// implementation with no poison-message classification yet (deferred, to be designed as one shared pattern across all CoreEx relays), so every propagated failure counts towards tripping the breaker. +public static class CosmosDbOutboxRelayResiliency +{ + /// + /// Creates a standardized with circuit breaker capabilities that automatically pauses and resumes a in response to a sustained run of + /// failures, then automatically recovers. + /// + /// The minimum throughput before the circuit breaker can evaluate the . + /// The sampling duration. + /// The initial duration for which the circuit breaker remains open before attempting to reset (exponentially increasing with each subsequent open). + /// The maximum duration for which the circuit breaker can remain open. + /// The failure ratio required to trip the circuit breaker. + /// A configured instance. + /// The default settings are: minimumThroughput = 5, samplingDuration = 30s, breakDuration = 15s, maxBreakDuration = 5m, failureRatio = 0.1. + public static ResiliencePipeline CreateRelayCircuitBreakerResiliency(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null, TimeSpan? maxBreakDuration = null, double failureRatio = 0.1) + => CircuitBreakerResiliency.Create( + "Cosmos DB outbox relay", + owner => owner.Logger, + (owner, pause, cancellationToken) => owner.PauseAsync($"Cosmos DB outbox relay circuit breaker has been tripped for container '{owner.Options.ContainerId}'; will resume automatically at: {Runtime.UtcNow.Add(pause):R}.", cancellationToken), + (owner, cancellationToken) => owner.ResumeAsync(cancellationToken), + shouldHandle: null, + minimumThroughput, samplingDuration, breakDuration, maxBreakDuration, failureRatio); + + /// + /// Gets the used to configure and manage resilience strategies for the . + /// + public static ResiliencePropertyKey ResiliencePropertyKey => ResilienceOwner.PropertyKey; + + /// + /// Gets the owning/invoking from the . + /// + public static CosmosDbOutboxRelay GetOwner(ResilienceContext context) => ResilienceOwner.GetOwner(context); +} diff --git a/src/CoreEx.Cosmos/README.md b/src/CoreEx.Cosmos/README.md new file mode 100644 index 00000000..c424f88c --- /dev/null +++ b/src/CoreEx.Cosmos/README.md @@ -0,0 +1,90 @@ +# CoreEx.Cosmos + +> Provides the core [Azure Cosmos DB](https://learn.microsoft.com/en-us/azure/cosmos-db/) access layer: `ICosmosDb`/`CosmosDb` as the CoreEx-Cosmos bridge, `CosmosDbContainer` and `CosmosDbMappedContainer` for typed CRUD + query operations, `CosmosDbQuery` as the composable, invoker-wrapped query/materialization type, `CosmosDbInvoker` for structured operation logging and exception mapping, a `CosmosDbUnitOfWork` transactional outbox (`TransactionalBatch`-based), and a Change Feed Processor-based outbox relay. + +## Overview + +`CoreEx.Cosmos` wraps the [`Microsoft.Azure.Cosmos`](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/cosmos.db) SDK with the same CoreEx data conventions used elsewhere in the framework: `ETag`/optimistic-concurrency checking (via Cosmos DB's native `If-Match` semantics), multi-tenancy filtering, logical-delete filtering, type-discriminator filtering (for several business model types sharing one container/partition), change-log stamping, `PagingArgs` paging, and `Result` (Railway-Oriented Programming) pipeline integration. + +The central type is `CosmosDb`, which holds the `CosmosClient`/`Database` and exposes `Container(id, configure?)` as the entry point for all strongly-typed CRUD. `CosmosDbContainer` provides `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `UpsertAsync`, and `Query` (returning a `CosmosDbQuery`) — each applying the applicable CoreEx cross-cutting pipeline. `CosmosDbMappedContainer` adds an `IBiDirectionMapper` layer for use cases where the Cosmos document model type differs from the domain entity type. + +The `Outbox` sub-namespace implements the [Transactional Outbox pattern](https://microservices.io/patterns/data/transactional-outbox.html) for Cosmos DB: `CosmosDbUnitOfWork` enlists business mutations and outbox event documents into the same `TransactionalBatch` (Cosmos DB's only atomic multi-operation primitive - atomic within a single container/logical partition key only), so the write is genuinely all-or-nothing without a separate outbox table. A Change Feed Processor-based relay (`CosmosDbOutboxRelay`) then decodes, publishes, and cleans up these documents, self-pausing/self-resuming via a circuit breaker on sustained publish failure. + +This is a **sibling package** to `CoreEx.Database`/`CoreEx.Database.SqlServer`/`CoreEx.Database.Postgres`, not a provider underneath `CoreEx.Database` — Cosmos DB is a document store with no ADO.NET-shaped connection/transaction/parameter surface, so it warrants its own package family while still sharing the same ergonomic CRUD/ROP/paging conventions, and (for the outbox relay specifically) the same harmonized metric names and shared trace-linking helper as `CoreEx.Database.SqlServer`/`CoreEx.Database.Postgres`. + +This package provides the core CRUD + query access layer, a `TransactionalBatch`-based transactional outbox (`CosmosDbUnitOfWork`/`CosmosDbEventPublisher`), and a Change Feed Processor-based outbox relay (`CosmosDbOutboxRelay`) - happy path only. **Not** included: poison-message/dead-letter handling for the relay (a permanently-failing outbox document is redelivered forever by the Change Feed Processor's own native backoff, with no built-in give-up - to be designed as one shared pattern across the SQL Server/Postgres/Cosmos relays, not Cosmos-specific), a multi-query (`IMultiQueryArgs`) equivalent, and any EF Core-Cosmos integration. + +## Key capabilities + +- 🔗 **Cosmos DB bridge**: `CosmosDb` wraps a DI-resolved `CosmosClient` (typically registered via Aspire's `builder.AddAzureCosmosClient("Cosmos")`) and caches both raw SDK `Container` instances (per container id) and `CosmosDbContainer` instances (per `(containerId, TModel)` pair - not container id alone, since a container may legitimately host more than one type-discriminated model). +- 📖 **Typed CRUD**: `CosmosDbContainer` provides `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `UpsertAsync` with automatic ETag/concurrency validation (native Cosmos DB `If-Match`), tenant isolation, and logical-delete handling. +- 🔁 **Mapped CRUD**: `CosmosDbMappedContainer` layers an `IBiDirectionMapper` over `CosmosDbContainer`, mapping between the domain entity type and the Cosmos DB document model type transparently for all CRUD operations. +- 🔍 **Composable query**: `CosmosDbContainer.Query(query?, args?)` returns a `CosmosDbQuery` — a dedicated wrapper (not a bare `IQueryable`) constructed over `Container.GetItemLinqQueryable()`; additional filtering/ordering is composed via the `query` delegate (standard LINQ `Where`/`OrderBy`), and any `WithTenantFilter`/`WithLogicalDeleteFilter`/`WithTypeDiscriminator` predicates from `CosmosDbModelOptions` are applied automatically (see `CosmosDbQuery.AsQueryable(CosmosDbArgs?)`, with an optional `CosmosDbArgs.BypassFilters` override). +- 📄 **Invoker-wrapped materializers**: `CosmosDbQuery` provides instance-method materializers — `ToListAsync`, `ToCollectionAsync`, `ToItemsResultAsync`, `SingleAsync`/`SingleOrDefaultAsync`/`FirstAsync`/`FirstOrDefaultAsync`, `ToMappedItemsAsync`, `ToMappedItemsResultAsync` (plus a `WithResultAsync` ROP counterpart for each) — using `Skip`/`Take` (translated by the Cosmos DB LINQ provider to `OFFSET…LIMIT`) via `WithPaging(PagingArgs?)`. Being instance methods on `CosmosDbQuery` rather than `IQueryable` extensions, they structurally cannot collide with `CoreEx.EntityFrameworkCore`'s identically-named `EfDbExtensions` (see [AGENTS.md](./AGENTS.md#do-not)), and every materializer routes through `CosmosDbInvoker` for structured logging and `CosmosException` mapping. +- 🏷️ **ETag / concurrency**: for an `UpdateAsync`, the model's `IETag.ETag` is mapped into `ItemRequestOptions.IfMatchEtag` (where `CosmosDbArgs.AutoMapETag` is `true`, the default); Cosmos DB enforces the optimistic-concurrency check server-side and returns a `412 Precondition Failed`, which `CosmosDbInvoker` converts to a `ConcurrencyException`/`Result.ConcurrencyError`. +- 🔒 **Multi-tenancy**: non-query operations automatically reject a mismatched `IReadOnlyTenantId.TenantId` as not-found; `Query()` only applies the equivalent `TenantId == executionContext.TenantId` predicate when `CosmosDbModelOptions.WithTenantFilter()` has been configured. +- 🗑️ **Logical delete**: entities implementing `ILogicallyDeleted` are soft-deleted (`IsDeleted = true` via a read-modify-`ReplaceItemAsync`) on `DeleteAsync` rather than physically removed; a physical `DeleteAsync` is idempotent (a `404` is not an error). +- 🏷️ **Type discriminator (multi-type containers)**: `CosmosDbModelOptions.WithTypeDiscriminator()` reuses the existing `ITypeDiscriminator`/`IReadOnlyTypeDiscriminator` hook (auto-populated by `Model.PrepareCreate`/`PrepareUpdate`) to let several business model types safely share one container/partition — no envelope/wrapper type required. +- ⏳ **Time-to-live**: `CosmosDbModelOptions.WithTimeToLive(Func)` computes and applies a document's `ttl` on `CreateAsync`/`UpdateAsync` (requires `TModel` to implement the *mutable* `ITimeToLive` — Cosmos DB's `ttl` is a document-body field, not a separate SDK request option, so a computed value can only take effect by being written back onto the model). Where not configured, a model's own `ITimeToLive.TimeToLive` value (if any) simply serializes through as-is; `ITimeToLive`/`IReadOnlyTimeToLive` live in core `CoreEx.Data` (alongside `IPartitionKey`/`ITypeDiscriminator`) for reuse by a future non-Cosmos NoSQL package. +- 🔑 **Fixed partition key**: `CosmosDbModelOptions.WithFixedPartitionKey(string?)` configures one constant partition key value for the whole container — suitable for small, bounded containers where partitioning isn't meaningful (Cosmos DB's own guidance: a container well under the 20 GB/10,000 RU/s per-logical-partition limits typically needs only one or two physical partitions regardless of partition key cardinality). It also defaults `GetAsync`/`DeleteAsync`'s `partitionKey` parameter (now optional) when the caller omits it — `WithPartitionKey(Func)`'s per-model function cannot do this, since Get/Delete have no model instance to invoke it against. The two are mutually exclusive (configuring both throws `InvalidOperationException`), and either always wins over — but must not silently disagree with — a non-null value the model already carries via `IReadOnlyPartitionKey` (a genuine mismatch throws rather than being overridden, since Cosmos DB itself requires the document body's partition-key-path value to agree with the value supplied for the operation). +- 📝 **Structured logging**: `CosmosDbInvoker` wraps every CRUD `CosmosDb` operation with structured log entries (tracing/`Activity` spans are intentionally disabled via `IsTracingDisabled` - CRUD is high-frequency) and converts `CosmosException` into the corresponding CoreEx exception (`NotFoundException`/`DuplicateException`/`ConcurrencyException`). The outbox relay's `CosmosDbOutboxRelayInvoker` (`Outbox` sub-namespace) is a separate, tracing-*enabled* invoker - relay batch processing is comparatively low-frequency and specifically where distributed-tracing visibility matters most. +- 🔄 **Transactional outbox**: `CosmosDbUnitOfWork` implements `IUnitOfWork`, enlisting `CosmosDbContainer` Create/Update/Delete calls made within its `TransactionAsync` scope into one ambient `TransactionalBatch` (client-side fail-fast if two enlisted operations target different containers/partition keys); `CosmosDbEventPublisher` (an `IEventPublisher`) enlists outbox event documents into the *same* batch, so the business mutation and its event are atomic without a separate outbox table. Outbox documents are auto-excluded from ordinary business queries via a reserved `$outbox` id-prefix (no opt-in required). `IUnitOfWork.SynchronizeETag` resolves a mapped contract's true, server-assigned `ETag` after the batch commits (deferred execution means it isn't known upfront) by correlating on `CompositeKey`, not object reference. +- 📤 **Outbox relay**: `CosmosDbOutboxRelay`/`CosmosDbOutboxRelayProcessor` (`Outbox` sub-namespace) consume outbox event documents via a Cosmos DB [Change Feed Processor](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/change-feed-processor) (push-based, SDK-managed - not a polling loop like the SQL Server/Postgres relay), decode/publish/cleanup-delete each batch, and self-pause/self-resume via a `CircuitBreakerResiliency`-based circuit breaker on a sustained publish-failure ratio. Register via `builder.AddCosmosDbOutboxRelayHostedService(containerId, servicesCount?)` - one call per outbox-hosting container, each with its own concurrency count. This registers the relay only; a genuine *destination* `IEventPublisher` (e.g. Azure Service Bus, matching the SQL Server/Postgres samples) must also be registered - see [AGENTS.md](./AGENTS.md#outbox-relay). `CosmosDbEventPublisher` (the write-side publisher, above) must never be registered for this role; `CosmosDbOutboxRelayProcessor.ProcessBatchAsync` detects that misconfiguration and throws immediately with an actionable message rather than failing deeper inside the publish call. +- 📊 **Outbox metrics**: `CosmosMetrics` exposes .NET `Meter` instruments harmonized with `SqlServerMetrics`/`PostgresMetrics`: `cosmos.outbox.enqueue` (counter), `cosmos.outbox.relay.publish` and `cosmos.outbox.relay.publish.failed` (counters), `cosmos.outbox.relay.oldest_lag` and `cosmos.outbox.relay.newest_lag` (histograms in ms), plus Cosmos-specific `cosmos.outbox.relay.cleanup.deleted`/`cosmos.outbox.relay.cleanup.failed` (the relay's own post-publish document cleanup has no SQL Server/Postgres equivalent). +- 📥 **Batch import & container provisioning**: `CosmosDbBatch.ImportBatchAsync(Async)`/`ImportDiscriminatedBatchAsync` load raw JSON directly into a `Container`/`Database` (no `CoreEx.Cosmos` model type involved); `CosmosDbContainerExtensions.ReplaceOrCreateContainerAsync`/`DeleteContainerIfExistsAsync` provision or reset a container from code. Neither depends on the rest of this package - useful for data seeding, bulk/one-off loads, and migrations alike. +- 🧩 **Multi-set queries**: `ICosmosDb.SelectMultiSetAsync`/`SelectMultiSetWithResultAsync(containerId, MultiSetOptions, cancellationToken?)` (`Extended` namespace) read multiple, type-discriminator-keyed sets of documents from the same container/partition in one round-trip - the Cosmos DB equivalent of `CoreEx.Database.Extended`'s positional/ordered multi-set queries, adapted for a discriminator-keyed (not positional) demux. `MultiSetSingleArgs`/`MultiSetCollArgs` accumulate matching documents (each per-item tenant/logical-delete/additive-filter-checked via `CosmosDbContainer.CheckModel`); `TModel` must implement `IReadOnlyTypeDiscriminator`. The type-discriminator's JSON property name is resolved once per call from the ambient `CosmosClientOptions.UseSystemTextJsonSerializerWithOptions` naming policy (e.g. `camelCase`). Co-located outbox event documents are always excluded server-side; where a model's `CosmosDbModelOptions.WithTenantFilter`/`WithLogicalDeleteFilter` is configured, an additional defensive, `IS_DEFINED`-guarded SQL predicate is layered in as a server-side (RU/bandwidth) optimization - never excluding a document purely for predating the property. `MultiSetOptions.Args.QueryRequestOptions`, where supplied, takes precedence over one built from `MultiSetOptions.PartitionKey` (a genuine mismatch between the two throws `ArgumentException`). `SelectMultiSetWithResultAsync` returns a `Result` (Railway-Oriented Programming) only for a genuine business/domain-level outcome (a mapped `CosmosException`, or a `WithFilter` authorization-style denial) - `MinimumRows`/`MaximumRows`/malformed-response/argument-validation conditions remain plain exceptions even from this method, consistent with `Result` being reserved for expected errors, not exceptions; `SelectMultiSetAsync` is a thin `ThrowOnError()` wrapper over it. See [AGENTS.md](./AGENTS.md#multi-set-queries) for the full mechanism. + +## Key types + +| Type | Description | +|------|-------------| +| **[`ICosmosDb`](./ICosmosDb.cs) / [`CosmosDb`](./CosmosDb.cs)** | CoreEx Cosmos DB bridge: holds `CosmosClient`, `Database`, `CosmosDbOptions`, `ExecutionContext`, ambient `CurrentTransaction` (for `CosmosDbUnitOfWork`); exposes `Container(containerId, configure?)` entry point; caches `Container` (per containerId) and `CosmosDbContainer` (per `(containerId, TModel)`) instances; maps `CosmosException` via `HandleCosmosException`. | +| **[`CosmosDbContainer`](./CosmosDbContainer.cs)** | Strongly-typed CRUD + query for a single Cosmos DB model type: `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `UpsertAsync`, `Query(query?, args?)`; applies the applicable CoreEx cross-cutting pipeline; transparently enlists into an ambient `CosmosDbUnitOfWork` transaction where one is active. | +| **[`CosmosDbMappedContainer`](./CosmosDbMappedContainer.cs)** | Adds an `IBiDirectionMapper` layer over `CosmosDbContainer` for domain entity ↔ Cosmos DB document model conversion; provides `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `UpsertAsync`. | +| **[`CosmosDbQuery`](./CosmosDbQuery.cs)** | Composable, invoker-wrapped query type returned by `CosmosDbContainer.Query(query?, args?)`: `AsQueryable(args?)`, `WithPaging(paging?)`, and materializers `ToListAsync`, `ToCollectionAsync`, `ToItemsResultAsync`, `SingleAsync`/`SingleOrDefaultAsync`/`FirstAsync`/`FirstOrDefaultAsync`, `ToMappedItemsAsync`, `ToMappedItemsResultAsync` (each with a `WithResultAsync` ROP counterpart). | +| **[`CosmosDbArgs`](./CosmosDbArgs.cs)** | Per-operation options: `NullOnNotFound`, `AutoMapETag`, `Refresh`, `ItemRequestOptions`, `QueryRequestOptions`; defaults sourced from `CosmosDbModelOptions.Args` then `CosmosDbOptions.Args`. | +| **[`CosmosDbOptions`](./CosmosDbOptions.cs)** | Instance-level options for `ICosmosDb` (typically a singleton): default `CosmosDbArgs`, per-`(containerId, TModel)` options registry via `GetOrAddModelOptions(containerId)`. | +| **[`CosmosDbModelOptions`](./CosmosDbModelOptions.cs)** | Per-container/model configuration: `WithArgs`, `WithGetKey`, `WithFormatIdentifier`, `WithPartitionKey`, `WithFixedPartitionKey`, `WithTimeToLive`, `WithTenantFilter`, `WithLogicalDeleteFilter`, `WithTypeDiscriminator`. | +| **[`CosmosDbInvoker`](./Extended/CosmosDbInvoker.cs)** | `InvokerBase` emitting structured log entries for every CRUD `CosmosDb` operation (tracing intentionally disabled); catches `CosmosException` and folds into `Result`/`Result` failures for ROP callers; also orchestrates `CosmosDbUnitOfWork` transaction commit/outbox-publish/exception-mapping. | +| **[`CosmosDbModelBase`](./CosmosDbModelBase.cs)** | Optional convenience abstract base implementing `IIdentifier`, `IETag`, `IPartitionKey`, `ITimeToLive` using the Cosmos DB reserved system property names (`id`, `_etag`, `ttl`). | +| **[`CosmosDbTransaction`](./Extended/CosmosDbTransaction.cs)** | The ambient ordinal-to-`CompositeKey` tracked `TransactionalBatch` scope bound to `CosmosDbUnitOfWork.TransactionAsync` - first enlisted operation binds the container/partition key; a mismatched later operation throws client-side, before any network call. | +| **[`CosmosDbBatch`](./Extended/CosmosDbBatch.cs)** | Raw-JSON (`JsonArray`/`JsonObject`) batch-import extensions (`ImportBatchAsync`, `ImportDiscriminatedBatchAsync`) over `Container`/`Database` - no `CoreEx.Cosmos` model type involved, so a caller controls the exact document shape (partition key, type-discriminator value) directly; suited to data seeding, bulk/one-off loads, and migrations alike. | +| **[`CosmosDbContainerExtensions`](./Extended/CosmosDbContainerExtensions.cs)** | Container lifecycle extensions (`ReplaceOrCreateContainerAsync`, `DeleteContainerIfExistsAsync`) over raw `Database`/`ContainerProperties` - no dependency on any other `CoreEx.Cosmos` type; useful for provisioning or resetting a database/container from code. | +| **[`IMultiSetArgs`](./Extended/IMultiSetArgs.cs) / [`IMultiSetArgs`](./Extended/IMultiSetArgsT.cs)** | Discriminator-keyed multi-set query contract (`Extended`): `ModelType`, `ResolveTypeDiscriminator(cosmosDb, containerId)` (resolves `TModel`'s configured `CosmosDbModelOptions.EffectiveTypeDiscriminator`), `AddItem`, `BuildFilterClause` (builds a defensive, `IS_DEFINED`-guarded tenant/logical-delete SQL predicate where `WithTenantFilter`/`WithLogicalDeleteFilter` is configured); extends the shared `CoreEx.Data.IMultiSetArgsCore` (`MinimumRows`, `MaximumRows`, `StopOnNull`, `InvokeResult`). | +| **[`MultiSetOptions`](./Extended/MultiSetOptions.cs)** | Bundles a multi-set query's per-call inputs (`PartitionKey`, `Args`, `MultiSetArgs`) into one record, avoiding an ever-growing method-parameter list as new capabilities are added. | +| **[`MultiSetSingleArgs`](./Extended/MultiSetSingleArgs.cs) / [`MultiSetCollArgs`](./Extended/MultiSetCollArgs.cs)** | Concrete `IMultiSetArgs` implementations (`Extended`) for a single item or a collection of items respectively; guard (via a static constructor) that `TModel` implements `IReadOnlyTypeDiscriminator`. | +| **[`CosmosDbMultiSetExtensions`](./Extended/CosmosDbMultiSetExtensions.cs)** | `ICosmosDb.SelectMultiSetAsync`/`SelectMultiSetWithResultAsync(containerId, MultiSetOptions, ...)` (`Extended`) - the multi-set query engine: resolves the discriminator JSON property name, builds/executes a raw stream query (honoring each `IMultiSetArgs.BuildFilterClause` and a `CosmosDbArgs.QueryRequestOptions` precedence rule), demuxes/deserializes/filters/accumulates per document, then validates `MinimumRows`/`MaximumRows` and invokes `InvokeResult()` per `IMultiSetArgs`, in supplied order, honoring `StopOnNull`. `SelectMultiSetAsync` is a `ThrowOnError()` wrapper over the `Result`-returning `SelectMultiSetWithResultAsync`. | +| **[`CosmosDbUnitOfWork`](./CosmosDbUnitOfWork.cs)** | `IUnitOfWork` implementation for `ICosmosDb`: `TransactionAsync` orchestration, optional `Outbox` (`IEventPublisher`, typically `CosmosDbEventPublisher`), and `SynchronizeETag` to resolve a mapped contract's true post-commit `ETag` by `CompositeKey`. | +| **[`CosmosDbEventPublisher`](./Outbox/CosmosDbEventPublisher.cs)** | `EventPublisherBase` that enlists outbox event documents (`CosmosDbOutboxEvent`) into the active `CosmosDbUnitOfWork`'s ambient `TransactionalBatch` - same container/partition as the paired business mutation, since a dedicated outbox container/table isn't possible while preserving atomicity. | +| **[`CosmosDbOutboxEvent`](./Outbox/CosmosDbOutboxEvent.cs)** | The outbox event document shape (`Id`, `PartitionKey`, `Destination`, `Event` as `JsonElement`, `TimeToLive`); identified by a reserved `$outbox` `Id` prefix, auto-excluded from ordinary business queries against the same container. | +| **[`CosmosDbOutboxRelay`](./Outbox/CosmosDbOutboxRelay.cs)** | Owns the Change Feed Processor for one monitored container: start/pause/resume/stop lifecycle, circuit-breaker-wrapped batch processing via `CosmosDbOutboxRelayProcessor`. | +| **[`CosmosDbOutboxRelayProcessor`](./Outbox/CosmosDbOutboxRelayProcessor.cs)** | Pure filter/decode/publish/cleanup-delete batch logic, with no Change Feed Processor SDK dependency - directly unit-testable by handing it a batch of `CosmosDbOutboxEvent` documents. | +| **[`CosmosDbOutboxRelayHostedService`](./Outbox/CosmosDbOutboxRelayHostedService.cs)** | Thin `HostedServiceBase` wrapper delegating to `CosmosDbOutboxRelay`; registered via `AddCosmosDbOutboxRelayHostedService(containerId, servicesCount?)`. | +| **[`CosmosMetrics`](./CosmosMetrics.cs)** | Static .NET `Meter` with counters/histograms for outbox enqueue throughput and relay publish/cleanup/lag, harmonized with `SqlServerMetrics`/`PostgresMetrics`. | + +## Namespaces + +| Namespace | Description | +|-----------|-------------| +| *(root)* | Core CRUD + query access layer: `CosmosDb`, `CosmosDbContainer`, `CosmosDbMappedContainer`, `CosmosDbQuery`, `CosmosDbModelOptions`. | +| [**`Extended`**](./Extended/) | `CosmosDbInvoker`, `CosmosDbTransaction`, `CosmosDbHealthCheck`, and `CosmosDbUnitOfWorkInvoker` - orchestrates `CosmosDbUnitOfWork` transaction commit via `CosmosDbInvoker`, mirroring `CoreEx.Database`'s `SqlServerUnitOfWorkInvoker`/`PostgresUnitOfWork` split of responsibility. Also `CosmosDbBatch`/`CosmosDbContainerExtensions` - raw-JSON batch import and container lifecycle helpers with no dependency on the rest of this package. Also `IMultiSetArgs`/`IMultiSetArgs`/`MultiSetSingleArgs`/`MultiSetCollArgs`/`CosmosDbMultiSetExtensions` - the discriminator-keyed multi-set query capability. | +| [**`Outbox`**](./Outbox/) | Transactional outbox write side (`CosmosDbEventPublisher`, `CosmosDbOutboxEvent`) and relay (`CosmosDbOutboxRelay`, `CosmosDbOutboxRelayProcessor`, `CosmosDbOutboxRelayOptions`, `CosmosDbOutboxRelayResiliency`, `CosmosDbOutboxRelayInvoker`, `CosmosDbOutboxRelayHostedService`). | + +## Related Namespaces + +- **[`CoreEx.Data`](../CoreEx.Data/README.md)** - `IUnitOfWork`, `PagingArgs`, `ItemsResult`, `DataResult`, `IPartitionKey`/`IReadOnlyPartitionKey`, `ITenantId`, `ILogicallyDeleted`, `ITypeDiscriminator`, `Model` (`PrepareCreate`/`PrepareUpdate`), `IMultiSetArgsCore` (shared base for `Extended.IMultiSetArgs`'s discriminator-keyed multi-set queries) — all reused as-is, unchanged; `CosmosDbUnitOfWork` implements `IUnitOfWork` directly (not a Cosmos-specific sub-interface), keeping application-layer services provider-agnostic. +- **[`CoreEx.Mapping`](../CoreEx/Mapping/README.md)** - `IBiDirectionMapper` is the mapper contract used by `CosmosDbMappedContainer`. +- **[`CoreEx.EntityFrameworkCore`](../CoreEx.EntityFrameworkCore/README.md)** - the closest structural analogue (`EfDb`/`EfDbModel`/`EfDbMappedModel`); `CosmosDbContainer` mirrors `EfDbModel`'s CRUD/ROP shape, adapted to the Cosmos DB SDK. +- **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `CosmosDbInvoker` and `CosmosDbOutboxRelayInvoker` extend `InvokerBase` for structured logging/tracing. +- **[`CoreEx.Events`](../CoreEx.Events/README.md)** - `IEventPublisher`/`EventPublisherBase` (`CosmosDbEventPublisher`'s base), `CloudEventTracingExtensions.LinkTraceContext` (used by the relay to connect its publish span back to each original producer's trace), and `EventFormatter`'s CloudEvents conversion, shared unchanged with `CoreEx.Database`'s outbox relay. +- **[`CoreEx.Hosting`](../CoreEx/Hosting/README.md)** - `HostedServiceBase`, `CircuitBreakerResiliency` (the relay's self-pause/self-resume mechanism, shared with `CoreEx.Azure.Messaging.ServiceBus`'s receiver), `ResilienceOwner`. +- **[`CoreEx.Database`](../CoreEx.Database/README.md)** - the relational sibling family's equivalent outbox relay (`DatabaseOutboxRelayBase`); Cosmos DB's Change Feed Processor-based push model is deliberately structured differently (mirroring the Azure Service Bus receiver instead), but shares metric naming and trace-linking with it. + +## Additional Resources + +- [Microsoft.Azure.Cosmos](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/cosmos.db) - The Azure Cosmos DB SDK this package uses. +- [Change Feed Processor](https://learn.microsoft.com/en-us/azure/cosmos-db/nosql/change-feed-processor) - The push-based mechanism underlying `CosmosDbOutboxRelay`. + +## AI Usage Guide + +An [`AGENTS.md`](./AGENTS.md) file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and `Do Not` rules for this package without requiring a local CoreEx checkout. diff --git a/src/CoreEx.Data/AGENTS.md b/src/CoreEx.Data/AGENTS.md index eb205b55..9e76fcf7 100644 --- a/src/CoreEx.Data/AGENTS.md +++ b/src/CoreEx.Data/AGENTS.md @@ -64,6 +64,15 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = defaul }).ConfigureAwait(false); ``` +## IMultiSetArgs — Shared Multi-Set Query Base + +`IMultiSetArgsCore` (`MinimumRows`, `MaximumRows`, `StopOnNull`, `InvokeResult()`) is a minimal, provider-agnostic base for one result-set within a "multi-set" query — reading several related sets of data in a single round-trip. It is not used directly; each provider extends it with its own matching mechanism and concrete `MultiSetSingleArgs`/`MultiSetCollArgs` implementations: + +- `CoreEx.Database.Extended.IMultiSetArgs` — relational, **positional**: result sets are matched to args by the order they're declared in `DatabaseCommand.SelectMultiSetAsync`'s `params` array (adds `DatasetRecord()` to build a raw-record-to-model mapper per set). +- `CoreEx.Cosmos.Extended.IMultiSetArgs` — Cosmos DB, **discriminator-keyed**: result documents are matched to args by a type-discriminator value rather than position, since Cosmos DB has no notion of an ordered multiple-result-set query (adds `ModelType`/`TypeDiscriminator`/`AddItem`). + +Do not implement `IMultiSetArgsCore` directly in application code — use a provider's concrete `MultiSetSingleArgs`/`MultiSetCollArgs` types. + ## Do Not - Prefer enqueuing events inside `TransactionAsync` so they are committed or rolled back atomically with the database write. Events added outside a transaction scope are still published but will not be rolled back if a subsequent operation fails — only do this intentionally when at-least-once delivery without rollback is the desired behaviour. @@ -73,6 +82,7 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = defaul - [README](./README.md) — full `IUnitOfWork`, `QueryArgsConfig`, and `DataResult` API reference. - [CoreEx.Database.SqlServer](../CoreEx.Database.SqlServer/README.md) / [CoreEx.Database.Postgres](../CoreEx.Database.Postgres/README.md) — concrete `IUnitOfWork` implementations. +- [CoreEx.Database](../CoreEx.Database/README.md#key-capabilities) / [CoreEx.Cosmos](../CoreEx.Cosmos/AGENTS.md#multi-set-queries) — concrete `IMultiSetArgs` providers (relational positional, Cosmos DB discriminator-keyed). - [CoreEx.EntityFrameworkCore](../CoreEx.EntityFrameworkCore/README.md) — `QueryArgsConfig` consumption via `EfDbModel`. - [Application layer](../../samples/docs/application-layer.md) — real-world `TransactionAsync` usage, event enqueuing inside the unit-of-work, and service orchestration patterns. - [Patterns](../../samples/docs/patterns.md) — transactional outbox, atomic commit with event publishing, and dynamic query patterns. diff --git a/src/CoreEx.Data/CoreEx.Data.csproj b/src/CoreEx.Data/CoreEx.Data.csproj index 826e6c4e..0b99ba05 100644 --- a/src/CoreEx.Data/CoreEx.Data.csproj +++ b/src/CoreEx.Data/CoreEx.Data.csproj @@ -7,6 +7,7 @@ + diff --git a/src/CoreEx.Data/GlobalUsing.cs b/src/CoreEx.Data/GlobalUsing.cs index 170e30eb..e61b1a9a 100644 --- a/src/CoreEx.Data/GlobalUsing.cs +++ b/src/CoreEx.Data/GlobalUsing.cs @@ -2,14 +2,21 @@ global using CoreEx.Data.Querying; global using CoreEx.Data.Querying.Expressions; global using CoreEx.Entities; +global using CoreEx.Entities.Abstractions; global using CoreEx.Events.Publishing; global using CoreEx.Http; +global using CoreEx.Json; global using CoreEx.RefData; global using CoreEx.RefData.Abstractions; global using CoreEx.Results; global using CoreEx.Results.Abstractions; +global using CoreEx.Security; +global using CoreEx.Text; global using System.Diagnostics.CodeAnalysis; global using System.Linq.Dynamic.Core; global using System.Text; global using System.Text.Json; -global using System.Text.RegularExpressions; \ No newline at end of file +global using System.Text.Json.Nodes; +global using System.Text.RegularExpressions; +global using YamlDotNet.Core.Events; +global using YamlDotNet.Serialization; \ No newline at end of file diff --git a/src/CoreEx.Data/IMultiSetArgsCore.cs b/src/CoreEx.Data/IMultiSetArgsCore.cs new file mode 100644 index 00000000..8c061e75 --- /dev/null +++ b/src/CoreEx.Data/IMultiSetArgsCore.cs @@ -0,0 +1,33 @@ +namespace CoreEx.Data; + +/// +/// Enables the base multi-set arguments used to read multiple result sets (or equivalent) from a single data source round-trip. +/// +/// This is the minimal, storage-agnostic contract shared by provider-specific multi-set capabilities (see CoreEx.Database.Extended.IMultiSetArgs for the +/// positional/ordered result-set variant, and CoreEx.Cosmos.Extended.IMultiSetArgs for the discriminator-keyed variant). Each provider adds its own record/item +/// callback shaped for its underlying data access mechanism. +/// Named (not IMultiSetArgs) so each provider's own IMultiSetArgs - which necessarily shares this simple name for its own callers' convenience - can +/// extend it unqualified (: IMultiSetArgsCore) from within a project that also has a global using IMultiSetArgs = ... alias pointing at its own provider-specific type; without the distinct +/// name, every reference to this base interface from such a project would require an awkward fully-qualified CoreEx.Data.IMultiSetArgs. +public interface IMultiSetArgsCore +{ + /// + /// Gets the minimum number of rows allowed. + /// + int MinimumRows { get; } + + /// + /// Gets the maximum number of rows allowed. + /// + int? MaximumRows { get; } + + /// + /// Indicates whether to stop further result set processing where the current set has resulted in a (i.e. no records). + /// + bool StopOnNull { get; } + + /// + /// Invokes the corresponding result function. + /// + void InvokeResult(); +} diff --git a/src/CoreEx.Data/IUnitOfWork.SynchronizeETag.cs b/src/CoreEx.Data/IUnitOfWork.SynchronizeETag.cs new file mode 100644 index 00000000..93429cd6 --- /dev/null +++ b/src/CoreEx.Data/IUnitOfWork.SynchronizeETag.cs @@ -0,0 +1,14 @@ +namespace CoreEx.Data; + +public partial interface IUnitOfWork +{ + /// + /// Synchronizes the 's with the true, underlying-store-persisted value, deriving the correlation key from 's own . + /// + /// The value . + /// The value (implementing both and ) whose is to be synchronized. + /// See for the full semantics. This convenience overload is only usable where already implements ; + /// where the value being synchronized is a mapped contract that does not (the common case for a published event's payload), use the primary overload + /// and supply the correlation key explicitly. + public void SynchronizeETag(T value) where T : IEntityKey, IETag => SynchronizeETag(value.EntityKey, value); +} diff --git a/src/CoreEx.Data/IUnitOfWork.cs b/src/CoreEx.Data/IUnitOfWork.cs index 27ab21e4..5de75057 100644 --- a/src/CoreEx.Data/IUnitOfWork.cs +++ b/src/CoreEx.Data/IUnitOfWork.cs @@ -53,4 +53,20 @@ public partial interface IUnitOfWork /// The . /// The resulting value. Task TransactionAsync(IDataArgs args, Func> work, CancellationToken cancellationToken = default); + + /// + /// Synchronizes the 's with the true, underlying-store-persisted value for the entity identified by , where the implementing + /// provider is unable to make that value available synchronously at the point of mutation (see remarks). + /// + /// The value . + /// The identifying the mutated entity within this unit-of-work. + /// The value (typically a mapped contract, not necessarily the same instance/type that was created/updated) whose is to be synchronized. + /// Most providers (e.g. a relational database via IDatabaseUnitOfWork) execute each statement immediately within the open transaction, so a mutated value already carries its true, + /// final by the time it is returned — for these, an implementation of this method is expected to be a no-op (ignore, not throw); there is nothing to synchronize. + /// A provider whose only atomic multi-operation primitive defers execution until the unit-of-work completes (e.g. Cosmos DB's TransactionalBatch, executed once at commit time) cannot + /// give a mutated value its true until that point — such a provider is expected to track mutations by during the unit-of-work and implement this method + /// to resolve and assign the real value once available, throwing where was not part of the most recently completed unit-of-work, or where called before it has completed. + /// (a value, not an object reference) is used rather than tracking the mutated instance itself, because the value passed here is often a separately mapped contract + /// (e.g. the value published as an event), not the same object instance the provider mutated — is expected to survive that mapping boundary even though object identity does not. + void SynchronizeETag(CompositeKey key, T value) where T : IETag; } \ No newline at end of file diff --git a/src/CoreEx.UnitTesting/Data/JsonDataReader.cs b/src/CoreEx.Data/Json/JsonDataReader.cs similarity index 99% rename from src/CoreEx.UnitTesting/Data/JsonDataReader.cs rename to src/CoreEx.Data/Json/JsonDataReader.cs index d74a20a8..dff1236d 100644 --- a/src/CoreEx.UnitTesting/Data/JsonDataReader.cs +++ b/src/CoreEx.Data/Json/JsonDataReader.cs @@ -1,4 +1,4 @@ -namespace CoreEx.UnitTesting.Data; +namespace CoreEx.Data.Json; /// /// Provides a hierarchical mutating reader for JSON or YAML data with dynamic property substitution support using the venerable . diff --git a/src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs b/src/CoreEx.Data/Json/JsonDataReaderArgs.cs similarity index 98% rename from src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs rename to src/CoreEx.Data/Json/JsonDataReaderArgs.cs index fde4675c..aba3115a 100644 --- a/src/CoreEx.UnitTesting/Data/JsonDataReaderArgs.cs +++ b/src/CoreEx.Data/Json/JsonDataReaderArgs.cs @@ -1,4 +1,4 @@ -namespace CoreEx.UnitTesting.Data; +namespace CoreEx.Data.Json; /// /// Provides the runtime arguments for the . diff --git a/src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs b/src/CoreEx.Data/Json/JsonDataReaderOptions.cs similarity index 94% rename from src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs rename to src/CoreEx.Data/Json/JsonDataReaderOptions.cs index 3d736193..f3807d11 100644 --- a/src/CoreEx.UnitTesting/Data/JsonDataReaderOptions.cs +++ b/src/CoreEx.Data/Json/JsonDataReaderOptions.cs @@ -1,4 +1,4 @@ -namespace CoreEx.UnitTesting.Data; +namespace CoreEx.Data.Json; /// /// Provides options for the . @@ -76,6 +76,7 @@ public JsonDataReaderOptions(JsonPropertyNamingConvention namingConvention = Jso /// /// Adds standard properties to the root where not already present. /// + /// Indicates whether to include the 'TenantId' property. /// The to support fluent-style method-chaining. /// The following standard properties (converted based on the ) are included: /// @@ -84,11 +85,13 @@ public JsonDataReaderOptions(JsonPropertyNamingConvention namingConvention = Jso /// 'TenantId' - Set to '^tenant_id'. /// /// - public JsonDataReaderOptions AddStandardProperties() + public JsonDataReaderOptions AddStandardProperties(bool includeTenantId = false) { Properties.TryAdd(ConvertPropertyName(nameof(ChangeLog.CreatedOn)), "^now"); Properties.TryAdd(ConvertPropertyName(nameof(ChangeLog.CreatedBy)), "^user_name"); - Properties.TryAdd(ConvertPropertyName(nameof(TenantId)), "^tenant_id"); + if (includeTenantId) + Properties.TryAdd(ConvertPropertyName(nameof(TenantId)), "^tenant_id"); + return this; } @@ -97,6 +100,7 @@ public JsonDataReaderOptions AddStandardProperties() /// /// The JSON property naming convention used by the ; defaults to . /// An optional function to generate the . + /// Indicates whether to include the 'TenantId' property. /// The . /// This method will configure the to convert a single key/value pair into 'code' and 'text' properties by convention. /// The following additional are included in addition to the : @@ -106,9 +110,9 @@ public JsonDataReaderOptions AddStandardProperties() /// 'sortOrder' - Uses the current array index where the is an element within a ; otherwise, zero. /// /// - public static JsonDataReaderOptions CreateForReferenceData(JsonPropertyNamingConvention namingConvention = JsonPropertyNamingConvention.PascalCase, Func? idGenerator = null) + public static JsonDataReaderOptions CreateForReferenceData(JsonPropertyNamingConvention namingConvention = JsonPropertyNamingConvention.PascalCase, Func? idGenerator = null, bool includeTenantId = false) { - var o = new JsonDataReaderOptions(namingConvention).AddStandardProperties(); + var o = new JsonDataReaderOptions(namingConvention).AddStandardProperties(includeTenantId); o.Properties.TryAdd(o.ConvertPropertyName(nameof(RefData.Abstractions.IReferenceData.Id)), idGenerator is null ? "^id" : "^__idGenerator"); o.Properties.TryAdd(o.ConvertPropertyName(nameof(RefData.Abstractions.IReferenceData.IsActive)), true); o.Properties.TryAdd(o.ConvertPropertyName(nameof(RefData.Abstractions.IReferenceData.SortOrder)), "^index"); @@ -154,4 +158,4 @@ public static JsonDataReaderOptions CreateForReferenceData(JsonPropertyNamingCon _ => propertyName }; } -} \ No newline at end of file +} diff --git a/src/CoreEx.UnitTesting/Data/JsonPropertyNamingConvention.cs b/src/CoreEx.Data/Json/JsonPropertyNamingConvention.cs similarity index 96% rename from src/CoreEx.UnitTesting/Data/JsonPropertyNamingConvention.cs rename to src/CoreEx.Data/Json/JsonPropertyNamingConvention.cs index 1e843e7c..1ea584c5 100644 --- a/src/CoreEx.UnitTesting/Data/JsonPropertyNamingConvention.cs +++ b/src/CoreEx.Data/Json/JsonPropertyNamingConvention.cs @@ -1,4 +1,4 @@ -namespace CoreEx.UnitTesting.Data; +namespace CoreEx.Data.Json; /// /// Defines the JSON property naming convention used by the when reading/deserializing JSON data. diff --git a/src/CoreEx.UnitTesting/Data/README.md b/src/CoreEx.Data/Json/README.md similarity index 100% rename from src/CoreEx.UnitTesting/Data/README.md rename to src/CoreEx.Data/Json/README.md diff --git a/src/CoreEx.Data/README.md b/src/CoreEx.Data/README.md index 566fce66..d7bedc91 100644 --- a/src/CoreEx.Data/README.md +++ b/src/CoreEx.Data/README.md @@ -20,6 +20,7 @@ - ⚙️ **Field-level configuration**: Each field is configured with its CLR type, allowed operators, model property name/prefix, case normalization, null handling, and custom statement override via a fluent `QueryArgsConfig.WithFilter` / `WithOrderBy` builder. - 🏷️ **Reference data filter fields**: `QueryFilterReferenceDataFieldConfig` maps a reference data `Code` string in the filter to its underlying `Id` for persistence queries. - 🔗 **IQueryable integration**: `QueryExtensions.Where` and `OrderBy` applies the filter and order-by to any `IQueryable`. +- 🧩 **Multi-set query contract**: `IMultiSetArgsCore` is the minimal, provider-agnostic base for a single result-set within a multi-set query (`MinimumRows`, `MaximumRows`, `StopOnNull`, `InvokeResult()`) - `CoreEx.Database.Extended.IMultiSetArgs` (positional/ordered relational result sets) and `CoreEx.Cosmos.Extended.IMultiSetArgs` (discriminator-keyed Cosmos DB document sets) each extend it with their own provider-specific matching mechanism, so a future NoSQL provider (e.g. Mongo) can reuse the same base contract and row-count semantics. ## Key types @@ -38,6 +39,7 @@ | [`QueryOrderByParserResult`](./Querying/QueryOrderByParserResult.cs) | Result of `QueryOrderByParser.Parse()`: ordered `QueryStatement` and any parse errors. | | _[`ModelBase`](./Models/ModelBase.cs)_ | Abstract persistence model base implementing `IIdentifier`, `IChangeLogEx`, `IETag`. | | _[`ReferenceDataModelBase`](./Models/ReferenceDataModelBase.cs)_ | Persistence model base for reference data tables with `Id`, `Code`, `Text`, `Description`, `IsActive`, `SortOrder`, `StartsOn`, `EndsOn`. | +| **[`IMultiSetArgsCore`](./IMultiSetArgsCore.cs)** | Provider-agnostic base contract for one result-set within a multi-set query: `MinimumRows`, `MaximumRows`, `StopOnNull`, `InvokeResult()`. Extended by `CoreEx.Database.Extended.IMultiSetArgs` (relational, positional) and `CoreEx.Cosmos.Extended.IMultiSetArgs` (Cosmos DB, discriminator-keyed); deliberately excludes any provider-specific matching mechanism (e.g. relational's `DatasetRecord()`, Cosmos's `TypeDiscriminator`/`AddItem`). | ## Namespaces @@ -49,7 +51,8 @@ ## Related Namespaces - **[`CoreEx`](../CoreEx/README.md)** - `QueryArgs` (filter/orderby strings and paging), `PagingArgs`, and `IEventQueue` are defined in the root `CoreEx` package and consumed here. -- **[`CoreEx.Database`](../CoreEx.Database/README.md)** - `IUnitOfWork` is implemented by the database unit-of-work; `QueryArgsConfig` is used by database query builders. +- **[`CoreEx.Database`](../CoreEx.Database/README.md)** - `IUnitOfWork` is implemented by the database unit-of-work; `QueryArgsConfig` is used by database query builders; `Extended.IMultiSetArgs`/`IMultiSetArgs`/`MultiSetSingleArgs`/`MultiSetCollArgs` extend `IMultiSetArgsCore` for positional, ordered relational multi-set queries. +- **[`CoreEx.Cosmos`](../CoreEx.Cosmos/README.md)** - `Extended.IMultiSetArgs`/`IMultiSetArgs`/`MultiSetSingleArgs`/`MultiSetCollArgs` extend `IMultiSetArgsCore` for discriminator-keyed Cosmos DB multi-set queries. - **[`CoreEx.EntityFrameworkCore`](../CoreEx.EntityFrameworkCore/README.md)** - EF Core `IQueryable` extensions consume `QueryArgsConfig` via `Where`/`OrderBy`. ## AI Usage Guide diff --git a/src/CoreEx.Database.Postgres/AGENTS.md b/src/CoreEx.Database.Postgres/AGENTS.md index 7ab076c5..7105a096 100644 --- a/src/CoreEx.Database.Postgres/AGENTS.md +++ b/src/CoreEx.Database.Postgres/AGENTS.md @@ -42,6 +42,12 @@ builder.Services builder.AddPostgresOutboxRelayHostedService(); // called on builder, not builder.Services ``` +`PostgresOutboxRelayHostedService` self-pauses/self-resumes via `DatabaseOutboxRelayHostedServiceBase.Resiliency` (a Polly circuit breaker, same shape as the Cosmos DB and Azure Service Bus relays) - a sustained failure ratio pauses the whole hosted service for an exponentially-increasing backoff, then automatically resumes to re-test recovery, without requiring a manual `ResumeAsync()` call. A failure for one partition no longer prevents other, unrelated partitions from being attempted in the same tick. + +Poison-message/dead-letter handling is still **not implemented** - a permanently-failing row is cancelled and rescheduled with backoff forever, with no built-in give-up. The claim query claims a *strictly contiguous* run starting from the oldest pending row for a given tenant/partition, stopping at the first still-leased-or-unavailable row - a permanently-failing row therefore stays the oldest pending row forever and blocks every row after it in the same partition from ever being claimed, indefinitely, not just delayed. The circuit breaker mitigates the blast radius (other partitions keep flowing, and the host self-recovers once the underlying cause clears) but does not solve this - the affected partition itself remains stuck until an operator intervenes. + +**Detecting a stuck partition:** `postgres.outbox.enqueue` continuing to climb while `postgres.outbox.relay.publish` stays flat for the same partition is the signal. `postgres.outbox.relay.oldest_lag`/`newest_lag` are recorded on both a successful and a failed publish attempt, so they keep climbing (rather than going silent) for as long as a batch keeps failing - alert on a sustained rise in `postgres.outbox.relay.oldest_lag`, not just on `postgres.outbox.relay.publish.failed`, since a low failure count can still mean one partition has been stuck for a long time. + ## OpenTelemetry ```csharp diff --git a/src/CoreEx.Database.Postgres/Extended/PostgresUnitOfWorkInvoker.cs b/src/CoreEx.Database.Postgres/Extended/PostgresUnitOfWorkInvoker.cs index a55b5911..ce57a8ba 100644 --- a/src/CoreEx.Database.Postgres/Extended/PostgresUnitOfWorkInvoker.cs +++ b/src/CoreEx.Database.Postgres/Extended/PostgresUnitOfWorkInvoker.cs @@ -6,7 +6,7 @@ namespace CoreEx.Database.Postgres.Extended; /// Implements transaction handling including automatic save-point support for nested unit-of-work invocations. Also, where the underlying work returns an , /// then an will trigger a rollback similar to an unhandled exception. /// Where a transactional outbox is supported () then the will -/// automatically be included within the root (top-most) transaction. This is achieved by executing the . Nested (child) transactional rollbacks are also supported by the . +/// automatically be included within the root (top-most) transaction. This is achieved by executing the . Nested (child) transactional rollbacks are also supported by the . /// Note that the underlying implementation is not thread-safe. [InvokerName("CoreEx.Database.Postgres.PostgresUnitOfWork")] public class PostgresUnitOfWorkInvoker : InvokerBase diff --git a/src/CoreEx.Database.Postgres/GlobalUsing.cs b/src/CoreEx.Database.Postgres/GlobalUsing.cs index ddc2f1a7..dd872606 100644 --- a/src/CoreEx.Database.Postgres/GlobalUsing.cs +++ b/src/CoreEx.Database.Postgres/GlobalUsing.cs @@ -1,12 +1,14 @@ global using CloudNative.CloudEvents.Extensions; global using CoreEx; global using CoreEx.Data; +global using IMultiSetArgs = CoreEx.Database.Extended.IMultiSetArgs; global using CoreEx.Database.Abstractions; global using CoreEx.Database.Extended; global using CoreEx.Database.Outbox; global using CoreEx.Database.Postgres; global using CoreEx.Database.Postgres.Extended; global using CoreEx.Database.Postgres.Outbox; +global using CoreEx.Entities; global using CoreEx.Events; global using CoreEx.Events.Publishing; global using CoreEx.Hosting; diff --git a/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs b/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs index 76e705f5..217a18eb 100644 --- a/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs +++ b/src/CoreEx.Database.Postgres/Outbox/PostgresOutboxRelay.cs @@ -55,8 +55,30 @@ protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, G if (EventPublisher.IsEmpty) return; - // Capture metrics; no need to capture each as this would be diminishing returns, as the oldest and newest are the most important. - PostgresMetrics.OutboxRelayBatchSize.Add(EventPublisher.Count); + PostgresMetrics.OutboxRelayPublished.Add(EventPublisher.Count); + RecordLagMetrics(); + } + + /// + protected async override Task CancelBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) + { + await base.CancelBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); + + if (EventPublisher.IsEmpty) + return; + + PostgresMetrics.OutboxRelayPublishFailed.Add(EventPublisher.Count); + RecordLagMetrics(); + } + + /// + /// Records the oldest/newest relay lag for the current batch, on both a successful and a failed publish attempt - so the histogram keeps reporting (and growing) for as long as a batch keeps + /// failing, rather than going silent, which is a far more useful signal to alert on than an absent metric. + /// + /// Indexes the first/last queued event rather than computing min/max - unlike Cosmos DB's Change Feed Processor (which can span multiple logical partition keys with no guaranteed + /// overall time ordering), the claim query returns rows pre-ordered by enqueue time. + private void RecordLagMetrics() + { PostgresMetrics.OutboxRelayOldestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[0].Event.Time ?? default)).TotalMilliseconds); PostgresMetrics.OutboxRelayNewestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[^1].Event.Time ?? default)).TotalMilliseconds); } diff --git a/src/CoreEx.Database.Postgres/PostgresMetrics.cs b/src/CoreEx.Database.Postgres/PostgresMetrics.cs index b833480e..4a1d8cc2 100644 --- a/src/CoreEx.Database.Postgres/PostgresMetrics.cs +++ b/src/CoreEx.Database.Postgres/PostgresMetrics.cs @@ -16,17 +16,26 @@ public static class PostgresMetrics public static Counter OutboxEnqueued { get; } = Meter.CreateCounter("postgres.outbox.enqueue", unit: "{message}", description: "Number of PostgreSQL outbox messages enqueued successfully."); /// - /// Gets the counter representing the total number of messages (batch) dequeued (relayed) successfully. + /// Gets the counter representing the total number of messages (batch) relayed (published) successfully. /// - public static Counter OutboxRelayBatchSize { get; } = Meter.CreateCounter("postgres.outbox.relay.batch.size", unit: "{message}", description: "Number of PostgreSQL outbox messages (batch) relayed successfully."); + public static Counter OutboxRelayPublished { get; } = Meter.CreateCounter("postgres.outbox.relay.publish", unit: "{message}", description: "Number of PostgreSQL outbox messages (batch) relayed successfully."); /// - /// Gets the histogram that tracks the oldest lag duration (now - enqueued time of first message in batch), in milliseconds, of successful PostgreSQL outbox relay operations; i.e. end-to-end relay lag. + /// Gets the counter representing the total number of messages (batch) that failed to relay (publish). /// - public static Histogram OutboxRelayOldestLagDuration { get; } = Meter.CreateHistogram("postgres.outbox.batch.oldest_lag", unit: "ms", description: "Oldest lag duration (now - enqueued time of first message in batch) of PostgreSQL outbox relay."); + /// Recorded for a batch that fails anywhere between claim and complete (publish failure, or a failure completing/cancelling the batch) - the batch is cancelled and made available for retry. + public static Counter OutboxRelayPublishFailed { get; } = Meter.CreateCounter("postgres.outbox.relay.publish.failed", unit: "{message}", description: "Number of PostgreSQL outbox messages (batch) that failed to relay."); /// - /// Gets the histogram that tracks the newest lag duration (now - enqueued time of last message in batch), in milliseconds, of successful PostgreSQL outbox relay operations; i.e. end-to-end relay lag. + /// Gets the histogram that tracks the oldest lag duration (now - enqueued time of first message in batch), in milliseconds, of a PostgreSQL outbox relay batch attempt; i.e. end-to-end relay lag. /// - public static Histogram OutboxRelayNewestLagDuration { get; } = Meter.CreateHistogram("postgres.outbox.batch.newest_lag", unit: "ms", description: "Newest lag duration (now - enqueued time of last message in batch) of PostgreSQL outbox relay."); -} \ No newline at end of file + /// Recorded on both a successful and a failed publish attempt, so this keeps climbing (rather than going silent) for as long as a batch keeps failing - a stuck relay is visible as an + /// ever-increasing oldest lag, not an absent metric. + public static Histogram OutboxRelayOldestLagDuration { get; } = Meter.CreateHistogram("postgres.outbox.relay.oldest_lag", unit: "ms", description: "Oldest lag duration (now - enqueued time of first message in batch) of PostgreSQL outbox relay."); + + /// + /// Gets the histogram that tracks the newest lag duration (now - enqueued time of last message in batch), in milliseconds, of a PostgreSQL outbox relay batch attempt; i.e. end-to-end relay lag. + /// + /// Recorded on both a successful and a failed publish attempt; see . + public static Histogram OutboxRelayNewestLagDuration { get; } = Meter.CreateHistogram("postgres.outbox.relay.newest_lag", unit: "ms", description: "Newest lag duration (now - enqueued time of last message in batch) of PostgreSQL outbox relay."); +} diff --git a/src/CoreEx.Database.Postgres/PostgresUnitOfWork.cs b/src/CoreEx.Database.Postgres/PostgresUnitOfWork.cs index 39ac5d00..6210b442 100644 --- a/src/CoreEx.Database.Postgres/PostgresUnitOfWork.cs +++ b/src/CoreEx.Database.Postgres/PostgresUnitOfWork.cs @@ -48,4 +48,9 @@ public Task TransactionAsync(IDataArgs args, Func work, /// public Task TransactionAsync(IDataArgs args, Func> work, CancellationToken cancellationToken = default) => UnitOfWorkInvoker.InvokeAsync(this, (PostgresDatabaseArgs)args, async (_, _, cancellationToken) => await work(cancellationToken).ConfigureAwait(false), cancellationToken); + + /// + /// A no-op — a Postgres-mutated value already carries its true, final by the time it is created/updated (each statement executes immediately within the open + /// transaction), so there is nothing to synchronize. + public void SynchronizeETag(CompositeKey key, T value) where T : IETag { } } \ No newline at end of file diff --git a/src/CoreEx.Database.Postgres/README.md b/src/CoreEx.Database.Postgres/README.md index 1876054f..2e112ff7 100644 --- a/src/CoreEx.Database.Postgres/README.md +++ b/src/CoreEx.Database.Postgres/README.md @@ -16,7 +16,7 @@ The outbox sub-namespace provides ready-to-use `PostgresOutboxPublisher`, `Postg - 🔢 **Error-code convention**: `SQLSTATE` values 56001–56007 and 56010 map to `ValidationException`, `BusinessException`, `AuthorizationException`, `ConcurrencyException`, `NotFoundException`, `ConflictException`, `DuplicateException`, and `DataConsistencyException` respectively — identical to the SQL Server convention. - 🔁 **PostgresUnitOfWork**: `IDatabaseUnitOfWork` implementation wrapping `TransactionAsync` with `PostgresUnitOfWorkInvoker`; optionally accepts an `IEventPublisher` outbox for transactional event enqueuing. - 📤 **Outbox relay**: `PostgresOutboxPublisher` (writes to outbox table), `PostgresOutboxRelay` (polls and publishes), and `PostgresOutboxRelayHostedService` (timer-driven hosted service) — all PostgreSQL-specific subclasses of the base `CoreEx.Database.Outbox` types. -- 📊 **Outbox metrics**: `PostgresMetrics` exposes .NET `Meter` instruments: `postgres.outbox.enqueue` (counter), `postgres.outbox.relay.batch.size` (counter), `postgres.outbox.batch.oldest_lag` and `postgres.outbox.batch.newest_lag` (histograms in ms). +- 📊 **Outbox metrics**: `PostgresMetrics` exposes .NET `Meter` instruments: `postgres.outbox.enqueue` (counter), `postgres.outbox.relay.publish` and `postgres.outbox.relay.publish.failed` (counters), `postgres.outbox.relay.oldest_lag` and `postgres.outbox.relay.newest_lag` (histograms in ms) — names harmonized with `SqlServerMetrics`/`CosmosMetrics`. - 📡 **OpenTelemetry**: `CoreExPostgresExtensions.WithCoreExPostgresTelemetry` wires `PostgresInvoker` activity sources and the outbox meter into the OTEL tracer and meter providers. - ⚙️ **DI registration**: `AddPostgresDatabase(services, configure?)` registers `PostgresDatabase` as a scoped service; `AddPostgresUnitOfWork(services, addAsIUnitOfWork = true)` registers `PostgresUnitOfWork`. diff --git a/src/CoreEx.Database.SqlServer/AGENTS.md b/src/CoreEx.Database.SqlServer/AGENTS.md index 7563b02f..69bdbe24 100644 --- a/src/CoreEx.Database.SqlServer/AGENTS.md +++ b/src/CoreEx.Database.SqlServer/AGENTS.md @@ -51,6 +51,12 @@ builder.Services builder.AddSqlServerOutboxRelayHostedService(); // called on builder, not builder.Services ``` +`SqlServerOutboxRelayHostedService` self-pauses/self-resumes via `DatabaseOutboxRelayHostedServiceBase.Resiliency` (a Polly circuit breaker, same shape as the Cosmos DB and Azure Service Bus relays) - a sustained failure ratio pauses the whole hosted service for an exponentially-increasing backoff, then automatically resumes to re-test recovery, without requiring a manual `ResumeAsync()` call. A failure for one partition no longer prevents other, unrelated partitions from being attempted in the same tick. + +Poison-message/dead-letter handling is still **not implemented** - a permanently-failing row is cancelled and rescheduled with backoff forever, with no built-in give-up. The claim query claims a *strictly contiguous* run starting from the oldest pending row for a given tenant/partition, stopping at the first still-leased-or-unavailable row - a permanently-failing row therefore stays the oldest pending row forever and blocks every row after it in the same partition from ever being claimed, indefinitely, not just delayed. The circuit breaker mitigates the blast radius (other partitions keep flowing, and the host self-recovers once the underlying cause clears) but does not solve this - the affected partition itself remains stuck until an operator intervenes. + +**Detecting a stuck partition:** `sqlserver.outbox.enqueue` continuing to climb while `sqlserver.outbox.relay.publish` stays flat for the same partition is the signal. `sqlserver.outbox.relay.oldest_lag`/`newest_lag` are recorded on both a successful and a failed publish attempt, so they keep climbing (rather than going silent) for as long as a batch keeps failing - alert on a sustained rise in `sqlserver.outbox.relay.oldest_lag`, not just on `sqlserver.outbox.relay.publish.failed`, since a low failure count can still mean one partition has been stuck for a long time. + ## OpenTelemetry ```csharp diff --git a/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs b/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs index e9423e74..d6bd7ef4 100644 --- a/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs +++ b/src/CoreEx.Database.SqlServer/Extended/SqlServerUnitOfWorkInvoker.cs @@ -6,7 +6,7 @@ namespace CoreEx.Database.SqlServer.Extended; /// Implements transaction handling including automatic save-point support for nested unit-of-work invocations. Also, where the underlying work returns an , /// then an will trigger a rollback similar to an unhandled exception. /// Where a transactional outbox is supported () then the will -/// automatically be included within the root (top-most) transaction. This is achieved by executing the . Nested (child) transactional rollbacks are also supported by the . +/// automatically be included within the root (top-most) transaction. This is achieved by executing the . Nested (child) transactional rollbacks are also supported by the . /// Note that the underlying implementation is not thread-safe. [InvokerName("CoreEx.Database.SqlServer.SqlServerUnitOfWork")] public class SqlServerUnitOfWorkInvoker : InvokerBase diff --git a/src/CoreEx.Database.SqlServer/GlobalUsing.cs b/src/CoreEx.Database.SqlServer/GlobalUsing.cs index 92e820ff..444a705a 100644 --- a/src/CoreEx.Database.SqlServer/GlobalUsing.cs +++ b/src/CoreEx.Database.SqlServer/GlobalUsing.cs @@ -1,12 +1,14 @@ global using CloudNative.CloudEvents.Extensions; global using CoreEx; global using CoreEx.Data; +global using IMultiSetArgs = CoreEx.Database.Extended.IMultiSetArgs; global using CoreEx.Database.Abstractions; global using CoreEx.Database.Extended; global using CoreEx.Database.Outbox; global using CoreEx.Database.SqlServer; global using CoreEx.Database.SqlServer.Extended; global using CoreEx.Database.SqlServer.Outbox; +global using CoreEx.Entities; global using CoreEx.Events; global using CoreEx.Events.Publishing; global using CoreEx.Hosting; diff --git a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs index 7726c10e..a65e1bd5 100644 --- a/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs +++ b/src/CoreEx.Database.SqlServer/Outbox/SqlServerOutboxRelay.cs @@ -50,8 +50,30 @@ protected async override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, G if (EventPublisher.IsEmpty) return; - // Capture metrics; no need to capture each as this would be diminishing returns, as the oldest and newest are the most important. - SqlServerMetrics.OutboxRelayBatchSize.Add(EventPublisher.Count); + SqlServerMetrics.OutboxRelayPublished.Add(EventPublisher.Count); + RecordLagMetrics(); + } + + /// + protected async override Task CancelBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) + { + await base.CancelBatchAsync(args, leaseId, cancellationToken).ConfigureAwait(false); + + if (EventPublisher.IsEmpty) + return; + + SqlServerMetrics.OutboxRelayPublishFailed.Add(EventPublisher.Count); + RecordLagMetrics(); + } + + /// + /// Records the oldest/newest relay lag for the current batch, on both a successful and a failed publish attempt - so the histogram keeps reporting (and growing) for as long as a batch keeps + /// failing, rather than going silent, which is a far more useful signal to alert on than an absent metric. + /// + /// Indexes the first/last queued event rather than computing min/max - unlike Cosmos DB's Change Feed Processor (which can span multiple logical partition keys with no guaranteed + /// overall time ordering), the claim query returns rows pre-ordered by enqueue time. + private void RecordLagMetrics() + { SqlServerMetrics.OutboxRelayOldestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[0].Event.Time ?? default)).TotalMilliseconds); SqlServerMetrics.OutboxRelayNewestLagDuration.Record((DateTimeOffset.UtcNow - (EventPublisher.GetEvents()[^1].Event.Time ?? default)).TotalMilliseconds); } diff --git a/src/CoreEx.Database.SqlServer/README.md b/src/CoreEx.Database.SqlServer/README.md index 1ed4f653..23c7fe58 100644 --- a/src/CoreEx.Database.SqlServer/README.md +++ b/src/CoreEx.Database.SqlServer/README.md @@ -17,7 +17,7 @@ The outbox sub-namespace provides ready-to-use `SqlServerOutboxPublisher`, `SqlS - 👤 **Session context**: `SetSqlSessionContextAsync(ExecutionContext?)` invokes `[dbo].[spSetSessionContext]` (configurable) to stamp `Username`, `Timestamp`, `TenantId`, and `UserId` into the SQL Server session context for row-level security and audit triggers. - 🔁 **SqlServerUnitOfWork**: `IDatabaseUnitOfWork` implementation wrapping `TransactionAsync` with `SqlServerUnitOfWorkInvoker`; optionally accepts an `IEventPublisher` outbox for transactional event enqueuing. - 📤 **Outbox relay**: `SqlServerOutboxPublisher` (writes to outbox table), `SqlServerOutboxRelay` (polls and publishes), and `SqlServerOutboxRelayHostedService` (timer-driven hosted service) — all SQL Server-specific subclasses of the base `CoreEx.Database.Outbox` types. -- 📊 **Outbox metrics**: `SqlServerMetrics` exposes .NET `Meter` instruments: `sqlserver.outbox.enqueue` (counter), `sqlserver.outbox.relay.batch.size` (counter), `sqlserver.outbox.batch.oldest_lag` and `sqlserver.outbox.batch.newest_lag` (histograms in ms). +- 📊 **Outbox metrics**: `SqlServerMetrics` exposes .NET `Meter` instruments: `sqlserver.outbox.enqueue` (counter), `sqlserver.outbox.relay.publish` and `sqlserver.outbox.relay.publish.failed` (counters), `sqlserver.outbox.relay.oldest_lag` and `sqlserver.outbox.relay.newest_lag` (histograms in ms) — names harmonized with `PostgresMetrics`/`CosmosMetrics`. - 📡 **OpenTelemetry**: `CoreExSqlServerExtensions.AddCoreExSqlServerOpenTelemetry` wires `SqlServerInvoker` activity sources and the outbox meter into the OTEL tracer and meter providers. - ⚙️ **DI registration**: `AddSqlServerDatabase(services, configure?)` registers `SqlServerDatabase` as a scoped service; `AddSqlServerUnitOfWork(services, configure?)` registers `SqlServerUnitOfWork`. diff --git a/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs b/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs index b1d1ca78..b4e2fe29 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerMetrics.cs @@ -16,17 +16,26 @@ public static class SqlServerMetrics public static Counter OutboxEnqueued { get; } = Meter.CreateCounter("sqlserver.outbox.enqueue", unit: "{message}", description: "Number of SQL Server outbox messages enqueued successfully."); /// - /// Gets the counter representing the total number of messages (batch) dequeued (relayed) successfully. + /// Gets the counter representing the total number of messages (batch) relayed (published) successfully. /// - public static Counter OutboxRelayBatchSize { get; } = Meter.CreateCounter("sqlserver.outbox.relay.batch.size", unit: "{message}", description: "Number of SQL Server outbox messages (batch) relayed successfully."); + public static Counter OutboxRelayPublished { get; } = Meter.CreateCounter("sqlserver.outbox.relay.publish", unit: "{message}", description: "Number of SQL Server outbox messages (batch) relayed successfully."); /// - /// Gets the histogram that tracks the oldest lag duration (now - enqueued time of first message in batch), in milliseconds, of successful SQL Server outbox relay operations; i.e. end-to-end relay lag. + /// Gets the counter representing the total number of messages (batch) that failed to relay (publish). /// - public static Histogram OutboxRelayOldestLagDuration { get; } = Meter.CreateHistogram("sqlserver.outbox.batch.oldest_lag", unit: "ms", description: "Oldest lag duration (now - enqueued time of first message in batch) of SQL Server outbox relay."); + /// Recorded for a batch that fails anywhere between claim and complete (publish failure, or a failure completing/cancelling the batch) - the batch is cancelled and made available for retry. + public static Counter OutboxRelayPublishFailed { get; } = Meter.CreateCounter("sqlserver.outbox.relay.publish.failed", unit: "{message}", description: "Number of SQL Server outbox messages (batch) that failed to relay."); /// - /// Gets the histogram that tracks the newest lag duration (now - enqueued time of last message in batch), in milliseconds, of successful SQL Server outbox relay operations; i.e. end-to-end relay lag. + /// Gets the histogram that tracks the oldest lag duration (now - enqueued time of first message in batch), in milliseconds, of a SQL Server outbox relay batch attempt; i.e. end-to-end relay lag. /// - public static Histogram OutboxRelayNewestLagDuration { get; } = Meter.CreateHistogram("sqlserver.outbox.batch.newest_lag", unit: "ms", description: "Newest lag duration (now - enqueued time of last message in batch) of SQL Server outbox relay."); -} \ No newline at end of file + /// Recorded on both a successful and a failed publish attempt, so this keeps climbing (rather than going silent) for as long as a batch keeps failing - a stuck relay is visible as an + /// ever-increasing oldest lag, not an absent metric. + public static Histogram OutboxRelayOldestLagDuration { get; } = Meter.CreateHistogram("sqlserver.outbox.relay.oldest_lag", unit: "ms", description: "Oldest lag duration (now - enqueued time of first message in batch) of SQL Server outbox relay."); + + /// + /// Gets the histogram that tracks the newest lag duration (now - enqueued time of last message in batch), in milliseconds, of a SQL Server outbox relay batch attempt; i.e. end-to-end relay lag. + /// + /// Recorded on both a successful and a failed publish attempt; see . + public static Histogram OutboxRelayNewestLagDuration { get; } = Meter.CreateHistogram("sqlserver.outbox.relay.newest_lag", unit: "ms", description: "Newest lag duration (now - enqueued time of last message in batch) of SQL Server outbox relay."); +} diff --git a/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs b/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs index eaa50df1..cde10f45 100644 --- a/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs +++ b/src/CoreEx.Database.SqlServer/SqlServerUnitOfWork.cs @@ -48,4 +48,9 @@ public Task TransactionAsync(IDataArgs args, Func work, /// public Task TransactionAsync(IDataArgs args, Func> work, CancellationToken cancellationToken = default) => UnitOfWorkInvoker.InvokeAsync(this, (SqlServerDatabaseArgs)args, async (_, _, cancellationToken) => await work(cancellationToken).ConfigureAwait(false), cancellationToken); + + /// + /// A no-op — a SQL Server-mutated value already carries its true, final by the time it is created/updated (each statement executes immediately within the open + /// transaction), so there is nothing to synchronize. + public void SynchronizeETag(CompositeKey key, T value) where T : IETag { } } \ No newline at end of file diff --git a/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs b/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs index e4d53cda..b6f92063 100644 --- a/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs +++ b/src/CoreEx.Database/Abstractions/DatabaseInvoker.cs @@ -51,6 +51,10 @@ public static async Task OrchestrateUnitOfWorkTransactionAsync var savePoint = isRootTxn ? string.Empty : unitOfWork.Database.GetNextSavePointName(); var eventStartCount = unitOfWork.Outbox?.Count ?? 0; + // Tracks whether THIS invocation is the one that actually called Outbox.PublishAsync - see its remarks (below) for why this cannot rely on Outbox.HasBeenPublished, which is global to the + // (typically request-scoped, reused-across-calls) Outbox instance, not scoped to this invocation. + var publishedByThisInvocation = false; + tracer.Activity?.AddTag("database.id", unitOfWork.Database.DatabaseId); // Reusable rollback logic. @@ -92,8 +96,24 @@ async Task RollbackAsync(Exception exception) } } - // Where outbox/events are supported then also rollback any added events. - unitOfWork.Outbox?.Rollback(Math.Max(0, unitOfWork.Outbox.Count - eventStartCount)); + // Where outbox/events are supported then also roll back any added events - Dequeue only functions pre-publish; where THIS invocation itself already published (e.g. it happened successfully + // but the transaction/save-point itself still failed to commit afterward), use RollbackAsync instead to undo that already-captured publish. This deliberately checks publishedByThisInvocation + // rather than Outbox.HasBeenPublished: the latter is a one-way, publisher-lifetime flag (see IEventPublisher.HasBeenPublished) that stays true for as long as the same Outbox instance is + // reused across multiple, entirely independent OrchestrateUnitOfWorkTransactionAsync calls within one scope (e.g. a request-scoped IUnitOfWork used for several sequential TransactionAsync + // calls) - relying on it here would wrongly invoke RollbackAsync for a later, unrelated failed invocation that never itself published anything, undoing an earlier invocation's genuinely + // successful and already-committed publish. Dequeue itself also refuses to run at all once HasBeenPublished is (globally) true - regardless of count - so it must only be called when THIS + // invocation actually added events of its own to remove; a later, unrelated failed invocation that added none has nothing to dequeue and must not touch the Outbox at all. + if (unitOfWork.Outbox is not null) + { + if (publishedByThisInvocation) + await unitOfWork.Outbox.RollbackAsync(cancellationToken).ConfigureAwait(false); + else + { + var addedByThisInvocation = Math.Max(0, unitOfWork.Outbox.Count - eventStartCount); + if (addedByThisInvocation > 0) + unitOfWork.Outbox.Dequeue(addedByThisInvocation); + } + } } // Perform the unit-of-work within a transaction or save-point as appropriate. @@ -137,6 +157,7 @@ async Task RollbackAsync(Exception exception) { outboxEnqueued = unitOfWork.Outbox!.Count; await unitOfWork.Outbox!.PublishAsync(cancellationToken).ConfigureAwait(false); + publishedByThisInvocation = true; } // Commit the work and outbox. diff --git a/src/CoreEx.Database/Extended/IMultiSetArgs.cs b/src/CoreEx.Database/Extended/IMultiSetArgs.cs index 4c4268d4..c1a32e75 100644 --- a/src/CoreEx.Database/Extended/IMultiSetArgs.cs +++ b/src/CoreEx.Database/Extended/IMultiSetArgs.cs @@ -3,31 +3,11 @@ namespace CoreEx.Database.Extended; /// /// Enables the multi-set arguments /// -public interface IMultiSetArgs +public interface IMultiSetArgs : IMultiSetArgsCore { - /// - /// Gets the minimum number of rows allowed. - /// - int MinimumRows { get; } - - /// - /// Gets the maximum number of rows allowed. - /// - int? MaximumRows { get; } - - /// - /// Indicates whether to stop further query result set processing where the current set has resulted in a (i.e. no records). - /// - bool StopOnNull { get; } - /// /// The method invoked for each record for its respective dataset. /// /// The . void DatasetRecord(DatabaseRecord dr); - - /// - /// Invokes the corresponding result function. - /// - void InvokeResult(); } \ No newline at end of file diff --git a/src/CoreEx.Database/Extended/README.md b/src/CoreEx.Database/Extended/README.md index 1520ccf7..04263298 100644 --- a/src/CoreEx.Database/Extended/README.md +++ b/src/CoreEx.Database/Extended/README.md @@ -20,10 +20,11 @@ The multi-result-set types allow a single stored procedure or multi-statement SQ | **[`DatabaseWildcard`](./DatabaseWildcard.cs)** | Translates `WildcardResult` to a database `LIKE` pattern with configurable multi-char (`%`), single-char (`_`), and escape character; exposes `Replace(string)` for raw wildcard text. | | **[`MultiSetSingleArgs`](./MultiSetSingleArgsT.cs)** | `IMultiSetArgs` for a single-row result set: invokes `IDatabaseMapper.MapFromDb` once and stores the mapped value; supports `IsMandatory` / `StopOnNull`. | | **[`MultiSetCollArgs`](./MultiSetCollArgsT.cs)** | `IMultiSetArgs` for a collection result set: invokes mapper for each row, accumulates into a list; supports `MinimumRows`, `MaximumRows`, `StopOnNull`. | -| [`IMultiSetArgs`](./IMultiSetArgs.cs) | Base interface for multi-result-set descriptors: `MinimumRows`, `MaximumRows`, `StopOnNull`, `DatasetRecord(DatabaseRecord)`. | +| [`IMultiSetArgs`](./IMultiSetArgs.cs) | Base interface for multi-result-set descriptors: extends `CoreEx.Data.IMultiSetArgsCore` (`MinimumRows`, `MaximumRows`, `StopOnNull`, `InvokeResult()`) adding `DatasetRecord(DatabaseRecord)`. | | [`IMultiSetArgs`](./IMultiSetArgsT.cs) | Generic variant adding a `Mapper` property and `GetResult()` for retrieving the mapped output after dataset processing. | ## Related Namespaces - **[`CoreEx.Database`](../README.md)** - `DatabaseCommand.SelectMultiSetAsync` consumes `IMultiSetArgs` descriptors; `IDatabase.NamedColumns` returns a `DatabaseColumns` instance; `IDatabase.Wildcard` is a `DatabaseWildcard`. +- **[`CoreEx.Data`](../../CoreEx.Data/README.md)** - `IMultiSetArgs` (this namespace) extends the shared, storage-agnostic `CoreEx.Data.IMultiSetArgsCore` base (`MinimumRows`, `MaximumRows`, `StopOnNull`, `InvokeResult()`); `CoreEx.Cosmos.Extended.IMultiSetArgs` is the discriminator-keyed sibling for Cosmos. - **[`CoreEx.Wildcards`](../../CoreEx/Wildcards/README.md)** - `WildcardResult` is the input type consumed by `DatabaseWildcard.Replace`. \ No newline at end of file diff --git a/src/CoreEx.Database/GlobalUsing.cs b/src/CoreEx.Database/GlobalUsing.cs index 7986ce4e..3906a45f 100644 --- a/src/CoreEx.Database/GlobalUsing.cs +++ b/src/CoreEx.Database/GlobalUsing.cs @@ -1,6 +1,7 @@ global using CloudNative.CloudEvents.Extensions; global using CoreEx.Abstractions; global using CoreEx.Data; +global using IMultiSetArgs = CoreEx.Database.Extended.IMultiSetArgs; global using CoreEx.Database.Abstractions; global using CoreEx.Database.Extended; global using CoreEx.Database.Mapping; @@ -20,6 +21,7 @@ global using Microsoft.Extensions.Diagnostics.HealthChecks; global using Microsoft.Extensions.Logging; global using OpenTelemetry; +global using Polly; global using System.Collections; global using System.Data; global using System.Data.Common; diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs index ca21f9a2..e649dcd4 100644 --- a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayArgs.cs @@ -24,4 +24,11 @@ public class DatabaseOutboxRelayArgs /// Gets the backoff duration used to push out availability of the underlying event within the outbox when cancelling a batch. /// public TimeSpan BackOffDuration { get; init; } + + /// + /// Gets the used to protect each partition's relay attempt. + /// + /// always supplies one (backed by its inherited ); a direct/test caller that constructs + /// itself must supply one too - e.g. (work, ct) => work(ct) for an unprotected pass-through. + public required DatabaseOutboxRelayResiliencyExecutor ResiliencyExecutor { get; init; } } \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs index 460ab223..f43361d9 100644 --- a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayBase.cs @@ -108,9 +108,26 @@ public async Task RelayAsync(DatabaseOutboxRelayArgs args, CancellationTok using var leaseCancellationTokenSource = new CancellationTokenSource(args.LeaseDuration); try { - var relay = await RelayAsync(args, partitionId, leaseCancellationTokenSource.Token).ConfigureAwait(false); - if (relay) + // The partition attempt is protected by the caller's resiliency pipeline (e.g. a circuit breaker owned by the hosted service). + var partitionRelayed = false; + + var result = await args.ResiliencyExecutor(async ct => + { + try + { + partitionRelayed = await RelayAsync(args, partitionId, ct).ConfigureAwait(false); + return Result.Success; + } + catch (Exception ex) + { + return Result.Fail(ex); + } + }, leaseCancellationTokenSource.Token).ConfigureAwait(false); + + if (partitionRelayed) relayed = true; + + result.ThrowOnError(); } catch (Exception ex) when (ex.IsCanceled()) { @@ -120,6 +137,13 @@ public async Task RelayAsync(DatabaseOutboxRelayArgs args, CancellationTok // Keep throwing as the cancellation is likely to be due to exceeding the lease duration which is a serious failure that should be surfaced and not treated as a transient exception. throw; } + catch (Exception ex) + { + // A failure for one partition must not prevent other, unrelated partitions from being attempted within the same tick - continue on to the next partition rather than aborting the + // whole tick. Where a resiliency pipeline was supplied, it has already observed this failure (and, on a sustained ratio, will have paused the caller for future ticks). + if (Logger?.IsEnabled(LogLevel.Error) is true) + Logger.LogError(ex, "The relay operation for partition '{PartitionId}' failed: {Error}", partitionId, ex.Message); + } } return relayed; @@ -156,43 +180,20 @@ private async Task RelayAsync(DatabaseOutboxRelayArgs args, int partitionI using (SuppressInstrumentationScope.Begin(!IsInstrumentationEnabledForPublishing)) { await Invoker.InvokeAsync(this, async (tracer, cancellationToken) => - { + { if (tracer.Activity is not null) { tracer.Activity.AddTag("outbox.partition", partitionId); tracer.Activity.AddTag("outbox.events.count", events.Count); - - foreach (var e in events) - { - if (!e.Event.TryGetExtensionAttribute("traceparent", out var traceParent) || string.IsNullOrEmpty(traceParent)) - continue; - - e.Event.TryGetExtensionAttribute("tracestate", out var traceState); - if (ActivityContext.TryParse(traceParent, traceState, out var ac)) - tracer.Activity.AddLink(new ActivityLink(ac)); - - if (e.Event.TryGetExtensionAttribute("baggage", out var baggageHeader) && !string.IsNullOrEmpty(baggageHeader)) - { - // Parse W3C Baggage format: "key1=value1,key2=value2;property1;property2" - // Note: OpenTelemetry doesn't expose a public baggage parser, so we implement per W3C spec. - foreach (var member in baggageHeader.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - // Take only the key-value part (before any optional properties after semicolon). - var keyValue = member.Split(';', 2)[0].Trim(); - var parts = keyValue.Split('=', 2); - if (parts.Length == 2 && !string.IsNullOrWhiteSpace(parts[0])) - { - // Decode URL-encoded values per W3C Baggage spec. - var key = Uri.UnescapeDataString(parts[0].Trim()); - var value = Uri.UnescapeDataString(parts[1].Trim()); - tracer.Activity.AddBaggage(key, value); - } - } - } - } + tracer.Activity.LinkTraceContext(events.Select(e => e.Event)); } await EventPublisher.PublishAsync(cancellationToken).ConfigureAwait(false); + + // Only now that the publish has actually succeeded, give every originating trace (e.g. the API request that raised the event) a deterministic, visible "relayed" marker - see + // EmitRelayMarkers remarks for why this exists alongside (not instead of) the batch-level link above. Emitting this before the publish would risk a false-positive "relayed" + // marker for an event whose publish subsequently throws (and is then retried as part of the whole batch being cancelled/re-claimed). + events.EmitRelayMarkers(tracer.Activity); }, cancellationToken).ConfigureAwait(false); } diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs index 38a6fa73..a334f3ae 100644 --- a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBase.cs @@ -3,17 +3,35 @@ namespace CoreEx.Database.Outbox; /// /// Provides the base execution leveraging a . /// -/// The . -/// The . -public abstract class DatabaseOutboxRelayHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) : TimerHostedServiceBase(serviceProvider, logger) +/// Each partition's relay attempt is protected by the inherited , opted into by default here (unlike the general-purpose base, where it is +/// opt-in) since it is the primary safety net for a sustained relay failure; is disabled by default accordingly (it remains available, +/// and overridable back on, purely as a fallback for something unexpected outside the relay call itself). +/// Applies itself, per partition (see ), rather than per tick - since a partition +/// failure is already caught and logged within the tick (see ) rather than propagating +/// out of it, the generic per-tick wrap would otherwise almost always observe success regardless of the actual per-partition failure rate, diluting the failure ratio. +public abstract class DatabaseOutboxRelayHostedServiceBase : TimerHostedServiceBase { private PartitionPicker? _partitionPicker; + /// + /// Initializes a new instance of the class. + /// + /// The . + /// The . + public DatabaseOutboxRelayHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider, logger) + { + Resiliency = CreateDefaultResiliency(); + PauseOnUnhandledException = false; + } + + /// + protected override bool IsSelfApplyingResiliency => true; + /// /// Gets or sets the batch size. /// /// Defaults to '25'. - public int BatchSize { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + public int BatchSize { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } /// /// Gets or sets the lease duration used to lock when claiming a batch. @@ -31,7 +49,7 @@ public abstract class DatabaseOutboxRelayHostedServiceBase(IServiceProvider serv /// Gets or sets the partition size. /// /// Defaults to . - public int PartitionSize { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + public int PartitionSize { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } /// /// Gets or sets the per-worker partition count. @@ -55,7 +73,10 @@ protected async override Task OnInitializeAsync(CancellationToken cancellationTo LeaseDuration = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:LeaseDuration", "CoreEx:Host:Services:OutboxRelay:LeaseDuration", TimeSpan.FromMinutes(5), Configuration); BackOffDuration = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:BackOffDuration", "CoreEx:Host:Services:OutboxRelay:BackOffDuration", TimeSpan.FromSeconds(5), Configuration); PartitionSize = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:PartitionSize", "CoreEx:Host:Services:OutboxRelay:PartitionSize", PartitionKey.DefaultPartitionSize, Configuration); - PerWorkerPartitionCount = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:PerWorkerPartitionCount", "CoreEx:Host:Services:OutboxRelay:PerWorkerPartitionCount", 6, Configuration); + + // Default capped at PartitionSize (whatever it resolved to above, default or configured) - PartitionPicker requires perWorkerPartitionCount <= partitionSize; an unconditional literal default + // here would silently throw at startup whenever it exceeds the resolved PartitionSize (e.g. the out-of-the-box defaults: PartitionSize=4 but a literal 6 here). + PerWorkerPartitionCount = Internal.GetConfigurationValueWithFallback($"CoreEx:Host:Services:{ServiceConfigurationSectionName}:OutboxRelay:PerWorkerPartitionCount", "CoreEx:Host:Services:OutboxRelay:PerWorkerPartitionCount", Math.Min(6, PartitionSize), Configuration); _partitionPicker = new PartitionPicker(PartitionSize, PerWorkerPartitionCount); diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs index f7a0f068..c666e70a 100644 --- a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayHostedServiceBaseT.cs @@ -8,32 +8,62 @@ namespace CoreEx.Database.Outbox; /// The . public abstract class DatabaseOutboxRelayHostedServiceBase(IServiceProvider serviceProvider, ILogger logger) : DatabaseOutboxRelayHostedServiceBase(serviceProvider, logger) where TOutboxRelay : IDatabaseOutboxRelay { + private DatabaseOutboxRelayArgs? _args; + /// /// Gets or sets the factory method to create the . /// - public Func? RelayFactory { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + public Func? RelayFactory { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } /// - protected override async Task OnExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) + protected override async Task OnInitializeAsync(CancellationToken cancellationToken) { - // Instantiate the relay via the factory where specified. - var relay = RelayFactory is null - ? ExecutionContext.GetRequiredService() - : RelayFactory(executionContext.ServiceProvider.ThrowIfNull()) ?? throw new InvalidOperationException($"The {typeof(TOutboxRelay).Name} was not be created using the specified {nameof(RelayFactory)}."); + await base.OnInitializeAsync(cancellationToken).ConfigureAwait(false); - // Create the arguments. - var args = new DatabaseOutboxRelayArgs + // Built once and reused for every tick, rather than per tick - every field here is immutable once initialization completes (PartitionPicker is itself explicitly designed to be reused + // for the worker's whole lifetime; see its own remarks). + _args = new DatabaseOutboxRelayArgs { PartitionPicker = PartitionPicker, BatchSize = BatchSize, LeaseDuration = LeaseDuration, - BackOffDuration = BackOffDuration + BackOffDuration = BackOffDuration, + // Resiliency may be explicitly set to null by a consumer that wants to fully opt out (falling back to TimerHostedServiceBase.PauseOnUnhandledException instead) - degrade to an + // unprotected pass-through in that case, rather than failing, while still always supplying a ResiliencyExecutor. + ResiliencyExecutor = Resiliency is null + ? (work, ct) => work(ct) + : async (work, ct) => + { + var context = ResilienceContextPool.Shared.Get(ct); + try + { + // Keyed by TimerHostedServiceBase (not DatabaseOutboxRelayHostedServiceBase) to match the TOwner CreateDefaultResiliency builds the pipeline with - the same convention + // TimerHostedServiceBase's own generic per-tick wrap uses; a custom Resiliency pipeline must be built with the same TOwner to resolve correctly here. + context.Properties.Set(ResilienceOwner.PropertyKey, this); + return await Resiliency.ExecuteAsync(static async (rc, w) => await w(rc.CancellationToken).ConfigureAwait(false), context, work).ConfigureAwait(false); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } }; + } + + /// + protected override async Task OnExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) + { + // Instantiate the relay via the factory where specified. + var relay = RelayFactory is null + ? ExecutionContext.GetRequiredService() + : RelayFactory(executionContext.ServiceProvider.ThrowIfNull()) ?? throw new InvalidOperationException($"The {typeof(TOutboxRelay).Name} was not be created using the specified {nameof(RelayFactory)}."); + + var args = _args ?? throw new InvalidOperationException($"{nameof(_args)} has not yet been initialized; this should not be accessed before {nameof(OnInitializeAsync)}."); // Execute the relay. var relayed = await relay.RelayAsync(args, cancellationToken).ConfigureAwait(false); // Immediately re-execute where work was done (doesn't matter how much); otherwise, sleep. - return relayed; + return relayed; } } \ No newline at end of file diff --git a/src/CoreEx.Database/Outbox/DatabaseOutboxRelayResiliencyExecutor.cs b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayResiliencyExecutor.cs new file mode 100644 index 00000000..eb6997d6 --- /dev/null +++ b/src/CoreEx.Database/Outbox/DatabaseOutboxRelayResiliencyExecutor.cs @@ -0,0 +1,11 @@ +namespace CoreEx.Database.Outbox; + +/// +/// A function that executes with resiliency (e.g. circuit-breaker) protection applied, returning its . +/// +/// The work to execute. +/// The . +/// Exists so (constructed fresh per relay attempt) can have each partition attempt protected by a resiliency pipeline owned by a +/// longer-lived caller (typically , a singleton) without needing to know anything about CircuitBreakerResiliency{TOwner}/ResilienceOwner{TOwner} +/// or which type owns the pipeline. +public delegate Task DatabaseOutboxRelayResiliencyExecutor(Func> work, CancellationToken cancellationToken); diff --git a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs index 05d151b9..ec73b8c9 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbArgs.cs @@ -32,7 +32,13 @@ public record class EfDbArgs : DatabaseArgsBase /// /// Indicates whether to bypass all configured filters (where allowed). /// - /// This is an advanced feature that should only be used where specifically desired, and/or applying the filtering manually, to avoid unintended side-effects. + /// This is an advanced feature that should only be used where specifically desired, and/or applying the filtering manually, to avoid unintended side-effects. + /// Important: this never affects the built-in tenant () or logical-delete (-backed + /// non-query) checks — applies both unconditionally, for point operations, regardless of this setting. A caller that sets expecting a point + /// GetAsync/DeleteAsync to surface a soft-deleted row or another tenant's row will still get a /. Only an additive + /// registration (including the one itself adds) explicitly marked allowFilterBypass: true + /// is ever actually bypassed by this flag, and then only for (queries) — registrations only affect + /// non-query operations when a nonQueryResult is supplied. public bool BypassFilters { get; init; } = false; /// diff --git a/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs b/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs index 9468761f..6050ac61 100644 --- a/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs +++ b/src/CoreEx.EntityFrameworkCore/EfDbModelOptions.cs @@ -10,7 +10,6 @@ public class EfDbModelOptions where TModel : class private Func? _onBeforeCreateOrUpdate; private Func? _updateModelMapper; private bool _tenantFilterEnabled; - private bool _tenantFilterAllowBypass; /// /// Indicates whether and/or is supported for the . @@ -114,19 +113,21 @@ public EfDbModelOptions WithLogicalDeleteFilter(bool allowFilterBypass = /// /// Adds a tenant () query-only filter (where is supported). /// - /// Indicates whether the filter can be bypassed via the ; defaults to . /// The to support fluent-style method-chaining. - /// Unlike , this is applied directly by using the resolved by the owning (see ) + /// Non-query operations (GetAsync, etc.) always check the where supported (see ) irrespective of + /// whether this filter has been configured, and irrespective of — this only controls whether also applies the equivalent predicate to + /// . There is deliberately no allowFilterBypass parameter here (unlike /): a bypass knob that + /// only ever affected the query side while the point-op check stayed unconditional would be misleading, so tenant isolation is never bypassable at all, matching CosmosDbModelOptions.WithTenantFilter. + /// Unlike , this is applied directly by using the resolved by the owning (see ) /// rather than a stored predicate closure — this instance is commonly shared/cached across multiple instances (e.g. as a singleton service), so it cannot itself /// hold a reference to any one caller's ; a stored closure would otherwise have no choice but to fall back to the ambient , which does not honour an - /// explicitly-injected, non-ambient passed to the constructor. - public EfDbModelOptions WithTenantFilter(bool allowFilterBypass = false) + /// explicitly-injected, non-ambient passed to the constructor. + public EfDbModelOptions WithTenantFilter() { if (!TenantSupport.IsSupported) throw new NotSupportedException($"{nameof(WithTenantFilter)} is not supported; model must implement {nameof(IReadOnlyTenantId)} to enable."); _tenantFilterEnabled = true; - _tenantFilterAllowBypass = allowFilterBypass; return this; } @@ -145,12 +146,13 @@ public EfDbModelOptions WithTenantFilter(bool allowFilterBypass = false) /// The resolved by the owning (see ); used only by the predicate, where configured. /// The filtered . /// This applies all specified filters to the excluding the non-query result handling; unless, is set to . + /// The tenant filter (see ) is never bypassable and is applied unconditionally, irrespective of . /// See for more information. public IQueryable ApplyFilters(EfDbArgs args, IQueryable query, ExecutionContext executionContext) { query.ThrowIfNull(); - if (_tenantFilterEnabled && !(args.BypassFilters && _tenantFilterAllowBypass)) + if (_tenantFilterEnabled) { var tenantId = executionContext.ThrowIfNull().TenantId; query = query.Where(m => ((IReadOnlyTenantId)m).TenantId == tenantId); diff --git a/src/CoreEx.Events/CloudEventTracingExtensions.cs b/src/CoreEx.Events/CloudEventTracingExtensions.cs new file mode 100644 index 00000000..3bdeb43d --- /dev/null +++ b/src/CoreEx.Events/CloudEventTracingExtensions.cs @@ -0,0 +1,105 @@ +namespace CoreEx.Events; + +/// +/// Provides / distributed-tracing extensions. +/// +public static class CloudEventTracingExtensions +{ + /// + /// Gets the name of the dedicated used by : 'CoreEx.Events.Outbox.Relay'. + /// + /// Registered as an OpenTelemetry tracing source via . + /// Named to sit alongside the relay batch-span sources CoreEx.Database.Outbox.Relay (CoreEx.Database.Outbox.DatabaseOutboxRelayInvoker) and + /// CoreEx.Cosmos.Outbox.Relay (CoreEx.Cosmos.Outbox.CosmosDbOutboxRelayInvoker) - all three form the *.Outbox.Relay family for outbox-relay telemetry. + public const string RelayMarkerActivitySourceName = "CoreEx.Events.Outbox.Relay"; + + private static readonly ActivitySource _relayMarkerActivitySource = new(RelayMarkerActivitySourceName); + + /// + /// Links the to each of the ' originating W3C trace context (the traceparent/tracestate extension attributes, + /// added as an ). + /// + /// The to link/enrich; a no-op where . + /// The s being relayed. + /// Used by an outbox relay to connect its own publish span back to each original producer's trace - the events being relayed were not necessarily raised within the relay's own current trace, + /// so a plain parent/child relationship does not apply; a link is the correct W3C/OpenTelemetry mechanism for associating spans that are causally related but not nested. + /// Deliberately does not propagate the events' W3C baggage extension attribute onto . A link is a one-way reference + /// with no propagation effect, but baggage is ambient context that flows forward into whatever the current activity does next - including the relay's own outgoing publish call. A batch of events + /// relayed together generally originates from multiple causally-unrelated operations; merging their baggage onto one shared activity would leak each event's originating context (tenant id, feature + /// flags, anything else carried as baggage) into the outgoing call for every other event in the same batch. There is no merge strategy (first-wins, last-wins, de-duplicated by key) that avoids + /// this - the fan-in shape of a batched relay is fundamentally incompatible with baggage's propagation semantics, so it is not attempted at all. + public static void LinkTraceContext(this Activity? activity, IEnumerable events) + { + if (activity is null) + return; + + // De-duplicated per call - a batch can legitimately contain multiple events raised within the same originating operation (same traceparent); linking the identical context once per event would + // add redundant, identical links and inflate span cardinality for no benefit. Lazily allocated so the (common) no-tracing-headers-at-all case costs nothing. + HashSet? seenTraceParents = null; + + foreach (var @event in events) + { + if (!TryGetTraceContext(@event, out var traceParent, out var ac)) + continue; + + seenTraceParents ??= []; + if (!seenTraceParents.Add(traceParent)) + continue; + + activity.AddLink(new ActivityLink(ac)); + } + } + + /// + /// Emits a short marker per entry, started as a child of that event's own originating W3C trace context (the traceparent/tracestate + /// extension attributes) - a no-op for any event with no trace context (or where has no listener). + /// + /// The s being relayed. + /// The optional batch-level relay (see ) to link back to from each marker, so the + /// batch's own trace remains reachable from any individual originating trace. + /// The marker name; defaults to 'outbox.relay.publish'. + /// Deliberately the inverse of : that method links the relay's one batch-level span back to many originating + /// traces (correct for the relay's own fan-in operation), whereas this emits one small marker per originating trace so every producer (e.g. the API request that raised the event) gets a + /// deterministic, visible "this event was relayed" node regardless of how many other, unrelated events happened to share the same physical batch. Reparenting the relay's own batch span into a single + /// originating trace was considered and rejected - it would only work when a batch happens to contain events from exactly one trace, making the relay's visibility a runtime accident rather than a + /// guaranteed outcome. + /// Callers must only invoke this after the corresponding publish has completed successfully (see the call sites in DatabaseOutboxRelayBase and CosmosDbOutboxRelayProcessor), + /// never beforehand. A marker denotes "this event was relayed" - emitting it before the publish call risks a false-positive marker for an event whose publish subsequently throws and is retried + /// (as part of the whole batch being cancelled/re-claimed, or redelivered by the Change Feed Processor), since the marker would already show as a completed, successful span. + /// Each marker is started and immediately disposed regardless - no per-event duration can be meaningfully attributed even post-publish, since a single batched publish call covers every event in + /// the batch; the marker exists purely to make the relay hop visible within the originating trace, not to time it. + public static void EmitRelayMarkers(this IEnumerable events, Activity? relayActivity = null, string activityName = "outbox.relay.publish") + { + var relayLinks = relayActivity is null ? null : new[] { new ActivityLink(relayActivity.Context) }; + + foreach (var de in events) + { + if (!TryGetTraceContext(de.Event, out _, out var ac)) + continue; + + using var marker = _relayMarkerActivitySource.StartActivity(activityName, ActivityKind.Producer, ac, links: relayLinks); + if (marker is null) + continue; + + marker.SetTag("outbox.destination", de.Destination); + marker.SetTag("outbox.event.id", de.Event.Id); + marker.SetTag("outbox.event.type", de.Event.Type); + } + } + + /// + /// Attempts to parse the 's W3C traceparent/tracestate extension attributes into an . + /// + private static bool TryGetTraceContext(CloudEvent @event, out string traceParent, out ActivityContext context) + { + context = default; + if (!@event.TryGetExtensionAttribute("traceparent", out traceParent!) || string.IsNullOrEmpty(traceParent)) + return false; + + @event.TryGetExtensionAttribute("tracestate", out var traceState); + + // isRemote: true - this context always originates from a different process (the original event producer), never the relay's own trace; confirmed empirically that the 2-arg TryParse overload + // defaults IsRemote to false, which would otherwise mislabel every link/marker as local-origin. + return ActivityContext.TryParse(traceParent, traceState, isRemote: true, out context); + } +} diff --git a/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs b/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs index b2d8b7e6..746a0c85 100644 --- a/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs +++ b/src/CoreEx.Events/CoreExEventsExtensions.OpenTelemetry.cs @@ -15,7 +15,8 @@ public static class CoreExEventsExtensions public static OpenTelemetryBuilder WithCoreExEventsSources(this OpenTelemetryBuilder builder) => builder.ThrowIfNull() .WithTracing(t => t .AddInvokerAsSource() - .AddInvokerAsSource()) + .AddInvokerAsSource() + .AddSource(CoreEx.Events.CloudEventTracingExtensions.RelayMarkerActivitySourceName)) .WithMetrics(m => m .AddMeter(CoreEx.Events.Subscribing.EventSubscriberMetrics.Meter.Name)); } \ No newline at end of file diff --git a/src/CoreEx.Events/Publishing/EventPublisherBase.cs b/src/CoreEx.Events/Publishing/EventPublisherBase.cs index 793a3c23..51661e8e 100644 --- a/src/CoreEx.Events/Publishing/EventPublisherBase.cs +++ b/src/CoreEx.Events/Publishing/EventPublisherBase.cs @@ -127,9 +127,9 @@ public void Reset() => Synchronize(() => }, false); /// - public void Rollback(int count) => Synchronize(() => + public void Dequeue(int count) => Synchronize(() => { - count.ThrowWhen(count => count > _queue.Count, $"A {nameof(Rollback)} count cannot exceed the current queue length/count."); + count.ThrowWhen(count => count > _queue.Count, $"A {nameof(Dequeue)} count cannot exceed the current queue length/count."); if (count > 0) { @@ -140,6 +140,10 @@ public void Rollback(int count) => Synchronize(() => } }); + /// + /// A no-op by default; see for the rationale. + public virtual Task RollbackAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + /// /// This will also prior to the underlying . public async Task PublishAsync(CancellationToken cancellationToken = default) diff --git a/src/CoreEx.Events/Publishing/IEventPublisher.cs b/src/CoreEx.Events/Publishing/IEventPublisher.cs index c85bdec7..5d04e900 100644 --- a/src/CoreEx.Events/Publishing/IEventPublisher.cs +++ b/src/CoreEx.Events/Publishing/IEventPublisher.cs @@ -28,11 +28,22 @@ public interface IEventPublisher : IEventQueue void Reset(); /// - /// Rollback (i.e. dequeue) the specified number of previous Add operations. + /// Dequeues the specified number of previous Add operations. /// - /// The number of Add operations to roll back. - /// The rollback will only function where is . - void Rollback(int count); + /// The number of Add operations to dequeue. + /// This will only function where is ; see for the equivalent once already published. + void Dequeue(int count); + + /// + /// Rolls back a previous that has turned out not to have actually taken effect (e.g. a surrounding unit-of-work transaction it was enlisted within + /// subsequently failed to commit). + /// + /// The . + /// A no-op by default (see ) - a real publisher's underlying send is typically already irreversible (or, for a deferred-commit provider such as + /// CosmosDbEventPublisher, was never actually sent in the first place if the surrounding batch failed to commit), so there is usually nothing to undo. This exists purely so a + /// test-only decorator (see CoreEx.UnitTesting.Events.EventPublisherDecorator) can be told "the publish you just captured didn't really happen" and correct its own captured state + /// accordingly - only ever called after has already completed (i.e. is ), never before. + Task RollbackAsync(CancellationToken cancellationToken = default); /// /// Gets all destination events currently available. diff --git a/src/CoreEx.Events/README.md b/src/CoreEx.Events/README.md index 1be215e8..740f7520 100644 --- a/src/CoreEx.Events/README.md +++ b/src/CoreEx.Events/README.md @@ -12,7 +12,8 @@ ## Key capabilities -- 🔄 **`EventData` ↔ CloudEvents bridge**: `IEventFormatter` / `EventFormatter` convert between the CoreEx `EventData` envelope and the CloudNative CloudEvents spec, including distributed-tracing header propagation (`traceparent`, `tracestate`, baggage). +- 🔄 **`EventData` ↔ CloudEvents bridge**: `IEventFormatter` / `EventFormatter` convert between the CoreEx `EventData` envelope and the CloudNative CloudEvents spec, including distributed-tracing header propagation (`traceparent`, `tracestate`, baggage) attached when an event is *published*. +- 🔗 **Outbox relay trace-linking**: `CloudEventTracingExtensions.LinkTraceContext` reads a *previously-stored* event's `traceparent`/`tracestate` extension attributes back out and adds them as an `ActivityLink` on the current `Activity` - used by an outbox relay to connect its own publish span back to each original producer's trace (deliberately does not propagate `baggage`; see the XML doc remarks for why a batched, fan-in relay can't do that safely). Shared unchanged by `CoreEx.Database.Outbox` and `CoreEx.Cosmos.Outbox`'s relays. - 🧹 **App-wide payload redaction**: `EventFormatter.DataExcludePaths` applies a `CoreEx.Json.JsonFilter` exclude (recursive descent, e.g. `$..etag`) to every event's `Data` during `Format()`, so a property can be stripped from all published events in one place rather than at every `EventData.WithValue()` call site. Defaults to excluding `$..etag` — an optimistic-concurrency token that has no meaning to a downstream consumer and cannot be reliably captured for events raised transactionally via an outbox against a NoSQL store; set to `null`/empty to opt out. - 📤 **Queue-then-publish pipeline**: Events are buffered in-process and dispatched atomically via `PublishAsync()`; `Rollback(count)` and `Reset()` support outbox and retry patterns. - 📍 **Destination resolution**: `IDestinationProvider` dynamically generates topic/queue names from an `EventData`, an explicit destination string, or from `MessageType` and domain name. @@ -28,6 +29,7 @@ | [`IEventFormatter`](./IEventFormatter.cs) | Formats/parses `EventData`, converts to/from `CloudEvent`, adds distributed-tracing headers. | | **[`EventFormatter`](./EventFormatter.cs)** | Default `IEventFormatter` implementation; handles CloudEvents attribute mapping, trace propagation, and (via `DataExcludePaths`) app-wide `JsonFilter`-based redaction of the event `Data` payload. | | **[`MessageType`](./MessageType.cs)** | Enum: `Event`, `Command`, `ReplyTo` — used in destination-name generation. | +| **[`CloudEventTracingExtensions`](./CloudEventTracingExtensions.cs)** | `LinkTraceContext(Activity?, IEnumerable)` - links an activity to each event's originating W3C trace context; used by an outbox relay's publish span, not by ordinary publishing. | ## Namespaces @@ -39,7 +41,7 @@ ## Related namespaces - **[`CoreEx`](../CoreEx/README.md)** - Defines `EventData`, `CloudEvent` interop, `ExecutionContext`, and `Result` used throughout the events pipeline. -- **[`CoreEx.Database.Outbox`](../CoreEx.Database/Outbox/README.md)** - Outbox-pattern publisher that wraps `IEventPublisher`; persists events transactionally and relays them via a background relay host. +- **[`CoreEx.Database.Outbox`](../CoreEx.Database/Outbox/README.md)** / **[`CoreEx.Cosmos.Outbox`](../CoreEx.Cosmos/README.md#namespaces)** - Outbox-pattern publishers that wrap `IEventPublisher`; persist events transactionally (relational outbox table / Cosmos DB `TransactionalBatch`) and relay them via a background host (poll-loop / Change Feed Processor), sharing the same `CloudEventTracingExtensions` and harmonized metric naming. - **[`CoreEx.DomainDriven`](../CoreEx.DomainDriven/README.md)** - `Aggregate` accumulates `EventData` internally; the application layer forwards those to the publishing queue within the same unit-of-work. - **[`CoreEx.Invokers`](../CoreEx/Invokers/README.md)** - `EventPublisherInvoker` and `SubscribedInvoker` provide OpenTelemetry activity wrapping for publish and receive operations. diff --git a/src/CoreEx.Events/Subscribing/SubscribedManager.cs b/src/CoreEx.Events/Subscribing/SubscribedManager.cs index 112915a4..eee361e8 100644 --- a/src/CoreEx.Events/Subscribing/SubscribedManager.cs +++ b/src/CoreEx.Events/Subscribing/SubscribedManager.cs @@ -24,6 +24,18 @@ public sealed class SubscribedManager(SubscribedInvoker? invoker = null) /// Defaults to . public ErrorHandling AmbiguousSubscriberHandling { get; set; } = ErrorHandling.Catastrophic; + /// + /// Indicates whether distributed tracing should be recorded for an event for which no subscriber was matched (see ). + /// + /// Defaults to (i.e. suppressed). + /// A shared topic/subscription commonly carries multiple, unrelated event types; every subscriber host receives every message published to its subscription and silently completes the ones + /// it has no registered for (see 's default of ). In a busy multi-event-type topology this + /// "not for me" outcome is expected to be the most common one, so emitting a full trace (including the underlying transport's own receive/process/settle spans, where suppressible) for every such + /// message is overwhelmingly noise rather than signal - it is dropped by default so traces stay focused on messages that were actually matched and processed. The + /// counter still records the outcome regardless (metrics are low-cardinality/cheap and "how many messages did we see that weren't ours" remains a useful operational signal) - only per-message tracing is + /// affected. Set to to retain full visibility, e.g. when actively diagnosing why a specific event was not matched. + public bool IsTracingEnabledForUnsubscribed { get; set; } = false; + /// /// Indicates whether all subscribers require an inbox check (unless explicitly overridden) before processing. /// @@ -145,6 +157,9 @@ public Result Match(ExecutionContext executionContext, EventSubs // Handle where no subscriber is found. if (subscribers.Length == 0) { + if (!IsTracingEnabledForUnsubscribed) + SuppressUnsubscribedTracing(); + var eha = new ErrorHandlerArgs { SubscriberArgs = args, SourceType = GetType(), ErrorHandlingOverride = NotSubscribedHandling, Exception = new InvalidOperationException("No subscriber matched the event.") }; return args.Owner.ErrorHandler.Handle(eha, defaultErrorHandling: null); } @@ -167,6 +182,36 @@ public Result Match(ExecutionContext executionContext, EventSubs } } + /// + /// Suppresses distributed-tracing export for the current "not subscribed" event (see ). + /// + /// Marks the current - typically the transport receiver's own invoker span (e.g. ServiceBusReceiverInvoker) - and its immediate parent (typically the underlying + /// transport's own native receive/process span, e.g. Azure Service Bus' ServiceBusProcessor.ProcessMessage) as not requiring data collection, additionally clearing + /// on both so that any further activity the transport (or CoreEx itself, e.g. an explicit message-completion call made before the current activity stops) subsequently creates as a child of + /// either one (e.g. a settle/complete span) is also excluded by the ambient OpenTelemetry ParentBasedSampler (the OpenTelemetry SDK's own default), which otherwise defaults every child's sampling + /// decision to its immediate parent's live state at the moment the child is created - not necessarily the state of the activity that was current when this method ran. + /// Both and are ordinary, publicly mutable properties - the OpenTelemetry SDK re-checks + /// live when an activity stops (not a value cached at start), so setting it to here reliably drops an activity from being forwarded to any processor/exporter, even one that was + /// already started (and, for the current activity, possibly already tagged) by the time this determination is made. + /// Deliberately transport-agnostic: this operates purely against the ambient chain, with no dependency on any specific transport. Where there is no current activity, or no + /// parent, this is a no-op. + private static void SuppressUnsubscribedTracing() + { + var current = Activity.Current; + if (current is null) + return; + + current.IsAllDataRequested = false; + current.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + + var parent = current.Parent; + if (parent is null) + return; + + parent.IsAllDataRequested = false; + parent.ActivityTraceFlags &= ~ActivityTraceFlags.Recorded; + } + /// /// Receives and processes the using the instance. /// diff --git a/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props b/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props index 74cdc11e..ba1a95d7 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props +++ b/src/CoreEx.Template/content/CoreEx.Core/_Directory.Packages.props @@ -85,6 +85,6 @@ - + diff --git a/src/CoreEx.Template/content/CoreEx.Core/tests/app-name.Test.Unit/GlobalUsing.cs b/src/CoreEx.Template/content/CoreEx.Core/tests/app-name.Test.Unit/GlobalUsing.cs index 873e60fe..532e14b6 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/tests/app-name.Test.Unit/GlobalUsing.cs +++ b/src/CoreEx.Template/content/CoreEx.Core/tests/app-name.Test.Unit/GlobalUsing.cs @@ -8,7 +8,7 @@ global using CoreEx.Results; // #endif global using CoreEx.UnitTesting; -global using CoreEx.UnitTesting.Data; +global using CoreEx.Data.Json; global using CoreEx.Validation; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Hosting; diff --git a/src/CoreEx.UnitTesting/AGENTS.md b/src/CoreEx.UnitTesting/AGENTS.md index d5a07d30..2298f08f 100644 --- a/src/CoreEx.UnitTesting/AGENTS.md +++ b/src/CoreEx.UnitTesting/AGENTS.md @@ -111,10 +111,17 @@ public class OrderSubscriberTest : UnitTestBase ## JSON Seed Data +`JsonDataReader` (in `CoreEx.Data.Json`, a transitive dependency — not owned by this package) parses YAML/JSON with `^token` placeholder substitution. Relational providers seed via `Migrate*DataAsync`; Cosmos DB seeds via `CosmosDbBatch.ImportBatchAsync` (in `CoreEx.Cosmos.Extended`, likewise transitive) against the host's own `ICosmosDb`-resolved database: + ```csharp -// Load seed data from embedded YAML with token substitution -var data = await JsonDataReader.ParseYamlAsync("Resources/data.yaml"); -await db.SeedAsync(data); +// SQL Server / PostgreSQL - migrate + seed from an embedded resource +await Test.MigrateSqlServerDataAsync(["data.yaml"]); +await Test.MigratePostgresDataAsync(["data.yaml"]); + +// Cosmos DB - reset/provision a container, then import raw JSON +var database = await Test.GetCosmosDatabaseAsync(); +var container = await database.ReplaceOrCreateContainerAsync("orders", "/customerId"); +await container.ImportBatchAsync(JsonDataReader.ParseYaml("data.yaml"), "Orders"); ``` ## ExecutionContext Scoping diff --git a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj index df931210..d064d242 100644 --- a/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj +++ b/src/CoreEx.UnitTesting/CoreEx.UnitTesting.csproj @@ -3,6 +3,10 @@ CoreEx .NET Unit Testing extensions. Core .NET extensions and abstractions for the testing of backend services. coreex microservices domain-based event-driven railway-oriented reference-data unit-testing integration-testing intra-domain inter-domain mocking mstest xunit nunit unittestex json-data + + true @@ -10,12 +14,13 @@ - + + diff --git a/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs b/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs index 0c29ebcd..7b8732c9 100644 --- a/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs +++ b/src/CoreEx.UnitTesting/Events/EventPublisherDecorator.cs @@ -63,7 +63,18 @@ public class EventPublisherDecorator(string key, TestSharedState testSharedState public void Reset() => _innerEventPublisher.Reset(); /// - public void Rollback(int count) => _innerEventPublisher.Rollback(count); + public void Dequeue(int count) => _innerEventPublisher.Dequeue(count); + + /// + /// Removes this decorator's own captured "published" events from for the current request - called where a surrounding unit-of-work transaction enlisted these + /// events atomically alongside a business mutation (e.g. CosmosDbUnitOfWork), then subsequently failed to actually commit; from the test's perspective, nothing was really published. + public async Task RollbackAsync(CancellationToken cancellationToken = default) + { + var requestId = _sharedState.GetHttpRequestId(); + _sharedState.RequestStateData(requestId).TryRemove(Key, out _); + + await _innerEventPublisher.RollbackAsync(cancellationToken).ConfigureAwait(false); + } /// public DestinationEvent[] GetEvents() => _innerEventPublisher.GetEvents(); diff --git a/src/CoreEx.UnitTesting/GlobalUsing.cs b/src/CoreEx.UnitTesting/GlobalUsing.cs index d08644db..cad63d63 100644 --- a/src/CoreEx.UnitTesting/GlobalUsing.cs +++ b/src/CoreEx.UnitTesting/GlobalUsing.cs @@ -4,7 +4,10 @@ global using CloudNative.CloudEvents; global using CoreEx; global using CoreEx.Azure.Messaging.ServiceBus; +global using CoreEx.Cosmos; +global using CoreEx.Cosmos.Outbox; global using CoreEx.Data; +global using CoreEx.Data.Json; global using CoreEx.Database.Postgres.Outbox; global using CoreEx.Database.SqlServer.Outbox; global using CoreEx.Entities; @@ -25,6 +28,7 @@ global using DbEx.Migration.Data; global using DbEx.Postgres.Migration; global using DbEx.SqlServer.Migration; +global using Microsoft.Azure.Cosmos; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using System.Collections.Concurrent; @@ -40,7 +44,5 @@ global using UnitTestEx.Assertors; global using UnitTestEx.Expectations; global using UnitTestEx.Hosting; -global using YamlDotNet.Core.Events; -global using YamlDotNet.Serialization; global using Asb = Azure.Messaging.ServiceBus; global using ExecutionContext = CoreEx.ExecutionContext; diff --git a/src/CoreEx.UnitTesting/README.md b/src/CoreEx.UnitTesting/README.md index 4f77cf0e..e5a214b0 100644 --- a/src/CoreEx.UnitTesting/README.md +++ b/src/CoreEx.UnitTesting/README.md @@ -1,6 +1,6 @@ # CoreEx.UnitTesting -> Provides the complete CoreEx unit- and integration-testing toolkit: fluent expectations, event-capture assertions, JSON seed-data loading, and convenience extensions that bridge UnitTestEx with every major CoreEx subsystem. +> Provides the complete CoreEx unit- and integration-testing toolkit: fluent expectations, event-capture assertions, and convenience extensions that bridge UnitTestEx with every major CoreEx subsystem. ## Overview @@ -8,7 +8,7 @@ The package extends [UnitTestEx](https://github.com/Avanade/UnitTestEx) — the CoreEx-recommended test host — with CoreEx-specific registration helpers, expectations, and assertion extensions. These operate by injecting an `EventPublisherDecorator` into the DI container so published events are captured during a test run and then asserted after the fact, without touching the real publisher. The one-off setup hook (`UnitTestExOneOffTestSetUp`) wires CoreEx defaults (`JsonDefaults`, `AuthenticationUser`, `ValidationException` error matching) into the UnitTestEx infrastructure automatically when the assembly is loaded. -A companion `Data` child namespace provides `JsonDataReader` — a JSON-to-`JsonNode` bridge that supports parameterised `{{token}}`-style substitutions for generated values (ids, timestamps, tenant/user context) and multiple naming conventions. This makes it straightforward to seed SQL Server or PostgreSQL test databases directly from `data.yaml` or JSON resource files. +`CoreEx.Data.Json`'s `JsonDataReader` — a JSON-to-`JsonNode` bridge that supports parameterised `^token`-style substitutions for generated values (ids, timestamps, tenant/user context) and multiple naming conventions — is a transitive dependency (via `CoreEx.Data`), not owned by this package. It makes it straightforward to seed SQL Server, PostgreSQL, or Cosmos DB test databases directly from `data.yaml` or JSON resource files. ## Motivation @@ -16,7 +16,6 @@ A companion `Data` child namespace provides `JsonDataReader` — a JSON-to-`Json - Keeping test support consolidated maximises discoverability: developers find assertions for events, reference data, outbox patterns, caching, validation, and HTTP all in one place rather than hunting across multiple packages. - Automatic one-off set-up ensures CoreEx defaults (serialization, user context, `ValidationException` error mapping) are applied consistently across all test frameworks (NUnit, xUnit, MSTest) without manual wiring. - `EventPublisherDecorator` allows event expectations to be declared and asserted against the real outbox/publisher pipeline without requiring live infrastructure. -- `JsonDataReader` enables repeatable, parameterised seed data from JSON/YAML resources rather than hard-coded insert statements. ## Key capabilities @@ -28,7 +27,6 @@ A companion `Data` child namespace provides `JsonDataReader` — a JSON-to-`Json - 🔧 **Broad extension surface**: `UnitTestExExtensions` adds `Scoped`/`ExecutionContext` scoping helpers, `CreateCloudEventFrom` event builders, `AssertProblemDetails` HTTP response assertions, `ClearFusionCacheAsync` cache reset, and validator-level `AssertSuccess`/`AssertErrors` shortcuts directly on `TesterBase` and `IValidator`. - 📝 **Validation shortcuts**: `AssertSuccess` and `AssertErrors` extension methods execute a `CoreEx.Validation.IValidator` and assert the outcome inline, using AwesomeAssertions for readable failure messages. - 🔄 **FusionCache test reset**: `ClearFusionCacheAsync` clears the registered `IFusionCache` between test runs to prevent state bleed in cached reference-data or other cache-backed scenarios. -- 📊 **JSON/YAML seed-data loading**: `JsonDataReader` parses JSON or YAML into a `JsonNode` tree and deep-copies it with `^token` and `(^token)` placeholder substitution, returning a fully resolved `JsonNode` (or directly deserialised value) ready to seed a test database or drive request bodies. Built-in tokens cover `^id`, `^now`, `^tenant_id`, `^user_name`, and more; custom tokens are registered on `JsonDataReaderOptions.Parameters`. ## Key types @@ -42,16 +40,17 @@ A companion `Data` child namespace provides `JsonDataReader` — a JSON-to-`Json | Namespace | Description | Documentation | |-----------|-------------|---------------| -| **`CoreEx.UnitTesting.Data`** | `JsonDataReader` — a hierarchical mutating reader that parses JSON or YAML and deep-copies the `JsonNode` tree with `^token` placeholder substitution; consumers use the resolved node (or `Deserialize`) to seed databases or build request payloads. | [📖 README](./Data/README.md) | | **`CoreEx.UnitTesting.Events`** | `EventExpectationsConfig`, `EventExpectationAssertor`, `EventExpectations`, and `EventPublisherDecorator`; the infrastructure for capturing and asserting published events in tests. | [📖 README](./Events/README.md) | ## Related namespaces - **[`CoreEx`](../CoreEx/README.md)** - Provides `ExecutionContext`, `ValidationException`, `EventData`, and `JsonDefaults` wired in by `UnitTestExOneOffTestSetUp`. +- **[`CoreEx.Data`](../CoreEx.Data/Json/README.md)** - `JsonDataReader` (in the `CoreEx.Data.Json` namespace) — a transitive dependency, not owned by this package — parses JSON/YAML with `^token` placeholder substitution to seed SQL Server, PostgreSQL, or Cosmos DB test databases directly from `data.yaml`/JSON resource files. - **[`CoreEx.Events`](../CoreEx.Events/README.md)** - Defines `IEventPublisher` and `EventData` that `EventPublisherDecorator` wraps for event capture. - **[`CoreEx.Azure.Messaging.ServiceBus`](../CoreEx.Azure.Messaging.ServiceBus/README.md)** - `ServiceBusPublisher` whose service key is targeted by `ExpectAzureServiceBusEvents`. -- **[`CoreEx.Database.SqlServer`](../CoreEx.Database.SqlServer/README.md)** - `SqlServerOutboxPublisher` whose service key is targeted by `ExpectSqlServerOutboxEvents`; `JsonDataReader` seeds SQL Server test databases. -- **[`CoreEx.Database.Postgres`](../CoreEx.Database.Postgres/README.md)** - `PostgresOutboxPublisher` whose service key is targeted by `ExpectPostgresOutboxEvents`; `JsonDataReader` seeds PostgreSQL test databases. +- **[`CoreEx.Database.SqlServer`](../CoreEx.Database.SqlServer/README.md)** - `SqlServerOutboxPublisher` whose service key is targeted by `ExpectSqlServerOutboxEvents`. +- **[`CoreEx.Database.Postgres`](../CoreEx.Database.Postgres/README.md)** - `PostgresOutboxPublisher` whose service key is targeted by `ExpectPostgresOutboxEvents`. +- **[`CoreEx.Cosmos`](../CoreEx.Cosmos/README.md)** - `CosmosDbBatch`/`CosmosDbContainerExtensions` (in the `CoreEx.Cosmos.Extended` namespace) — a transitive dependency, not owned by this package — provision/reset a Cosmos DB container and import raw JSON for test seeding. - **[`CoreEx.Caching.FusionCache`](../CoreEx.Caching.FusionCache/README.md)** - `IFusionCache` implementation cleared by `ClearFusionCacheAsync`. - **[`CoreEx.Validation`](../CoreEx.Validation/README.md)** - `IValidator` and `ValidationException` targeted by the `AssertSuccess`/`AssertErrors` and one-off setup extensions. @@ -59,7 +58,6 @@ A companion `Data` child namespace provides `JsonDataReader` — a JSON-to-`Json - [UnitTestEx](https://github.com/Avanade/UnitTestEx) - The underlying test-host framework that `CoreEx.UnitTesting` extends; provides `TesterBase`, `ApiTester`, `GenericTester`, `IExpectations`, and the framework-agnostic assertion infrastructure. - [AwesomeAssertions](https://github.com/AwesomeAssertions/AwesomeAssertions) - Fluent assertion library used by the validation shortcuts and internal assertion helpers. -- [YamlDotNet](https://github.com/aaubry/YamlDotNet) - Used internally to parse `data.yaml` seed files before they are handed to `JsonDataReader`. ## AI Usage Guide diff --git a/src/CoreEx.UnitTesting/UnitTestExExpectations.Cosmos.cs b/src/CoreEx.UnitTesting/UnitTestExExpectations.Cosmos.cs new file mode 100644 index 00000000..6ffccadd --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExpectations.Cosmos.cs @@ -0,0 +1,30 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx.Expectations; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +/// +/// Provides -specific extension methods to . +/// +public static partial class UnitTestExExpectations +{ + /// + /// Expects that no events will have been published for the keyed ( defaults to ). + /// + /// The tester. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectNoCosmosDbOutboxEvents(this IExpectations tester, string serviceKey = CosmosDbEventPublisher.DefaultServiceKey) where TSelf : IExpectations + => ExpectNoEvents(tester, serviceKey); + + /// + /// Expects that events will have been published for the keyed ( defaults to ). + /// + /// The tester. + /// The action to enable events expectations configuration. + /// The service key used for the keyed registration. + /// The instance to support fluent-style method-chaining. + /// The must be the same as used when registering the underlying . + public static TSelf ExpectCosmosDbOutboxEvents(this IExpectations tester, Action? configure = null, string serviceKey = CosmosDbEventPublisher.DefaultServiceKey) where TSelf : IExpectations + => ExpectEvents(tester, serviceKey, configure, Assembly.GetCallingAssembly()); +} diff --git a/src/CoreEx.UnitTesting/UnitTestExExtensions.Cosmos.cs b/src/CoreEx.UnitTesting/UnitTestExExtensions.Cosmos.cs new file mode 100644 index 00000000..7e8ed1ce --- /dev/null +++ b/src/CoreEx.UnitTesting/UnitTestExExtensions.Cosmos.cs @@ -0,0 +1,53 @@ +#pragma warning disable IDE0130 // Namespace does not match folder structure; by design. +namespace UnitTestEx; +#pragma warning restore IDE0130 // Namespace does not match folder structure + +public static partial class UnitTestExExtensions +{ + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions. + /// + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// The to support fluent-style method-chaining. + /// This is a convenience method that defaults the to where invoking the underlying . + /// The when set to will bypass the pass-through to the original event publisher and leverage the instead. + public static IServiceCollection UseExpectedCosmosDbOutboxPublisher(this IServiceCollection services, string serviceKey = CosmosDbEventPublisher.DefaultServiceKey, bool bypassPassThrough = false) + => UseExpectedEventPublisher(services, serviceKey, bypassPassThrough); + + /// + /// Replaces the registered with a decorator () that also captures the published events for expectation assertions; whilst also adding post-run expectations for the captured events. + /// + /// The API startup . + /// The . + /// The service key for the previously registered . + /// Indicates whether to bypass the pass-through to the original event publisher. + /// Indicates whether to expect no events to be published. + /// The instance to support fluent-style method-chaining. + /// The parameter is only actioned when no explicit event expectations are defined for the underlying test; acts as a catch all. + public static AspNetCore.ApiTester UseExpectedCosmosDbOutboxPublisher(this AspNetCore.ApiTester tester, string serviceKey = CosmosDbEventPublisher.DefaultServiceKey, bool bypassPassThrough = false, bool expectNoEvents = true) where TEntryPoint : class + => tester.ConfigureServices(services => services.UseExpectedCosmosDbOutboxPublisher(serviceKey, bypassPassThrough)) + .AddEventExpectationsPostRun(serviceKey, expectNoEvents); + + /// + /// Gets the resolved from the running host's own registration, ensuring it exists (see ). + /// + /// The . + /// The . + /// The . + /// Deliberately resolves from (the actual running host's DI container) rather than constructing a new, test-owned + /// from configuration - unlike a relational connection string, a Cosmos DB database identifier is typically a host-owned literal (e.g. services.AddCosmosDb<TCosmosDb>("contoso")), not + /// something re-derivable from configuration alone; resolving the host's own guarantees test seeding always targets the exact same database the host itself reads/writes, + /// structurally ruling out a test/host database-name mismatch rather than merely avoiding it by convention. + public static async Task GetCosmosDatabaseAsync(this TesterBase tester, CancellationToken cancellationToken = default) + { + // ICosmosDb is registered scoped (see CoreExCosmosExtensions.AddCosmosDb), so it cannot be resolved directly from the host's root IServiceProvider - a short-lived scope is created purely to + // resolve it; the underlying CosmosClient it wraps is independently DI-registered (typically as a singleton via Aspire's AddAzureCosmosClient), so it, and the Database reference obtained from + // it, remain perfectly usable after this scope is disposed. + using var scope = tester.ThrowIfNull().Services.CreateScope(); + var cosmosDb = scope.ServiceProvider.GetRequiredService(); + await cosmosDb.Client.CreateDatabaseIfNotExistsAsync(cosmosDb.Database.Id, cancellationToken: cancellationToken).ConfigureAwait(false); + return cosmosDb.Database; + } +} diff --git a/src/CoreEx/Data/IReadOnlyPartitionKey.cs b/src/CoreEx/Data/IReadOnlyPartitionKey.cs index ac8e1f99..bd8733d1 100644 --- a/src/CoreEx/Data/IReadOnlyPartitionKey.cs +++ b/src/CoreEx/Data/IReadOnlyPartitionKey.cs @@ -3,6 +3,11 @@ namespace CoreEx.Data; /// /// Enables a read-only . /// +/// A partition key's intended meaning is layer/purpose-specific, not invariant - e.g. a Cosmos DB physical partition/shard key (chosen for storage distribution and RU throughput) and an +/// event's ordering/session key (chosen so related events are processed in order) are routinely different values for the same logical entity. Because of this, 's +/// standard property mapping deliberately does not auto-copy between a source and destination (unlike, say, ) - set it +/// deliberately at each destination instead (e.g. CosmosDbModelOptions{TModel}.WithPartitionKey/WithFixedPartitionKey for Cosmos, or explicitly on the contract/EventData for +/// events) rather than implementing this interface on a shared type and relying on it flowing through automatically. public interface IReadOnlyPartitionKey { /// diff --git a/src/CoreEx/Data/IReadOnlyTimeToLive.cs b/src/CoreEx/Data/IReadOnlyTimeToLive.cs new file mode 100644 index 00000000..7386f4e5 --- /dev/null +++ b/src/CoreEx/Data/IReadOnlyTimeToLive.cs @@ -0,0 +1,19 @@ +namespace CoreEx.Data; + +/// +/// Enables a read-only time-to-live (TTL) capability. +/// +public interface IReadOnlyTimeToLive +{ + /// + /// Gets the number of seconds until the item expires. + /// + /// A value indicates that the item will not expire via this mechanism. The exact expiry semantics (e.g. relative to last-modified time, whether -1/0 carry special + /// meaning, whether a store-level default applies where unspecified) are store-specific — see the implementing store's own documentation for the precise behaviour. + /// This is a relative seconds value, chosen because it maps directly onto Cosmos DB's reserved ttl system property (also relative — re-evaluated from the document's last-modified time on + /// every write). It is not a direct field-level fit for MongoDB's TTL-index mechanism, which requires an indexed Date field: a per-document variable expiry in MongoDB needs an absolute + /// expiry instant (an index with expireAfterSeconds: 0 over a `Date` field holding that instant), not a relative seconds count. A MongoDB implementation of this interface is expected to translate + /// into an absolute expiry (e.g. DateTime.UtcNow.AddSeconds(value)) at write time and persist that into whatever field its TTL index targets, rather than storing the relative + /// value verbatim — the same kind of per-store reinterpretation already expected of (a single opaque value with a different physical meaning per store). + int? TimeToLive { get; } +} diff --git a/src/CoreEx/Data/ITimeToLive.cs b/src/CoreEx/Data/ITimeToLive.cs new file mode 100644 index 00000000..b8a0efc6 --- /dev/null +++ b/src/CoreEx/Data/ITimeToLive.cs @@ -0,0 +1,16 @@ +namespace CoreEx.Data; + +/// +/// Enables a mutable time-to-live (TTL) capability. +/// +public interface ITimeToLive : IReadOnlyTimeToLive +{ + /// + int? IReadOnlyTimeToLive.TimeToLive => TimeToLive; + + /// + /// Gets or sets the number of seconds until the item expires. + /// + /// See for expiry semantics, which are store-specific. + new int? TimeToLive { get; set; } +} diff --git a/src/CoreEx/Data/Model.cs b/src/CoreEx/Data/Model.cs index 831476a9..7bba0252 100644 --- a/src/CoreEx/Data/Model.cs +++ b/src/CoreEx/Data/Model.cs @@ -84,8 +84,13 @@ public static class Model if (model is null || model is not ITypeDiscriminator td) return model; - if (string.IsNullOrEmpty(typeDiscriminator) && Schema.TryGetMetadata(out var metadata)) + if (string.IsNullOrEmpty(typeDiscriminator)) + { + // TryGetMetadata's out-param is always populated with a sensible default (SchemaAttribute.Name, or the type name where no attribute exists); + // its bool return only indicates whether an actual [Schema] attribute was found, so it must not gate use of the defaulted Name here. + Schema.TryGetMetadata(out var metadata); typeDiscriminator = metadata.Name; + } td.TypeDiscriminator = typeDiscriminator; return model; diff --git a/src/CoreEx/GlobalUsing.cs b/src/CoreEx/GlobalUsing.cs index 28a4372a..550baa7b 100644 --- a/src/CoreEx/GlobalUsing.cs +++ b/src/CoreEx/GlobalUsing.cs @@ -34,6 +34,9 @@ global using OpenTelemetry; global using OpenTelemetry.Metrics; global using OpenTelemetry.Trace; +global using Polly; +global using Polly.CircuitBreaker; +global using Polly.Retry; global using System.Buffers; global using System.Buffers.Binary; global using System.Collections; diff --git a/src/CoreEx/Hosting/CircuitBreakerResiliency.cs b/src/CoreEx/Hosting/CircuitBreakerResiliency.cs new file mode 100644 index 00000000..4bb5e024 --- /dev/null +++ b/src/CoreEx/Hosting/CircuitBreakerResiliency.cs @@ -0,0 +1,103 @@ +namespace CoreEx.Hosting; + +/// +/// Provides a reusable, generic circuit breaker factory for any that exposes pause/resume semantics (e.g. a message receiver or a +/// change-feed processor wrapper), protecting it from a sustained run of failures by automatically pausing it for an increasing backoff, then automatically resuming to re-test recovery. +/// +/// The owning to be paused/resumed when the circuit breaker trips. +/// Originally introduced within CoreEx.Azure.Messaging.ServiceBus for its ServiceBusReceiverBase, and promoted here so that other push/callback-driven processors with the same +/// "the SDK owns the loop, we own start/pause/resume/stop" shape (e.g. a Cosmos DB change feed processor) can share the exact same self-pause/self-resume behaviour rather than each re-implementing it. +public static class CircuitBreakerResiliency +{ + /// + /// Creates a standardized circuit-breaker that automatically pauses and resumes the owning in response to a sustained run of failures, + /// then automatically recovers. + /// + /// A short, human-readable description of used only for log messages (e.g. "Service bus receiver", "Cosmos DB change feed processor"). + /// Accessor for the owning 's . + /// Invoked to pause the owner when the breaker trips; receives the computed pause duration (for logging/messaging purposes). + /// Invoked to resume the owner once the pause duration has elapsed. + /// An optional additional predicate a failing must satisfy to count towards tripping the breaker (e.g. to exclude an error type that is already handled + /// elsewhere and should not itself pause the owner). Defaults to (every failure counts). + /// The . + /// The . + /// The initial duration for which the circuit breaker remains open before attempting to reset (exponentially increasing with each subsequent open). + /// The maximum duration for which the circuit breaker can remain open. + /// The . + /// A configured instance. + /// The default settings are: minimumThroughput = 5, samplingDuration = 30s, breakDuration = 15s, maxBreakDuration = 5m, failureRatio = 0.1. + /// The caller is responsible for flowing the owning instance into the via before + /// executing the pipeline. + public static ResiliencePipeline Create(string ownerName, Func logger, Func pauseAsync, Func resumeAsync, + Func? shouldHandle = null, int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null, TimeSpan? maxBreakDuration = null, double failureRatio = 0.1) + { + var circuitBreakerOpens = 0; + + samplingDuration ??= TimeSpan.FromSeconds(30); + breakDuration ??= TimeSpan.FromSeconds(15); + maxBreakDuration ??= TimeSpan.FromMinutes(5); + + return new ResiliencePipelineBuilder() + .AddCircuitBreaker(new CircuitBreakerStrategyOptions() + { + ShouldHandle = args => ValueTask.FromResult(args.Outcome.Result.IsFailure && (shouldHandle?.Invoke(args.Outcome.Result) ?? true)), + MinimumThroughput = minimumThroughput, + SamplingDuration = samplingDuration.Value, + FailureRatio = failureRatio, + BreakDurationGenerator = args => + { + // Exponential backoff on each open, similar to: 15s, 30s, 60s, ... with a cap at maxBreakDuration (the default: 5 minutes). + var n = Interlocked.Increment(ref circuitBreakerOpens); + var seconds = Math.Min(breakDuration.Value.TotalSeconds * Math.Pow(2, n - 1), maxBreakDuration.Value.TotalSeconds); + return ValueTask.FromResult(TimeSpan.FromSeconds(seconds)); + }, + OnOpened = args => + { + // Breaker is open; pause the owner. + var owner = ResilienceOwner.GetOwner(args.Context); + var ownerLogger = logger(owner); + var pause = args.BreakDuration.Add(TimeSpan.FromMilliseconds(100)); // Add a small buffer to ensure the breaker has fully opened before resuming. + + if (ownerLogger.IsEnabled(LogLevel.Warning)) + ownerLogger.LogWarning("{OwnerName} circuit breaker has been tripped for {BreakDuration}ms due to unhandled errors; will be paused.", ownerName, args.BreakDuration.TotalMilliseconds); + + _ = Task.Run(async () => + { + try + { + await pauseAsync(owner, pause, default).ConfigureAwait(false); + await Task.Delay(pause).ConfigureAwait(false); + await resumeAsync(owner, default).ConfigureAwait(false); + } + catch (Exception ex) + { + // This pause/resume is the circuit breaker's own protective mechanism; a failure here must not be silently lost as an unobserved task exception. + if (ownerLogger.IsEnabled(LogLevel.Error)) + ownerLogger.LogError(ex, "{OwnerName} circuit breaker pause/resume failed; it may not have been paused/resumed as expected.", ownerName); + } + }); + + return ValueTask.CompletedTask; + }, + OnHalfOpened = args => + { + var ownerLogger = logger(ResilienceOwner.GetOwner(args.Context)); + if (ownerLogger.IsEnabled(LogLevel.Information)) + ownerLogger.LogInformation("{OwnerName} circuit breaker is attempting to recover in a limited state; has been resumed.", ownerName); + + return ValueTask.CompletedTask; + }, + OnClosed = args => + { + var ownerLogger = logger(ResilienceOwner.GetOwner(args.Context)); + if (ownerLogger.IsEnabled(LogLevel.Information)) + ownerLogger.LogInformation("{OwnerName} circuit breaker has fully recovered; is running.", ownerName); + + // Reset after recovery. + Interlocked.Exchange(ref circuitBreakerOpens, 0); + return ValueTask.CompletedTask; + } + }) + .Build(); + } +} diff --git a/src/CoreEx/Hosting/README.md b/src/CoreEx/Hosting/README.md index 878617d4..22b51d1b 100644 --- a/src/CoreEx/Hosting/README.md +++ b/src/CoreEx/Hosting/README.md @@ -4,9 +4,9 @@ ## Overview -`CoreEx.Hosting` addresses two distinct but related concerns: running background work reliably within an .NET hosted-service model, and tracking the state of long-running distributed operations that outlive a single request or host invocation. +`CoreEx.Hosting` addresses three distinct but related concerns: running background work reliably within an .NET hosted-service model, protecting a push/callback-driven processor (an external SDK owns the delivery loop, not a timer we control) from a sustained run of failures, and tracking the state of long-running distributed operations that outlive a single request or host invocation. -The hosted service base classes (`TimerHostedServiceBase`, `SynchronizedTimerHostedServiceBase`) provide a consistent foundation for background polling workers — handling pause/resume, error-interval back-off, no-op mode for tests, DI-scoped execution, and health check integration via `HostedServiceHealthCheck`. `HostSettings` standardizes how a host, including ASP.NET Core, exposes its solution name, domain name, environment, and source URI to the rest of the application. +The hosted service base classes (`TimerHostedServiceBase`, `SynchronizedTimerHostedServiceBase`) provide a consistent foundation for background polling workers — handling pause/resume, error-interval back-off, no-op mode for tests, DI-scoped execution, and health check integration via `HostedServiceHealthCheck`. `CircuitBreakerResiliency`/`RetryResiliency` are for the *other* shape of hosted service — one that doesn't drive its own poll loop (e.g. an Azure Service Bus `ServiceBusProcessor` or a Cosmos DB Change Feed Processor keeps calling the handler regardless of failure) - self-pausing/self-resuming (circuit breaker) or retrying a caller-classified subset of failures (retry), for any owner that exposes pause/resume semantics. `HostSettings` standardizes how a host, including ASP.NET Core, exposes its solution name, domain name, environment, and source URI to the rest of the application. `WorkOrchestrator` tracks the lifecycle of explicitly managed long-running work items (think: async job processing, background import, orchestration hand-off). Each work item has a `WorkState` record persisted via a pluggable `IWorkProvider`, with a `WorkStatus` lifecycle (Created → Started → Indeterminate/Completed/Failed/Abandoned) and automatic expiry. @@ -19,6 +19,9 @@ The hosted service base classes (`TimerHostedServiceBase`, `SynchronizedTimerHos - 📋 **Work orchestration**: `WorkOrchestrator` manages the create → start → complete/fail lifecycle for long-running work items, with automatic expiry and pluggable `IWorkProvider` storage. - 🗄️ **Cache-backed work provider**: `HybridCacheWorkProvider` implements `IWorkProvider` using `IHybridCache`, enabling work state persistence without requiring a dedicated database. - 🔗 **Distributed synchronization**: `ISynchronizer` / `HybridCacheSynchronizer` provide a distributed advisory lock used by `SynchronizedTimerHostedServiceBase` to prevent concurrent execution across host replicas. +- 🛡️ **Self-pausing circuit breaker**: `CircuitBreakerResiliency.Create(...)` builds a Polly `ResiliencePipeline` that pauses (with exponential backoff) then self-resumes any owner exposing pause/resume methods, once a sustained failure ratio is observed - for a push/callback-driven processor where an external SDK owns the delivery loop (an ASP.NET Core hosted service can't otherwise control its own retry cadence the way `TimerHostedServiceBase` can). Originally implemented for `CoreEx.Azure.Messaging.ServiceBus`'s `ServiceBusReceiverBase`, promoted here so `CoreEx.Cosmos`'s Change Feed Processor-based outbox relay (and any future processor with the same shape) shares the exact same behaviour. +- 🔁 **Classified-failure retry**: `RetryResiliency.Create(shouldHandle, ...)` builds a bounded retry-with-backoff pipeline for a caller-supplied subset of failures (no "retry everything" default - unlike the circuit breaker, blindly retrying an unclassified failure risks retrying one retrying can never fix). Promoted alongside the circuit breaker for the same reason. +- 🔑 **Shared owner-context plumbing**: `ResilienceOwner` flows an owning instance through a Polly `ResilienceContext` so `CircuitBreakerResiliency`/`RetryResiliency` callbacks can resolve it back out (`PropertyKey`/`GetOwner`) - the plumbing both share, split out since it isn't circuit-breaker- or retry-specific. ## Key types @@ -39,9 +42,14 @@ The hosted service base classes (`TimerHostedServiceBase`, `SynchronizedTimerHos | [`IHostSettings`](./IHostSettings.cs) | Interface exposing `SolutionName`, `DomainName`, `EnvironmentName`, and `Source` from `HostSettings`. | | [`IWorkProvider`](./Work/IWorkProvider.cs) | Pluggable storage interface for `WorkState` persistence: get, create, update. | | [`ISynchronizer`](./Synchronization/ISynchronizer.cs) | Distributed advisory lock interface: `EnterAsync` / `ExitAsync` for type-and-name-scoped locking. | +| **[`CircuitBreakerResiliency`](./CircuitBreakerResiliency.cs)** | Generic self-pausing/self-resuming Polly `ResiliencePipeline` factory for any owner exposing pause/resume methods; used by `CoreEx.Azure.Messaging.ServiceBus` and `CoreEx.Cosmos`'s outbox relay. | +| **[`RetryResiliency`](./RetryResiliency.cs)** | Generic bounded retry-with-backoff Polly `ResiliencePipeline` factory for a caller-classified subset of failures; no "retry everything" default. | +| **[`ResilienceOwner`](./ResilienceOwner.cs)** | Flows an owning `TOwner` instance through a Polly `ResilienceContext` (`PropertyKey`/`GetOwner`) so `CircuitBreakerResiliency`/`RetryResiliency` callbacks can resolve it. | ## Related Namespaces - **[`CoreEx`](../README.md)** - `ExecutionContext` is created per hosted service invocation; `HostSettings` is consumed by `DefaultCacheKeyProvider` and event naming. - **[`CoreEx.Caching`](../Caching/README.md)** - `IHybridCache` is the backing store for both `HybridCacheWorkProvider` and `HybridCacheSynchronizer`. -- **[`CoreEx.Invokers`](../Invokers/README.md)** - `HostedServiceInvoker` and `WorkOrchestratorInvoker` use the invoker tracing pipeline to emit OpenTelemetry spans for hosted service and work executions. \ No newline at end of file +- **[`CoreEx.Invokers`](../Invokers/README.md)** - `HostedServiceInvoker` and `WorkOrchestratorInvoker` use the invoker tracing pipeline to emit OpenTelemetry spans for hosted service and work executions. +- **[`CoreEx.Azure.Messaging.ServiceBus`](../../CoreEx.Azure.Messaging.ServiceBus/README.md)** - `ServiceBusReceiverResiliency` is now a thin wrapper supplying the service-bus-specific pause-reason/dead-letter-exclusion/retry-classification wiring over `CircuitBreakerResiliency`/`RetryResiliency`. +- **[`CoreEx.Cosmos`](../../CoreEx.Cosmos/README.md)** - `CosmosDbOutboxRelayResiliency` mirrors the same delegation for the Change Feed Processor-based outbox relay. \ No newline at end of file diff --git a/src/CoreEx/Hosting/ResilienceOwner.cs b/src/CoreEx/Hosting/ResilienceOwner.cs new file mode 100644 index 00000000..f4809dc8 --- /dev/null +++ b/src/CoreEx/Hosting/ResilienceOwner.cs @@ -0,0 +1,21 @@ +namespace CoreEx.Hosting; + +/// +/// Provides the means to flow an owning instance through a so it can be resolved from within a 's callbacks +/// (see and ). +/// +/// The owning . +public static class ResilienceOwner +{ + /// + /// Gets the used to flow the owning instance through a . The caller executing a pipeline is + /// responsible for setting it, e.g. context.Properties.Set(ResilienceOwner<TOwner>.PropertyKey, owner), before executing the pipeline. + /// + public static ResiliencePropertyKey PropertyKey { get; } = new(typeof(TOwner).FullName ?? typeof(TOwner).Name); + + /// + /// Gets the owning instance from the (previously set via ). + /// + /// The . + public static TOwner GetOwner(ResilienceContext context) => context.Properties.GetValue(PropertyKey, default!); +} diff --git a/src/CoreEx/Hosting/RetryResiliency.cs b/src/CoreEx/Hosting/RetryResiliency.cs new file mode 100644 index 00000000..17c90e84 --- /dev/null +++ b/src/CoreEx/Hosting/RetryResiliency.cs @@ -0,0 +1,45 @@ +namespace CoreEx.Hosting; + +/// +/// Provides a reusable, generic retry factory for any , retrying a bounded number of times (with backoff) for failures that satisfy a +/// caller-supplied predicate, before allowing the failure through. +/// +/// The owning (used only to log each retry attempt via its ). +/// Originally introduced within CoreEx.Azure.Messaging.ServiceBus for its ServiceBusReceiverBase (retrying an in-process message handler on EventSubscriberRetryException), and +/// promoted here alongside so other owners (e.g. a Cosmos DB change feed processor) can reuse the same bounded, classified-failure retry pattern. +public static class RetryResiliency +{ + /// + /// Creates a standardized with retry capabilities for a caller-classified subset of failures. + /// + /// The predicate a failing must satisfy to be retried (e.g. a specific, known-transient exception type); a failure that does not satisfy this is never + /// retried and is allowed straight through. Unlike , there is no "retry everything" default - blindly retrying an unclassified failure risks retrying one + /// that retrying can never fix, so the caller must always specify what is worth retrying. + /// Accessor for the owning 's , used to log each retry attempt. + /// The delay between retry attempts. + /// The maximum number of retry attempts. + /// The strategy. + /// A configured instance. + /// The caller is responsible for flowing the owning instance into the via before + /// executing the pipeline. + public static ResiliencePipeline Create(Func shouldHandle, Func logger, TimeSpan? delay = null, int maxRetryAttempts = 3, DelayBackoffType backoffType = DelayBackoffType.Exponential) + { + return new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions() + { + ShouldHandle = args => ValueTask.FromResult(args.Outcome.Result.IsFailure && shouldHandle(args.Outcome.Result)), + Delay = delay ?? TimeSpan.FromSeconds(2), + MaxRetryAttempts = maxRetryAttempts, + BackoffType = backoffType, + OnRetry = args => + { + var ownerLogger = logger(ResilienceOwner.GetOwner(args.Context)); + if (ownerLogger.IsEnabled(LogLevel.Information)) + ownerLogger.LogInformation("Retry attempt {AttemptCount} in {AttemptDelay}ms.", args.AttemptNumber + 1, args.RetryDelay.TotalMilliseconds); + + return ValueTask.CompletedTask; + } + }) + .Build(); + } +} diff --git a/src/CoreEx/Hosting/TimerHostedServiceBase.cs b/src/CoreEx/Hosting/TimerHostedServiceBase.cs index a30efe27..e61b8bb6 100644 --- a/src/CoreEx/Hosting/TimerHostedServiceBase.cs +++ b/src/CoreEx/Hosting/TimerHostedServiceBase.cs @@ -30,32 +30,62 @@ public abstract class TimerHostedServiceBase : HostedServiceBase /// Gets or sets the first timer start interval. /// /// Defaults to . This is used as a maximum, in that the actual start is determined using a random value up to this value to ensure staggering of execution where multiple hosts are triggered at the same time. - public TimeSpan FirstInterval { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + public TimeSpan FirstInterval { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } /// /// Gets or sets the timer interval . /// /// Defaults to 500 milliseconds. - public TimeSpan Interval { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } = TimeSpan.FromMilliseconds(500); + public TimeSpan Interval { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } = TimeSpan.FromMilliseconds(500); /// /// Gets or sets the timer start interval after an unhandled that occurs during the execution where is . /// /// Defaults to . - public TimeSpan OnUnhandledInterval { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } + public TimeSpan OnUnhandledInterval { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } /// /// Indicates whether to automatically halt the service on an unhandled that occurs during the execution of the method. /// /// indicates that the service should be ; otherwise, indicates to continue executing after the next interval. /// Defaults to . - public bool PauseOnUnhandledException { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value); } = true; + public bool PauseOnUnhandledException { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } = true; + + /// + /// Gets or sets the used to protect each tick with a self-pausing/self-resuming circuit breaker. + /// + /// Defaults to - opt-in, so an existing subclass sees no behavior change unless it explicitly sets this. When set, a tick's failure is observed by the pipeline (and, + /// on a sustained ratio, pauses this instance via its own already-working / - + /// no new state machine is needed) rather than being handled by /, which remains available purely as a fallback for + /// something unexpected outside the wrapped call. See for a convenience factory. + public ResiliencePipeline? Resiliency { get; set => field = SetValueWhenStatusIsInitializedOnly(value); } + + /// + /// Indicates whether a subclass applies itself, at its own granularity, from within . + /// + /// Defaults to (the generic per-tick wrap applies , when set). A subclass that applies it at a finer granularity itself (e.g. per work + /// item within a tick, rather than per tick) should override this to return - otherwise the same failures would be recorded twice into one pipeline's sliding window + /// (once per finer-grained attempt, once for the whole tick), diluting the failure ratio the finer-grained wrapping is meant to observe. + protected virtual bool IsSelfApplyingResiliency => false; + + /// + /// Creates the default-shaped pipeline (wired to this instance's own pause/resume), with caller-overridable thresholds. + /// + /// The owner => owner.Logger wiring can only be written inside this class hierarchy, since is protected - this is why the factory lives here + /// rather than as an external static class. + protected static ResiliencePipeline CreateDefaultResiliency(int minimumThroughput = 5, TimeSpan? samplingDuration = null, TimeSpan? breakDuration = null, TimeSpan? maxBreakDuration = null, double failureRatio = 0.1) + => CircuitBreakerResiliency.Create( + "Timer hosted service", + owner => owner.Logger, + (owner, pause, ct) => owner.PauseAsync(ct), + (owner, ct) => owner.ResumeAsync(ct), + null, minimumThroughput, samplingDuration, breakDuration, maxBreakDuration, failureRatio); /// /// Gets or sets the maximum number of consecutive immediate executions (i.e. without an interval) before forcing a sleep interval. /// /// Defaults to 100. This is a safety mechanism to prevent runaway execution where the method continually returns indicating to execute immediately without an interval. - public int MaxConsecutiveExecutions { get => field; set => field = SetValueWhenStatusIsInitializedOnly(value.ThrowIfLessThanOrEqualToZero()); } = 100; + public int MaxConsecutiveExecutions { get; set => field = SetValueWhenStatusIsInitializedOnly(value.ThrowIfLessThanOrEqualToZero()); } = 100; /// /// Gets the last execution . @@ -233,7 +263,44 @@ await HostedServiceInvoker.InvokeAsync(this, async (_, cancellationToken) => // Execute the work! try { - immediate = await OnExecuteAsync(ec, cancellationToken).ConfigureAwait(false); + if (Resiliency is null || IsSelfApplyingResiliency) + { + immediate = await OnExecuteAsync(ec, cancellationToken).ConfigureAwait(false); + } + else + { + // Where configured, a tick's failure is observed by the resiliency pipeline (and, on a sustained ratio, self-pauses this instance) rather than by OnUnhandledException - + // swallowed here (not passed to ExceptionHandling) so the two mechanisms don't both react to the same failure. + var context = ResilienceContextPool.Shared.Get(cancellationToken); + try + { + context.Properties.Set(ResilienceOwner.PropertyKey, this); + + var result = await Resiliency.ExecuteAsync(async rc => + { + try + { + immediate = await OnExecuteAsync(ec, rc.CancellationToken).ConfigureAwait(false); + return Result.Success; + } + catch (Exception ex) when (!ex.IsCanceled()) + { + return Result.Fail(ex); + } + }, context).ConfigureAwait(false); + + if (result.IsFailure) + { + immediate = false; + if (Logger.IsEnabled(LogLevel.Error)) + Logger.LogError(result.Error, "{ServiceName} execution failed (observed by circuit breaker): {Error}", ServiceName, result.Error?.Message); + } + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } } catch (Exception ex) { @@ -516,4 +583,4 @@ protected override void Dispose(bool disposing) /// to re-execute after the configured . /// Warning: this is intended for advanced scenarios, such as testing, and improper usage may result in unexpected behavior. public async Task OneOffExecuteAsync(ExecutionContext executionContext, CancellationToken cancellationToken) => await OnExecuteAsync(executionContext, cancellationToken).ConfigureAwait(false); -} \ No newline at end of file +} diff --git a/src/CoreEx/Invokers/InvokerTracer.cs b/src/CoreEx/Invokers/InvokerTracer.cs index 7a715d5b..664aac0c 100644 --- a/src/CoreEx/Invokers/InvokerTracer.cs +++ b/src/CoreEx/Invokers/InvokerTracer.cs @@ -167,7 +167,6 @@ internal readonly void TraceComplete(bool isSuccess) Activity.SetTag(InvokerResultName, CompleteStateText); Invoker.OnActivityComplete(this); Activity.SetStatus(ActivityStatusCode.Ok); - Activity.Stop(); } if (Logger?.IsEnabled(LogLevel.Debug) ?? false) @@ -177,6 +176,7 @@ internal readonly void TraceComplete(bool isSuccess) } } + // Sole Activity.Stop() call for both the success (above) and error/exception (TraceException, which does not stop) paths. Activity?.Stop(); } diff --git a/src/CoreEx/Mapping/Mapper.cs b/src/CoreEx/Mapping/Mapper.cs index bc0f6a5d..6cef98b1 100644 --- a/src/CoreEx/Mapping/Mapper.cs +++ b/src/CoreEx/Mapping/Mapper.cs @@ -19,12 +19,15 @@ public static class Mapper /// -> /// -> /// -> - /// -> /// -> /// -> /// -> or /// -> or /// + /// Deliberately excludes / - unlike the properties above, a partition key's meaning is layer/purpose-specific rather than + /// invariant (e.g. a Cosmos DB physical partition/shard key versus an event's ordering/session key are routinely different values for the same logical entity), so silently copying it across + /// a mapping would conflate two unrelated concerns rather than merely duplicate one. Set it deliberately at each destination instead (e.g. CosmosDbModelOptions{TModel}.WithPartitionKey + /// for Cosmos, or explicitly on the contract/EventData for events). /// See also . /// public static TDestination MapStandardFrom(this TDestination destination, TSource source, bool mapChangeLog = true) where TSource : class where TDestination : class @@ -46,12 +49,15 @@ public static TDestination MapStandardFrom(this TDestinat /// -> /// -> /// -> - /// -> /// -> /// -> /// -> or /// -> or /// + /// Deliberately excludes / - unlike the properties above, a partition key's meaning is layer/purpose-specific rather than + /// invariant (e.g. a Cosmos DB physical partition/shard key versus an event's ordering/session key are routinely different values for the same logical entity), so silently copying it across + /// a mapping would conflate two unrelated concerns rather than merely duplicate one. Set it deliberately at each destination instead (e.g. CosmosDbModelOptions{TModel}.WithPartitionKey + /// for Cosmos, or explicitly on the contract/EventData for events). /// See also . /// public static void MapStandardInto(TSource source, TDestination destination, bool mapChangeLog = true) where TSource : class where TDestination : class @@ -68,9 +74,6 @@ public static void MapStandardInto(TSource source, TDesti if (source is IReadOnlyTenantId sti && destination is ITenantId dti) dti.TenantId = sti.TenantId; - if (source is IReadOnlyPartitionKey spk && destination is IPartitionKey dpk) - dpk.PartitionKey = spk.PartitionKey; - if (source is IReadOnlyLogicallyDeleted sld && destination is ILogicallyDeleted dld) dld.IsDeleted = sld.IsDeleted; diff --git a/src/CoreEx/Mapping/README.md b/src/CoreEx/Mapping/README.md index 4ba5f7c9..4e91825d 100644 --- a/src/CoreEx/Mapping/README.md +++ b/src/CoreEx/Mapping/README.md @@ -8,21 +8,21 @@ Bi-directional mappers (`IBiDirectionMapper`) combine `T1→T2` and `T2→T1` in a single class, and the two- and three-type variants allow reuse across related mapping paths. `MapperExtensions` adds `MapInto` extension methods on any source type to enable fluent calling syntax without explicit mapper references. -The static `Mapper` utility handles the cross-cutting "standard property" mapping across CoreEx entity contracts (`IIdentifier`, `IETag`, `ITenantId`, `IPartitionKey`, `ILogicallyDeleted`, `ITypeDiscriminator`, `IChangeLog`), so explicit mapper implementations call one line (`destination.MapStandardFrom(source)`) rather than hand-coding the same guard-then-assign pattern for every entity property. +The static `Mapper` utility handles the cross-cutting "standard property" mapping across CoreEx entity contracts (`IIdentifier`, `IETag`, `ITenantId`, `ILogicallyDeleted`, `ITypeDiscriminator`, `IChangeLog`), so explicit mapper implementations call one line (`destination.MapStandardFrom(source)`) rather than hand-coding the same guard-then-assign pattern for every entity property. `IPartitionKey` is deliberately excluded from this list - its meaning is layer/purpose-specific (e.g. a Cosmos DB physical partition key versus an event's ordering/session key are routinely different values for the same entity), so it must be set explicitly at each destination rather than auto-copied. ## Key capabilities - 🔁 **Explicit mapping contracts**: `IMapper` and `IIntoMapper` provide typed, single-responsibility mapper interfaces with no reflection overhead. - ↔️ **Bi-directional mappers**: `IBiDirectionMapper` combines both directions in one class, reducing class proliferation for symmetric domain↔DTO mappings. - 🔗 **Tri-type mappers**: `IMapper` and `IBiDirectionMapper` support three-way mapping (e.g. entity → create DTO + update DTO) in a single implementation. -- 🏗️ **Standard property mapping**: `Mapper.MapStandardFrom()` automatically copies `IIdentifier`, `IETag`, `ITenantId`, `IPartitionKey`, `ILogicallyDeleted`, `ITypeDiscriminator`, and `IChangeLog`/`IChangeLogEx` properties between source and destination where both implement the matching interfaces. +- 🏗️ **Standard property mapping**: `Mapper.MapStandardFrom()` automatically copies `IIdentifier`, `IETag`, `ITenantId`, `ILogicallyDeleted`, `ITypeDiscriminator`, and `IChangeLog`/`IChangeLogEx` properties between source and destination where both implement the matching interfaces (`IPartitionKey` is deliberately excluded - see below). - 🧩 **Fluent extension methods**: `MapperExtensions.MapInto(this TSource, IMapper)` enables `source.MapInto(mapper)` syntax for clean, readable mapping call sites. ## Key types | Type | Description | |------|-------------| -| **[`Mapper`](./Mapper.cs)** | Static utility: `MapStandardFrom` copies standard CoreEx entity contract properties (identifier, ETag, tenant, partition key, change log, etc.) between source and destination. | +| **[`Mapper`](./Mapper.cs)** | Static utility: `MapStandardFrom` copies standard CoreEx entity contract properties (identifier, ETag, tenant, change log, etc. - deliberately excluding partition key) between source and destination. | | [`IMapper`](./IMapperT.cs) | Core mapping interface: `Map(TSource source) → TDestination`. | | [`IIntoMapper`](./IIntoMapperT.cs) | Variant of `IMapper` that maps into an existing destination: `MapInto(TSource source, TDestination destination)`. | | [`IBiDirectionMapper`](./IBiDirectionMapper.cs) | Combines `IMapper` and `IMapper` in a single interface for symmetric mappings. | @@ -32,6 +32,6 @@ The static `Mapper` utility handles the cross-cutting "standard property" mappin ## Related Namespaces - **[`CoreEx.Entities`](../Entities/README.md)** - Defines the standard entity contract interfaces (`IIdentifier`, `IETag`, `IChangeLog`, etc.) that `Mapper.MapStandardFrom` automatically copies. -- **[`CoreEx.Data`](../Data/README.md)** - `ITenantId`, `IPartitionKey`, `ILogicallyDeleted`, and `ITypeDiscriminator` from `CoreEx.Data` are also handled by `Mapper.MapStandardFrom`. +- **[`CoreEx.Data`](../Data/README.md)** - `ITenantId`, `ILogicallyDeleted`, and `ITypeDiscriminator` from `CoreEx.Data` are also handled by `Mapper.MapStandardFrom`; `IPartitionKey` lives here too but is deliberately excluded (see above). - **[`CoreEx.Database`](../../CoreEx.Database/README.md)** - Database mappers implement `IMapper` and call `MapStandardFrom` for the standard fields. - **[`CoreEx.EntityFrameworkCore`](../../CoreEx.EntityFrameworkCore/README.md)** - EF Core mappers follow the same `IMapper` pattern. \ No newline at end of file diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreExServiceBusExtensionsOpenTelemetryTests.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreExServiceBusExtensionsOpenTelemetryTests.cs new file mode 100644 index 00000000..ee005eec --- /dev/null +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/CoreExServiceBusExtensionsOpenTelemetryTests.cs @@ -0,0 +1,33 @@ +using OpenTelemetry.Trace; + +namespace CoreEx.Azure.Messaging.ServiceBus.Test.Unit; + +[TestFixture] +public class CoreExServiceBusExtensionsOpenTelemetryTests +{ + [TestCase("ServiceBusReceiver.RenewMessageLock")] + [TestCase("ServiceBusSessionReceiver.RenewSessionLock")] + [TestCase("ServiceBusReceiver.Receive")] + public void IsBackgroundPollingActivity_ReturnsTrue_ForKnownBackgroundPollingActivityNames(string activityName) + => CoreExServiceBusExtensions.IsBackgroundPollingActivity(activityName).Should().BeTrue(); + + [TestCase("ServiceBusSender.Send")] + [TestCase("ServiceBusProcessor.ProcessMessage")] + [TestCase("ServiceBusSessionProcessor.ProcessSessionMessage")] + [TestCase(null)] + [TestCase("")] + public void IsBackgroundPollingActivity_ReturnsFalse_ForOtherActivityNames(string? activityName) + => CoreExServiceBusExtensions.IsBackgroundPollingActivity(activityName).Should().BeFalse(); + + [Test] + public void RenewMessageLockActivityName_MatchesAzureSdkDiagnosticPropertyValue() + => CoreExServiceBusExtensions.RenewMessageLockActivityName.Should().Be("ServiceBusReceiver.RenewMessageLock"); + + [Test] + public void RenewSessionLockActivityName_MatchesAzureSdkDiagnosticPropertyValue() + => CoreExServiceBusExtensions.RenewSessionLockActivityName.Should().Be("ServiceBusSessionReceiver.RenewSessionLock"); + + [Test] + public void ReceiveActivityName_MatchesAzureSdkDiagnosticPropertyValue() + => CoreExServiceBusExtensions.ReceiveActivityName.Should().Be("ServiceBusReceiver.Receive"); +} diff --git a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs index 7ee9d3b0..2ab3edda 100644 --- a/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs +++ b/tests/CoreEx.Azure.Messaging.ServiceBus.Test.Unit/ServiceBusReceiverTests.cs @@ -37,20 +37,26 @@ private async Task ReceiveAllMessages() } [Test] + [Order(-1)] // Runs this before every other (default Order(0)) test in THIS fixture, on every TFM, as a defensive measure - see the marker-filtering below for the actual fix. public void GetAndClearAzureServiceBusAsync_ReturnsAllPublishedMessages() => Test.ScopedType(async test => { // Regression (against a real Service Bus emulator, not a mock): proves GetAndClearAzureServiceBusAsync's internal // receive poll reliably drains every message published just beforehand, closing the gap left by CoreEx.UnitTesting's // "no live infra to verify" note for this method. + // The "unit-test"/"default" topic/subscription is shared by other fixtures in this assembly (e.g. ServiceBusPublisherTests) that publish their own messages independently of this one - + // a message published elsewhere can still be "in flight" (not yet indexed by the emulator) when this test's own drain runs, then land during this test's own receive window, which is + // exactly what caused this test to flake with "expected 10, found 16" (an [Order(-1)]-only fix does not help here, since it only orders tests within this fixture, not across fixtures). + // Tagging each published item with a test-run-unique marker and filtering on it - rather than asserting the subscription's total message count - makes this immune to any such cross-fixture leakage. + var marker = Guid.NewGuid().ToString("N"); var sp = (ServiceBusPublisher)test.Services.GetRequiredKeyedService(ServiceBusPublisher.DefaultServiceKey); for (var i = 0; i < 10; i++) - sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = i, Sku = $"SKU-{i}" }, "Created")); + sp.Add(EventData.CreateEventWith(new Subscribers.Product { Id = i, Sku = $"{marker}-{i}" }, "Created")); await sp.PublishAsync(); var messages = await Test.GetAndClearAzureServiceBusAsync(ServiceBusReceiverOptions.CreateForTopicSubscription("unit-test", "default")); - messages.Should().HaveCount(10); + messages.Where(m => m.Body.ToString().Contains(marker)).Should().HaveCount(10); }); [Test] @@ -215,9 +221,9 @@ public void ReceiveAsync_Retry_Then_DeadLetter() => Test.ScopedType { @@ -345,10 +351,10 @@ private bool ReceiveAsync_CircuitBreaker_Internal() } }).AssertException(); - circuitBreakerTripped = assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker has been tripped for 333ms due to unhandled errors; receiver will be paused.") == true) - && assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker has been tripped for 666ms due to unhandled errors; receiver will be paused.") == true) - && assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker has been tripped for 1332ms due to unhandled errors; receiver will be paused.") == true) - && assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker is attempting to recover in a limited state; receiver has been resumed.") == true); + circuitBreakerTripped = assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker has been tripped for 333ms due to unhandled errors; will be paused.") == true) + && assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker has been tripped for 666ms due to unhandled errors; will be paused.") == true) + && assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker has been tripped for 1332ms due to unhandled errors; will be paused.") == true) + && assertor.LogMessages.Any(x => x?.Contains("Service bus receiver circuit breaker is attempting to recover in a limited state; has been resumed.") == true); }); return circuitBreakerTripped; diff --git a/tests/CoreEx.Cosmos.Test.Unit/CoreEx.Cosmos.Test.Unit.csproj b/tests/CoreEx.Cosmos.Test.Unit/CoreEx.Cosmos.Test.Unit.csproj new file mode 100644 index 00000000..c1c0fe89 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CoreEx.Cosmos.Test.Unit.csproj @@ -0,0 +1,41 @@ + + + net8.0;net9.0;net10.0 + enable + enable + preview + + false + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbBatchTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbBatchTests.cs new file mode 100644 index 00000000..b14a90cf --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbBatchTests.cs @@ -0,0 +1,55 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Verifies does not corrupt the caller-owned it temporarily mutates to stamp each +/// group's value. +/// +[TestFixture] +public class CosmosDbBatchTests : CosmosTestBase +{ + private const string ContainerId = "discriminated-batch-items"; + + private const string Yaml = """ + discriminated-batch-items: + - Animal: + - { id: ^guid, partitionKey: batch-pk, name: Dog } + - Plant: + - { id: ^guid, partitionKey: batch-pk, name: Fern } + """; + + /// + /// A caller who has already set the 'typeDiscriminator' property (e.g. via its own or a prior, unrelated import) must get that + /// exact value back afterward - not have it silently wiped, which would corrupt any further reuse of the same for other, unrelated seeding. + /// + [Test] + public async Task ImportDiscriminatedBatchAsync_PreExistingTypeDiscriminatorProperty_IsRestoredAfterwards() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var options = new JsonDataReaderOptions(JsonPropertyNamingConvention.CamelCase); + options.Properties["typeDiscriminator"] = "pre-existing-value"; + + var jdr = JsonDataReader.ParseYaml(Yaml, options); + + await CosmosDbBatch.ImportDiscriminatedBatchAsync(TestDatabase, jdr).ConfigureAwait(false); + + options.Properties["typeDiscriminator"].Should().Be("pre-existing-value", "the caller's own pre-existing property value must be restored, not left cleared or overwritten by the last-processed discriminator"); + } + + /// + /// Where the caller had not set the 'typeDiscriminator' property at all, it must be absent again afterward - the pre-fix behavior of an unconditional removal happened to get this + /// case right, so this is a regression guard rather than a reproduction of the original bug. + /// + [Test] + public async Task ImportDiscriminatedBatchAsync_NoPreExistingTypeDiscriminatorProperty_LeavesPropertyAbsentAfterwards() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var options = new JsonDataReaderOptions(JsonPropertyNamingConvention.CamelCase); + var jdr = JsonDataReader.ParseYaml(Yaml, options); + + await CosmosDbBatch.ImportDiscriminatedBatchAsync(TestDatabase, jdr).ConfigureAwait(false); + + options.Properties.Should().NotContainKey("typeDiscriminator"); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerConcurrencyTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerConcurrencyTests.cs new file mode 100644 index 00000000..c9e8c329 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerConcurrencyTests.cs @@ -0,0 +1,49 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerConcurrencyTests : CosmosTestBase +{ + private static async Task> GetContainerAsync() + { + await GetOrCreateContainerAsync("concurrency-items").ConfigureAwait(false); + return CreateCosmosDb().Container("concurrency-items", o => o.WithPartitionKey(m => m.PartitionKey)); + } + + [Test] + public async Task UpdateAsync_WithStaleETag_Throws_ConcurrencyException() + { + var container = await GetContainerAsync(); + var id = NewId(); + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = id, Name = "Original" }); + + // Simulate a concurrent update from elsewhere. + var winner = created.Value; + winner.Name = "Winner"; + await container.UpdateAsync(winner); + + // Attempt to update using the now-stale ETag captured before the winning update. + var stale = created.Value; + stale.Name = "Loser"; + + Assert.ThrowsAsync(async () => await container.UpdateAsync(stale)); + } + + [Test] + public async Task UpdateWithResultAsync_WithStaleETag_ReturnsConcurrencyError() + { + var container = await GetContainerAsync(); + var id = NewId(); + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = id, Name = "Original" }); + + var winner = created.Value; + winner.Name = "Winner"; + await container.UpdateAsync(winner); + + var stale = created.Value; + stale.Name = "Loser"; + + var result = await container.UpdateWithResultAsync(stale); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerCrudTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerCrudTests.cs new file mode 100644 index 00000000..c91d0bd8 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerCrudTests.cs @@ -0,0 +1,85 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerCrudTests : CosmosTestBase +{ + private static async Task> GetContainerAsync() + { + await GetOrCreateContainerAsync("crud-items").ConfigureAwait(false); + return CreateCosmosDb().Container("crud-items", o => o.WithPartitionKey(m => m.PartitionKey)); + } + + [Test] + public async Task CreateAsync_Then_GetAsync_RoundTrips() + { + var container = await GetContainerAsync(); + var id = NewId(); + var model = new TestItem { Id = id, PartitionKey = id, Name = "Widget" }; + + var created = await container.CreateAsync(model); + created.WasMutated.Should().BeTrue(); + created.Value.Id.Should().Be(id); + created.Value.Name.Should().Be("Widget"); + created.Value.ETag.Should().NotBeNullOrEmpty(); + + var fetched = await container.GetAsync(CompositeKey.Create(id), id); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Widget"); + fetched.ETag.Should().Be(created.Value.ETag); + } + + [Test] + public async Task UpdateAsync_PersistsChanges_AndChangesETag() + { + var container = await GetContainerAsync(); + var id = NewId(); + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = id, Name = "Original" }); + + var toUpdate = created.Value; + toUpdate.Name = "Updated"; + var updated = await container.UpdateAsync(toUpdate); + + updated.WasMutated.Should().BeTrue(); + updated.Value.Name.Should().Be("Updated"); + updated.Value.ETag.Should().NotBe(created.Value.ETag); + + var fetched = await container.GetAsync(CompositeKey.Create(id), id); + fetched!.Name.Should().Be("Updated"); + } + + [Test] + public async Task DeleteAsync_RemovesItem_AndIsIdempotent() + { + var container = await GetContainerAsync(); + var id = NewId(); + await container.CreateAsync(new TestItem { Id = id, PartitionKey = id, Name = "ToDelete" }); + + var deleted = await container.DeleteAsync(CompositeKey.Create(id), id); + deleted.WasMutated.Should().BeTrue(); + + var fetched = await container.GetAsync(CompositeKey.Create(id), id); + fetched.Should().BeNull(); + + // Idempotent: deleting again is not an error and reports no mutation. + var deletedAgain = await container.DeleteAsync(CompositeKey.Create(id), id); + deletedAgain.WasMutated.Should().BeFalse(); + } + + [Test] + public async Task UpsertAsync_CreatesWhenMissing_ThenUpdatesWhenExisting() + { + var container = await GetContainerAsync(); + var id = NewId(); + + var upserted1 = await container.UpsertAsync(new TestItem { Id = id, PartitionKey = id, Name = "First" }); + upserted1.Value.Name.Should().Be("First"); + + var toUpsert = upserted1.Value; + toUpsert.Name = "Second"; + var upserted2 = await container.UpsertAsync(toUpsert); + upserted2.Value.Name.Should().Be("Second"); + + var fetched = await container.GetAsync(CompositeKey.Create(id), id); + fetched!.Name.Should().Be("Second"); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerFilterTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerFilterTests.cs new file mode 100644 index 00000000..61c1f6e1 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerFilterTests.cs @@ -0,0 +1,219 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerFilterTests : CosmosTestBase +{ + private const string ContainerId = "filter-items"; + + private static async Task> GetContainerAsync(Func? nonQueryResult = null, bool allowFilterBypass = false) + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + return CreateCosmosDb().Container(ContainerId, o => + { + o.WithPartitionKey(m => m.PartitionKey); + o.WithFilter(q => q.Where(m => !m.Name.StartsWith("Hidden")), nonQueryResult, allowFilterBypass); + }); + } + + [Test] + public async Task Query_WithFilter_ExcludesFilteredItems() + { + // Query-only filter (no nonQueryResult) - Create is unaffected, only Query excludes matches. + var container = await GetContainerAsync(); + var pk = NewId(); + + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Visible" }); + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Hidden" }); + + var items = await container.Query(q => q.Where(m => m.PartitionKey == pk)).ToListAsync(); + + items.Should().ContainSingle(); + items[0].Name.Should().Be("Visible"); + } + + [Test] + public async Task Get_WithNonQueryFilter_ReturnsConfiguredErrorUnlessBypassed() + { + // Non-query filter with a nonQueryResult and allowFilterBypass - Get on a filtered-out item fails with the + // configured Result unless CosmosDbArgs.BypassFilters is set. + var container = await GetContainerAsync((_, _) => Result.AuthenticationError(), allowFilterBypass: true); + var id = NewId(); + + // Seed directly via the raw SDK container, bypassing CoreEx.Cosmos's own filter enforcement on Create. + await container.Container.CreateItemAsync(new TestItem { Id = id, PartitionKey = id, Name = "Hidden" }, new PartitionKey(id)); + + var blocked = await container.GetWithResultAsync(CompositeKey.Create(id), id); + blocked.IsFailure.Should().BeTrue(); + blocked.Error.Should().BeOfType(); + + var bypassed = await container.GetWithResultAsync(new CosmosDbArgs { BypassFilters = true }, CompositeKey.Create(id), id); + bypassed.IsSuccess.Should().BeTrue(); + bypassed.Value.Name.Should().Be("Hidden"); + } + + [Test] + public async Task Delete_WithNonQueryFilter_ReturnsConfiguredErrorUnlessBypassed() + { + // Non-query filter with a nonQueryResult and allowFilterBypass - Delete on a filtered-out item fails with the configured Result unless CosmosDbArgs.BypassFilters is set (the presence of the + // filter forces Delete's pre-read path, since there is now something for CheckModel to check). + var container = await GetContainerAsync((_, _) => Result.AuthenticationError(), allowFilterBypass: true); + var id = NewId(); + + // Seed directly via the raw SDK container, bypassing CoreEx.Cosmos's own filter enforcement on Create. + await container.Container.CreateItemAsync(new TestItem { Id = id, PartitionKey = id, Name = "Hidden" }, new PartitionKey(id)); + + var blocked = await container.DeleteWithResultAsync(CompositeKey.Create(id), id); + blocked.IsFailure.Should().BeTrue(); + blocked.Error.Should().BeOfType(); + + var bypassed = await container.DeleteWithResultAsync(new CosmosDbArgs { BypassFilters = true }, CompositeKey.Create(id), id); + bypassed.IsSuccess.Should().BeTrue(); + bypassed.Value.WasMutated.Should().BeTrue(); + } + + [Test] + public async Task Update_WithNonQueryFilter_ReturnsConfiguredErrorUnlessBypassed() + { + // Same shape as Delete's equivalent test above - HasFilters forces Update's generalized pre-read, whose CheckModel now enforces a non-query filter's configured Result against the PERSISTED + // document (not the incoming replace payload), rather than allowing a blind replace of a filtered-out (e.g. unauthorized) item. + var container = await GetContainerAsync((_, _) => Result.AuthenticationError(), allowFilterBypass: true); + var id = NewId(); + + // Seed directly via the raw SDK container, bypassing CoreEx.Cosmos's own filter enforcement on Create. + await container.Container.CreateItemAsync(new TestItem { Id = id, PartitionKey = id, Name = "Hidden" }, new PartitionKey(id)); + + var blocked = await container.UpdateWithResultAsync(new TestItem { Id = id, PartitionKey = id, Name = "Overwritten" }); + blocked.IsFailure.Should().BeTrue(); + blocked.Error.Should().BeOfType(); + + var bypassed = await container.UpdateWithResultAsync(new CosmosDbArgs { BypassFilters = true }, new TestItem { Id = id, PartitionKey = id, Name = "Overwritten" }); + bypassed.IsSuccess.Should().BeTrue(); + bypassed.Value.Value.Name.Should().Be("Overwritten"); + } + + [Test] + public async Task AsQueryable_WithBypassFilters_OnlyBypassesFiltersRegisteredAsBypassable() + { + // Query-only filter registered WITHOUT allowFilterBypass (defaults to false) - CosmosDbArgs.BypassFilters must have no effect on it; matching CoreEx.EntityFrameworkCore.EfDbModelOptions.ApplyFilters, + // the call site (CosmosDbQuery.AsQueryable) always invokes ApplyFilters and the bypass decision is made per-registration, inside ApplyFilters, not by skipping it altogether. + var container = await GetContainerAsync(); + var pk = NewId(); + + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Visible" }); + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Hidden" }); + + var query = container.Query(q => q.Where(m => m.PartitionKey == pk)); + + var filteredItems = await DrainAsync(query.AsQueryable()); + filteredItems.Select(m => m.Name).Should().BeEquivalentTo(["Visible"]); + + // Not registered with allowFilterBypass: true, so BypassFilters must NOT surface "Hidden". + var bypassedItems = await DrainAsync(query.AsQueryable(new CosmosDbArgs { BypassFilters = true })); + bypassedItems.Select(m => m.Name).Should().BeEquivalentTo(["Visible"]); + } + + [Test] + public async Task AsQueryable_WithBypassFilters_BypassesFilterRegisteredAsBypassable() + { + // Query-only filter registered WITH allowFilterBypass: true - CosmosDbArgs.BypassFilters should surface the otherwise-excluded item, per the documented per-registration opt-in contract. + var container = await GetContainerAsync(allowFilterBypass: true); + var pk = NewId(); + + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Visible" }); + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Hidden" }); + + var query = container.Query(q => q.Where(m => m.PartitionKey == pk)); + + var bypassedItems = await DrainAsync(query.AsQueryable(new CosmosDbArgs { BypassFilters = true })); + bypassedItems.Select(m => m.Name).Should().BeEquivalentTo(["Visible", "Hidden"]); + } + + [Test] + public async Task AsQueryable_WithBypassFilters_TenantFilterStillApplies() + { + // The mandatory tenant filter (WithTenantFilter, no allowFilterBypass parameter exists for it at all) must remain applied by AsQueryable regardless of CosmosDbArgs.BypassFilters - this is + // the exact regression covered by the review comment: the prior implementation short-circuited ApplyFilters entirely on BypassFilters, silently exposing other tenants' documents to a query. + const string containerId = "tenant-filter-items"; + await GetOrCreateContainerAsync(containerId).ConfigureAwait(false); + + var containerA = CreateCosmosDb("tenant-a").Container(containerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTenantFilter()); + var containerB = CreateCosmosDb("tenant-b").Container(containerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTenantFilter()); + + await containerA.CreateAsync(new TenantItem { Id = NewId(), PartitionKey = NewId(), Name = "Owned by tenant-a" }); + + var items = await DrainAsync(containerB.Query().AsQueryable(new CosmosDbArgs { BypassFilters = true })); + items.Should().BeEmpty(); + } + + [Test] + public async Task AsQueryable_WithBypassFilters_LogicalDeleteFilterStillApplies() + { + // The mandatory logical-delete filter (WithLogicalDeleteFilter, no allowFilterBypass parameter exists for it at all) must remain applied by AsQueryable regardless of CosmosDbArgs.BypassFilters. + const string containerId = "soft-delete-filter-items"; + await GetOrCreateContainerAsync(containerId).ConfigureAwait(false); + + var container = CreateCosmosDb().Container(containerId, o => o.WithPartitionKey(m => m.PartitionKey).WithLogicalDeleteFilter()); + var pk = NewId(); + + var deletedId = NewId(); + await container.CreateAsync(new SoftDeleteItem { Id = NewId(), PartitionKey = pk, Name = "Visible" }); + await container.CreateAsync(new SoftDeleteItem { Id = deletedId, PartitionKey = pk, Name = "Deleted" }); + await container.DeleteAsync(CompositeKey.Create(deletedId), pk); // Logical delete - sets IsDeleted = true rather than physically removing the document. + + var items = await DrainAsync(container.Query(q => q.Where(m => m.PartitionKey == pk)).AsQueryable(new CosmosDbArgs { BypassFilters = true })); + items.Select(m => m.Name).Should().BeEquivalentTo(["Visible"]); + } + + [Test] + public async Task UpdateAsync_ReturnsNotFound_WhenPersistedItemIsLogicallyDeleted() + { + // "No undelete via Update" - matching CoreEx.EntityFrameworkCore.EfDbModel's own CheckModel behavior (used as the consistency reference for this fix), LogicalDeleteSupport.IsSupported forces + // Update's generalized pre-read, whose CheckModel rejects a replace targeting a persisted-but-logically-deleted document, rather than silently reviving it with the incoming payload's content. + const string containerId = "soft-delete-update-items"; + await GetOrCreateContainerAsync(containerId).ConfigureAwait(false); + + var container = CreateCosmosDb().Container(containerId, o => o.WithPartitionKey(m => m.PartitionKey).WithLogicalDeleteFilter()); + var pk = NewId(); + var id = NewId(); + + await container.CreateAsync(new SoftDeleteItem { Id = id, PartitionKey = pk, Name = "Deleted" }); + await container.DeleteAsync(CompositeKey.Create(id), pk); // Logical delete - sets IsDeleted = true rather than physically removing the document. + + var result = await container.UpdateWithResultAsync(new SoftDeleteItem { Id = id, PartitionKey = pk, Name = "Resurrected" }); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + private static async Task> DrainAsync(IQueryable queryable) + { + var items = new List(); + using var iterator = queryable.ToFeedIterator(); + + while (iterator.HasMoreResults) + items.AddRange(await iterator.ReadNextAsync().ConfigureAwait(false)); + + return items; + } + + private static async Task> DrainAsync(IQueryable queryable) + { + var items = new List(); + using var iterator = queryable.ToFeedIterator(); + + while (iterator.HasMoreResults) + items.AddRange(await iterator.ReadNextAsync().ConfigureAwait(false)); + + return items; + } + + private static async Task> DrainAsync(IQueryable queryable) + { + var items = new List(); + using var iterator = queryable.ToFeedIterator(); + + while (iterator.HasMoreResults) + items.AddRange(await iterator.ReadNextAsync().ConfigureAwait(false)); + + return items; + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerFixedPartitionKeyTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerFixedPartitionKeyTests.cs new file mode 100644 index 00000000..52e91b5b --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerFixedPartitionKeyTests.cs @@ -0,0 +1,55 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerFixedPartitionKeyTests : CosmosTestBase +{ + private const string ContainerId = "fixed-pk-items"; + + [Test] + public async Task WithFixedPartitionKey_CreateGetDelete_NoExplicitPartitionKeyNeeded() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var fixedKey = NewId(); + var container = CreateCosmosDb().Container(ContainerId, o => o.WithFixedPartitionKey(fixedKey)); + + var id = NewId(); + + // No PartitionKey set on the model itself - the fixed value resolves it (and is written back onto the model) on Create. + var created = await container.CreateAsync(new TestItem { Id = id, Name = "Widget" }); + created.Value.Name.Should().Be("Widget"); + created.Value.PartitionKey.Should().Be(fixedKey); + + // Get/Delete omit the partitionKey parameter entirely - falls back to the configured fixed value. + var fetched = await container.GetAsync(CompositeKey.Create(id)); + fetched!.Name.Should().Be("Widget"); + + var deleted = await container.DeleteAsync(CompositeKey.Create(id)); + deleted.WasMutated.Should().BeTrue(); + } + + [Test] + public async Task UpdateAsync_WithFixedPartitionKey_ModelAlreadyMatchesFromCreate_NoMismatchThrown() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var fixedKey = NewId(); + var container = CreateCosmosDb().Container(ContainerId, o => o.WithFixedPartitionKey(fixedKey)); + + var id = NewId(); + var created = await container.CreateAsync(new TestItem { Id = id, Name = "Original" }); + + // The model returned from Create already has PartitionKey written back to the fixed value (per the prior test) - updating + // it should succeed without tripping the "model disagrees with configured value" mismatch check, since the two now agree. + var toUpdate = created.Value; + toUpdate.Name = "Updated"; + var updated = await container.UpdateAsync(toUpdate); + + updated.WasMutated.Should().BeTrue(); + updated.Value.Name.Should().Be("Updated"); + updated.Value.PartitionKey.Should().Be(fixedKey); + + // Confirm it was actually persisted (Get again omitting the partitionKey parameter, as in the Create/Get/Delete test). + var fetched = await container.GetAsync(CompositeKey.Create(id)); + fetched!.Name.Should().Be("Updated"); + fetched.PartitionKey.Should().Be(fixedKey); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerNotFoundTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerNotFoundTests.cs new file mode 100644 index 00000000..a898768b --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerNotFoundTests.cs @@ -0,0 +1,55 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerNotFoundTests : CosmosTestBase +{ + private static async Task> GetContainerAsync() + { + await GetOrCreateContainerAsync("notfound-items").ConfigureAwait(false); + return CreateCosmosDb().Container("notfound-items", o => o.WithPartitionKey(m => m.PartitionKey)); + } + + [Test] + public async Task GetAsync_ThrowingForm_NullOnNotFound_ReturnsNull() + { + var container = await GetContainerAsync(); + var id = NewId(); + + var result = await container.GetAsync(CompositeKey.Create(id), id); + + result.Should().BeNull(); + } + + [Test] + public async Task GetAsync_ThrowingForm_NullOnNotFoundFalse_ThrowsNotFoundException() + { + var container = await GetContainerAsync(); + var id = NewId(); + + Assert.ThrowsAsync(async () => await container.GetAsync(container.Args with { NullOnNotFound = false }, CompositeKey.Create(id), id)); + } + + [Test] + public async Task GetWithResultAsync_ReturnsNotFoundError() + { + var container = await GetContainerAsync(); + var id = NewId(); + + var result = await container.GetWithResultAsync(CompositeKey.Create(id), id); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public async Task DeleteAsync_NotFound_IsNotAnError() + { + var container = await GetContainerAsync(); + var id = NewId(); + + var result = await container.DeleteWithResultAsync(CompositeKey.Create(id), id); + + result.IsSuccess.Should().BeTrue(); + result.Value.WasMutated.Should().BeFalse(); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerQueryTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerQueryTests.cs new file mode 100644 index 00000000..e625f7e1 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerQueryTests.cs @@ -0,0 +1,91 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerQueryTests : CosmosTestBase +{ + private const string ContainerId = "query-items"; + private static string? _partitionKey; + + private static async Task> GetContainerAsync() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + return CreateCosmosDb().Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + } + + /// + /// Seeds (once) 5 items sharing a single partition so that Skip/Take paging is deterministic within that partition. + /// + private static async Task<(CosmosDbContainer Container, string PartitionKey)> GetSeededContainerAsync() + { + var container = await GetContainerAsync(); + + if (_partitionKey is null) + { + var pk = NewId(); + for (var i = 0; i < 5; i++) + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = $"Item-{i:D2}" }); + + _partitionKey = pk; + } + + return (container, _partitionKey); + } + + [Test] + public async Task Query_WithPartitionFilter_ReturnsAllSeededItems() + { + var (container, partitionKey) = await GetSeededContainerAsync(); + + var items = await container.Query(q => q.Where(m => m.PartitionKey == partitionKey)).ToListAsync(); + + items.Should().HaveCount(5); + } + + [Test] + public async Task ToItemsResultAsync_AppliesSkipAndTake() + { + var (container, partitionKey) = await GetSeededContainerAsync(); + + var query = container.Query(q => q.Where(m => m.PartitionKey == partitionKey).OrderBy(m => m.Name)); + + var page = await query.WithPaging(PagingArgs.CreateWithCount(skip: 2, take: 2)).ToItemsResultAsync(); + + page.Items.Should().HaveCount(2); + page.Items!.Select(m => m.Name).Should().ContainInOrder("Item-02", "Item-03"); + page.Paging!.TotalCount.Should().Be(5); + } + + [Test] + public async Task ToItemsResultAsync_NoPagingSpecified_DefaultsToDefaultTake() + { + // A caller that never calls WithPaging(...) at all must not get an unbounded result set - CosmosDbQuery delegates straight to the shared IQueryable.WithPaging(PagingArgs?) extension (the + // same one CoreEx.EntityFrameworkCore's own queries call directly), which defaults an unset PagingArgs to PagingArgs.Create() (applying PagingArgs.DefaultTake) rather than "no limit at all" - + // PagingArgs.None is required to explicitly opt into an unbounded query. Own dedicated partition (not GetSeededContainerAsync's 5-item one) since this needs more rows than the default take. + var container = await GetContainerAsync(); + var pk = NewId(); + + for (var i = 0; i < PagingArgs.DefaultTake + 5; i++) + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = $"Item-{i:D3}" }); + + var items = await container.Query(q => q.Where(m => m.PartitionKey == pk)).ToItemsResultAsync(); + + items.Items.Should().HaveCount(PagingArgs.DefaultTake); + } + + [Test] + public async Task ToListAsync_NoPagingSpecified_ReturnsUnboundedResultSet() + { + // Unlike ToItemsResultAsync (above), the plain list/collection/mapped-item materializers (ToListAsync/ToCollectionAsync/ToMappedItemsAsync) must NOT apply PagingArgs.DefaultTake just because + // WithPaging(...) was never called - that would silently truncate, e.g., a generated reference-data repository's "get all" query, diverging from CoreEx.EntityFrameworkCore's equivalent + // IQueryable.ToMappedItemsAsync (unbounded by default). Own dedicated partition since this needs more rows than the default take. + var container = await GetContainerAsync(); + var pk = NewId(); + + for (var i = 0; i < PagingArgs.DefaultTake + 5; i++) + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = $"Item-{i:D3}" }); + + var items = await container.Query(q => q.Where(m => m.PartitionKey == pk)).ToListAsync(); + + items.Should().HaveCount(PagingArgs.DefaultTake + 5); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTenantTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTenantTests.cs new file mode 100644 index 00000000..4d6b9b0c --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTenantTests.cs @@ -0,0 +1,76 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerTenantTests : CosmosTestBase +{ + private const string ContainerId = "tenant-items"; + + [Test] + public async Task DeleteAsync_CrossTenant_TreatsAsNotFound_DoesNotDelete() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var containerA = CreateCosmosDb("tenant-a").Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var containerB = CreateCosmosDb("tenant-b").Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + + var id = NewId(); + await containerA.CreateAsync(new TenantItem { Id = id, PartitionKey = id, Name = "Owned by tenant-a" }); + + // Tenant B attempts to delete Tenant A's document by (known/guessed) id + partition key - the pre-read's tenant check (TenantSupport.IsSupported forces the pre-read path even with no + // logical delete or WithFilter configured) means this is treated as not-found rather than actually deleting Tenant A's document. + var deleted = await containerB.DeleteAsync(CompositeKey.Create(id), id); + deleted.WasMutated.Should().BeFalse(); + + // Confirm it still exists, untouched, for Tenant A. + var stillThere = await containerA.GetAsync(CompositeKey.Create(id), id); + stillThere.Should().NotBeNull(); + stillThere!.Name.Should().Be("Owned by tenant-a"); + } + + [Test] + public async Task DeleteAsync_SameTenant_Succeeds() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var containerA = CreateCosmosDb("tenant-a").Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + + var id = NewId(); + await containerA.CreateAsync(new TenantItem { Id = id, PartitionKey = id, Name = "Owned by tenant-a" }); + + var deleted = await containerA.DeleteAsync(CompositeKey.Create(id), id); + deleted.WasMutated.Should().BeTrue(); + } + + [Test] + public async Task UpdateAsync_CrossTenant_ReturnsNotFound_DoesNotUpdate() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var containerA = CreateCosmosDb("tenant-a").Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var containerB = CreateCosmosDb("tenant-b").Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + + var id = NewId(); + await containerA.CreateAsync(new TenantItem { Id = id, PartitionKey = id, Name = "Owned by tenant-a" }); + + // Tenant B attempts to blindly replace Tenant A's document by (known/guessed) id + partition key - TenantSupport.IsSupported forces Update's pre-read, whose CheckModel rejects the cross-tenant + // read, so this is treated as not-found rather than silently overwriting Tenant A's document with Tenant B's content. + var result = await containerB.UpdateWithResultAsync(new TenantItem { Id = id, PartitionKey = id, Name = "Hijacked by tenant-b" }); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + + // Confirm it still exists, untouched, for Tenant A. + var stillThere = await containerA.GetAsync(CompositeKey.Create(id), id); + stillThere.Should().NotBeNull(); + stillThere!.Name.Should().Be("Owned by tenant-a"); + } + + [Test] + public async Task UpdateAsync_SameTenant_Succeeds() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var containerA = CreateCosmosDb("tenant-a").Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + + var id = NewId(); + await containerA.CreateAsync(new TenantItem { Id = id, PartitionKey = id, Name = "Owned by tenant-a" }); + + var updated = await containerA.UpdateAsync(new TenantItem { Id = id, PartitionKey = id, Name = "Renamed by tenant-a" }); + updated.Value.Name.Should().Be("Renamed by tenant-a"); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTimeToLiveTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTimeToLiveTests.cs new file mode 100644 index 00000000..65accb92 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTimeToLiveTests.cs @@ -0,0 +1,54 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbContainerTimeToLiveTests : CosmosTestBase +{ + private const string ContainerId = "ttl-items"; + + [Test] + public async Task CreateAsync_WithTimeToLive_ComputesAndPersistsTtl() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var container = CreateCosmosDb().Container(ContainerId, o => o + .WithPartitionKey(m => m.PartitionKey) + .WithTimeToLive(_ => 3600)); + + var id = NewId(); + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = id, Name = "Expiring" }); + + created.Value.TimeToLive.Should().Be(3600); + + // Confirm it was actually persisted (not just present on the in-memory returned instance). + var fetched = await container.GetAsync(CompositeKey.Create(id), id); + fetched!.TimeToLive.Should().Be(3600); + } + + [Test] + public async Task UpdateAsync_WithTimeToLive_RecomputesTtl() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var ttlSeconds = 60; + var container = CreateCosmosDb().Container(ContainerId, o => o + .WithPartitionKey(m => m.PartitionKey) + .WithTimeToLive(_ => ttlSeconds)); + + var id = NewId(); + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = id, Name = "Original" }); + + ttlSeconds = 120; + var toUpdate = created.Value; + toUpdate.Name = "Updated"; + var updated = await container.UpdateAsync(toUpdate); + + updated.Value.TimeToLive.Should().Be(120); + } + + [Test] + public void WithTimeToLive_ModelWithoutITimeToLive_Throws() + { + var options = new CosmosDbModelOptions(); + options.TimeToLiveSupport.IsMutable.Should().BeFalse(); + + Assert.Throws(() => options.WithTimeToLive(_ => 60)); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTypeDiscriminatorTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTypeDiscriminatorTests.cs new file mode 100644 index 00000000..a5320c2a --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbContainerTypeDiscriminatorTests.cs @@ -0,0 +1,155 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Verifies that two distinct business model types ( and ) can safely share the same container/partition using +/// - no envelope/wrapper type required. +/// +[TestFixture] +public class CosmosDbContainerTypeDiscriminatorTests : CosmosTestBase +{ + private const string ContainerId = "discriminator-items"; + + [Test] + public async Task Query_OnlyReturnsMatchingTypeDiscriminator_WhenTypesShareContainerAndPartition() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + // One SHARED CosmosDb instance for both types - matching real usage (one scoped ICosmosDb injected into application code that then asks for Container() against the same containerId for more + // than one type). Using two separate CosmosDb instances here (as an earlier version of this test did) masks a real bug: CosmosDb/CosmosDbOptions used to cache per-containerId alone, so the second + // type sharing a containerId from the SAME instance would throw InvalidCastException trying to cast the first type's cached CosmosDbContainer/CosmosDbModelOptions to its own. + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + + // The type discriminator is auto-stamped by Model.PrepareCreate (via Model.PrepareTypeDiscriminator, from each model's [Schema(Name = ...)] attribute). + var dog = await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + var cat = await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Cat" }); + var fern = await plants.CreateAsync(new PlantItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Fern" }); + + dog.Value.TypeDiscriminator.Should().Be(nameof(AnimalItem)); + fern.Value.TypeDiscriminator.Should().Be(nameof(PlantItem)); + + var animalResults = await animals.Query(q => q.Where(m => m.PartitionKey == sharedPartition)).ToListAsync(); + animalResults.Select(m => m.Name).Should().BeEquivalentTo(["Dog", "Cat"]); + + var plantResults = await plants.Query(q => q.Where(m => m.PartitionKey == sharedPartition)).ToListAsync(); + plantResults.Select(m => m.Name).Should().BeEquivalentTo(["Fern"]); + } + + [Test] + public async Task GetAsync_ReturnsNotFound_WhenIdAndPartitionMatchADifferentConfiguredTypeDiscriminator() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var partitionKey = NewId(); + var id = NewId(); + await animals.CreateAsync(new AnimalItem { Id = id, PartitionKey = partitionKey, Name = "Dog" }); + + // Same id + partition, but requested as a PlantItem - CheckModel must reject the cross-type read rather than deserializing/returning the AnimalItem document. + var result = await plants.GetWithResultAsync(CompositeKey.Create(id), partitionKey); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public async Task UpdateAsync_ReturnsNotFound_WhenIdAndPartitionMatchADifferentConfiguredTypeDiscriminator() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var partitionKey = NewId(); + var id = NewId(); + await animals.CreateAsync(new AnimalItem { Id = id, PartitionKey = partitionKey, Name = "Dog" }); + + // A PlantItem replace targeting the AnimalItem's id/partition must not silently overwrite it with a differently-typed document. + var result = await plants.UpdateWithResultAsync(new PlantItem { Id = id, PartitionKey = partitionKey, Name = "Fern" }); + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + + // Confirm the original AnimalItem document is untouched. + var animal = await animals.GetAsync(CompositeKey.Create(id), partitionKey); + animal.Should().NotBeNull(); + animal!.Name.Should().Be("Dog"); + } + + [Test] + public async Task DeleteAsync_DoesNotDelete_WhenIdAndPartitionMatchADifferentConfiguredTypeDiscriminator() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var partitionKey = NewId(); + var id = NewId(); + await animals.CreateAsync(new AnimalItem { Id = id, PartitionKey = partitionKey, Name = "Dog" }); + + // A PlantItem delete targeting the AnimalItem's id/partition must not physically delete it - WithTypeDiscriminator forces the pre-read (fast-path is disabled) so CheckModel can reject it. + var deleted = await plants.DeleteWithResultAsync(CompositeKey.Create(id), partitionKey); + deleted.Value.WasMutated.Should().BeFalse(); + + // Confirm the AnimalItem document still exists, untouched. + var animal = await animals.GetAsync(CompositeKey.Create(id), partitionKey); + animal.Should().NotBeNull(); + animal!.Name.Should().Be("Dog"); + } + + [Test] + public async Task CreateAsync_StampsExplicitTypeDiscriminatorOverride_WhenConfigured() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + const string explicitDiscriminator = "CustomAnimal"; + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator(explicitDiscriminator)); + + var partitionKey = NewId(); + var id = NewId(); + + // Model.PrepareCreate's own default resolution (nameof(AnimalItem)) must be overridden by the explicit WithTypeDiscriminator value configured above - not left as the default. + var created = await animals.CreateAsync(new AnimalItem { Id = id, PartitionKey = partitionKey, Name = "Dog" }); + created.Value.TypeDiscriminator.Should().Be(explicitDiscriminator); + + // The persisted document must also be retrievable via this same container - i.e. it must not have been stamped with the default value that CheckModel would then reject. + var fetched = await animals.GetAsync(CompositeKey.Create(id), partitionKey); + fetched.Should().NotBeNull(); + fetched!.TypeDiscriminator.Should().Be(explicitDiscriminator); + + var queried = await animals.Query(q => q.Where(m => m.PartitionKey == partitionKey)).ToListAsync(); + queried.Should().ContainSingle(); + queried[0].TypeDiscriminator.Should().Be(explicitDiscriminator); + } + + [Test] + public async Task UpdateAsync_StampsExplicitTypeDiscriminatorOverride_WhenConfigured() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + const string explicitDiscriminator = "CustomAnimal"; + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator(explicitDiscriminator)); + + var partitionKey = NewId(); + var id = NewId(); + await animals.CreateAsync(new AnimalItem { Id = id, PartitionKey = partitionKey, Name = "Dog" }); + + // An update round-trip must also re-stamp the explicit override, not Model.PrepareUpdate's own default. + var updated = await animals.UpdateAsync(new AnimalItem { Id = id, PartitionKey = partitionKey, Name = "Puppy" }); + updated.Value.TypeDiscriminator.Should().Be(explicitDiscriminator); + + var fetched = await animals.GetAsync(CompositeKey.Create(id), partitionKey); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Puppy"); + fetched.TypeDiscriminator.Should().Be(explicitDiscriminator); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbMappedContainerTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbMappedContainerTests.cs new file mode 100644 index 00000000..8c4d58f7 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbMappedContainerTests.cs @@ -0,0 +1,41 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbMappedContainerTests : CosmosTestBase +{ + private const string ContainerId = "mapped-items"; + + private static async Task> GetMappedContainerAsync() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var container = CreateCosmosDb().Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + return container.ToMappedModel(new TestValueMapper()); + } + + [Test] + public async Task CreateAsync_GetAsync_UpdateAsync_DeleteAsync_RoundTripsAsContractValue() + { + var mapped = await GetMappedContainerAsync(); + var id = NewId(); + + var created = await mapped.CreateAsync(new TestValue { Id = id, Name = "Contract" }); + created.Value.Should().BeOfType(); + created.Value.Id.Should().Be(id); + created.Value.Name.Should().Be("Contract"); + created.Value.ETag.Should().NotBeNullOrEmpty(); + + var fetched = await mapped.GetAsync(CompositeKey.Create(id), id); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Contract"); + + fetched.Name = "Updated Contract"; + var updated = await mapped.UpdateAsync(fetched); + updated.Value.Name.Should().Be("Updated Contract"); + + var deleted = await mapped.DeleteAsync(CompositeKey.Create(id), id); + deleted.WasMutated.Should().BeTrue(); + + var afterDelete = await mapped.GetAsync(CompositeKey.Create(id), id); + afterDelete.Should().BeNull(); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbModelOptionsPartitionKeyTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbModelOptionsPartitionKeyTests.cs new file mode 100644 index 00000000..23651b70 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbModelOptionsPartitionKeyTests.cs @@ -0,0 +1,122 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Pure unit tests for 's WithPartitionKey/WithFixedPartitionKey resolution and validation logic - deliberately does not derive from +/// , as none of this requires a live Cosmos DB endpoint. +/// +[TestFixture] +public class CosmosDbModelOptionsPartitionKeyTests +{ + [Test] + public void WithFixedPartitionKey_ThenWithPartitionKey_Throws() + { + var options = new CosmosDbModelOptions().WithFixedPartitionKey("shared"); + Assert.Throws(() => options.WithPartitionKey(m => m.PartitionKey)); + } + + [Test] + public void WithPartitionKey_ThenWithFixedPartitionKey_Throws() + { + var options = new CosmosDbModelOptions().WithPartitionKey(m => m.PartitionKey); + Assert.Throws(() => options.WithFixedPartitionKey("shared")); + } + + [Test] + public void GetPartitionKey_Model_FixedConfigured_ModelValueNull_UsesFixed() + { + var options = new CosmosDbModelOptions().WithFixedPartitionKey("shared"); + var model = new TestItem { Id = "id1", Name = "X" }; // PartitionKey left null. + + options.GetPartitionKey(model).Should().Be(new PartitionKey("shared")); + model.PartitionKey.Should().Be("shared"); // Written back onto the model - required for Cosmos DB to accept the write. + } + + [Test] + public void GetPartitionKey_Model_FixedConfigured_ModelValueMatches_UsesFixed() + { + var options = new CosmosDbModelOptions().WithFixedPartitionKey("shared"); + var model = new TestItem { Id = "id1", PartitionKey = "shared", Name = "X" }; + + options.GetPartitionKey(model).Should().Be(new PartitionKey("shared")); + } + + [Test] + public void GetPartitionKey_Model_FixedConfigured_ModelValueDiffers_Throws() + { + var options = new CosmosDbModelOptions().WithFixedPartitionKey("shared"); + var model = new TestItem { Id = "id1", PartitionKey = "different", Name = "X" }; + + Assert.Throws(() => options.GetPartitionKey(model)); + } + + [Test] + public void GetPartitionKey_Model_FuncConfigured_ModelValueDiffers_Throws() + { + // The func always resolves to "computed" regardless of the model - the model's own (different) value should be flagged, not silently ignored. + var options = new CosmosDbModelOptions().WithPartitionKey(_ => "computed"); + var model = new TestItem { Id = "id1", PartitionKey = "different", Name = "X" }; + + Assert.Throws(() => options.GetPartitionKey(model)); + } + + [Test] + public void GetPartitionKey_Model_FuncConfigured_WritesResolvedValueBackOntoModel() + { + var options = new CosmosDbModelOptions().WithPartitionKey(_ => "computed"); + var model = new TestItem { Id = "id1", Name = "X" }; // PartitionKey left null. + + options.GetPartitionKey(model).Should().Be(new PartitionKey("computed")); + model.PartitionKey.Should().Be("computed"); + } + + [Test] + public void GetPartitionKey_Model_NoOverrideConfigured_FallsBackToModelValue() + { + var options = new CosmosDbModelOptions(); + var model = new TestItem { Id = "id1", PartitionKey = "own-value", Name = "X" }; + + options.GetPartitionKey(model).Should().Be(new PartitionKey("own-value")); + } + + [Test] + public void GetPartitionKey_Explicit_ReturnsExplicitValue_IgnoringFixed() + { + var options = new CosmosDbModelOptions().WithFixedPartitionKey("fixed"); + + options.GetPartitionKey("explicit").Should().Be(new PartitionKey("explicit")); + } + + [Test] + public void GetPartitionKey_Explicit_Null_FallsBackToFixed() + { + var options = new CosmosDbModelOptions().WithFixedPartitionKey("fixed"); + + options.GetPartitionKey((string?)null).Should().Be(new PartitionKey("fixed")); + } + + [Test] + public void GetPartitionKey_Explicit_NullAndNoFixedConfigured_ReturnsNone() + { + var options = new CosmosDbModelOptions(); + + options.GetPartitionKey((string?)null).Should().Be(PartitionKey.None); + } + + [Test] + public void GetPartitionKey_Model_NoOverrideConfigured_ModelValueEmpty_ReturnsNone() + { + var options = new CosmosDbModelOptions(); + var model = new TestItem { Id = "id1", Name = "X" }; // PartitionKey left null. + + options.GetPartitionKey(model).Should().Be(PartitionKey.None); + } + + [Test] + public void GetPartitionKey_Model_NoOverrideConfigured_NoModelSupport_ReturnsNone() + { + var options = new CosmosDbModelOptions(); + var model = new NoPartitionKeyItem { Id = "id1", Name = "X" }; + + options.GetPartitionKey(model).Should().Be(PartitionKey.None); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbMultiSetTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbMultiSetTests.cs new file mode 100644 index 00000000..3cba32a5 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbMultiSetTests.cs @@ -0,0 +1,516 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Verifies and its (Railway-Oriented Programming) counterpart +/// - reading multiple, type-discriminator-keyed sets of documents from the +/// same container/partition in a single round-trip - mirroring the same shared-container setup as . +/// +[TestFixture] +public class CosmosDbMultiSetTests : CosmosTestBase +{ + private const string ContainerId = "multiset-items"; + private const string TenantContainerId = "multiset-tenant-items"; + private const string SoftDeleteContainerId = "multiset-softdelete-items"; + + private static readonly System.Text.Json.JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }; + + [Test] + public async Task SelectMultiSetAsync_ReturnsMatchingTypes_FromSharedContainerAndPartition() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Cat" }); + await plants.CreateAsync(new PlantItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Fern" }); + + List? animalResults = null; + PlantItem? plantResult = null; + + await cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = + [ + new MultiSetCollArgs, AnimalItem>(r => animalResults = r, minimumRows: 1), + new MultiSetSingleArgs(r => plantResult = r) + ] + }); + + animalResults.Should().NotBeNull(); + animalResults!.Select(a => a.Name).Should().BeEquivalentTo(["Dog", "Cat"]); + plantResult.Should().NotBeNull(); + plantResult!.Name.Should().Be("Fern"); + } + + [Test] + public async Task SelectMultiSetAsync_MandatorySingleNotFound_Throws() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + + // No PlantItem created in this partition - the mandatory MultiSetSingleArgs (isMandatory defaults true, MinimumRows == 1) must throw. + Func act = () => cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = + [ + new MultiSetCollArgs, AnimalItem>(_ => { }), + new MultiSetSingleArgs(_ => { }) + ] + }); + + await act.Should().ThrowAsync().WithMessage("*less items*"); + } + + [Test] + public async Task SelectMultiSetAsync_MaximumRowsExceeded_Throws() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Cat" }); + + // MultiSetSingleArgs allows at most one (MaximumRows == 1) - two exist in this partition, so it must throw. + Func act = () => cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions { PartitionKey = sharedPartition, MultiSetArgs = [new MultiSetSingleArgs(_ => { })] }); + + await act.Should().ThrowAsync().WithMessage("*more items*"); + } + + [Test] + public async Task SelectMultiSetAsync_StopOnNull_ShortCircuitsSubsequentResults() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await plants.CreateAsync(new PlantItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Fern" }); + + // No AnimalItem created - the first (optional, StopOnNull) MultiSetSingleArgs resolves to null, which must stop processing before the PlantItem result is invoked. + var animalInvoked = false; + var plantInvoked = false; + + await cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = + [ + new MultiSetSingleArgs(_ => animalInvoked = true, isMandatory: false, stopOnNull: true), + new MultiSetSingleArgs(_ => plantInvoked = true) + ] + }); + + animalInvoked.Should().BeFalse(); + plantInvoked.Should().BeFalse(); + } + + [Test] + public async Task SelectMultiSetAsync_ExcludesCoLocatedOutboxDocuments() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + + // A co-located outbox event document, physically sharing the same container/partition (see CosmosDbEventPublisher) - must never surface as a multi-set result nor break demux/deserialization. + using var eventDoc = System.Text.Json.JsonDocument.Parse("{}"); + await animals.Container.CreateItemAsync( + new CosmosDbOutboxEvent { Id = $"{CosmosDbOutboxEvent.OutboxKeyPrefix}-{NewId()}", PartitionKey = sharedPartition, Destination = "irrelevant", Event = eventDoc.RootElement, TimeToLive = -1 }, + new PartitionKey(sharedPartition)); + + List? animalResults = null; + + await cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions { PartitionKey = sharedPartition, MultiSetArgs = [new MultiSetCollArgs, AnimalItem>(r => animalResults = r, minimumRows: 1)] }); + + animalResults.Should().NotBeNull(); + animalResults!.Select(a => a.Name).Should().BeEquivalentTo(["Dog"]); + } + + [Test] + public async Task SelectMultiSetAsync_AppliesPerItemTenantFilter() + { + await GetOrCreateContainerAsync(TenantContainerId).ConfigureAwait(false); + + var cosmosDbA = CreateCosmosDb("tenant-a"); + var animalsA = cosmosDbA.Container(TenantContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + cosmosDbA.Container(TenantContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animalsA.CreateAsync(new TenantAnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog", TenantId = "tenant-a" }); + + // A different ICosmosDb (tenant-b) queries the SAME container/partition - the tenant-a-owned document must be excluded by the per-item CheckModel check, not merely by the discriminator match. + var cosmosDbB = CreateCosmosDb("tenant-b"); + cosmosDbB.Container(TenantContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + cosmosDbB.Container(TenantContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + List? animalResults = null; + + await cosmosDbB.SelectMultiSetAsync(TenantContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = + [ + new MultiSetCollArgs, TenantAnimalItem>(r => animalResults = r), + new MultiSetCollArgs, TenantPlantItem>(_ => { }) + ] + }); + + animalResults.Should().BeNull(); + } + + [Test] + public void BuildFilterClause_NeitherFilterConfigured_ReturnsNull() + { + var cosmosDb = CreateCosmosDb(); + cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var msa = new MultiSetSingleArgs(_ => { }); + var parameters = new Dictionary(); + + ((IMultiSetArgs)msa).BuildFilterClause(cosmosDb, ContainerId, JsonOptions, "@f0", parameters).Should().BeNull(); + parameters.Should().BeEmpty(); + } + + [Test] + public void BuildFilterClause_WithLogicalDeleteFilter_ReturnsIsDefinedGuardedPredicate() + { + var cosmosDb = CreateCosmosDb(); + cosmosDb.Container(SoftDeleteContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator().WithLogicalDeleteFilter()); + + var msa = new MultiSetSingleArgs(_ => { }); + var parameters = new Dictionary(); + + var clause = ((IMultiSetArgs)msa).BuildFilterClause(cosmosDb, SoftDeleteContainerId, JsonOptions, "@f0", parameters); + + clause.Should().Be("(NOT IS_DEFINED(c[\"isDeleted\"]) OR c[\"isDeleted\"] = false)"); + parameters.Should().BeEmpty(); + } + + [Test] + public void BuildFilterClause_WithTenantFilter_ReturnsIsDefinedGuardedPredicateAndParameter() + { + var cosmosDb = CreateCosmosDb("tenant-a"); + cosmosDb.Container(TenantContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator().WithTenantFilter()); + + var msa = new MultiSetSingleArgs(_ => { }); + var parameters = new Dictionary(); + + var clause = ((IMultiSetArgs)msa).BuildFilterClause(cosmosDb, TenantContainerId, JsonOptions, "@f0", parameters); + + clause.Should().Be("(NOT IS_DEFINED(c[\"tenantId\"]) OR c[\"tenantId\"] = @f0_tenantId)"); + parameters.Should().ContainKey("@f0_tenantId").WhoseValue.Should().Be("tenant-a"); + } + + [Test] + public async Task SelectMultiSetAsync_LogicalDeleteFilter_ExcludesDeletedButLetsThroughLegacyDocumentMissingProperty() + { + await GetOrCreateContainerAsync(SoftDeleteContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(SoftDeleteContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator().WithLogicalDeleteFilter()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new SoftDeleteAnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog", IsDeleted = false }); + + // CosmosDbContainer.CreateAsync rejects an already-deleted model outright (by design), so insert the deleted document directly via the raw SDK container to simulate one that was + // subsequently soft-deleted (an update, not a create) - the multi-set query itself has no notion of update-vs-create, only what is currently persisted. + await animals.Container.CreateItemAsync(new SoftDeleteAnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Cat", TypeDiscriminator = nameof(SoftDeleteAnimalItem), IsDeleted = true }, new PartitionKey(sharedPartition)); + + // A raw, hand-crafted "legacy" document sharing the container/partition/discriminator but predating the isDeleted property being added at all (no isDeleted field present whatsoever) - + // the IS_DEFINED-guarded SQL predicate (see IMultiSetArgs.BuildFilterClause) must let it through rather than silently excluding it. + await animals.Container.CreateItemAsync( + new { id = NewId(), partitionKey = sharedPartition, typeDiscriminator = nameof(SoftDeleteAnimalItem), name = "Fox" }, + new PartitionKey(sharedPartition)); + + List? animalResults = null; + + await cosmosDb.SelectMultiSetAsync(SoftDeleteContainerId, new MultiSetOptions { PartitionKey = sharedPartition, MultiSetArgs = [new MultiSetCollArgs, SoftDeleteAnimalItem>(r => animalResults = r, minimumRows: 1)] }); + + animalResults.Should().NotBeNull(); + animalResults!.Select(a => a.Name).Should().BeEquivalentTo(["Dog", "Fox"]); + } + + [Test] + public async Task SelectMultiSetAsync_MandatorySingle_OnlyMatchIsExcludedByCheckModel_ThrowsRatherThanSilentlySucceedingWithNoValue() + { + // Regression: IMultiSetArgs.AddItem's Result only ever signals a genuine failure (e.g. a WithFilter authorization denial) - a document silently excluded by CheckModel (e.g. logically + // deleted, here) still returns Result.Success. The caller must count a received row solely from the AddItem Result.Value (was it actually added?), never merely from + // Result.IsFailure/IsSuccess - otherwise a filtered-out document would still satisfy MinimumRows, and this mandatory MultiSetSingleArgs would silently invoke nothing (InvokeResult sees + // its own _value still null) rather than this method throwing to signal the mandatory item was never found. + await GetOrCreateContainerAsync(SoftDeleteContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + + // Deliberately omit WithLogicalDeleteFilter() - the query-level filter would exclude the deleted document before it ever reached AddItem, which would make MinimumRows fail for the + // wrong reason (never queried) rather than the reason under test (queried, then excluded by CheckModel's application-level logical-delete check). + var animals = cosmosDb.Container(SoftDeleteContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.Container.CreateItemAsync( + new SoftDeleteAnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Cat", TypeDiscriminator = nameof(SoftDeleteAnimalItem), IsDeleted = true }, + new PartitionKey(sharedPartition)); + + var invoked = false; + Func act = () => cosmosDb.SelectMultiSetAsync(SoftDeleteContainerId, new MultiSetOptions { PartitionKey = sharedPartition, MultiSetArgs = [new MultiSetSingleArgs(_ => invoked = true)] }); + + await act.Should().ThrowAsync(); + invoked.Should().BeFalse(); + } + + [Test] + public async Task SelectMultiSetAsync_ArgsQueryRequestOptionsPartitionKeyMismatch_Throws() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var options = new MultiSetOptions + { + PartitionKey = NewId(), + Args = new CosmosDbArgs { QueryRequestOptions = new QueryRequestOptions { PartitionKey = new PartitionKey(NewId()) } }, + MultiSetArgs = [new MultiSetSingleArgs(_ => { }, isMandatory: false)] + }; + + Func act = () => cosmosDb.SelectMultiSetAsync(ContainerId, options); + + await act.Should().ThrowAsync().WithMessage("*does not match*"); + } + + [Test] + public async Task SelectMultiSetAsync_ArgsQueryRequestOptionsPartitionKeyTakesPrecedence_WhenNoExplicitPartitionKeyProvided() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + + List? animalResults = null; + var options = new MultiSetOptions + { + Args = new CosmosDbArgs { QueryRequestOptions = new QueryRequestOptions { PartitionKey = new PartitionKey(sharedPartition) } }, + MultiSetArgs = [new MultiSetCollArgs, AnimalItem>(r => animalResults = r, minimumRows: 1)] + }; + + await cosmosDb.SelectMultiSetAsync(ContainerId, options); + + animalResults.Should().NotBeNull(); + animalResults!.Select(a => a.Name).Should().BeEquivalentTo(["Dog"]); + } + + [Test] + public async Task SelectMultiSetWithResultAsync_ReturnsMatchingTypes_FromSharedContainerAndPartition() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + var plants = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + await plants.CreateAsync(new PlantItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Fern" }); + + List? animalResults = null; + PlantItem? plantResult = null; + + var result = await cosmosDb.SelectMultiSetWithResultAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = + [ + new MultiSetCollArgs, AnimalItem>(r => animalResults = r, minimumRows: 1), + new MultiSetSingleArgs(r => plantResult = r) + ] + }); + + result.IsSuccess.Should().BeTrue(); + animalResults.Should().NotBeNull(); + animalResults!.Select(a => a.Name).Should().BeEquivalentTo(["Dog"]); + plantResult.Should().NotBeNull(); + plantResult!.Name.Should().Be("Fern"); + } + + [Test] + public async Task SelectMultiSetWithResultAsync_MandatorySingleNotFound_StillThrows() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + + // MinimumRows/MaximumRows violations are invariant/guard-clause conditions (InvalidOperationException), not business-level outcomes - they throw directly even from this Result-returning + // method, exactly as they do from the exception-based SelectMultiSetAsync (see this method's remarks). No PlantItem created in this partition - the mandatory MultiSetSingleArgs + // (isMandatory defaults true, MinimumRows == 1) must throw. + Func act = () => cosmosDb.SelectMultiSetWithResultAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = + [ + new MultiSetCollArgs, AnimalItem>(_ => { }), + new MultiSetSingleArgs(_ => { }) + ] + }); + + await act.Should().ThrowAsync().WithMessage("*less items*"); + } + + [Test] + public async Task SelectMultiSetWithResultAsync_MaximumRowsExceeded_StillThrows() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Cat" }); + + // MultiSetSingleArgs allows at most one (MaximumRows == 1) - two exist in this partition; this is a guard-clause invariant violation, not a business outcome, so it must throw + // even from this Result-returning method. + Func act = () => cosmosDb.SelectMultiSetWithResultAsync(ContainerId, new MultiSetOptions { PartitionKey = sharedPartition, MultiSetArgs = [new MultiSetSingleArgs(_ => { })] }); + + await act.Should().ThrowAsync().WithMessage("*more items*"); + } + + [Test] + public async Task SelectMultiSetWithResultAsync_AddItemFilterFailure_ReturnsFailure() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + + // A genuine business-level outcome (an authorization-style WithFilter denial via CheckModel/CheckFilters) IS surfaced as a Result.IsFailure here - unlike the guard-clause/invariant cases above. + var animals = cosmosDb.Container(ContainerId, o => + { + o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator(); + o.WithFilter(q => q.Where(m => !m.Name.StartsWith("Hidden")), (_, _) => Result.AuthenticationError()); + }); + + var sharedPartition = NewId(); + + // Seed directly via the raw SDK container, bypassing CoreEx.Cosmos's own filter enforcement on Create - "Hidden" is excluded by the filter above, triggering the configured nonQueryResult. + await animals.Container.CreateItemAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Hidden", TypeDiscriminator = nameof(AnimalItem) }, new PartitionKey(sharedPartition)); + + var result = await cosmosDb.SelectMultiSetWithResultAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = [new MultiSetCollArgs, AnimalItem>(_ => { }, minimumRows: 1)] + }); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().BeOfType(); + } + + [Test] + public async Task SelectMultiSetAsync_ModelWithQueryOnlyFilter_ThrowsNotSupported() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + + // Regression: a WithFilter registered WITHOUT a nonQueryResult is "query-only" - applied server-side by a normal CosmosDbQuery (via ApplyFilters), but NOT checked per-item by + // CheckFilters/CheckModel (by design), and cannot be safely translated into a multi-set query's raw SQL text (an arbitrary Func, IQueryable> has no such translation + // outside of a real Cosmos LINQ query). Previously, using such a model in a multi-set query silently ignored the filter entirely, returning documents an equivalent single-set query would have + // excluded. It must now fail fast with NotSupportedException instead. + cosmosDb.Container(ContainerId, o => + { + o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator(); + o.WithFilter(q => q.Where(m => !m.Name.StartsWith("Hidden"))); + }); + + Func act = () => cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions + { + PartitionKey = NewId(), + MultiSetArgs = [new MultiSetCollArgs, AnimalItem>(_ => { })] + }); + + await act.Should().ThrowAsync().WithMessage("*query-only*"); + } + + [Test] + public async Task SelectMultiSetAsync_PreservesOtherQueryRequestOptionsSettings_WhenLayeringInPartitionKey() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + for (var i = 0; i < 5; i++) + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = $"Dog{i}" }); + + List? animalResults = null; + + // MaxItemCount = 1 forces the iterator to page one item at a time - if the caller's QueryRequestOptions were discarded (rather than cloned-and-layered) this setting would be lost and the + // behaviour would be indistinguishable; it is asserted indirectly here via the full result set still being correctly aggregated across the many forced pages. + var options = new MultiSetOptions + { + PartitionKey = sharedPartition, + Args = new CosmosDbArgs { QueryRequestOptions = new QueryRequestOptions { MaxItemCount = 1 } }, + MultiSetArgs = [new MultiSetCollArgs, AnimalItem>(r => animalResults = r, minimumRows: 1)] + }; + + await cosmosDb.SelectMultiSetAsync(ContainerId, options); + + animalResults.Should().NotBeNull(); + animalResults!.Should().HaveCount(5); + } + + [Test] + public async Task SelectMultiSetAsync_FindsModelConfiguredWithExplicitTypeDiscriminatorOverride() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + + // Regression: CosmosDbModelOptions.ApplyTypeDiscriminator stamps an explicit WithTypeDiscriminator(value) override onto every created/updated document (overriding whatever default + // Model.PrepareCreate/PrepareUpdate/PrepareTypeDiscriminator already stamped) - see CosmosDbContainerTypeDiscriminatorTests. Previously, IMultiSetArgs.TypeDiscriminator always resolved the + // unconfigured schema/CLR-name default regardless of any such override, so a multi-set query would build its SQL predicate using the wrong discriminator value and never find these documents + // (a mandatory MultiSetSingleArgs would incorrectly throw "less items than expected"). It must now resolve the same EffectiveTypeDiscriminator value that was actually persisted. + var animals = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator("custom-animal")); + cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey).WithTypeDiscriminator()); + + var sharedPartition = NewId(); + await animals.CreateAsync(new AnimalItem { Id = NewId(), PartitionKey = sharedPartition, Name = "Dog" }); + + AnimalItem? animalResult = null; + + await cosmosDb.SelectMultiSetAsync(ContainerId, new MultiSetOptions + { + PartitionKey = sharedPartition, + MultiSetArgs = [new MultiSetSingleArgs(r => animalResult = r)] + }); + + animalResult.Should().NotBeNull(); + animalResult!.Name.Should().Be("Dog"); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbOptionsTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbOptionsTests.cs new file mode 100644 index 00000000..eb9acd61 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbOptionsTests.cs @@ -0,0 +1,103 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Verifies caches per (containerId, TModel) pair, not per alone - a container is legitimately +/// shared by multiple distinct model types (see ), so keying by containerId alone would let the first TModel +/// registered for a given containerId "win" the cache slot - for the lifetime of this (typically singleton) instance - with every other type sharing that containerId throwing +/// when it tries to cast the cached entry back to its own . +/// +[TestFixture] +public class CosmosDbOptionsTests +{ + [Test] + public void GetOrAddModelOptions_DifferentModelTypes_SameContainerId_ReturnsDistinctInstances() + { + var options = new CosmosDbOptions(); + + var animalOptions = options.GetOrAddModelOptions("shared-container"); + var plantOptions = options.GetOrAddModelOptions("shared-container"); + + animalOptions.Should().NotBeNull(); + plantOptions.Should().NotBeNull(); + + // Re-fetching returns the SAME cached instance per type (proves the cache still works correctly, just now correctly scoped per-type rather than per-containerId-alone). + options.GetOrAddModelOptions("shared-container").Should().BeSameAs(animalOptions); + options.GetOrAddModelOptions("shared-container").Should().BeSameAs(plantOptions); + } + + [Test] + public void TryGetModelOptions_DifferentModelTypes_SameContainerId_EachResolvesItsOwn() + { + var options = new CosmosDbOptions(); + var animalOptions = options.GetOrAddModelOptions("shared-container"); + var plantOptions = options.GetOrAddModelOptions("shared-container"); + + options.TryGetModelOptions("shared-container", out var foundAnimalOptions).Should().BeTrue(); + foundAnimalOptions.Should().BeSameAs(animalOptions); + + options.TryGetModelOptions("shared-container", out var foundPlantOptions).Should().BeTrue(); + foundPlantOptions.Should().BeSameAs(plantOptions); + } + + [Test] + public void TryGetModelOptions_NotRegistered_ReturnsFalse() + { + var options = new CosmosDbOptions(); + + options.TryGetModelOptions("never-registered", out var modelOptions).Should().BeFalse(); + modelOptions.Should().BeNull(); + } + + // Regression test for a review-flagged bug: CosmosDbOptions is typically a long-lived singleton shared across every CosmosDb instance (e.g. one per request/scope). CosmosDb.Container + // previously called GetOrAddModelOptions(containerId) THEN unconditionally invoked its own configure callback against whatever was returned - including an already-configured, shared + // instance from a prior CosmosDb instance/scope - so a configure callback appending state (e.g. WithFilter) kept accumulating duplicate registrations for as long as the process ran. Moving + // construction+configure inside GetOrAddModelOptions's own GetOrAdd factory ensures configure only ever runs once-ever per (containerId, TModel), regardless of how many separate callers ask. + [Test] + public void GetOrAddModelOptions_WithConfigure_OnlyInvokedOnce_AcrossMultipleCallsForSameKey() + { + var options = new CosmosDbOptions(); + var invocationCount = 0; + + var first = options.GetOrAddModelOptions("shared-container", o => { invocationCount++; o.WithFilter(q => q); }); + invocationCount.Should().Be(1); + + // Simulates a second CosmosDb instance (e.g. a new request/scope) sharing this same CosmosDbOptions and requesting the same container/model again with its own configure callback. + var second = options.GetOrAddModelOptions("shared-container", o => { invocationCount++; o.WithFilter(q => q); }); + + second.Should().BeSameAs(first); + invocationCount.Should().Be(1, "configure must only be invoked the first time CosmosDbModelOptions is created for a given (containerId, TModel) - never re-invoked against an already-shared instance."); + } + + [Test] + public void GetOrAddModelOptions_WithoutConfigure_DoesNotThrow() + { + var options = new CosmosDbOptions(); + + var modelOptions = options.GetOrAddModelOptions("no-configure-container"); + + modelOptions.Should().NotBeNull(); + options.GetOrAddModelOptions("no-configure-container").Should().BeSameAs(modelOptions); + } + + // End-to-end version of the regression above through the public entry point a consumer actually calls (CosmosDb.Container), proving the fix holds across genuinely separate CosmosDb + // instances (each with its own empty _modelContainers cache) that merely happen to share one CosmosDbOptions - exactly the singleton-options/scoped-CosmosDb topology the bug affected. + [Test] + public void Container_WithConfigure_SharedOptionsAcrossMultipleCosmosDbInstances_OnlyInvokesConfigureOnce() + { + // A CosmosClient can be constructed, and Container() called on it, without any network I/O - GetDatabase/GetContainer are client-side reference factories only. + var client = new CosmosClient("https://localhost:8081", "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="); + var sharedOptions = new CosmosDbOptions(); + var invocationCount = 0; + + var firstCosmosDb = new CosmosDb(client, "test-db", sharedOptions); + var firstContainer = firstCosmosDb.Container("shared-container", o => { invocationCount++; o.WithFilter(q => q); }); + invocationCount.Should().Be(1); + + // A brand-new CosmosDb instance - as would happen per request/scope in a real app - has its own empty _modelContainers cache, but shares the same (singleton-style) CosmosDbOptions. + var secondCosmosDb = new CosmosDb(client, "test-db", sharedOptions); + var secondContainer = secondCosmosDb.Container("shared-container", o => { invocationCount++; o.WithFilter(q => q); }); + + invocationCount.Should().Be(1, "configure must not be re-invoked just because a new CosmosDb instance/scope is the first to request an already-configured, shared model options instance."); + secondContainer.Options.Should().BeSameAs(firstContainer.Options); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbOutboxRelayTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbOutboxRelayTests.cs new file mode 100644 index 00000000..ff445c21 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbOutboxRelayTests.cs @@ -0,0 +1,429 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbOutboxRelayTests : CosmosTestBase +{ + private const string ContainerId = "relay-items"; + + private static async Task> QueryOutboxDocsAsync(Container rawContainer, string pk) + { + var query = rawContainer.GetItemLinqQueryable().Where(e => e.PartitionKey == pk && e.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + var docs = new List(); + using var iterator = query.ToFeedIterator(); + while (iterator.HasMoreResults) + docs.AddRange(await iterator.ReadNextAsync()); + + return docs; + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!condition() && sw.Elapsed < timeout) + await Task.Delay(100); + } + + /// + /// Creates a test for a , with registered scoped (via a factory, not a shared instance) - matching production's + /// AddScoped<ICosmosDb> wiring so each batch's scope gets its own, empty-model-container-cache , exactly as it would in a real host. Sharing one + /// instance between the test's own setup code and the processor's scope would incorrectly collide on 's per-container-id (not per-TModel) cache. + /// + private static ServiceProvider CreateServiceProvider(IEventPublisher eventPublisher) + { + var services = new ServiceCollection(); + services.AddScoped(_ => CreateCosmosDb()); + services.AddSingleton(eventPublisher); + return services.BuildServiceProvider(); + } + + [Test] + public async Task ProcessBatchAsync_PublishesAndDeletes() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var outboxDocs = await QueryOutboxDocsAsync(rawContainer, pk); + outboxDocs.Should().ContainSingle(); + + var testPublisher = new TestEventPublisher(); + using var sp = CreateServiceProvider(testPublisher); + var processor = new CosmosDbOutboxRelayProcessor(sp, ContainerId, NullLogger.Instance); + + await processor.ProcessBatchAsync(outboxDocs, CancellationToken.None); + + testPublisher.Published.Should().ContainSingle(); + testPublisher.Published[0].Destination.Should().Be(outboxDocs[0].Destination); + + // Cleanup - the outbox document should now be gone. + var remaining = await QueryOutboxDocsAsync(rawContainer, pk); + remaining.Should().BeEmpty(); + } + + [Test] + public async Task ProcessBatchAsync_CosmosDbEventPublisherRegisteredAsDestination_ThrowsClearGuidance() + { + // Regression: registering CosmosDbEventPublisher (the outbox write-side publisher) as the relay's default IEventPublisher must fail immediately with an actionable message naming the + // misconfiguration - not the deeper, less obvious "no active transaction" exception CosmosDbEventPublisher.OnPublishAsync would otherwise throw once actually invoked. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var outboxDocs = await QueryOutboxDocsAsync(rawContainer, pk); + outboxDocs.Should().ContainSingle(); + + // Misconfiguration under test: CosmosDbEventPublisher (write-side) registered as the relay's own destination IEventPublisher. + using var sp = CreateServiceProvider(new CosmosDbEventPublisher(CreateCosmosDb())); + var processor = new CosmosDbOutboxRelayProcessor(sp, ContainerId, NullLogger.Instance); + + var ex = Assert.ThrowsAsync(async () => await processor.ProcessBatchAsync(outboxDocs, CancellationToken.None)); + ex!.Message.Should().Contain(nameof(CosmosDbEventPublisher)).And.Contain(ContainerId); + + // Nothing should have been deleted - the guard fires before any cleanup work is attempted. + var remaining = await QueryOutboxDocsAsync(rawContainer, pk); + remaining.Should().ContainSingle(); + } + + [Test] + public async Task ProcessBatchAsync_PartitionKeyNone_PublishesAndDeletes() + { + // Regression: DeleteOneAsync previously always called the partitionKey-taking DeleteAsync overload with a null-forgiving bang on doc.PartitionKey - which is a real, valid value (PartitionKey.None) + // for a model with no WithPartitionKey/WithFixedPartitionKey configured, not a missing one. That overload ThrowIfNull()s its partitionKey argument, so cleanup-delete failed for every such + // document (silently, per ProcessBatchAsync's remarks - caught, logged, counted - so it would linger until TTL rather than actually being removed). + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new NoPartitionKeyItem { Id = id, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var query = rawContainer.GetItemLinqQueryable().Where(e => e.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + + async Task> QueryAllNoPartitionOutboxDocsAsync() + { + var docs = new List(); + using var iterator = query.ToFeedIterator(); + while (iterator.HasMoreResults) + docs.AddRange(await iterator.ReadNextAsync()); + + return docs.Where(d => d.PartitionKey is null && d.Event.GetProperty("subject").GetString() == id).ToList(); + } + + var outboxDocs = await QueryAllNoPartitionOutboxDocsAsync(); + outboxDocs.Should().ContainSingle(); + + var testPublisher = new TestEventPublisher(); + using var sp = CreateServiceProvider(testPublisher); + var processor = new CosmosDbOutboxRelayProcessor(sp, ContainerId, NullLogger.Instance); + + await processor.ProcessBatchAsync(outboxDocs, CancellationToken.None); + + testPublisher.Published.Should().ContainSingle(); + + // Cleanup - the outbox document should now actually be gone, not left lingering due to a failed cleanup-delete. + var remaining = await QueryAllNoPartitionOutboxDocsAsync(); + remaining.Should().BeEmpty(); + } + + [Test] + public async Task ProcessBatchAsync_EmitsPerEventRelayMarker_ParentedToOriginatingTrace() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var id = NewId(); + + // A standalone, listened-to Activity simulating the originating operation (e.g. the API request that raised the event) - independent of the test runner's own ambient activity, so its + // traceparent is deterministically embedded into the outbox document (via IEventFormatter.AddTracing, which uses Activity.Current when no explicit trace context has already been set). + using var producerSource = new System.Diagnostics.ActivitySource($"test.producer.{NewId()}"); + using var producerListener = new System.Diagnostics.ActivityListener + { + ShouldListenTo = s => s.Name == producerSource.Name, + Sample = (ref System.Diagnostics.ActivityCreationOptions _) => System.Diagnostics.ActivitySamplingResult.AllDataAndRecorded + }; + System.Diagnostics.ActivitySource.AddActivityListener(producerListener); + using var producerActivity = producerSource.StartActivity("original-request"); + producerActivity.Should().NotBeNull(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var outboxDocs = await QueryOutboxDocsAsync(rawContainer, pk); + outboxDocs.Should().ContainSingle(); + + var markers = new List(); + using var markerListener = new System.Diagnostics.ActivityListener + { + ShouldListenTo = s => s.Name == CoreEx.Events.CloudEventTracingExtensions.RelayMarkerActivitySourceName, + Sample = (ref System.Diagnostics.ActivityCreationOptions _) => System.Diagnostics.ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => { lock (markers) markers.Add(a); } + }; + System.Diagnostics.ActivitySource.AddActivityListener(markerListener); + + var testPublisher = new TestEventPublisher(); + using var sp = CreateServiceProvider(testPublisher); + var processor = new CosmosDbOutboxRelayProcessor(sp, ContainerId, NullLogger.Instance); + + await processor.ProcessBatchAsync(outboxDocs, CancellationToken.None); + + markers.Should().ContainSingle(); + markers[0].TraceId.Should().Be(producerActivity!.TraceId); + markers[0].ParentSpanId.Should().Be(producerActivity.SpanId); + } + + [Test] + public async Task ProcessBatchAsync_PublishFailure_StillRecordsLagMetrics() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var outboxDocs = await QueryOutboxDocsAsync(rawContainer, pk); + outboxDocs.Should().ContainSingle(); + + var testPublisher = new TestEventPublisher { ThrowOnPublish = true }; + using var sp = CreateServiceProvider(testPublisher); + var processor = new CosmosDbOutboxRelayProcessor(sp, ContainerId, NullLogger.Instance); + + var oldestLagRecorded = false; + using var meterListener = new System.Diagnostics.Metrics.MeterListener(); + meterListener.InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == CosmosMetrics.Meter.Name && instrument.Name == "cosmos.outbox.relay.oldest_lag") + listener.EnableMeasurementEvents(instrument); + }; + meterListener.SetMeasurementEventCallback((_, _, _, _) => oldestLagRecorded = true); + meterListener.Start(); + + // A failed publish must still propagate (feeds the circuit breaker/Change Feed's own redelivery) - but the lag metric must be recorded regardless, so a stuck relay shows up as growing + // lag rather than an absent metric. + Assert.ThrowsAsync(async () => await processor.ProcessBatchAsync(outboxDocs, CancellationToken.None)); + + oldestLagRecorded.Should().BeTrue(); + } + + [Test] + public async Task ProcessBatchAsync_NonOutboxDocument_IsIgnored() + { + var testPublisher = new TestEventPublisher(); + using var sp = CreateServiceProvider(testPublisher); + var processor = new CosmosDbOutboxRelayProcessor(sp, "irrelevant-container", NullLogger.Instance); + + // A co-located business document change, as delivered verbatim by the Change Feed Processor (not $outbox,-prefixed) - must never reach the publisher. + var businessDoc = new CosmosDbOutboxEvent { Id = NewId(), PartitionKey = "pk", Destination = "irrelevant", Event = default }; + + await processor.ProcessBatchAsync([businessDoc], CancellationToken.None); + + testPublisher.Published.Should().BeEmpty(); + } + + // Fixed (not per-run GUID-suffixed) container names, matching every other fixture in this project - the local emulator caps the TOTAL number of containers across the whole account + // (AZURE_COSMOS_EMULATOR_PARTITION_COUNT, see docker-compose.yml), and a new container pair per test run/rerun burns through that budget fast for no benefit (confirmed the hard way this session). + private const string CircuitBreakerContainerId = "relay-cb-items"; + private const string CircuitBreakerLeaseContainerId = "relay-cb-items-leases"; + + [Test] + public async Task Relay_CircuitBreaker_TripsOnRepeatedPublishFailure_ThenSelfRecovers() + { + var containerId = CircuitBreakerContainerId; + var leaseContainerId = CircuitBreakerLeaseContainerId; + await GetOrCreateContainerAsync(containerId).ConfigureAwait(false); + await TestDatabase.CreateContainerIfNotExistsAsync(leaseContainerId, "/id").ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(containerId, o => o.WithPartitionKey(m => m.PartitionKey)); + + var testPublisher = new TestEventPublisher { ThrowOnPublish = true }; + using var sp = CreateServiceProvider(testPublisher); + var processor = new CosmosDbOutboxRelayProcessor(sp, containerId, NullLogger.Instance); + + var options = new CosmosDbOutboxRelayOptions + { + ContainerId = containerId, + LeaseContainerId = leaseContainerId, + InstanceName = $"instance-{NewId()}", + PollInterval = TimeSpan.FromMilliseconds(200), + // Confirmed empirically (not assumed): the Change Feed Processor batches whatever is pending at each poll rather than delivering one item per poll, so staggering distinct item creations does not + // reliably produce distinct pipeline executions the way a SQL claim-a-batch-per-tick loop would. What IS consistent, observed across repeated runs: the processor's own retry-of-a-failing-batch + // backoff delivers attempt 1 near-immediately and attempt 2 within roughly 13-15s - so minimumThroughput=2 with a samplingDuration comfortably wider than that gap trips reliably after attempt 2, + // without needing a 3rd attempt (which the backoff stretches out much further). + Resiliency = CosmosDbOutboxRelayResiliency.CreateRelayCircuitBreakerResiliency(minimumThroughput: 2, samplingDuration: TimeSpan.FromSeconds(30), breakDuration: TimeSpan.FromMilliseconds(500)) + }; + + await using var relay = new CosmosDbOutboxRelay(cosmosDb.Database, options, processor, NullLogger.Instance); + await relay.StartAsync(); + try + { + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "AlwaysFails" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + await WaitUntilAsync(() => relay.Status == ServiceStatus.Paused, TimeSpan.FromSeconds(30)); + relay.Status.Should().Be(ServiceStatus.Paused); + relay.StatusReason.Should().Contain(containerId); + + // Remove the failure condition; the relay should self-resume and the stuck event should finally be relayed successfully. + testPublisher.ThrowOnPublish = false; + + await WaitUntilAsync(() => relay.Status == ServiceStatus.Running, TimeSpan.FromSeconds(10)); + relay.Status.Should().Be(ServiceStatus.Running); + + await WaitUntilAsync(() => testPublisher.Published.Count > 0, TimeSpan.FromSeconds(15)); + testPublisher.Published.Should().NotBeEmpty(); + } + finally + { + await relay.StopAsync(); + } + } + + private const string DisposeLeaseContainerId = "relay-items-leases"; + + [Test] + public async Task DisposeAsync_WithoutPriorStop_StopsProcessorAndIsIdempotent() + { + // Regression: DisposeAsync previously only disposed the semaphore, never stopping a running Change Feed Processor - leaving it running (with its callbacks racing disposed state/resources) + // if the caller disposed a started relay without an explicit preceding StopAsync. It must now stop the processor as part of disposal, and remain safe to call more than once. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + await TestDatabase.CreateContainerIfNotExistsAsync(DisposeLeaseContainerId, "/id").ConfigureAwait(false); + + var cosmosDb = CreateCosmosDb(); + using var sp = CreateServiceProvider(new TestEventPublisher()); + var processor = new CosmosDbOutboxRelayProcessor(sp, ContainerId, NullLogger.Instance); + + var options = new CosmosDbOutboxRelayOptions + { + ContainerId = ContainerId, + LeaseContainerId = DisposeLeaseContainerId, + InstanceName = $"instance-{NewId()}" + }; + + var relay = new CosmosDbOutboxRelay(cosmosDb.Database, options, processor, NullLogger.Instance); + await relay.StartAsync(); + relay.Status.Should().Be(ServiceStatus.Running); + + await relay.DisposeAsync(); + relay.Status.Should().Be(ServiceStatus.Stopped); + + // Idempotent - a second dispose must not throw (e.g. re-disposing the semaphore, or calling StopAsync again against already-disposed synchronization resources). + Func act = async () => await relay.DisposeAsync(); + await act.Should().NotThrowAsync(); + } + + private const string AutoProvisionContainerId = "relay-autoprovision-items"; + private const string AutoProvisionLeaseContainerId = "relay-autoprovision-items-leases"; + + [Test] + public async Task StartAsync_LeaseContainerDoesNotAlreadyExist_IsAutoProvisioned() + { + // Regression: StartAsync previously only ever resolved the lease container via Database.GetContainer (a proxy reference, not a create) - against a fresh Cosmos DB database where the lease + // container had never been created, the underlying ChangeFeedProcessor.StartAsync would fail trying to acquire leases against a container that does not exist. Deliberately does NOT pre-create + // the lease container here (unlike every other test in this fixture), to prove StartAsync itself now provisions it. + await GetOrCreateContainerAsync(AutoProvisionContainerId).ConfigureAwait(false); + + // Confirm the lease container genuinely does not exist yet - a stale container from a prior interrupted run would invalidate this test's premise. + try + { + await TestDatabase.GetContainer(AutoProvisionLeaseContainerId).DeleteContainerAsync().ConfigureAwait(false); + } + catch (CosmosException cex) when (cex.StatusCode == HttpStatusCode.NotFound) + { + // Expected - already absent. + } + + var cosmosDb = CreateCosmosDb(); + using var sp = CreateServiceProvider(new TestEventPublisher()); + var processor = new CosmosDbOutboxRelayProcessor(sp, AutoProvisionContainerId, NullLogger.Instance); + + var options = new CosmosDbOutboxRelayOptions + { + ContainerId = AutoProvisionContainerId, + LeaseContainerId = AutoProvisionLeaseContainerId, + InstanceName = $"instance-{NewId()}" + }; + + await using var relay = new CosmosDbOutboxRelay(cosmosDb.Database, options, processor, NullLogger.Instance); + + Func act = async () => await relay.StartAsync(); + await act.Should().NotThrowAsync(); + relay.Status.Should().Be(ServiceStatus.Running); + + // The lease container must now actually exist (StartAsync provisioned it) - ReadContainerAsync throws CosmosException(NotFound) otherwise. + var readResponse = await TestDatabase.GetContainer(AutoProvisionLeaseContainerId).ReadContainerAsync().ConfigureAwait(false); + readResponse.Resource.Id.Should().Be(AutoProvisionLeaseContainerId); + + await relay.StopAsync(); + } + + private sealed class TestEventPublisher : EventPublisherBase + { + public List Published { get; } = []; + + public bool ThrowOnPublish { get; set; } + + protected override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) + { + if (ThrowOnPublish) + throw new InvalidOperationException("Simulated publish failure."); + + lock (Published) + Published.AddRange(events); + + return Task.CompletedTask; + } + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbSeedingTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbSeedingTests.cs new file mode 100644 index 00000000..783ec150 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbSeedingTests.cs @@ -0,0 +1,42 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Proves the CoreEx.Cosmos.Extended container-reset + YAML-driven batch-import primitives end-to-end: reset a container to a known-empty state, seed it from an embedded +/// *.seed.yaml fixture via + ImportBatchAsync, then read/assert against the seeded data - directly analogous to the SQL Server/Postgres samples' +/// Test.MigratePostgresDataAsync/MigrateSqlServerDataAsync seeding pattern, for containers rather than relational tables. +/// +[TestFixture] +public class CosmosDbSeedingTests : CosmosTestBase +{ + private const string ContainerId = "seed-read-items"; + private static Container? _seededContainer; + + /// + /// Resets (deletes + recreates) and seeds it from Data/read-data.seed.yaml, once per test run. + /// + private static async Task GetSeededContainerAsync() + { + if (_seededContainer is not null) + return _seededContainer; + + var container = await TestDatabase.ReplaceOrCreateContainerAsync(ContainerId, "/partitionKey").ConfigureAwait(false); + + var jdr = JsonDataReader.ParseYaml("read-data.seed.yaml"); + (await container.ImportBatchAsync(jdr, ContainerId).ConfigureAwait(false)).Should().BeTrue("the fixture's top-level 'seed-read-items' key must resolve to an array of items to import"); + + return _seededContainer = container; + } + + [Test] + public async Task ImportBatchAsync_SeedsContainer_ThenQueryableViaCosmosDbContainer() + { + await GetSeededContainerAsync(); + + var items = await CreateCosmosDb().Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)) + .Query(q => q.Where(m => m.PartitionKey == "seed-pk").OrderBy(m => m.Name)) + .ToListAsync(); + + items.Should().HaveCount(3); + items.Select(m => m.Name).Should().ContainInOrder("Item-01", "Item-02", "Item-03"); + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosDbUnitOfWorkTests.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbUnitOfWorkTests.cs new file mode 100644 index 00000000..f9fbe0aa --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosDbUnitOfWorkTests.cs @@ -0,0 +1,626 @@ +namespace CoreEx.Cosmos.Test.Unit; + +[TestFixture] +public class CosmosDbUnitOfWorkTests : CosmosTestBase +{ + private const string ContainerId = "uow-items"; + + [Test] + public async Task TransactionAsync_SamePartition_CommitsBothAtomically() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var id1 = NewId(); + var id2 = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = id1, PartitionKey = pk, Name = "One" }, ct).ConfigureAwait(false); + await container.CreateAsync(new TestItem { Id = id2, PartitionKey = pk, Name = "Two" }, ct).ConfigureAwait(false); + }); + + var fetched1 = await container.GetAsync(CompositeKey.Create(id1), pk); + var fetched2 = await container.GetAsync(CompositeKey.Create(id2), pk); + + fetched1.Should().NotBeNull(); + fetched1!.Name.Should().Be("One"); + fetched2.Should().NotBeNull(); + fetched2!.Name.Should().Be("Two"); + } + + [Test] + public async Task TransactionAsync_CrossPartition_ThrowsBeforeAnyNetworkCall_AndPersistsNothing() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pkA = NewId(); + var pkB = NewId(); + var idA = NewId(); + var idB = NewId(); + + Func act = () => unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = idA, PartitionKey = pkA, Name = "A" }, ct).ConfigureAwait(false); + await container.CreateAsync(new TestItem { Id = idB, PartitionKey = pkB, Name = "B" }, ct).ConfigureAwait(false); + }); + + await act.Should().ThrowAsync(); + + // Neither item should exist - the second (mismatched) call never even reached Cosmos DB, and the first was never executed (deferred until the whole batch commits). + var fetchedA = await container.GetAsync(CompositeKey.Create(idA), pkA); + fetchedA.Should().BeNull(); + } + + [Test] + public async Task TransactionAsync_ResultFailureInsideWork_DiscardsBatch() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var id = NewId(); + + var result = await unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Should not persist" }, ct).ConfigureAwait(false); + return Result.AuthenticationError(); + }); + + result.IsFailure.Should().BeTrue(); + + var fetched = await container.GetAsync(CompositeKey.Create(id), pk); + fetched.Should().BeNull(); + } + + [Test] + public async Task TransactionAsync_WithOutbox_WritesEventDocumentAtomically() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + var fetched = await container.GetAsync(CompositeKey.Create(id), pk); + fetched.Should().NotBeNull(); + + // Confirm the paired outbox event document exists in the SAME container/partition, findable by explicitly targeting the reserved prefix (the relay's future "internal explicit read"). + var rawContainer = cosmosDb.GetContainer(ContainerId); + var query = rawContainer.GetItemLinqQueryable() + .Where(e => e.PartitionKey == pk && e.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + + var outboxDocs = new List(); + using (var iterator = query.ToFeedIterator()) + { + while (iterator.HasMoreResults) + outboxDocs.AddRange(await iterator.ReadNextAsync()); + } + + outboxDocs.Should().ContainSingle(); + outboxDocs[0].Destination.Should().NotBeNullOrEmpty(); + outboxDocs[0].TimeToLive.Should().Be(CosmosDbEventPublisher.DefaultOutboxTimeToLiveSeconds); + } + + [Test] + public async Task TransactionAsync_WithOutbox_PartitionKeyNone_WritesEventDocumentAtomically() + { + // NoPartitionKeyItem implements neither IPartitionKey nor IReadOnlyPartitionKey, and no WithPartitionKey/WithFixedPartitionKey is configured here either - the business mutation's own partition + // key resolves to PartitionKey.None, a real, valid single logical partition (not an error) - confirms the outbox event document can still be enlisted/co-located there too (see the + // CosmosDbEventPublisher.OnPublishAsync fix this test guards against regressing). + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new NoPartitionKeyItem { Id = id, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + var fetched = await container.GetAsync(CompositeKey.Create(id)); + fetched.Should().NotBeNull(); + + // Confirm the paired outbox event document exists in the SAME container/PartitionKey.None partition. Filtering "PartitionKey == null" server-side would not match a truly absent field in + // Cosmos SQL (undefined != null), so the None-partition check is applied client-side after retrieval instead. Scoped to this run's own event via the CloudEvent "subject" (the entity id) - + // unlike the sibling TransactionAsync_WithOutbox_WritesEventDocumentAtomically test (scoped by a unique per-run partition key value), every run of this test shares the same PartitionKey.None + // partition, so leftover documents from earlier runs against this same (never-reset) container would otherwise also match. + var rawContainer = cosmosDb.GetContainer(ContainerId); + var query = rawContainer.GetItemLinqQueryable() + .Where(e => e.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + + var outboxDocs = new List(); + using (var iterator = query.ToFeedIterator()) + { + while (iterator.HasMoreResults) + outboxDocs.AddRange(await iterator.ReadNextAsync()); + } + + var matchingDocs = outboxDocs.Where(d => d.PartitionKey is null && d.Event.GetProperty("subject").GetString() == id).ToList(); + matchingDocs.Should().ContainSingle(); + matchingDocs[0].Destination.Should().NotBeNullOrEmpty(); + } + + // Regression test for a review-flagged bug: CosmosDbOutboxEvent always serializes its partition key under the fixed JSON property name "partitionKey", which is only correct where the container's + // actual, physical partition-key path (set at container-creation time, independent of C# property names) is literally "/partitionKey" - the convention every other test container in this fixture + // uses. A container configured with a different path (here "/tenantId") would otherwise silently produce an outbox document with no value at that path, and the paired TransactionalBatch would then + // fail with an undiagnosable BadRequest. CosmosDbEventPublisher.OnPublishAsync now validates this up front (see EnsureOutboxPartitionKeyPathAsync) and fails fast with a clear, actionable exception - + // and, since that check runs before anything is enlisted, the business mutation itself is never committed either. + [Test] + public async Task TransactionAsync_WithOutbox_ContainerPartitionKeyPathIsNotPartitionKey_ThrowsBeforeEnlistingAnything() + { + const string containerId = "uow-wrong-pk-path"; + await GetOrCreateContainerAsync(containerId, "/tenantId").ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(containerId); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var id = NewId(); + + Func act = () => unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new NoPartitionKeyItem { Id = id, Name = "Widget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + var thrown = await act.Should().ThrowAsync(); + thrown.Which.Message.Should().Contain("/tenantId"); + + // The business mutation must not have been committed either - the pre-flight partition-key-path check runs, and throws, before anything is enlisted into the TransactionalBatch. + var fetched = await container.GetAsync(CompositeKey.Create(id)); + fetched.Should().BeNull(); + } + + [Test] + public async Task Query_WithOutboxDocumentsPresent_AutomaticallyExcludesThem_NoFilterConfiguredByTest() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Gadget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + // An ordinary business query against the SAME container/partition that now also holds an outbox event document - no WithFilter/WithTypeDiscriminator configured by this test at all. + var items = await container.Query(q => q.Where(m => m.PartitionKey == pk)).ToListAsync(); + + items.Should().ContainSingle(); + items[0].Name.Should().Be("Gadget"); + } + + [Test] + public async Task Query_WithOutboxDocumentsPresent_AutomaticallyExcludesThem_ModelWithoutIIdentifierInterface() + { + // NonIdentifierKeyedItem deliberately implements neither IIdentifier nor IReadOnlyIdentifier (its Cosmos DB "id" is exposed via a differently-named, [JsonPropertyName("id")] + // decorated property instead) - regression test for CosmosDbModelOptions.ApplyFilters' reflection-based fallback, confirming the automatic outbox-document exclusion still applies even + // when IdentifierSupport is not supported. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new NonIdentifierKeyedItem { DocumentId = id, PartitionKey = pk, Name = "Gadget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value.DocumentId, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + // An ordinary business query against the SAME container/partition that now also holds an outbox event document. + var items = await container.Query(q => q.Where(m => m.PartitionKey == pk)).ToListAsync(); + + items.Should().ContainSingle(); + items[0].Name.Should().Be("Gadget"); + } + + [Test] + public async Task Query_WithOutboxDocumentsPresent_AutomaticallyExcludesThem_ModelWithConventionalUnannotatedIdProperty() + { + // ConventionIdKeyedItem deliberately implements neither IIdentifier nor IReadOnlyIdentifier, and its "Id" property carries no [JsonPropertyName] attribute at all - regression test + // for CosmosDbModelOptions.ApplyFilters' reflection-based fallback, confirming the automatic outbox-document exclusion also applies to a plain, conventionally-named "Id" property (as a + // serializer configured with a naming policy, e.g. camelCase, would map to Cosmos DB's reserved "id"), not just one carrying an explicit [JsonPropertyName("id")] attribute. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new ConventionIdKeyedItem { Id = id, PartitionKey = pk, Name = "Gadget" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value.Id, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk)); + }); + + // An ordinary business query against the SAME container/partition that now also holds an outbox event document. + var items = await container.Query(q => q.Where(m => m.PartitionKey == pk)).ToListAsync(); + + items.Should().ContainSingle(); + items[0].Name.Should().Be("Gadget"); + } + + [Test] + public async Task SynchronizeETag_MultipleEntities_ResolvesEachByKey_NotReference() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var id1 = NewId(); + var id2 = NewId(); + + await unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = id1, PartitionKey = pk, Name = "One" }, ct).ConfigureAwait(false); + await container.CreateAsync(new TestItem { Id = id2, PartitionKey = pk, Name = "Two" }, ct).ConfigureAwait(false); + }); + + // Simulate two separately-mapped contracts - distinct object instances/types from the TestItem models actually mutated above (SynchronizeETag cannot rely on reference identity). + var contract1 = new TestValue { Id = id1, Name = "One" }; + var contract2 = new TestValue { Id = id2, Name = "Two" }; + + unitOfWork.SynchronizeETag(CompositeKey.Create(id1), contract1); + unitOfWork.SynchronizeETag(CompositeKey.Create(id2), contract2); + + var fetched1 = await container.GetAsync(CompositeKey.Create(id1), pk); + var fetched2 = await container.GetAsync(CompositeKey.Create(id2), pk); + + // Each contract must resolve its OWN document's true ETag (proving correlation is by key, not by position/reference) - not asserting the two ETags differ from each other, since the emulator can + // legitimately assign the same _etag to multiple documents committed within the same physical TransactionalBatch; that's an emulator/Cosmos DB implementation detail, not part of this contract. + contract1.ETag.Should().NotBeNullOrEmpty(); + contract1.ETag.Should().Be(fetched1!.ETag); + contract2.ETag.Should().NotBeNullOrEmpty(); + contract2.ETag.Should().Be(fetched2!.ETag); + } + + [Test] + public void SynchronizeETag_BeforeAnyTransaction_Throws() + { + var cosmosDb = CreateCosmosDb(); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + var contract = new TestValue { Id = NewId() }; + + Assert.Throws(() => unitOfWork.SynchronizeETag(CompositeKey.Create(contract.Id), contract)); + } + + [Test] + public async Task SynchronizeETag_KeyNotPartOfTransaction_Throws() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var id = NewId(); + + await unitOfWork.TransactionAsync(async ct => await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "X" }, ct).ConfigureAwait(false)); + + var unrelatedContract = new TestValue { Id = NewId() }; + Assert.Throws(() => unitOfWork.SynchronizeETag(CompositeKey.Create(unrelatedContract.Id), unrelatedContract)); + } + + [Test] + public async Task TransactionAsync_DeleteNonExistentItem_DoesNotFailBatch_AndOtherOperationsStillCommit() + { + // TransactionalBatch fails the WHOLE batch if any enlisted operation targets a non-existent item (confirmed empirically) - the pre-read forced inside a unit-of-work must catch this before + // enlisting, so a benign "already gone" delete never takes down an otherwise-valid Create bundled in the same transaction. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var createdId = NewId(); + var neverExistedId = NewId(); + + var deleted = default(DataResult); + await unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = createdId, PartitionKey = pk, Name = "Survivor" }, ct).ConfigureAwait(false); + var result = await container.DeleteWithResultAsync(CompositeKey.Create(neverExistedId), pk, ct).ConfigureAwait(false); + deleted = result.Value; + }); + + deleted.WasMutated.Should().BeFalse(); + + var fetched = await container.GetAsync(CompositeKey.Create(createdId), pk); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Survivor"); + } + + [Test] + public async Task TransactionAsync_DeleteExisting_WhereMutated_QueuesEvent_DeleteNonExistent_DoesNot() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + + var pk = NewId(); + var anchorId = NewId(); + var existingId = NewId(); + var neverExistedId = NewId(); + + // Seed an item to actually delete, outside any unit-of-work. + await container.CreateAsync(new TestItem { Id = existingId, PartitionKey = pk, Name = "ToDelete" }); + + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + await unitOfWork.TransactionAsync(async ct => + { + // A Delete-only unit-of-work has no model instance to derive a raw partition key value from, which the paired outbox event write needs (see CosmosDbEventPublisher/CosmosDbContainer.Delete.cs + // remarks) - a preceding Create/Update in the same unit-of-work is required to bind one. This mirrors a realistic scenario (e.g. moving an item, or updating a related aggregate root) rather + // than being an artificial workaround. + await container.CreateAsync(new TestItem { Id = anchorId, PartitionKey = pk, Name = "Anchor" }, ct).ConfigureAwait(false); + + var deletedExisting = await container.DeleteWithResultAsync(CompositeKey.Create(existingId), pk, ct).ConfigureAwait(false); + deletedExisting.Value.WhereMutated(() => unitOfWork.Events.Add(EventData.CreateEventWith(existingId, EventAction.Deleted).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk))); + + var deletedMissing = await container.DeleteWithResultAsync(CompositeKey.Create(neverExistedId), pk, ct).ConfigureAwait(false); + deletedMissing.Value.WhereMutated(() => unitOfWork.Events.Add(EventData.CreateEventWith(neverExistedId, EventAction.Deleted).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute)).WithPartitionKey(pk))); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var query = rawContainer.GetItemLinqQueryable().Where(e => e.PartitionKey == pk && e.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + + var outboxDocs = new List(); + using (var iterator = query.ToFeedIterator()) + { + while (iterator.HasMoreResults) + outboxDocs.AddRange(await iterator.ReadNextAsync()); + } + + // Exactly one event - for the deletion that actually happened, not the one that was already gone. + outboxDocs.Should().ContainSingle(); + } + + [Test] + public async Task TransactionAsync_UpsertNewKey_CreatesItem_DoesNotFailBatch() + { + // Regression: UpsertAsync's non-transactional "try Update, retry as Create on Not Found" cannot work inside a CosmosDbUnitOfWork - ReplaceItem is only enlisted (queued), so a missing item's 404 + // can only be observed once the whole TransactionalBatch executes, by which point retrying is too late and the entire batch fails instead. A forced pre-read must determine existence up-front. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var newId = NewId(); + + var upserted = default(DataResult); + await unitOfWork.TransactionAsync(async ct => + { + upserted = await container.UpsertAsync(new TestItem { Id = newId, PartitionKey = pk, Name = "Brand New" }, ct).ConfigureAwait(false); + }); + + upserted.WasMutated.Should().BeTrue(); + + var fetched = await container.GetAsync(CompositeKey.Create(newId), pk); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Brand New"); + } + + [Test] + public async Task TransactionAsync_UpsertExistingKey_UpdatesItem() + { + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + + var pk = NewId(); + var id = NewId(); + + // Seed outside any unit-of-work. + await container.CreateAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Original" }); + + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + await unitOfWork.TransactionAsync(async ct => await container.UpsertAsync(new TestItem { Id = id, PartitionKey = pk, Name = "Replaced" }, ct).ConfigureAwait(false)); + + var fetched = await container.GetAsync(CompositeKey.Create(id), pk); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Replaced"); + } + + [Test] + public async Task TransactionAsync_UpsertExistingKey_PartitionKeySelectorDependsOnStampedTenantId_UpdatesItem() + { + // Regression: the transactional upsert's pre-read must resolve the partition key AFTER the tenant stamping that Model.PrepareCreate/PrepareUpdate perform, not before. Here WithPartitionKey + // selects off TenantId, which is auto-stamped from the ExecutionContext and never caller-supplied. Resolving the partition key from the caller's unstamped model (TenantId still null) would + // read under PartitionKey.None, miss the already-existing item (seeded under the real "tenant-a" partition), and wrongly enlist a Create instead of an Update - which then fails the whole + // batch with a conflict, since a document with that id already exists (just in a different partition than the one the buggy pre-read checked). + const string containerId = "uow-tenant-pk-items"; + await GetOrCreateContainerAsync(containerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb("tenant-a"); + var container = cosmosDb.Container(containerId, o => o.WithPartitionKey(m => m.TenantId)); + + var id = NewId(); + + // Seed outside any unit-of-work; TenantId (and therefore the partition key) is stamped automatically from the ExecutionContext ("tenant-a"). + await container.CreateAsync(new TenantItem { Id = id, Name = "Original" }); + + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + // The caller does not set TenantId - by design it is never caller-supplied, only auto-stamped - so the model handed to UpsertAsync starts with a null TenantId/partition key. + await unitOfWork.TransactionAsync(async ct => await container.UpsertAsync(new TenantItem { Id = id, Name = "Replaced" }, ct).ConfigureAwait(false)); + + var fetched = await container.GetAsync(CompositeKey.Create(id), "tenant-a"); + fetched.Should().NotBeNull(); + fetched!.Name.Should().Be("Replaced"); + } + + [Test] + public async Task TransactionAsync_NestedFailureIgnoredByOuterWork_AbortsWholeBatch_NothingPersists() + { + // Regression: a nested TransactionAsync failure only returns the failed IResult - it does not itself prevent a later root commit, since Cosmos DB execution is deferred until the root call ends + // and everything enlisted so far (root and nested) is still sitting in the same ambient TransactionalBatch. If the outer work below ignores/swallows that failure (a caller bug) and otherwise + // reports its own success, the whole unit-of-work must still be discarded - not partially committed - per CosmosDbUnitOfWork's documented "a nested failure discards the whole batch" model. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb); + + var pk = NewId(); + var outerCreatedId = NewId(); + var nestedCreatedId = NewId(); + + Func act = () => unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = outerCreatedId, PartitionKey = pk, Name = "Outer" }, ct).ConfigureAwait(false); + + var nestedResult = await unitOfWork.TransactionAsync(async ct2 => + { + await container.CreateAsync(new TestItem { Id = nestedCreatedId, PartitionKey = pk, Name = "Nested" }, ct2).ConfigureAwait(false); + return Result.AuthenticationError(); + }); + + // Deliberately not checking nestedResult - simulates a caller bug that ignores a nested TransactionAsync failure and continues regardless. + _ = nestedResult; + }); + + await act.Should().ThrowAsync(); + + var fetchedOuter = await container.GetAsync(CompositeKey.Create(outerCreatedId), pk); + var fetchedNested = await container.GetAsync(CompositeKey.Create(nestedCreatedId), pk); + fetchedOuter.Should().BeNull(); + fetchedNested.Should().BeNull(); + } + + [Test] + public async Task TransactionAsync_ReusedAfterFailure_DoesNotLeakEventFromAbandonedTransaction() + { + // Regression: an event queued inside a failed/abandoned TransactionAsync must be removed from the shared outbox queue - otherwise a later, successful reuse of the SAME CosmosDbUnitOfWork would + // publish it alongside (or instead of) the genuinely new event, breaking atomic outbox semantics. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new CosmosDbEventPublisher(cosmosDb); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var abandonedId = NewId(); + var succeededId = NewId(); + + var failResult = await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = abandonedId, PartitionKey = pk, Name = "Abandoned" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + return Result.AuthenticationError(); + }); + + failResult.IsFailure.Should().BeTrue(); + unitOfWork.Events.IsEmpty.Should().BeTrue(); + + // Reuse the SAME unit-of-work for a genuinely successful transaction. + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = succeededId, PartitionKey = pk, Name = "Real" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + var rawContainer = cosmosDb.GetContainer(ContainerId); + var query = rawContainer.GetItemLinqQueryable().Where(e => e.PartitionKey == pk && e.Id.StartsWith(CosmosDbOutboxEvent.OutboxKeyPrefix)); + + var outboxDocs = new List(); + using (var iterator = query.ToFeedIterator()) + { + while (iterator.HasMoreResults) + outboxDocs.AddRange(await iterator.ReadNextAsync()); + } + + // Exactly one event - for the successful transaction, not a leaked one from the earlier abandoned/failed transaction. + outboxDocs.Should().ContainSingle(); + outboxDocs[0].Event.GetProperty("subject").GetString().Should().Be(succeededId); + } + + [Test] + public async Task TransactionAsync_ReusedAfterSuccessfulPublish_SubsequentUnrelatedFailure_DoesNotRollBackEarlierPublish() + { + // Regression: IEventPublisher.HasBeenPublished is a one-way, publisher-lifetime flag (see EventPublisherBase) - it stays true for as long as the same Outbox instance is reused across multiple, + // entirely independent TransactionAsync calls on the SAME CosmosDbUnitOfWork (a typical request-scoped lifetime). Previously, CosmosDbInvoker.OrchestrateUnitOfWorkTransactionAsync's DiscardAsync + // checked Outbox.HasBeenPublished to decide whether to call Outbox.RollbackAsync() - so a LATER, unrelated failed transaction (that itself never published anything) would still wrongly invoke + // RollbackAsync(), incorrectly undoing an EARLIER transaction's genuinely successful and already-committed publish. It must now only do so when THIS invocation is the one that actually published. + await GetOrCreateContainerAsync(ContainerId).ConfigureAwait(false); + var cosmosDb = CreateCosmosDb(); + var container = cosmosDb.Container(ContainerId, o => o.WithPartitionKey(m => m.PartitionKey)); + var outbox = new RollbackTrackingEventPublisher(); + var unitOfWork = new CosmosDbUnitOfWork(cosmosDb, outbox); + + var pk = NewId(); + var succeededId = NewId(); + + // First transaction: genuinely publishes successfully. + await unitOfWork.TransactionAsync(async ct => + { + var created = await container.CreateAsync(new TestItem { Id = succeededId, PartitionKey = pk, Name = "Real" }, ct).ConfigureAwait(false); + unitOfWork.Events.Add(EventData.CreateEventWith(created.Value, EventAction.Created).WithSource(new Uri("https://unittest/coreex-cosmos", UriKind.Absolute))); + }); + + outbox.HasBeenPublished.Should().BeTrue(); + outbox.RollbackCallCount.Should().Be(0); + + // Second, later transaction on the SAME (reused) unit-of-work: adds no events of its own and fails - must NOT roll back the earlier, already-committed publish. + var failResult = await unitOfWork.TransactionAsync(async ct => + { + await container.CreateAsync(new TestItem { Id = NewId(), PartitionKey = pk, Name = "Unrelated" }, ct).ConfigureAwait(false); + return Result.AuthenticationError(); + }); + + failResult.IsFailure.Should().BeTrue(); + outbox.RollbackCallCount.Should().Be(0); + outbox.HasBeenPublished.Should().BeTrue(); + } + + /// + /// A minimal that never actually sends anything (a no-op ) but tracks how many times is invoked - used to assert + /// that a later, unrelated failed does not wrongly roll back an earlier invocation's genuine publish. + /// + private sealed class RollbackTrackingEventPublisher : EventPublisherBase + { + public int RollbackCallCount { get; private set; } + + protected override Task OnPublishAsync(DestinationEvent[] events, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public override Task RollbackAsync(CancellationToken cancellationToken = default) + { + RollbackCallCount++; + return base.RollbackAsync(cancellationToken); + } + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/CosmosTestBase.cs b/tests/CoreEx.Cosmos.Test.Unit/CosmosTestBase.cs new file mode 100644 index 00000000..36e0e595 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/CosmosTestBase.cs @@ -0,0 +1,103 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// Base class for tests that require a live Cosmos DB endpoint (the local emulator started via the root docker-compose.yml cosmos-emulator service - bring it up with +/// podman compose -f docker-compose.yml up -d cosmos-emulator or docker compose -f docker-compose.yml up -d cosmos-emulator). +/// +/// Where the emulator is not reachable (e.g. not started, or still warming up) all tests in the deriving fixture are skipped () rather than failed, consistent with +/// this repository's general preference for real dependencies over mocks while still allowing the broader test run to succeed in environments where the emulator cannot be brought up. +public abstract class CosmosTestBase +{ + private static readonly Lazy _configuration = new(() => new ConfigurationBuilder().AddJsonFile("appsettings.unittest.json").Build()); + private static CosmosClient? _client; + private static Microsoft.Azure.Cosmos.Database? _database; + private static bool? _isAvailable; + + /// + /// Gets the shared (Gateway mode, pointed at the local emulator, accepting its self-signed certificate). + /// + protected static CosmosClient Client => _client ??= new CosmosClient(Endpoint, Key, new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator }), + // CosmosDbModelBase uses System.Text.Json's [JsonPropertyName] to map the id/_etag/ttl reserved properties; the SDK's default serializer is Newtonsoft.Json-based and would not honour those + // attributes, so opt into the SDK's System.Text.Json serializer explicitly (camelCase for everything else, matching typical Cosmos DB document conventions). + UseSystemTextJsonSerializerWithOptions = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase } + }); + + /// + /// Gets the test (created on first use). + /// + protected static Microsoft.Azure.Cosmos.Database TestDatabase => _database ?? throw new InvalidOperationException($"{nameof(TestDatabase)} is not available; ensure {nameof(EnsureAvailableOrIgnoreAsync)} has been awaited first."); + + private static string Endpoint => _configuration.Value["CosmosEmulator:Endpoint"] ?? "https://localhost:8081"; + + private static string Key => _configuration.Value["CosmosEmulator:Key"] ?? throw new InvalidOperationException("CosmosEmulator:Key configuration is required."); + + private static string DatabaseId => _configuration.Value["CosmosEmulator:DatabaseId"] ?? "CoreEx.Cosmos.Test.Unit"; + + /// + /// Creates a new wrapping the shared /. + /// + protected static CosmosDb CreateCosmosDb() => CreateCosmosDb("tenant-a"); + + /// + /// Creates a new wrapping the shared /, with the specified - used to simulate two different callers for + /// multi-tenancy isolation tests. + /// + protected static CosmosDb CreateCosmosDb(string tenantId) => new(Client, DatabaseId, executionContext: new ExecutionContext { TenantId = tenantId }); + + /// + /// Ensures the Cosmos DB emulator is reachable and the test database exists; where not reachable, ignores (skips) the current test. + /// + [SetUp] + public async Task EnsureAvailableOrIgnoreAsync() + { + if (_isAvailable is null) + { + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var response = await Client.CreateDatabaseIfNotExistsAsync(DatabaseId, cancellationToken: cts.Token).ConfigureAwait(false); + _database = response.Database; + _isAvailable = true; + } + catch (Exception ex) + { + _isAvailable = false; + TestContext.Progress.WriteLine($"Cosmos DB emulator is not reachable at '{Endpoint}': {ex.Message}"); + } + } + + if (_isAvailable != true) + Assert.Ignore($"Cosmos DB emulator is not reachable at '{Endpoint}'; start it with 'podman compose -f docker-compose.yml up -d cosmos-emulator' (or the 'docker compose' equivalent) and retry."); + } + + /// + /// Creates (if not already existing) a test container with the specified and (defaults to /partitionKey). + /// + /// The local emulator occasionally responds with a transient 503 ServiceUnavailable ("high demand") when several containers are created in quick succession; a short retry-with-backoff + /// smooths over this. Note: this exact response is also what the emulator returns when its AZURE_COSMOS_EMULATOR_PARTITION_COUNT (the cap on the total number of containers it can host, not + /// "partitions per container") has been exhausted - that failure mode is deterministic, not transient, and no amount of retrying fixes it (confirmed the hard way); see the setting's own comment in + /// docker-compose.yml. If this retry starts failing consistently for a new container, check whether the count needs raising before assuming it is another transient blip. + protected static async Task GetOrCreateContainerAsync(string id, string partitionKeyPath = "/partitionKey") + { + for (var attempt = 1; ; attempt++) + { + try + { + var response = await TestDatabase.CreateContainerIfNotExistsAsync(id, partitionKeyPath).ConfigureAwait(false); + return response.Container; + } + catch (CosmosException cex) when (cex.StatusCode == HttpStatusCode.ServiceUnavailable && attempt < 5) + { + await Task.Delay(TimeSpan.FromSeconds(attempt * 2)).ConfigureAwait(false); + } + } + } + + /// + /// Generates a new unique identifier (string) suitable for use as a test document id/partition key. + /// + protected static string NewId() => Guid.NewGuid().ToString("N"); +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/Data/read-data.seed.yaml b/tests/CoreEx.Cosmos.Test.Unit/Data/read-data.seed.yaml new file mode 100644 index 00000000..e519baa3 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/Data/read-data.seed.yaml @@ -0,0 +1,4 @@ +seed-read-items: + - { id: seed-1, partitionKey: seed-pk, name: Item-01 } + - { id: seed-2, partitionKey: seed-pk, name: Item-02 } + - { id: seed-3, partitionKey: seed-pk, name: Item-03 } diff --git a/tests/CoreEx.Cosmos.Test.Unit/GlobalUsing.cs b/tests/CoreEx.Cosmos.Test.Unit/GlobalUsing.cs new file mode 100644 index 00000000..afa479bf --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/GlobalUsing.cs @@ -0,0 +1,20 @@ +global using CoreEx; +global using CoreEx.Cosmos; +global using CoreEx.Cosmos.Extended; +global using CoreEx.Cosmos.Outbox; +global using CoreEx.Data; +global using CoreEx.Data.Json; +global using CoreEx.Entities; +global using CoreEx.Events; +global using CoreEx.Events.Publishing; +global using CoreEx.Hosting; +global using CoreEx.Mapping; +global using CoreEx.Results; +global using Microsoft.Azure.Cosmos; +global using Microsoft.Azure.Cosmos.Linq; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging.Abstractions; +global using System.Net; +global using PartitionKey = Microsoft.Azure.Cosmos.PartitionKey; diff --git a/tests/CoreEx.Cosmos.Test.Unit/TestModels.cs b/tests/CoreEx.Cosmos.Test.Unit/TestModels.cs new file mode 100644 index 00000000..ecf84fd0 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/TestModels.cs @@ -0,0 +1,212 @@ +namespace CoreEx.Cosmos.Test.Unit; + +/// +/// A simple single-partition test model (partition key equals ) used for basic CRUD/concurrency/not-found tests. +/// +public class TestItem : CosmosDbModelBase, IEntityKey +{ + public string Name { get; set; } = string.Empty; + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing used for logical-delete tests. +/// +public class SoftDeleteItem : CosmosDbModelBase, IEntityKey, ILogicallyDeleted +{ + public string Name { get; set; } = string.Empty; + + public bool IsDeleted { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing used for tenant-isolation tests. +/// +public class TenantItem : CosmosDbModelBase, IEntityKey, ITenantId +{ + public string Name { get; set; } = string.Empty; + + public string? TenantId { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing ("animal") used, alongside , for multi-type container tests. +/// +/// Decorated with an explicit so Model.PrepareCreate stamps a specific, readable value - without it, Model.PrepareTypeDiscriminator +/// would still stamp a value (falling back to the type name itself, per 's doc remarks), just the less descriptive default. +[Schemas.Schema(Name = nameof(AnimalItem))] +public class AnimalItem : CosmosDbModelBase, IEntityKey, ITypeDiscriminator +{ + public string Name { get; set; } = string.Empty; + + public string? TypeDiscriminator { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing ("plant") used, alongside , for multi-type container tests. +/// +[Schemas.Schema(Name = nameof(PlantItem))] +public class PlantItem : CosmosDbModelBase, IEntityKey, ITypeDiscriminator +{ + public string Name { get; set; } = string.Empty; + + public string? TypeDiscriminator { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing both and - used, alongside , to verify that per-item tenant filtering (see +/// ) is still applied when a model is read via a multi-set query. +/// +[Schemas.Schema(Name = nameof(TenantAnimalItem))] +public class TenantAnimalItem : CosmosDbModelBase, IEntityKey, ITypeDiscriminator, ITenantId +{ + public string Name { get; set; } = string.Empty; + + public string? TypeDiscriminator { get; set; } + + public string? TenantId { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing both and - used, alongside , to verify that per-item tenant filtering (see +/// ) is still applied when a model is read via a multi-set query. +/// +[Schemas.Schema(Name = nameof(TenantPlantItem))] +public class TenantPlantItem : CosmosDbModelBase, IEntityKey, ITypeDiscriminator, ITenantId +{ + public string Name { get; set; } = string.Empty; + + public string? TypeDiscriminator { get; set; } + + public string? TenantId { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing both and - used to verify the query-level, IS_DEFINED-guarded logical-delete SQL optimization +/// (see ) applied by a multi-set query. +/// +[Schemas.Schema(Name = nameof(SoftDeleteAnimalItem))] +public class SoftDeleteAnimalItem : CosmosDbModelBase, IEntityKey, ITypeDiscriminator, ILogicallyDeleted +{ + public string Name { get; set; } = string.Empty; + + public string? TypeDiscriminator { get; set; } + + public bool IsDeleted { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing the standard interfaces directly (not via ) and deliberately omitting , used to test the +/// guard. +/// +public class NoTimeToLiveItem : IEntityKey, IIdentifier, IETag, IPartitionKey +{ + public string Id { get; set; } = string.Empty; + + public string? ETag { get; set; } + + public string? PartitionKey { get; set; } + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing neither nor at all, used to test 's "no configuration, no model +/// support" fallback to - the simplest possible container shape. +/// +public class NoPartitionKeyItem : IEntityKey, IIdentifier, IETag +{ + public string Id { get; set; } = string.Empty; + + public string? ETag { get; set; } + + public string Name { get; set; } = string.Empty; + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A test model implementing but deliberately not / - its Cosmos DB id is instead exposed +/// via a differently-named property decorated with , used to verify 's +/// automatic outbox-document exclusion still applies (via its reflection-based fallback) even when the CoreEx identifier interfaces are not implemented. +/// +public class NonIdentifierKeyedItem : IEntityKey, IPartitionKey +{ + [System.Text.Json.Serialization.JsonPropertyName("id")] + public string DocumentId { get; set; } = string.Empty; + + public string? PartitionKey { get; set; } + + public string Name { get; set; } = string.Empty; + + public CompositeKey EntityKey => CompositeKey.Create(DocumentId); +} + +/// +/// A test model implementing but deliberately not / - unlike , +/// its Cosmos DB id property has no at all, relying purely on the conventional Id property name (as a serializer +/// configured with a naming policy, e.g. JsonNamingPolicy.CamelCase, would map it). Used to verify 's reflection-based fallback also covers this +/// unannotated-convention case, not just the explicit-attribute one. +/// +public class ConventionIdKeyedItem : IEntityKey, IPartitionKey +{ + public string Id { get; set; } = string.Empty; + + public string? PartitionKey { get; set; } + + public string Name { get; set; } = string.Empty; + + public CompositeKey EntityKey => CompositeKey.Create(Id); +} + +/// +/// A domain "contract" value used to exercise (mapped to/from ), and +/// (a distinct object instance/type from the model a actually mutates - the scenario that mechanism exists for). +/// +public class TestValue : IETag +{ + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? ETag { get; set; } +} + +/// +/// A hand-written between and . +/// +public class TestValueMapper : IBiDirectionMapper +{ + public IMapper To { get; } = new ToMapper(); + + public IMapper From { get; } = new FromMapper(); + + private sealed class ToMapper : IMapper + { + public TestItem? Map(TestValue? source) => source is null + ? null + : new TestItem { Id = source.Id ?? string.Empty, PartitionKey = source.Id, Name = source.Name ?? string.Empty, ETag = source.ETag }; + } + + private sealed class FromMapper : IMapper + { + public TestValue? Map(TestItem? source) => source is null + ? null + : new TestValue { Id = source.Id, Name = source.Name, ETag = source.ETag }; + } +} diff --git a/tests/CoreEx.Cosmos.Test.Unit/appsettings.unittest.json b/tests/CoreEx.Cosmos.Test.Unit/appsettings.unittest.json new file mode 100644 index 00000000..e8901931 --- /dev/null +++ b/tests/CoreEx.Cosmos.Test.Unit/appsettings.unittest.json @@ -0,0 +1,7 @@ +{ + "CosmosEmulator": { + "Endpoint": "https://localhost:8081", + "Key": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + "DatabaseId": "CoreEx.Cosmos.Test.Unit" + } +} diff --git a/tests/CoreEx.Data.Test.Unit/Json/JsonDataReaderTests.cs b/tests/CoreEx.Data.Test.Unit/Json/JsonDataReaderTests.cs new file mode 100644 index 00000000..b14701b3 --- /dev/null +++ b/tests/CoreEx.Data.Test.Unit/Json/JsonDataReaderTests.cs @@ -0,0 +1,247 @@ +using CoreEx.Data.Json; + +namespace CoreEx.Data.Test.Unit.Json; + +public class JsonDataReaderTests +{ + public class Widget + { + public string? Code { get; set; } + public string? Text { get; set; } + public Guid Id { get; set; } + public Guid Ref { get; set; } + public DateTimeOffset Now { get; set; } + public DateTimeOffset Tomorrow { get; set; } + public DateTimeOffset Yesterday { get; set; } + public bool IsActive { get; set; } + public int SortOrder { get; set; } + public string? Sku { get; set; } + public decimal Price { get; set; } + public string? TenantId { get; set; } + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset CreatedOn { get; set; } + } + + [Test] + public void ParseJson_Deserialize_SimpleObject() + { + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "ABC", "text": "A widget" } }"""); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().Be("ABC"); + w.Text.Should().Be("A widget"); + } + + [Test] + public void ParseJson_Deserialize_MissingPath_ReturnsDefault() + { + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "ABC" } }"""); + var w = jdr.Deserialize("does-not-exist"); + + w.Should().BeNull(); + } + + [Test] + public void ParseJson_DynamicParameters_GuidAndDates() + { + var jdr = JsonDataReader.ParseJson("""{ "widget": { "id": "^guid", "ref": "^guid", "now": "^now", "tomorrow": "^tomorrow", "yesterday": "^yesterday" } }"""); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Id.Should().NotBe(Guid.Empty); + w.Ref.Should().NotBe(Guid.Empty); + w.Id.Should().NotBe(w.Ref, "each '^guid' substitution must generate an independent value"); + w.Tomorrow.Should().BeAfter(w.Now); + w.Yesterday.Should().BeBefore(w.Now); + } + + [Test] + public void ParseJson_ArrayIndex_UsesElementPosition() + { + var jdr = JsonDataReader.ParseJson("""{ "widgets": [ { "sortOrder": "^index" }, { "sortOrder": "^index" } ] }"""); + var widgets = jdr.Deserialize>("widgets"); + + widgets.Should().NotBeNull().And.HaveCount(2); + widgets![0].SortOrder.Should().Be(0); + widgets[1].SortOrder.Should().Be(1); + } + + [Test] + public void CreateForReferenceData_SingleKey_MapsToCodeAndText() + { + var jdr = JsonDataReader.ParseJson("""{ "widget": { "ABC": "A widget" } }""", JsonDataReaderOptions.CreateForReferenceData(JsonPropertyNamingConvention.CamelCase)); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().Be("ABC"); + w.Text.Should().Be("A widget"); + w.IsActive.Should().BeTrue(); + w.Id.Should().NotBe(Guid.Empty); + } + + [Test] + public void CreateForReferenceData_MultiKey_LeavesExplicitPropertiesAlone() + { + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "XYZ", "text": "Explicit widget", "sortOrder": 5 } }""", JsonDataReaderOptions.CreateForReferenceData(JsonPropertyNamingConvention.CamelCase)); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().Be("XYZ"); + w.Text.Should().Be("Explicit widget"); + w.SortOrder.Should().Be(5, "an explicitly-supplied property must not be overwritten by the standard/reference-data defaults"); + } + + [Test] + public void ParseYaml_Deserialize_SimpleObject() + { + // YAML is the primary real-world path (every sample *.yaml/*.seed.yaml fixture) - previously entirely untested, only ParseJson was ever exercised. + var jdr = JsonDataReader.ParseYaml(""" + widget: + code: ABC + text: A widget + """); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().Be("ABC"); + w.Text.Should().Be("A widget"); + } + + [Test] + public void ParseYaml_LeadingZeroNumber_StaysString() + { + // The custom YamlNodeTypeResolver must keep a leading-zero value as a string - "007" is not a valid JSON number, and real fixtures rely on this (e.g. SKUs, codes with leading zeros). + var jdr = JsonDataReader.ParseYaml(""" + widget: + sku: 007 + """); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Sku.Should().Be("007"); + } + + [Test] + public void ParseYaml_BoolAndDecimalLiterals_CoerceCorrectly() + { + var jdr = JsonDataReader.ParseYaml(""" + widget: + isActive: true + price: 16.99 + """); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.IsActive.Should().BeTrue(); + w.Price.Should().Be(16.99m); + } + + [Test] + public void ParseJson_NumericDynamicParameter_GeneratesDeterministicGuidFromInt() + { + // The convention every real fixture file uses (e.g. "product_id: ^1") - a bare integer key deterministically maps to the same Guid every time, and different integers map to different Guids. + var jdr = JsonDataReader.ParseJson("""{ "widgets": [ { "id": "^1" }, { "id": "^1" }, { "id": "^2" } ] }"""); + var widgets = jdr.Deserialize>("widgets"); + + widgets.Should().NotBeNull().And.HaveCount(3); + widgets![0].Id.Should().Be(widgets[1].Id, "the same numeric token must always resolve to the same deterministic Guid"); + widgets[0].Id.Should().NotBe(widgets[2].Id, "different numeric tokens must resolve to different Guids"); + widgets[0].Id.Should().NotBe(Guid.Empty); + } + + [Test] + public void ParseJson_EmbeddedDynamicParameter_ReplacesOnlyThePlaceholderPortion() + { + // Distinct from a whole-value '^xxx' replacement - '(^xxx)' substitutes just the placeholder within a larger string, leaving the rest of the string intact. + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "order-(^guid)-suffix" } }"""); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().StartWith("order-").And.EndWith("-suffix"); + w.Code.Should().MatchRegex(@"^order-[0-9a-fA-F-]{36}-suffix$"); + } + + [Test] + public void ParseJson_EmbeddedDynamicParameter_ResolvesRecursively() + { + // A parameter function whose own returned value contains another '(^xxx)' placeholder must have that inner placeholder resolved too, not left as a literal string. + var options = new JsonDataReaderOptions(); + options.Parameters.Add("nested", _ => "prefix-(^guid)"); + + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "(^nested)" } }""", options); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().MatchRegex(@"^prefix-[0-9a-fA-F-]{36}$", "the inner '(^guid)' placeholder produced by the 'nested' parameter must itself be resolved, not left literal"); + } + + [Test] + public void ParseJson_TenantIdUserIdUserNameTokens_ProduceNonEmptyValues() + { + // No ambient ExecutionContext is expected in this test - these tokens must still resolve via their documented fallback (Options.TenantId / AuthenticationUser.EnvironmentUser) rather than + // throwing or producing an empty value. + var jdr = JsonDataReader.ParseJson("""{ "widget": { "tenantId": "^tenant_id", "userId": "^user_id", "userName": "^user_name" } }"""); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.UserId.Should().NotBeNullOrEmpty(); + w.UserName.Should().NotBeNullOrEmpty(); + } + + [Test] + public void AddStandardProperties_FillsMissing_ButNeverOverwritesExplicitValues() + { + var options = new JsonDataReaderOptions(JsonPropertyNamingConvention.CamelCase).AddStandardProperties(); + + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "ABC", "createdBy": "explicit-user" } }""", options); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.CreatedBy.Should().Be("explicit-user", "an explicitly-supplied standard property must not be overwritten"); + w.CreatedOn.Should().NotBe(default(DateTimeOffset), "a standard property missing from the source must be filled in via '^now'"); + } + + [Test] + public void ParseJson_RootNotAnObject_Throws() + { + // The constructor enforces that the root node must be a JsonObject - a top-level JSON array is not a valid data reader source. + var act = () => JsonDataReader.ParseJson("""[ { "code": "ABC" } ]"""); + + act.Should().Throw(); + } + + [Test] + public void ParseJson_SnakeCaseNamingConvention_AppliesToStandardProperties() + { + // Only CamelCase was ever exercised previously (via the reference-data tests) - PascalCase is the default and SnakeCase/KebabCase were entirely untested. + var options = new JsonDataReaderOptions(JsonPropertyNamingConvention.SnakeCase).AddStandardProperties(); + + var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "ABC" } }""", options); + var w = jdr.Deserialize("widget", new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower }); + + w.Should().NotBeNull(); + w!.CreatedOn.Should().NotBe(default(DateTimeOffset), "the 'created_on' standard property (snake_case) must have been applied and successfully bound"); + } + + [Test] + public void RootNodePreProcessor_CustomHook_CanMutateRootMostObject() + { + var options = new JsonDataReaderOptions(JsonPropertyNamingConvention.CamelCase) + { + RootNodePreProcessor = args => + { + if (args.CurrentNode is System.Text.Json.Nodes.JsonObject jo) + jo["code"] = "INJECTED"; + } + }; + + var jdr = JsonDataReader.ParseJson("""{ "widget": { "text": "A widget" } }""", options); + var w = jdr.Deserialize("widget"); + + w.Should().NotBeNull(); + w!.Code.Should().Be("INJECTED", "a user-supplied RootNodePreProcessor must be able to mutate the root-most object before substitution/property application"); + } +} diff --git a/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs b/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs index 0f795740..fb36f205 100644 --- a/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs +++ b/tests/CoreEx.Database.Postgres.Test.Unit/EntityFrameworkBehaviorTests.cs @@ -39,7 +39,7 @@ public void Query_TenantFilter_UsesInjectedExecutionContext_NotAmbient() => Test // ExecutionContext and a tenant filter enabled - Query() must filter by the injected tenant, not the ambient one. var dc = ExecutionContext.GetRequiredService(); var injectedContext = new ExecutionContext { TenantId = "B" }; - var options = new EfDbOptions().WithModel(mo => mo.WithTenantFilter(allowFilterBypass: false)); + var options = new EfDbOptions().WithModel(mo => mo.WithTenantFilter()); var ef = new EfDb(dc, options, injectedContext); // Seed data has two TenantId "B" rows (TableId 4 and 5); all others are "A" (see Data\data.yaml). diff --git a/tests/CoreEx.Database.Postgres.Test.Unit/Repository/TestEfDb.cs b/tests/CoreEx.Database.Postgres.Test.Unit/Repository/TestEfDb.cs index 80d56539..fae0638a 100644 --- a/tests/CoreEx.Database.Postgres.Test.Unit/Repository/TestEfDb.cs +++ b/tests/CoreEx.Database.Postgres.Test.Unit/Repository/TestEfDb.cs @@ -8,7 +8,7 @@ public class TestEfDb(TestDbContext dbContext) : EfDb(dbContext, { private static readonly EfDbOptions _options = new EfDbOptions() .WithModel(mo => mo - .WithTenantFilter(allowFilterBypass: false) + .WithTenantFilter() .WithLogicalDeleteFilter(allowFilterBypass: true) .WithFilter(q => q.Where(x => x.Flag != null && x.Flag == true), (_, _) => Result.AuthorizationError(), allowFilterBypass: true)); diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs index 658f8c53..94fb0af3 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/EntityFrameworkBehaviorTests.cs @@ -39,7 +39,7 @@ public void Query_TenantFilter_UsesInjectedExecutionContext_NotAmbient() => Test // ExecutionContext and a tenant filter enabled - Query() must filter by the injected tenant, not the ambient one. var dc = ExecutionContext.GetRequiredService(); var injectedContext = new ExecutionContext { TenantId = "B" }; - var options = new EfDbOptions().WithModel(mo => mo.WithTenantFilter(allowFilterBypass: false)); + var options = new EfDbOptions().WithModel(mo => mo.WithTenantFilter()); var ef = new EfDb(dc, options, injectedContext); // Seed data has two TenantId "B" rows (TableId 4 and 5); all others are "A" (see Data\data.yaml). diff --git a/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs index 0f3436a6..56078d3f 100644 --- a/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs +++ b/tests/CoreEx.Database.SqlServer.Test.Unit/Repository/TestEfDb.cs @@ -8,7 +8,7 @@ public class TestEfDb(TestDbContext dbContext) : EfDb(dbContext, { private static readonly EfDbOptions _options = new EfDbOptions() .WithModel(mo => mo - .WithTenantFilter(allowFilterBypass: false) + .WithTenantFilter() .WithLogicalDeleteFilter(allowFilterBypass: true) .WithFilter(q => q.Where(x => x.Flag != null && x.Flag == true), (_, _) => Result.AuthorizationError(), allowFilterBypass: true)); diff --git a/tests/CoreEx.Database.Test.Unit/Outbox/DatabaseOutboxRelayBaseTests.cs b/tests/CoreEx.Database.Test.Unit/Outbox/DatabaseOutboxRelayBaseTests.cs new file mode 100644 index 00000000..d3a7b0a2 --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/Outbox/DatabaseOutboxRelayBaseTests.cs @@ -0,0 +1,127 @@ +using CoreEx.Data; +using CoreEx.Database.Outbox; +using CoreEx.Database.SqlServer; +using CoreEx.Events; +using CoreEx.Events.Publishing; +using Microsoft.Data.SqlClient; +using System.Diagnostics; + +namespace CoreEx.Database.Test.Unit.Outbox; + +[TestFixture] +public class DatabaseOutboxRelayBaseTests +{ + private static SqlServerDatabase CreateDatabase() => new((SqlConnection)SqlClientFactory.Instance.CreateConnection()); + + private static DatabaseOutboxRelayArgs CreateArgs(DatabaseOutboxRelayResiliencyExecutor? resiliencyExecutor = null) => new() + { + // partitionSize == perWorkerPartitionCount triggers PartitionPicker's "probe all partitions" path - deterministic, covers every partition every call. + PartitionPicker = new PartitionPicker(partitionSize: 2, perWorkerPartitionCount: 2), + BatchSize = 10, + LeaseDuration = TimeSpan.FromSeconds(5), + BackOffDuration = TimeSpan.FromSeconds(1), + ResiliencyExecutor = resiliencyExecutor ?? ((work, ct) => work(ct)) + }; + + [Test] + public async Task RelayAsync_OnePartitionFailure_DoesNotBlockSiblingPartitions() + { + // Regression: a failure for one partition must not abort the whole tick - every other assigned partition must still be attempted. + var relay = new TestOutboxRelay(CreateDatabase(), new NoOpEventPublisher()) { FailingPartitionId = 0 }; + + await relay.RelayAsync(CreateArgs(), CancellationToken.None); + + relay.AttemptedPartitions.Should().BeEquivalentTo([0, 1]); + } + + [Test] + public async Task RelayAsync_ResiliencyExecutor_ObservesEachPartitionOutcome() + { + // The resiliency executor (owned by a caller such as DatabaseOutboxRelayHostedServiceBase) must be invoked once per partition, seeing both the failure and the success. + var relay = new TestOutboxRelay(CreateDatabase(), new NoOpEventPublisher()) { FailingPartitionId = 0 }; + + var observed = new List(); + DatabaseOutboxRelayResiliencyExecutor executor = async (work, ct) => + { + var result = await work(ct).ConfigureAwait(false); + observed.Add(result.IsSuccess); + return result; + }; + + await relay.RelayAsync(CreateArgs(executor), CancellationToken.None); + + observed.Should().HaveCount(2); + observed.Should().Contain(false); // partition 0, failed + observed.Should().Contain(true); // partition 1, succeeded + } + + [Test] + public async Task RelayAsync_EmitsPerEventRelayMarker_ParentedToOriginatingTrace() + { + // Simulate the original producer's trace (e.g. the API request that raised the event) - completely independent of, and unaware of, the relay. + using var producerSource = new ActivitySource($"test.producer.{Guid.NewGuid()}"); + using var producerListener = new ActivityListener + { + ShouldListenTo = s => s.Name == producerSource.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(producerListener); + + using var producerActivity = producerSource.StartActivity("original-request"); + producerActivity.Should().NotBeNull(); + + var cloudEvent = new CloudNative.CloudEvents.CloudEvent { Id = "evt-1", Type = "test.event", Source = new Uri("urn:test"), Time = DateTimeOffset.UtcNow }; + cloudEvent.SetExtensionAttribute("traceparent", producerActivity!.Id); + + var markers = new List(); + using var markerListener = new ActivityListener + { + ShouldListenTo = s => s.Name == CloudEventTracingExtensions.RelayMarkerActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => { lock (markers) markers.Add(a); } + }; + ActivitySource.AddActivityListener(markerListener); + + var relay = new TestOutboxRelay(CreateDatabase(), new NoOpEventPublisher()) + { + EventsForPartition = partitionId => partitionId == 0 ? [new DestinationEvent("test-destination", cloudEvent)] : [] + }; + + await relay.RelayAsync(CreateArgs(), CancellationToken.None); + + markers.Should().ContainSingle(); + markers[0].TraceId.Should().Be(producerActivity.TraceId); + markers[0].ParentSpanId.Should().Be(producerActivity.SpanId); + markers[0].GetTagItem("outbox.event.id").Should().Be("evt-1"); + markers[0].GetTagItem("outbox.destination").Should().Be("test-destination"); + } + + private sealed class TestOutboxRelay(SqlServerDatabase database, IEventPublisher eventPublisher) : DatabaseOutboxRelayBase(database, eventPublisher) + { + public List AttemptedPartitions { get; } = []; + + public int? FailingPartitionId { get; set; } + + public Func>? EventsForPartition { get; set; } + + public override void SetStatementsByConvention(string? schema = null) { } + + protected override Task> ClaimNextBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, int partitionId, CancellationToken cancellationToken) + { + lock (AttemptedPartitions) + AttemptedPartitions.Add(partitionId); + + if (partitionId == FailingPartitionId) + throw new InvalidOperationException($"Simulated claim failure for partition {partitionId}."); + + // No events claimed by default - keeps the pre-existing tests focused purely on the outer per-partition loop, not the claim/publish/complete pipeline. + return Task.FromResult(EventsForPartition?.Invoke(partitionId) ?? []); + } + + // No-op - avoids requiring a real database connection for tests that DO claim events (CreateDatabase() has no live connection); the claim/publish path is exercised via + // ClaimNextBatchAsync/EventPublisher instead, which is all these tests care about. + protected override Task CompleteBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) => Task.CompletedTask; + + protected override Task CancelBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/CoreEx.Database.Test.Unit/Outbox/DatabaseOutboxRelayHostedServiceBaseTests.cs b/tests/CoreEx.Database.Test.Unit/Outbox/DatabaseOutboxRelayHostedServiceBaseTests.cs new file mode 100644 index 00000000..13e9c4e7 --- /dev/null +++ b/tests/CoreEx.Database.Test.Unit/Outbox/DatabaseOutboxRelayHostedServiceBaseTests.cs @@ -0,0 +1,118 @@ +using CoreEx.Database.Outbox; +using CoreEx.Database.SqlServer; +using CoreEx.Events.Publishing; +using CoreEx.Hosting; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; + +namespace CoreEx.Database.Test.Unit.Outbox; + +[TestFixture] +public class DatabaseOutboxRelayHostedServiceBaseTests +{ + private static SqlServerDatabase CreateDatabase() => new((SqlConnection)SqlClientFactory.Instance.CreateConnection()); + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > timeout) + throw new TimeoutException("Condition was not met within the timeout."); + + await Task.Delay(10); + } + } + + [Test] + public async Task DefaultSettings_DoNotThrowOnStart() + { + // Regression: PartitionSize (default 4) and PerWorkerPartitionCount (previously an unconditional literal 6) used to be mutually incompatible out of the box - PartitionPicker's constructor + // throws when perWorkerPartitionCount > partitionSize, so starting with zero configuration overrides threw at startup. PerWorkerPartitionCount's default is now capped at whatever + // PartitionSize resolves to. + var sc = new ServiceCollection(); + sc.AddSingleton(new ConfigurationBuilder().Build()); + sc.AddExecutionContext(); + using var sp = sc.BuildServiceProvider(); + + var svc = new TestOutboxRelayHostedService(sp, NullLogger.Instance) + { + RelayFactory = _ => new TestOutboxRelay(CreateDatabase(), new NoOpEventPublisher(), new TestRelayState { ThrowAlways = false }) + }; + + Assert.DoesNotThrowAsync(async () => await svc.StartAsync(CancellationToken.None)); + await svc.StopAsync(CancellationToken.None); + } + + [Test] + public async Task Relay_CircuitBreaker_TripsOnSustainedFailure_ThenSelfRecovers() + { + var state = new TestRelayState(); + var sc = new ServiceCollection(); + + // Explicit single-partition config here for deterministic per-partition test timing - not needed to avoid the (now-fixed) incompatible-defaults bug, see DefaultSettings_DoNotThrowOnStart. + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + [ + new("CoreEx:Host:Services:OutboxRelay:PartitionSize", "1"), + new("CoreEx:Host:Services:OutboxRelay:PerWorkerPartitionCount", "1") + ]) + .Build(); + sc.AddSingleton(configuration); + sc.AddExecutionContext(); + using var sp = sc.BuildServiceProvider(); + + var svc = new TestOutboxRelayHostedService(sp, NullLogger.Instance) + { + Interval = TimeSpan.FromMilliseconds(30), + FirstInterval = TimeSpan.FromMilliseconds(5), + RelayFactory = _ => new TestOutboxRelay(CreateDatabase(), new NoOpEventPublisher(), state) + }; + + await svc.StartAsync(CancellationToken.None); + try + { + // Every partition attempt fails, so the breaker should trip well within a couple of ticks - pausing the hosted service without any manual ResumeAsync() call. + await WaitUntilAsync(() => svc.Status == ServiceStatus.Paused, TimeSpan.FromSeconds(10)); + svc.Status.Should().Be(ServiceStatus.Paused); + + // Remove the failure condition; the breaker's own timer should resume the service automatically, and the next tick should succeed. + state.ThrowAlways = false; + + await WaitUntilAsync(() => svc.Status != ServiceStatus.Paused && svc.Status != ServiceStatus.Pausing, TimeSpan.FromSeconds(5)); + svc.Status.Should().NotBe(ServiceStatus.Paused); + } + finally + { + await svc.StopAsync(CancellationToken.None); + } + } + + private sealed class TestRelayState + { + public volatile bool ThrowAlways = true; + } + + private sealed class TestOutboxRelay(SqlServerDatabase database, IEventPublisher eventPublisher, TestRelayState state) : DatabaseOutboxRelayBase(database, eventPublisher) + { + public override void SetStatementsByConvention(string? schema = null) { } + + protected override Task> ClaimNextBatchAsync(DatabaseOutboxRelayArgs args, Guid leaseId, int partitionId, CancellationToken cancellationToken) + { + if (state.ThrowAlways) + throw new InvalidOperationException("Simulated persistent claim failure."); + + return Task.FromResult(new List()); + } + } + + private sealed class TestOutboxRelayHostedService : DatabaseOutboxRelayHostedServiceBase + { + public TestOutboxRelayHostedService(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider, logger) + => Resiliency = CreateDefaultResiliency(minimumThroughput: 2, samplingDuration: TimeSpan.FromSeconds(30), breakDuration: TimeSpan.FromMilliseconds(200)); + } +} diff --git a/tests/CoreEx.Events.Test.Unit/CloudEventTracingExtensionsTests.cs b/tests/CoreEx.Events.Test.Unit/CloudEventTracingExtensionsTests.cs new file mode 100644 index 00000000..69e24c86 --- /dev/null +++ b/tests/CoreEx.Events.Test.Unit/CloudEventTracingExtensionsTests.cs @@ -0,0 +1,149 @@ +using CloudNative.CloudEvents; +using CloudNative.CloudEvents.Extensions; +using CoreEx.Events.Publishing; +using System.Diagnostics; + +namespace CoreEx.Events.Test.Unit; + +[TestFixture] +public class CloudEventTracingExtensionsTests +{ + private static CloudEvent CreateCloudEvent(string id, string? traceParent = null, string? traceState = null) + { + var ce = new CloudEvent { Id = id, Type = "test.event", Source = new Uri("urn:test"), Time = DateTimeOffset.UtcNow }; + + if (traceParent is not null) + ce.SetExtensionAttribute("traceparent", traceParent); + + if (traceState is not null) + ce.SetExtensionAttribute("tracestate", traceState); + + return ce; + } + + /// + /// Starts a standalone, listened-to on its own uniquely-named - simulating an originating trace (e.g. an API request, or a batch-level relay span) + /// that is entirely independent of the test's own ambient activity. + /// + private static ActivityScope StartActivity(string activityName) => new(activityName); + + private sealed class ActivityScope : IDisposable + { + private readonly ActivitySource _source; + private readonly ActivityListener _listener; + + public ActivityScope(string activityName) + { + _source = new ActivitySource($"test.{activityName}.{Guid.NewGuid()}"); + _listener = new ActivityListener + { + ShouldListenTo = s => s.Name == _source.Name, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + ActivitySource.AddActivityListener(_listener); + + Activity = _source.StartActivity(activityName)!; + Activity.Should().NotBeNull(); + } + + public Activity Activity { get; } + + public void Dispose() + { + Activity.Dispose(); + _listener.Dispose(); + _source.Dispose(); + } + } + + private static List ListenForMarkers() + { + var markers = new List(); + var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == CloudEventTracingExtensions.RelayMarkerActivitySourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = a => { lock (markers) markers.Add(a); } + }; + ActivitySource.AddActivityListener(listener); + return markers; + } + + [Test] + public void LinkTraceContext_NullActivity_IsNoOp() + { + // Must not throw - the extension is called unconditionally from every relay implementation regardless of whether instrumentation is enabled. + ((Activity?)null).LinkTraceContext([CreateCloudEvent("evt-1", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")]); + } + + [Test] + public void LinkTraceContext_AddsOneLinkPerDistinctTraceParent() + { + using var producer = StartActivity("original-request"); + + var events = new[] + { + CreateCloudEvent("evt-1", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), + CreateCloudEvent("evt-2", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"), // same traceparent as evt-1 - must not add a second, redundant link. + CreateCloudEvent("evt-3", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"), + CreateCloudEvent("evt-4") // no traceparent at all - must be skipped without error. + }; + + producer.Activity.LinkTraceContext(events); + + producer.Activity.Links.Should().HaveCount(2); + } + + [Test] + public void EmitRelayMarkers_NoTraceParent_EmitsNoMarker() + { + var markers = ListenForMarkers(); + + new[] { new DestinationEvent("dest", CreateCloudEvent("evt-1")) }.EmitRelayMarkers(); + + markers.Should().BeEmpty(); + } + + [Test] + public void EmitRelayMarkers_PerEvent_IsParentedToItsOwnOriginatingTrace_AndLinkedToTheBatchActivity() + { + using var producer = StartActivity("original-request"); + using var batch = StartActivity("relay-batch"); + var markers = ListenForMarkers(); + + var destinationEvents = new[] { new DestinationEvent("test-destination", CreateCloudEvent("evt-1", producer.Activity.Id!)) }; + + destinationEvents.EmitRelayMarkers(batch.Activity); + + markers.Should().ContainSingle(); + var marker = markers[0]; + marker.TraceId.Should().Be(producer.Activity.TraceId); + marker.ParentSpanId.Should().Be(producer.Activity.SpanId); + marker.Kind.Should().Be(ActivityKind.Producer); + marker.GetTagItem("outbox.destination").Should().Be("test-destination"); + marker.GetTagItem("outbox.event.id").Should().Be("evt-1"); + marker.GetTagItem("outbox.event.type").Should().Be("test.event"); + marker.Links.Should().ContainSingle(l => l.Context.SpanId == batch.Activity.SpanId); + } + + [Test] + public void EmitRelayMarkers_MultipleEvents_EachGetsItsOwnMarkerInItsOwnTrace() + { + using var producer1 = StartActivity("original-request-1"); + using var producer2 = StartActivity("original-request-2"); + var markers = ListenForMarkers(); + + // Two events from two entirely unrelated originating traces, relayed together in the same physical batch. + var destinationEvents = new[] + { + new DestinationEvent("dest-1", CreateCloudEvent("evt-1", producer1.Activity.Id!)), + new DestinationEvent("dest-2", CreateCloudEvent("evt-2", producer2.Activity.Id!)) + }; + + destinationEvents.EmitRelayMarkers(); + + markers.Should().HaveCount(2); + markers.Should().ContainSingle(m => m.TraceId == producer1.Activity.TraceId && (string?)m.GetTagItem("outbox.event.id") == "evt-1"); + markers.Should().ContainSingle(m => m.TraceId == producer2.Activity.TraceId && (string?)m.GetTagItem("outbox.event.id") == "evt-2"); + } +} diff --git a/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs b/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs index 5e116b81..672ebe66 100644 --- a/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs +++ b/tests/CoreEx.Events.Test.Unit/Publishing/EventPublisherBaseTests.cs @@ -114,7 +114,7 @@ public void Clear_ShouldClearQueue() } [Test] - public async Task Rollback_ShouldRemoveSpecifiedCountOfEvents() + public async Task Dequeue_ShouldRemoveSpecifiedCountOfEvents() { var e1 = new EventData { Id = "X" }; var e2 = new EventData(); @@ -123,10 +123,10 @@ public async Task Rollback_ShouldRemoveSpecifiedCountOfEvents() _publisher.Add(e1, e2, e3); _publisher.Count.Should().Be(3); - Action act = () => _publisher.Rollback(4); + Action act = () => _publisher.Dequeue(4); act.Should().Throw(); - _publisher.Rollback(2); + _publisher.Dequeue(2); _publisher.Count.Should().Be(1); await _publisher.PublishAsync(); @@ -135,10 +135,23 @@ public async Task Rollback_ShouldRemoveSpecifiedCountOfEvents() _publisher.PublishedEvents!.Length.Should().Be(1); _publisher.PublishedEvents![0].Event.Id.Should().Be("X"); - Action act2 = () => _publisher.Rollback(1); + Action act2 = () => _publisher.Dequeue(1); act2.Should().Throw(); } + [Test] + public async Task RollbackAsync_IsNoOpByDefault() + { + _publisher.Add(new EventData { Id = "X" }); + await _publisher.PublishAsync(); + + // A no-op by default (see EventPublisherBase.RollbackAsync's remarks) - the base publisher has no captured test state of its own to undo; HasBeenPublished is left untouched. + await _publisher.RollbackAsync(); + + _publisher.HasBeenPublished.Should().BeTrue(); + _publisher.PublishCallCount.Should().Be(1); + } + [Test] public async Task Reset_ShouldResetHasPublished() { diff --git a/tests/CoreEx.Events.Test.Unit/Subscribing/SubscribedManagerTests.cs b/tests/CoreEx.Events.Test.Unit/Subscribing/SubscribedManagerTests.cs index 01c4b361..dff0728b 100644 --- a/tests/CoreEx.Events.Test.Unit/Subscribing/SubscribedManagerTests.cs +++ b/tests/CoreEx.Events.Test.Unit/Subscribing/SubscribedManagerTests.cs @@ -3,6 +3,7 @@ using CoreEx.Results; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; namespace CoreEx.Events.Test.Unit.Subscribing; @@ -60,6 +61,131 @@ public async Task ReceiveAsync_UnrelatedCancellation_NoErrorHandler_Propagates() result.Error.Should().BeOfType(); } + [Test] + public void Match_NoSubscriberFound_DefaultSuppressesTracing() + { + // Default (IsTracingEnabledForUnsubscribed = false): the current activity and its parent must be excluded from export, and the parent's Recorded flag cleared so subsequent siblings follow suit. + using var source = new ActivitySource(nameof(Match_NoSubscriberFound_DefaultSuppressesTracing)); + using var listener = CreateAllRecordingListener(source.Name); + + using var parent = source.StartActivity("parent")!; + using var current = source.StartActivity("current")!; + + parent.IsAllDataRequested.Should().BeTrue(); + current.IsAllDataRequested.Should().BeTrue(); + parent.Recorded.Should().BeTrue(); + + var (manager, executionContext, args) = CreateManager(); + var result = manager.Match(executionContext, args, "unmatched.event.subject"); + + result.IsFailure.Should().BeTrue(); + current.IsAllDataRequested.Should().BeFalse(); + current.Recorded.Should().BeFalse(); + parent.IsAllDataRequested.Should().BeFalse(); + parent.Recorded.Should().BeFalse(); + } + + [Test] + public void Match_NoSubscriberFound_Default_SuppressesChildCreatedOnCurrentBeforeItStops() + { + // Regression: a transport can complete/settle a message (e.g. CoreEx's own ServiceBusReceiverBaseT calling actions.CompleteMessageAsync) *inside* the current activity's own scope, i.e. before it + // stops - meaning that "settle" span parents on `current` (the receiver invoker span), not on `current.Parent` (the transport's native process span). A ParentBasedSampler decides that child's + // sampling purely from its immediate parent's live Recorded flag, so clearing Recorded only on the parent (and not on current itself) would let such a child leak through unsuppressed. + using var source = new ActivitySource(nameof(Match_NoSubscriberFound_Default_SuppressesChildCreatedOnCurrentBeforeItStops)); + using var listener = CreateParentBasedListener(source.Name); + + using var parent = source.StartActivity("parent")!; + using var current = source.StartActivity("current")!; + + var (manager, executionContext, args) = CreateManager(); + var result = manager.Match(executionContext, args, "unmatched.event.subject"); + result.IsFailure.Should().BeTrue(); + + // Simulate the transport creating a "settle" span as a child of `current`, before `current` stops. + using var settle = source.StartActivity("settle"); + settle.Should().BeNull("a ParentBasedSampler must drop this child once current's Recorded flag has been cleared"); + } + + [Test] + public void Match_NoSubscriberFound_OptedIn_LeavesTracingUntouched() + { + // IsTracingEnabledForUnsubscribed = true: nothing should be suppressed. + using var source = new ActivitySource(nameof(Match_NoSubscriberFound_OptedIn_LeavesTracingUntouched)); + using var listener = CreateAllRecordingListener(source.Name); + + using var parent = source.StartActivity("parent")!; + using var current = source.StartActivity("current")!; + + var (manager, executionContext, args) = CreateManager(); + manager.IsTracingEnabledForUnsubscribed = true; + var result = manager.Match(executionContext, args, "unmatched.event.subject"); + + result.IsFailure.Should().BeTrue(); + current.IsAllDataRequested.Should().BeTrue(); + parent.IsAllDataRequested.Should().BeTrue(); + parent.Recorded.Should().BeTrue(); + } + + [Test] + public async Task Match_SubscriberFound_LeavesTracingUntouched() + { + // A successful match must never be affected by the unsubscribed-suppression logic. + using var source = new ActivitySource(nameof(Match_SubscriberFound_LeavesTracingUntouched)); + using var listener = CreateAllRecordingListener(source.Name); + + using var parent = source.StartActivity("parent")!; + using var current = source.StartActivity("current")!; + + var services = new ServiceCollection().AddScoped(_ => new TestSubscribed(_ => { })).BuildServiceProvider(); + var executionContext = new ExecutionContext { ServiceProvider = services }; + var manager = new SubscribedManager().AddSubscriber(); + var args = new EventSubscriberArgs { Owner = new TestEventSubscriber() }; + + var result = manager.Match(executionContext, args, "test.entity.created"); + await Task.CompletedTask; + + result.IsSuccess.Should().BeTrue(); + current.IsAllDataRequested.Should().BeTrue(); + parent.IsAllDataRequested.Should().BeTrue(); + parent.Recorded.Should().BeTrue(); + } + + /// + /// Creates an that samples every activity from as - mirroring the OpenTelemetry SDK's + /// own ActivityStopped gating (see ), so these tests exercise the exact mechanism relies on rather than a re-implementation. + /// + private static ActivityListener CreateAllRecordingListener(string sourceName) + { + var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == sourceName, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded + }; + + ActivitySource.AddActivityListener(listener); + return listener; + } + + /// + /// Creates an that mimics OpenTelemetry's own default ParentBasedSampler: a root activity (no parent context) is always sampled, and any child activity's sampling + /// decision is derived purely from whether its immediate parent's currently has set - exercising the real mechanism + /// relies on to suppress not-yet-created sibling/child activities, rather than the simpler "always record" listener used by the other tests in this fixture. + /// + private static ActivityListener CreateParentBasedListener(string sourceName) + { + var listener = new ActivityListener + { + ShouldListenTo = s => s.Name == sourceName, + Sample = (ref ActivityCreationOptions options) => + options.Parent == default || options.Parent.TraceFlags.HasFlag(ActivityTraceFlags.Recorded) + ? ActivitySamplingResult.AllDataAndRecorded + : ActivitySamplingResult.None + }; + + ActivitySource.AddActivityListener(listener); + return listener; + } + private static (SubscribedManager Manager, ExecutionContext ExecutionContext, EventSubscriberArgs Args) CreateManager() { var services = new ServiceCollection().BuildServiceProvider(); diff --git a/tests/CoreEx.Test.Unit/Data/ModelTests.cs b/tests/CoreEx.Test.Unit/Data/ModelTests.cs new file mode 100644 index 00000000..19605445 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Data/ModelTests.cs @@ -0,0 +1,60 @@ +using CoreEx.Data; +using CoreEx.Schemas; + +namespace CoreEx.Test.Unit.Data; + +[TestFixture] +public class ModelTests +{ + [Test] + public void PrepareTypeDiscriminator_ExplicitOverride_UsesGivenValue() + { + var model = new WithSchemaModel(); + Model.PrepareTypeDiscriminator(model, "Explicit"); + model.TypeDiscriminator.Should().Be("Explicit"); + } + + [Test] + public void PrepareTypeDiscriminator_WithSchemaAttribute_UsesSchemaName() + { + var model = new WithSchemaModel(); + Model.PrepareTypeDiscriminator(model); + model.TypeDiscriminator.Should().Be("CustomSchemaName"); + } + + [Test] + public void PrepareTypeDiscriminator_NoSchemaAttribute_FallsBackToTypeName() + { + // Regression test: Schema.TryGetMetadata returns false when no [Schema] attribute is present, but its out-param is still + // populated with a defaulted SchemaAttribute whose Name is the type name. PrepareTypeDiscriminator must use that default + // rather than leaving TypeDiscriminator null, per the documented fallback on IReadOnlyTypeDiscriminator.TypeDiscriminator. + var model = new NoSchemaModel(); + Model.PrepareTypeDiscriminator(model); + model.TypeDiscriminator.Should().Be(nameof(NoSchemaModel)); + } + + [Test] + public void PrepareTypeDiscriminator_ModelNotITypeDiscriminator_NoOp() + { + var model = new object(); + var result = Model.PrepareTypeDiscriminator(model); + result.Should().BeSameAs(model); + } + + [Test] + public void PrepareTypeDiscriminator_NullModel_ReturnsNull() + { + Model.PrepareTypeDiscriminator(null).Should().BeNull(); + } + + [Schema(Name = "CustomSchemaName")] + private class WithSchemaModel : ITypeDiscriminator + { + public string? TypeDiscriminator { get; set; } + } + + private class NoSchemaModel : ITypeDiscriminator + { + public string? TypeDiscriminator { get; set; } + } +} diff --git a/tests/CoreEx.Test.Unit/Hosting/CircuitBreakerResiliencyTests.cs b/tests/CoreEx.Test.Unit/Hosting/CircuitBreakerResiliencyTests.cs new file mode 100644 index 00000000..4e7109c7 --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/CircuitBreakerResiliencyTests.cs @@ -0,0 +1,138 @@ +using CoreEx.Hosting; +using CoreEx.Results; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Polly; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class CircuitBreakerResiliencyTests +{ + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (!condition()) + { + if (sw.Elapsed > timeout) + throw new TimeoutException("Condition was not met within the timeout."); + + await Task.Delay(10); + } + } + + private static ResilienceContext CreateContext(TestOwner owner) + { + var ctx = ResilienceContextPool.Shared.Get(); + ctx.Properties.Set(ResilienceOwner.PropertyKey, owner); + return ctx; + } + + [Test] + public async Task Create_TripsAfterFailures_PausesThenSelfResumes() + { + var owner = new TestOwner(); + var pipeline = CircuitBreakerResiliency.Create("Test owner", o => o.Logger, (o, pause, ct) => o.PauseAsync(pause), (o, ct) => o.ResumeAsync(), + minimumThroughput: 2, samplingDuration: TimeSpan.FromSeconds(10), breakDuration: TimeSpan.FromMilliseconds(50), maxBreakDuration: TimeSpan.FromMilliseconds(200)); + + var ctx = CreateContext(owner); + try + { + // Two failures within the sampling window, at 100% failure ratio, is enough to trip the breaker (default failureRatio is 0.1). + (await pipeline.ExecuteAsync(async _ => Result.Fail(new InvalidOperationException("boom")), ctx)).IsFailure.Should().BeTrue(); + (await pipeline.ExecuteAsync(async _ => Result.Fail(new InvalidOperationException("boom")), ctx)).IsFailure.Should().BeTrue(); + + await WaitUntilAsync(() => owner.PauseDurations.Count > 0, TimeSpan.FromSeconds(2)); + owner.PauseDurations.Should().ContainSingle(); + + // The breaker's own scheduled pause/delay/resume should self-resume once the (short) break duration elapses. + await WaitUntilAsync(() => owner.ResumeCount > 0, TimeSpan.FromSeconds(2)); + owner.ResumeCount.Should().Be(1); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Test] + public async Task Create_ShouldHandleExcludesError_NeverTrips() + { + var owner = new TestOwner(); + var pipeline = CircuitBreakerResiliency.Create("Test owner", o => o.Logger, (o, pause, ct) => o.PauseAsync(pause), (o, ct) => o.ResumeAsync(), + shouldHandle: r => r.Error is not ExcludedException, + minimumThroughput: 2, samplingDuration: TimeSpan.FromSeconds(10), breakDuration: TimeSpan.FromMilliseconds(50)); + + var ctx = CreateContext(owner); + try + { + for (var i = 0; i < 10; i++) + (await pipeline.ExecuteAsync(async _ => Result.Fail(new ExcludedException()), ctx)).IsFailure.Should().BeTrue(); + + // Give any (unexpected) fire-and-forget pause a moment to have shown up were it going to. + await Task.Delay(100); + owner.PauseDurations.Should().BeEmpty(); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Test] + public async Task Create_PauseAsyncThrows_IsCaughtAndLogged_DoesNotThrowFromPipeline() + { + var owner = new TestOwner { ThrowOnPause = true }; + var pipeline = CircuitBreakerResiliency.Create("Test owner", o => o.Logger, (o, pause, ct) => o.PauseAsync(pause), (o, ct) => o.ResumeAsync(), + minimumThroughput: 2, samplingDuration: TimeSpan.FromSeconds(10), breakDuration: TimeSpan.FromMilliseconds(20)); + + var ctx = CreateContext(owner); + try + { + // Tripping the breaker schedules a fire-and-forget pause/delay/resume; a failure inside that must be swallowed (logged), never surfaced as an unobserved task exception. + (await pipeline.ExecuteAsync(async _ => Result.Fail(new InvalidOperationException("boom")), ctx)).IsFailure.Should().BeTrue(); + (await pipeline.ExecuteAsync(async _ => Result.Fail(new InvalidOperationException("boom")), ctx)).IsFailure.Should().BeTrue(); + + await WaitUntilAsync(() => owner.PauseAttempted, TimeSpan.FromSeconds(2)); + + // Resume must never be reached since pause itself threw. + await Task.Delay(200); + owner.ResumeCount.Should().Be(0); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + private sealed class ExcludedException : Exception; + + private sealed class TestOwner + { + public ILogger Logger { get; } = NullLogger.Instance; + + public List PauseDurations { get; } = []; + + public int ResumeCount { get; private set; } + + public bool ThrowOnPause { get; set; } + + public bool PauseAttempted { get; private set; } + + public Task PauseAsync(TimeSpan pause) + { + PauseAttempted = true; + if (ThrowOnPause) + throw new InvalidOperationException("Pause failed."); + + PauseDurations.Add(pause); + return Task.CompletedTask; + } + + public Task ResumeAsync() + { + ResumeCount++; + return Task.CompletedTask; + } + } +} diff --git a/tests/CoreEx.Test.Unit/Hosting/ResilienceOwnerTests.cs b/tests/CoreEx.Test.Unit/Hosting/ResilienceOwnerTests.cs new file mode 100644 index 00000000..4d1541ae --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/ResilienceOwnerTests.cs @@ -0,0 +1,65 @@ +using CoreEx.Hosting; +using Polly; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class ResilienceOwnerTests +{ + [Test] + public void GetOwner_ReturnsPreviouslySetOwner() + { + var owner = new TestOwner(); + var ctx = ResilienceContextPool.Shared.Get(); + try + { + ctx.Properties.Set(ResilienceOwner.PropertyKey, owner); + + ResilienceOwner.GetOwner(ctx).Should().BeSameAs(owner); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Test] + public void GetOwner_NotSet_ReturnsDefault() + { + var ctx = ResilienceContextPool.Shared.Get(); + try + { + ResilienceOwner.GetOwner(ctx).Should().BeNull(); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Test] + public void PropertyKey_IsDistinctPerOwnerType() + { + // Different closed generic types must not collide on the same underlying property key name (each TOwner gets its own key based on its own full type name). + var ctx = ResilienceContextPool.Shared.Get(); + try + { + var owner = new TestOwner(); + var otherOwner = new OtherTestOwner(); + + ctx.Properties.Set(ResilienceOwner.PropertyKey, owner); + ctx.Properties.Set(ResilienceOwner.PropertyKey, otherOwner); + + ResilienceOwner.GetOwner(ctx).Should().BeSameAs(owner); + ResilienceOwner.GetOwner(ctx).Should().BeSameAs(otherOwner); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + private sealed class TestOwner; + + private sealed class OtherTestOwner; +} diff --git a/tests/CoreEx.Test.Unit/Hosting/RetryResiliencyTests.cs b/tests/CoreEx.Test.Unit/Hosting/RetryResiliencyTests.cs new file mode 100644 index 00000000..806845cd --- /dev/null +++ b/tests/CoreEx.Test.Unit/Hosting/RetryResiliencyTests.cs @@ -0,0 +1,100 @@ +using CoreEx.Hosting; +using CoreEx.Results; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Polly; + +namespace CoreEx.Test.Unit.Hosting; + +[TestFixture] +public class RetryResiliencyTests +{ + private static ResilienceContext CreateContext(TestOwner owner) + { + var ctx = ResilienceContextPool.Shared.Get(); + ctx.Properties.Set(ResilienceOwner.PropertyKey, owner); + return ctx; + } + + [Test] + public async Task Create_RetriesMatchingFailure_UntilSuccessWithinBudget() + { + var owner = new TestOwner(); + var pipeline = RetryResiliency.Create(r => r.Error is TransientException, o => o.Logger, delay: TimeSpan.FromMilliseconds(1), maxRetryAttempts: 3); + + var ctx = CreateContext(owner); + try + { + var attempts = 0; + var result = await pipeline.ExecuteAsync(async _ => + { + attempts++; + return attempts < 3 ? Result.Fail(new TransientException()) : Result.Success; + }, ctx); + + result.IsSuccess.Should().BeTrue(); + attempts.Should().Be(3); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Test] + public async Task Create_NonMatchingFailure_IsNeverRetried() + { + var owner = new TestOwner(); + var pipeline = RetryResiliency.Create(r => r.Error is TransientException, o => o.Logger, delay: TimeSpan.FromMilliseconds(1), maxRetryAttempts: 3); + + var ctx = CreateContext(owner); + try + { + var attempts = 0; + var result = await pipeline.ExecuteAsync(async _ => + { + attempts++; + return Result.Fail(new InvalidOperationException("not retry-worthy")); + }, ctx); + + result.IsFailure.Should().BeTrue(); + attempts.Should().Be(1); + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + [Test] + public async Task Create_MatchingFailure_ExhaustsRetries_ThenFails() + { + var owner = new TestOwner(); + var pipeline = RetryResiliency.Create(r => r.Error is TransientException, o => o.Logger, delay: TimeSpan.FromMilliseconds(1), maxRetryAttempts: 3); + + var ctx = CreateContext(owner); + try + { + var attempts = 0; + var result = await pipeline.ExecuteAsync(async _ => + { + attempts++; + return Result.Fail(new TransientException()); + }, ctx); + + result.IsFailure.Should().BeTrue(); + attempts.Should().Be(4); // The initial attempt plus 3 retries. + } + finally + { + ResilienceContextPool.Shared.Return(ctx); + } + } + + private sealed class TransientException : Exception; + + private sealed class TestOwner + { + public ILogger Logger { get; } = NullLogger.Instance; + } +} diff --git a/tests/CoreEx.Test.Unit/Mapping/MapTests.cs b/tests/CoreEx.Test.Unit/Mapping/MapTests.cs index c2192af4..416331a8 100644 --- a/tests/CoreEx.Test.Unit/Mapping/MapTests.cs +++ b/tests/CoreEx.Test.Unit/Mapping/MapTests.cs @@ -82,7 +82,7 @@ public void Standard_MapsAllStandardProperties() dest.TenantId.Should().Be("tenant"); dest.IsDeleted.Should().BeTrue(); dest.TypeDiscriminator.Should().Be("type"); - dest.PartitionKey.Should().Be("pk"); + dest.PartitionKey.Should().BeNull("PartitionKey is layer/purpose-specific and must never be auto-copied by MapStandardInto"); dest.CreatedBy.Should().Be("cb"); dest.CreatedOn.Should().Be(src.CreatedOn); dest.UpdatedBy.Should().Be("ub"); @@ -114,7 +114,7 @@ public void Standard_MapsAllStandardProperties2() dest.TenantId.Should().Be("tenant"); dest.IsDeleted.Should().BeTrue(); dest.TypeDiscriminator.Should().Be("type"); - dest.PartitionKey.Should().Be("pk"); + dest.PartitionKey.Should().BeNull("PartitionKey is layer/purpose-specific and must never be auto-copied by MapStandardInto"); dest.ChangeLog.Should().NotBeNull(); dest.ChangeLog.CreatedBy.Should().Be("cb"); dest.ChangeLog.CreatedOn.Should().Be(src.CreatedOn); diff --git a/tests/CoreEx.UnitTesting.Test.Unit/JsonDataReaderTests.cs b/tests/CoreEx.UnitTesting.Test.Unit/JsonDataReaderTests.cs deleted file mode 100644 index b7399dce..00000000 --- a/tests/CoreEx.UnitTesting.Test.Unit/JsonDataReaderTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using CoreEx.UnitTesting.Data; - -namespace CoreEx.UnitTesting.Test.Unit; - -public class JsonDataReaderTests -{ - public class Widget - { - public string? Code { get; set; } - public string? Text { get; set; } - public Guid Id { get; set; } - public Guid Ref { get; set; } - public DateTimeOffset Now { get; set; } - public DateTimeOffset Tomorrow { get; set; } - public DateTimeOffset Yesterday { get; set; } - public bool IsActive { get; set; } - public int SortOrder { get; set; } - } - - [Test] - public void ParseJson_Deserialize_SimpleObject() - { - var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "ABC", "text": "A widget" } }"""); - var w = jdr.Deserialize("widget"); - - w.Should().NotBeNull(); - w!.Code.Should().Be("ABC"); - w.Text.Should().Be("A widget"); - } - - [Test] - public void ParseJson_Deserialize_MissingPath_ReturnsDefault() - { - var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "ABC" } }"""); - var w = jdr.Deserialize("does-not-exist"); - - w.Should().BeNull(); - } - - [Test] - public void ParseJson_DynamicParameters_GuidAndDates() - { - var jdr = JsonDataReader.ParseJson("""{ "widget": { "id": "^guid", "ref": "^guid", "now": "^now", "tomorrow": "^tomorrow", "yesterday": "^yesterday" } }"""); - var w = jdr.Deserialize("widget"); - - w.Should().NotBeNull(); - w!.Id.Should().NotBe(Guid.Empty); - w.Ref.Should().NotBe(Guid.Empty); - w.Id.Should().NotBe(w.Ref, "each '^guid' substitution must generate an independent value"); - w.Tomorrow.Should().BeAfter(w.Now); - w.Yesterday.Should().BeBefore(w.Now); - } - - [Test] - public void ParseJson_ArrayIndex_UsesElementPosition() - { - var jdr = JsonDataReader.ParseJson("""{ "widgets": [ { "sortOrder": "^index" }, { "sortOrder": "^index" } ] }"""); - var widgets = jdr.Deserialize>("widgets"); - - widgets.Should().NotBeNull().And.HaveCount(2); - widgets![0].SortOrder.Should().Be(0); - widgets[1].SortOrder.Should().Be(1); - } - - [Test] - public void CreateForReferenceData_SingleKey_MapsToCodeAndText() - { - var jdr = JsonDataReader.ParseJson("""{ "widget": { "ABC": "A widget" } }""", JsonDataReaderOptions.CreateForReferenceData(JsonPropertyNamingConvention.CamelCase)); - var w = jdr.Deserialize("widget"); - - w.Should().NotBeNull(); - w!.Code.Should().Be("ABC"); - w.Text.Should().Be("A widget"); - w.IsActive.Should().BeTrue(); - w.Id.Should().NotBe(Guid.Empty); - } - - [Test] - public void CreateForReferenceData_MultiKey_LeavesExplicitPropertiesAlone() - { - var jdr = JsonDataReader.ParseJson("""{ "widget": { "code": "XYZ", "text": "Explicit widget", "sortOrder": 5 } }""", JsonDataReaderOptions.CreateForReferenceData(JsonPropertyNamingConvention.CamelCase)); - var w = jdr.Deserialize("widget"); - - w.Should().NotBeNull(); - w!.Code.Should().Be("XYZ"); - w.Text.Should().Be("Explicit widget"); - w.SortOrder.Should().Be(5, "an explicitly-supplied property must not be overwritten by the standard/reference-data defaults"); - } -} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 00000000..66a391da --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,5 @@ + + + IDE1006 + +