diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41ccefa..e4a1c86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,12 +40,15 @@ jobs: run: dotnet test -c Release --no-build --no-restore --verbosity minimal env: TINYEVENTS_RUN_SQLSERVER_TESTS: true + TINYEVENTS_RUN_POSTGRESQL_TESTS: true - name: Pack (Release) run: | dotnet pack src/TinyEvents/TinyEvents.csproj -c Release --no-build -o ./artifacts dotnet pack src/TinyEvents.SqlServer.AdoNet/TinyEvents.SqlServer.AdoNet.csproj -c Release --no-build -o ./artifacts dotnet pack src/TinyEvents.SqlServer.EntityFrameworkCore/TinyEvents.SqlServer.EntityFrameworkCore.csproj -c Release --no-build -o ./artifacts + dotnet pack src/TinyEvents.PostgreSql.AdoNet/TinyEvents.PostgreSql.AdoNet.csproj -c Release --no-build -o ./artifacts + dotnet pack src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEvents.PostgreSql.EntityFrameworkCore.csproj -c Release --no-build -o ./artifacts dotnet pack src/TinyEvents.Worker/TinyEvents.Worker.csproj -c Release --no-build -o ./artifacts - name: Upload NuGet packages diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml new file mode 100644 index 0000000..45dafb4 --- /dev/null +++ b/.github/workflows/release-package.yml @@ -0,0 +1,110 @@ +name: Release Package + +on: + workflow_dispatch: + inputs: + package: + description: "Package to publish" + required: true + type: choice + options: + - TinyEvents + - TinyEvents.Worker + - TinyEvents.SqlServer.AdoNet + - TinyEvents.SqlServer.EntityFrameworkCore + - TinyEvents.PostgreSql.AdoNet + - TinyEvents.PostgreSql.EntityFrameworkCore + version: + description: "Package version to publish" + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: nuget-prod + + env: + DOTNET_NOLOGO: true + PACKAGE_ID: ${{ inputs.package }} + PACKAGE_VERSION: ${{ inputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Resolve package project + shell: bash + run: | + case "$PACKAGE_ID" in + TinyEvents) + PROJECT="src/TinyEvents/TinyEvents.csproj" + ;; + TinyEvents.Worker) + PROJECT="src/TinyEvents.Worker/TinyEvents.Worker.csproj" + ;; + TinyEvents.SqlServer.AdoNet) + PROJECT="src/TinyEvents.SqlServer.AdoNet/TinyEvents.SqlServer.AdoNet.csproj" + ;; + TinyEvents.SqlServer.EntityFrameworkCore) + PROJECT="src/TinyEvents.SqlServer.EntityFrameworkCore/TinyEvents.SqlServer.EntityFrameworkCore.csproj" + ;; + TinyEvents.PostgreSql.AdoNet) + PROJECT="src/TinyEvents.PostgreSql.AdoNet/TinyEvents.PostgreSql.AdoNet.csproj" + ;; + TinyEvents.PostgreSql.EntityFrameworkCore) + PROJECT="src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEvents.PostgreSql.EntityFrameworkCore.csproj" + ;; + *) + echo "Unsupported package: $PACKAGE_ID" + exit 1 + ;; + esac + + echo "PROJECT=$PROJECT" >> "$GITHUB_ENV" + + - name: Restore + run: dotnet restore + + - name: Build (Release) + run: dotnet build -c Release --no-restore + + - name: Test (Release) + run: dotnet test -c Release --no-build --no-restore + env: + TINYEVENTS_RUN_SQLSERVER_TESTS: true + TINYEVENTS_RUN_POSTGRESQL_TESTS: true + + - name: Pack selected package + run: dotnet pack "$PROJECT" -c Release --no-build -o ./artifacts /p:PackageVersion="$PACKAGE_VERSION" /p:Version="$PACKAGE_VERSION" + + - name: Verify selected package + shell: bash + run: | + PACKAGE="./artifacts/${PACKAGE_ID}.${PACKAGE_VERSION}.nupkg" + + if [ ! -f "$PACKAGE" ]; then + echo "ERROR: Expected $PACKAGE but it was not found." + exit 1 + fi + + - name: Publish selected package to NuGet + run: dotnet nuget push "./artifacts/${PACKAGE_ID}.${PACKAGE_VERSION}.nupkg" --api-key "${{ secrets.NUGET_API_KEY }}" --source "https://api.nuget.org/v3/index.json" --skip-duplicate + + - name: Upload selected package + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: ./artifacts/*.nupkg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31e1ddb..e0184f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ jobs: run: dotnet test -c Release --no-build --no-restore env: TINYEVENTS_RUN_SQLSERVER_TESTS: true + TINYEVENTS_RUN_POSTGRESQL_TESTS: true - name: Pack (Release) shell: bash @@ -45,6 +46,8 @@ jobs: dotnet pack src/TinyEvents/TinyEvents.csproj -c Release --no-build -o ./artifacts /p:PackageVersion="$TAG" /p:Version="$TAG" dotnet pack src/TinyEvents.SqlServer.AdoNet/TinyEvents.SqlServer.AdoNet.csproj -c Release --no-build -o ./artifacts /p:PackageVersion="$TAG" /p:Version="$TAG" dotnet pack src/TinyEvents.SqlServer.EntityFrameworkCore/TinyEvents.SqlServer.EntityFrameworkCore.csproj -c Release --no-build -o ./artifacts /p:PackageVersion="$TAG" /p:Version="$TAG" + dotnet pack src/TinyEvents.PostgreSql.AdoNet/TinyEvents.PostgreSql.AdoNet.csproj -c Release --no-build -o ./artifacts /p:PackageVersion="$TAG" /p:Version="$TAG" + dotnet pack src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEvents.PostgreSql.EntityFrameworkCore.csproj -c Release --no-build -o ./artifacts /p:PackageVersion="$TAG" /p:Version="$TAG" dotnet pack src/TinyEvents.Worker/TinyEvents.Worker.csproj -c Release --no-build -o ./artifacts /p:PackageVersion="$TAG" /p:Version="$TAG" - name: Verify package version matches tag @@ -55,7 +58,7 @@ jobs: echo "Artifacts:" ls -la ./artifacts - for PACKAGE_ID in TinyEvents TinyEvents.SqlServer.AdoNet TinyEvents.SqlServer.EntityFrameworkCore TinyEvents.Worker; do + for PACKAGE_ID in TinyEvents TinyEvents.SqlServer.AdoNet TinyEvents.SqlServer.EntityFrameworkCore TinyEvents.PostgreSql.AdoNet TinyEvents.PostgreSql.EntityFrameworkCore TinyEvents.Worker; do if [ ! -f "./artifacts/${PACKAGE_ID}.${TAG}.nupkg" ]; then echo "ERROR: Expected ./artifacts/${PACKAGE_ID}.${TAG}.nupkg but it was not found." exit 1 diff --git a/README.md b/README.md index b562e35..ea42cda 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ dotnet add package TinyEvents.SqlServer.EntityFrameworkCore --version 0.1.0-alph dotnet add package TinyEvents.Worker --version 0.1.0-alpha.1 ``` -Register TinyEvents and the EF Core provider: +Provider packages are database-specific. Use `TinyEvents.SqlServer.*` for SQL Server or `TinyEvents.PostgreSql.*` for PostgreSQL. + +Register TinyEvents and the SQL Server EF Core provider: ```csharp using Microsoft.EntityFrameworkCore; @@ -144,15 +146,15 @@ No runtime assembly scanning is required, and normal consumers do not need manua ## Providers -TinyEvents core is provider-agnostic. The current alpha includes SQL Server provider packages: +TinyEvents core is provider-agnostic. The current alpha includes SQL Server and PostgreSQL provider packages: - `TinyEvents.SqlServer.EntityFrameworkCore` - `TinyEvents.SqlServer.AdoNet` +- `TinyEvents.PostgreSql.EntityFrameworkCore` +- `TinyEvents.PostgreSql.AdoNet` - `TinyEvents.Worker` -Other databases may be supported later through separate providers. - -The SQL Server providers use SQL Server-specific claiming semantics, including SQL Server locking hints and atomic claim statements. +Each database family has its own provider package. SQL Server providers use SQL Server locking hints and atomic update/output statements. PostgreSQL providers use PostgreSQL claiming semantics, including `FOR UPDATE SKIP LOCKED` with update/returning statements. EF Core publishing adds the outbox message to the caller's scoped `DbContext`. The caller commits business data and outbox messages with `SaveChangesAsync`. @@ -178,29 +180,38 @@ TinyEvents owns the outbox schema definition. Applications own migration executi EF Core applications should call `modelBuilder.UseTinyEventsOutbox()` and create normal EF migrations. -ADO.NET applications should use the provided SQL Server script: +ADO.NET applications should use the script helper for their database provider. + +SQL Server: ```csharp var sql = TinySqlServerAdoNetSchema.CreateOutboxSql(); ``` -The package also includes the default SQL script as package content: +PostgreSQL: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql(); +``` + +The ADO.NET packages also include default SQL scripts as package content: ```text schema/sqlserver/001_CreateTinyOutbox.sql +schema/postgresql/001_CreateTinyOutbox.sql ``` TinyEvents does not run migrations automatically. ## Run the samples -First start SQL Server with Docker: +Start the sample database containers with Docker: ```bash -docker compose up -d sqlserver +docker compose up -d sqlserver postgresql ``` -Then run the EF Core sample: +Then run the SQL Server EF Core sample: ```bash dotnet run --project samples/TinyEvents.Sample.EfCore @@ -212,7 +223,19 @@ Or run the ADO.NET sample: dotnet run --project samples/TinyEvents.Sample.AdoNet ``` -The samples default to `TINYEVENTS_SAMPLE_SQLSERVER` or a command-line connection string. See [Samples](samples/README.md) for the full runbook and the package smoke sample. +Or run the PostgreSQL EF Core sample: + +```bash +dotnet run --project samples/TinyEvents.Sample.PostgreSql.EfCore +``` + +Or run the PostgreSQL ADO.NET sample: + +```bash +dotnet run --project samples/TinyEvents.Sample.PostgreSql.AdoNet +``` + +SQL Server samples default to `TINYEVENTS_SAMPLE_SQLSERVER`. PostgreSQL samples default to `TINYEVENTS_SAMPLE_POSTGRESQL`. All samples also accept a command-line connection string. See [Samples](samples/README.md) for the full runbook and the package smoke sample. ## Tiny suite @@ -253,8 +276,12 @@ TinyEvents is intentionally small. ## Documentation - [Getting Started](docs/getting-started.md) -- [EF Core Provider](docs/ef-core.md) -- [ADO.NET Provider](docs/ado-net.md) +- [EF Core Providers](docs/ef-core.md) +- [ADO.NET Providers](docs/ado-net.md) +- [SQL Server EF Core](docs/sql-server/ef-core.md) +- [SQL Server ADO.NET](docs/sql-server/ado-net.md) +- [PostgreSQL EF Core](docs/postgresql/ef-core.md) +- [PostgreSQL ADO.NET](docs/postgresql/ado-net.md) - [Workers and Leases](docs/workers.md) - [Schema and Migrations](docs/schema-and-migrations.md) - [The Tiny Suite](docs/tiny-suite.md) @@ -262,14 +289,15 @@ TinyEvents is intentionally small. - [Architecture](docs/architecture.md) - [Testing](docs/testing.md) - [Samples](samples/README.md) +- [Releasing](docs/releasing.md) - [Roadmap](docs/roadmap.md) ## Current limitations TinyEvents is an alpha. -- SQL Server is the only real database target in the current providers. -- SQL Server providers use SQL Server-specific atomic claiming. +- SQL Server and PostgreSQL are the current real database targets. +- Providers use database-specific atomic claiming. - There is no claim heartbeat or renewal in v1. - Long-running consumers must use a long enough `ClaimTimeout`. - There is no migration runner. @@ -286,7 +314,7 @@ The goal of the alpha is to validate: - package boundaries - generated registrations -- SQL Server provider behavior +- SQL Server and PostgreSQL provider behavior - worker processing - sample application ergonomics - how TinyDispatcher, TinyValidations, and TinyEvents fit together as an application layer diff --git a/TinyEvents.sln b/TinyEvents.sln index bc385d1..a2202d5 100644 --- a/TinyEvents.sln +++ b/TinyEvents.sln @@ -37,6 +37,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.Sample.EfCore", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.PackageSmoke", "samples\TinyEvents.PackageSmoke\TinyEvents.PackageSmoke.csproj", "{F32341BD-DA7B-4143-91B1-88F78659FF24}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.PostgreSql.AdoNet", "src\TinyEvents.PostgreSql.AdoNet\TinyEvents.PostgreSql.AdoNet.csproj", "{85D02657-2329-4B56-A4D6-BE10D9C4053D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.PostgreSql.EntityFrameworkCore", "src\TinyEvents.PostgreSql.EntityFrameworkCore\TinyEvents.PostgreSql.EntityFrameworkCore.csproj", "{BE429123-7A4B-475F-B8CC-263B799C85FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.PostgreSql.AdoNet.Tests", "tests\TinyEvents.PostgreSql.AdoNet.Tests\TinyEvents.PostgreSql.AdoNet.Tests.csproj", "{A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.PostgreSql.EntityFrameworkCore.Tests", "tests\TinyEvents.PostgreSql.EntityFrameworkCore.Tests\TinyEvents.PostgreSql.EntityFrameworkCore.Tests.csproj", "{E6BEADE4-4296-466D-9FDE-403633842E54}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.PostgreSql.Tests", "tests\TinyEvents.PostgreSql.Tests\TinyEvents.PostgreSql.Tests.csproj", "{BCB14138-4406-4AD6-AEF3-F389C09D1706}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.Sample.PostgreSql.EfCore", "samples\TinyEvents.Sample.PostgreSql.EfCore\TinyEvents.Sample.PostgreSql.EfCore.csproj", "{291625AF-33F0-42FF-B530-5B8A8896F760}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TinyEvents.Sample.PostgreSql.AdoNet", "samples\TinyEvents.Sample.PostgreSql.AdoNet\TinyEvents.Sample.PostgreSql.AdoNet.csproj", "{55CF774C-AFFA-4E39-A352-8DD94F74BFF2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -215,6 +229,90 @@ Global {F32341BD-DA7B-4143-91B1-88F78659FF24}.Release|x64.Build.0 = Release|Any CPU {F32341BD-DA7B-4143-91B1-88F78659FF24}.Release|x86.ActiveCfg = Release|Any CPU {F32341BD-DA7B-4143-91B1-88F78659FF24}.Release|x86.Build.0 = Release|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Debug|x64.ActiveCfg = Debug|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Debug|x64.Build.0 = Debug|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Debug|x86.ActiveCfg = Debug|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Debug|x86.Build.0 = Debug|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Release|Any CPU.Build.0 = Release|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Release|x64.ActiveCfg = Release|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Release|x64.Build.0 = Release|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Release|x86.ActiveCfg = Release|Any CPU + {85D02657-2329-4B56-A4D6-BE10D9C4053D}.Release|x86.Build.0 = Release|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Debug|x64.Build.0 = Debug|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Debug|x86.Build.0 = Debug|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Release|Any CPU.Build.0 = Release|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Release|x64.ActiveCfg = Release|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Release|x64.Build.0 = Release|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Release|x86.ActiveCfg = Release|Any CPU + {BE429123-7A4B-475F-B8CC-263B799C85FD}.Release|x86.Build.0 = Release|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Debug|x64.ActiveCfg = Debug|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Debug|x64.Build.0 = Debug|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Debug|x86.Build.0 = Debug|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Release|Any CPU.Build.0 = Release|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Release|x64.ActiveCfg = Release|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Release|x64.Build.0 = Release|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Release|x86.ActiveCfg = Release|Any CPU + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9}.Release|x86.Build.0 = Release|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Debug|x64.Build.0 = Debug|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Debug|x86.Build.0 = Debug|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Release|Any CPU.Build.0 = Release|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Release|x64.ActiveCfg = Release|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Release|x64.Build.0 = Release|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Release|x86.ActiveCfg = Release|Any CPU + {E6BEADE4-4296-466D-9FDE-403633842E54}.Release|x86.Build.0 = Release|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Debug|x64.ActiveCfg = Debug|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Debug|x64.Build.0 = Debug|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Debug|x86.ActiveCfg = Debug|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Debug|x86.Build.0 = Debug|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Release|Any CPU.Build.0 = Release|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Release|x64.ActiveCfg = Release|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Release|x64.Build.0 = Release|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Release|x86.ActiveCfg = Release|Any CPU + {BCB14138-4406-4AD6-AEF3-F389C09D1706}.Release|x86.Build.0 = Release|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Debug|Any CPU.Build.0 = Debug|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Debug|x64.ActiveCfg = Debug|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Debug|x64.Build.0 = Debug|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Debug|x86.ActiveCfg = Debug|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Debug|x86.Build.0 = Debug|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Release|Any CPU.ActiveCfg = Release|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Release|Any CPU.Build.0 = Release|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Release|x64.ActiveCfg = Release|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Release|x64.Build.0 = Release|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Release|x86.ActiveCfg = Release|Any CPU + {291625AF-33F0-42FF-B530-5B8A8896F760}.Release|x86.Build.0 = Release|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Debug|x64.ActiveCfg = Debug|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Debug|x64.Build.0 = Debug|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Debug|x86.ActiveCfg = Debug|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Debug|x86.Build.0 = Debug|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Release|Any CPU.Build.0 = Release|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Release|x64.ActiveCfg = Release|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Release|x64.Build.0 = Release|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Release|x86.ActiveCfg = Release|Any CPU + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -234,5 +332,12 @@ Global {1C66EB46-7588-408D-8D3A-6F4444C0F275} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {D602F870-F917-4D71-9841-E3F2088CE67C} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {F32341BD-DA7B-4143-91B1-88F78659FF24} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {85D02657-2329-4B56-A4D6-BE10D9C4053D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {BE429123-7A4B-475F-B8CC-263B799C85FD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A8EF6D4A-A8F0-412D-823B-BEFB93E32BB9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {E6BEADE4-4296-466D-9FDE-403633842E54} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {BCB14138-4406-4AD6-AEF3-F389C09D1706} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {291625AF-33F0-42FF-B530-5B8A8896F760} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {55CF774C-AFFA-4E39-A352-8DD94F74BFF2} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection EndGlobal diff --git a/Tomorrow.PostgreSqlProviderPlan.md b/Tomorrow.PostgreSqlProviderPlan.md new file mode 100644 index 0000000..51a2e56 --- /dev/null +++ b/Tomorrow.PostgreSqlProviderPlan.md @@ -0,0 +1,614 @@ +# Tomorrow: PostgreSQL Provider + +Goal: add PostgreSQL support to TinyEvents without disturbing the current SQL Server provider shape. + +Branch: `plan/postgres-provider` +Base: latest `main` pulled on 2026-06-13. +Scope: analysis first, implementation later in small reviewed slices. + +This is an analysis and implementation plan. It should guide small, testable slices. No slice should depend on hope. If a slice changes behavior, it should include a focused test that proves the behavior. + +Use the same code standard as TinyDispatcher: + +- calm +- explicit +- readable top down +- boring in the best way +- behavior tests over structural tests +- object-oriented code where it clarifies responsibility +- no leaky abstractions introduced only to make unit tests easy +- unit tests only where they add real design or behavioral value +- no clever abstractions unless they remove real complexity + +Think like principal engineers working on an OSS package: public APIs should be boring, durable, and easy to reason about under pressure. + +## Product Goal + +TinyEvents should support PostgreSQL as a first-class outbox database provider. + +The new provider should let applications: + +- publish outbox messages through EF Core using Npgsql +- publish outbox messages through ADO.NET using Npgsql +- run TinyEvents workers against PostgreSQL +- claim pending messages atomically with PostgreSQL locking semantics +- create the outbox schema through EF Core migrations or a provider SQL script + +The core TinyEvents package should remain provider-agnostic. + +## Proposed Packages + +Mirror the SQL Server package boundaries: + +```text +TinyEvents.PostgreSql.EntityFrameworkCore +TinyEvents.PostgreSql.AdoNet +``` + +Keep `TinyEvents.Worker` unchanged. The worker should continue to depend only on core abstractions: + +```text +ITinyOutboxStore +ITinyOutboxProcessor +IEventConsumer +``` + +## Provider Dependencies + +Expected package dependencies: + +```text +TinyEvents.PostgreSql.EntityFrameworkCore + -> Npgsql.EntityFrameworkCore.PostgreSQL + -> Microsoft.EntityFrameworkCore.Relational + -> TinyEvents + +TinyEvents.PostgreSql.AdoNet + -> Npgsql + -> TinyEvents +``` + +Test dependencies: + +```text +Testcontainers.PostgreSql +Npgsql +Npgsql.EntityFrameworkCore.PostgreSQL +``` + +## Database Semantics + +PostgreSQL claiming should use row-level locking with `FOR UPDATE SKIP LOCKED`. + +The important rule remains: + +> Query-then-update claiming is not acceptable. + +The likely shape is: + +```sql +WITH claimed AS +( + SELECT "Id" + FROM "TinyOutbox" + WHERE + ( + "Status" = @PendingStatus + AND ("NextAttemptAtUtc" IS NULL OR "NextAttemptAtUtc" <= @Now) + ) + OR + ( + "Status" = @ProcessingStatus + AND "ClaimExpiresAtUtc" <= @Now + ) + ORDER BY "CreatedAtUtc" + FOR UPDATE SKIP LOCKED + LIMIT @BatchSize +) +UPDATE "TinyOutbox" AS outbox +SET + "Status" = @ProcessingStatus, + "ClaimedBy" = @WorkerId, + "ClaimedAtUtc" = @Now, + "ClaimExpiresAtUtc" = @ClaimExpiresAtUtc +FROM claimed +WHERE outbox."Id" = claimed."Id" +RETURNING + outbox."Id", + outbox."EventType", + outbox."Payload", + outbox."Status", + outbox."AttemptCount", + outbox."ClaimedBy", + outbox."ClaimedAtUtc", + outbox."ClaimExpiresAtUtc", + outbox."CreatedAtUtc", + outbox."NextAttemptAtUtc", + outbox."ProcessedAtUtc", + outbox."LastError"; +``` + +This is the PostgreSQL equivalent of the SQL Server `UPDATE ... OUTPUT` claim pattern. + +## Schema Direction + +Start with the same logical outbox shape as SQL Server: + +- `Id` +- `EventType` +- `Payload` +- `Status` +- `AttemptCount` +- `ClaimedBy` +- `ClaimedAtUtc` +- `ClaimExpiresAtUtc` +- `CreatedAtUtc` +- `NextAttemptAtUtc` +- `ProcessedAtUtc` +- `LastError` + +Use quoted identifiers in generated SQL so the provider can match the existing PascalCase model cleanly. + +Default table: + +```text +public.TinyOutbox +``` + +Provider options should allow custom table names, matching the SQL Server provider pattern. + +Open naming decision: + +- Provider namespace and package should use `PostgreSql` for .NET naming consistency. +- User-facing docs can say PostgreSQL. + +## High-Level Implementation Strategy + +Do not start by abstracting over SQL Server and PostgreSQL. + +First, mirror the existing provider shape. Once both providers exist and tests are green, review duplicated code calmly. Only extract shared helpers if they clearly reduce real complexity. + +The first implementation should be easy to compare with SQL Server: + +```text +SqlServer provider +PostgreSql provider +``` + +That keeps review simple and avoids accidental core changes. + +## Slice Plan + +### Slice 1: Planning Document + +Goal: capture the design before changing code. + +Work: + +- Add this plan. +- Do not add provider projects yet. + +Tests: + +- None. This is documentation only. + +Commit: + +```text +Add PostgreSQL provider implementation plan +``` + +### Slice 2: Add Project Skeletons + +Goal: create provider package boundaries without behavior. + +Work: + +- Add `src/TinyEvents.PostgreSql.EntityFrameworkCore`. +- Add `src/TinyEvents.PostgreSql.AdoNet`. +- Add matching empty test projects: + - `tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests` + - `tests/TinyEvents.PostgreSql.AdoNet.Tests` + - optionally one combined integration project: + `tests/TinyEvents.PostgreSql.Tests` +- Add projects to `TinyEvents.sln`. +- Add package metadata mirroring SQL Server packages. + +Tests: + +- `dotnet test` should still pass. +- Add one trivial package-boundary test per new provider only if useful. + +Commit: + +```text +Add PostgreSQL provider project skeletons +``` + +### Slice 3: PostgreSQL Table Name Handling + +Goal: safely parse and quote PostgreSQL table names. + +Work: + +- Add `TinyPostgreSqlAdoNetTableName`. +- Add `TinyPostgreSqlEfCoreTableName` or a shared provider-local helper if both packages need the same behavior. +- Support: + - `TinyOutbox` + - `public.TinyOutbox` + - custom schema/table names +- Generate quoted SQL names safely. + +Tests: + +- parses default table name +- parses schema-qualified table name +- rejects null or whitespace +- quotes identifiers correctly +- rejects unsupported multipart names + +Commit: + +```text +Add PostgreSQL table name parsing +``` + +### Slice 4: ADO.NET Schema Script + +Goal: provide a PostgreSQL schema script before runtime logic. + +Work: + +- Add `TinyPostgreSqlAdoNetSchema.CreateOutboxSql()`. +- Add packed script: + - `schema/postgresql/001_CreateTinyOutbox.sql` +- Use PostgreSQL column types: + - `uuid` + - `text` + - `integer` + - `timestamp with time zone` +- Add useful indexes for claiming: + - pending due messages + - expired processing messages + +Tests: + +- default schema SQL contains expected table and indexes +- custom table SQL contains quoted custom name +- null/invalid table names are rejected + +Commit: + +```text +Add PostgreSQL outbox schema script +``` + +### Slice 5: ADO.NET Writer + +Goal: insert outbox messages through an application-owned Npgsql transaction. + +Work: + +- Add PostgreSQL ADO.NET options. +- Add transaction context types if SQL Server types cannot be reused cleanly. +- Add `TinyPostgreSqlAdoNetOutboxWriter`. +- Add insert SQL and parameter helpers. +- Keep transaction ownership rules identical to SQL Server: + - app owns connection + - app owns transaction + - TinyEvents does not commit, roll back, open, or dispose it + +Tests: + +- writer rejects null message +- writer requires configured current transaction +- insert participates in caller transaction +- rollback removes inserted outbox row +- commit persists inserted outbox row + +Commit: + +```text +Add PostgreSQL ADO.NET outbox writer +``` + +### Slice 6: ADO.NET Store Claiming + +Goal: implement atomic PostgreSQL worker claiming. + +Work: + +- Add worker connection factory. +- Add `TinyPostgreSqlAdoNetOutboxStore`. +- Implement: + - `ClaimPendingAsync` + - `MarkProcessedAsync` + - `MarkFailedAsync` +- Use `FOR UPDATE SKIP LOCKED` inside an atomic `WITH claimed AS (...) UPDATE ... RETURNING` statement. + +Tests: + +- claims due pending messages +- skips pending messages scheduled in the future +- skips active processing messages +- reclaims expired processing messages +- sets worker id and claim expiration +- returns claimed rows +- two workers do not claim the same unexpired row +- mark processed only works for the owning worker +- mark failed only works for the owning worker +- failed without retry becomes `Failed` +- failed with retry becomes `Pending` + +Commit: + +```text +Add PostgreSQL ADO.NET worker store +``` + +### Slice 7: EF Core Mapping + +Goal: support EF Core schema generation and publishing. + +Work: + +- Add `TinyEventsModelBuilderExtensions` for PostgreSQL provider. +- Map `TinyOutboxMessage`. +- Set table name and column constraints. +- Keep API parallel to SQL Server: + +```csharp +modelBuilder.UseTinyEventsOutbox(); +modelBuilder.UseTinyEventsOutbox("public.TinyOutbox"); +``` + +Tests: + +- model maps default table +- model maps custom table +- required columns and lengths match provider expectations +- EF Core can create database schema in PostgreSQL test container + +Commit: + +```text +Add PostgreSQL EF Core outbox mapping +``` + +### Slice 8: EF Core Writer + +Goal: publish through caller-owned `DbContext` changes. + +Work: + +- Add `TinyPostgreSqlEfCoreOutboxWriter`. +- Keep provider registration for the dedicated dependency injection slice. + +Tests: + +- writer rejects null message +- writer adds outbox entity to DbContext +- caller `SaveChangesAsync` persists outbox row +- business row and outbox row commit together in one save + +Commit: + +```text +Add PostgreSQL EF Core outbox writer +``` + +### Slice 9: EF Core Store + +Goal: support worker processing from EF Core provider. + +Work: + +- Add `TinyPostgreSqlEfCoreOutboxStore`. +- Reuse PostgreSQL claim/mark SQL shape. +- Open the underlying relational connection when needed. +- Attach current EF transaction if one exists. + +Tests: + +- mirror ADO.NET store tests where possible +- claim pending +- skip future scheduled +- reclaim expired processing +- mark processed by owning worker +- mark failed by owning worker + +Commit: + +```text +Add PostgreSQL EF Core worker store +``` + +### Slice 10: Dependency Injection + +Goal: expose provider registration APIs. + +Work: + +- Add: + +```csharp +services.UsePostgreSqlAdoNetOutbox(...) +services.UsePostgreSqlEntityFrameworkCoreOutbox(...) +``` + +- Register: + - TinyEvents core services + - generated contributions + - PostgreSQL ADO.NET writer and store + - PostgreSQL EF Core writer and store + - provider options + +Tests: + +- ADO.NET registration resolves `ITinyOutboxWriter` +- ADO.NET registration resolves `ITinyOutboxStore` +- EF Core registration resolves `ITinyOutboxWriter` +- EF Core registration resolves `ITinyOutboxStore` +- generated contributions are applied once + +Commit: + +```text +Add PostgreSQL provider registration +``` + +### Slice 11: End-To-End PostgreSQL Runtime Tests + +Goal: prove the provider with real PostgreSQL. + +Work: + +- Add PostgreSQL Testcontainers fixture. +- Add shared PostgreSQL integration settings. +- Use Docker PostgreSQL in CI. + +Tests: + +- publish event through EF Core, process it, mark processed +- publish event through ADO.NET, process it, mark processed +- consumer failure marks failed or retry state +- multiple workers do not process the same active message +- expired claims can be retried + +Commit: + +```text +Add PostgreSQL end-to-end runtime tests +``` + +### Slice 12: Docker Compose And Samples + +Goal: make local usage easy. + +Work: + +- Add PostgreSQL service to root `docker-compose.yml`. +- End with four provider-focused samples: + - SQL Server ADO.NET + - SQL Server EF Core + - PostgreSQL ADO.NET + - PostgreSQL EF Core +- Keep sample small and operational. + +Tests: + +- sample smoke can run against local PostgreSQL +- package smoke covers new provider packages if feasible + +Commit: + +```text +Add PostgreSQL local run support +``` + +### Slice 13: Documentation + +Goal: document PostgreSQL without overselling stability. + +Work: + +- Update README provider list. +- Add: + - `docs/postgresql-ef-core.md` + - `docs/postgresql-ado-net.md` +- Update: + - `docs/schema-and-migrations.md` + - `docs/workers.md` + - `docs/roadmap.md` + - `docs/README.md` + - `samples/README.md` +- Document `FOR UPDATE SKIP LOCKED` as the provider claiming mechanism. + +Tests: + +- None beyond normal docs review. + +Commit: + +```text +Document PostgreSQL providers +``` + +### Slice 14: Package And CI Review + +Goal: prepare PostgreSQL packages for alpha publishing. + +Work: + +- Confirm package metadata. +- Confirm package contents: + - README + - schema scripts + - dependencies +- Update CI to restore/build/test all new projects. +- Decide whether PostgreSQL integration tests require Docker in CI or stay opt-in initially. + +Tests: + +- `dotnet test` +- `dotnet pack` +- inspect nupkg contents + +Commit: + +```text +Prepare PostgreSQL provider packages +``` + +## Risks + +- PostgreSQL timestamp handling must be consistent with `DateTimeOffset`. +- Npgsql maps `timestamp with time zone` carefully; tests should prove round-tripping. +- Identifier quoting must be correct for default and custom table names. +- `FOR UPDATE SKIP LOCKED` must be inside one atomic claim operation. +- EF Core provider should not accidentally create SQL Server-shaped schema assumptions. +- ADO.NET provider should not own caller transactions. +- Provider registration should not duplicate core registration behavior. +- CI may need Docker-enabled PostgreSQL tests. + +## Open Questions + +### Do We Implement ADO.NET Or EF Core First? + +Recommended: ADO.NET first. + +Reason: ADO.NET makes the SQL and transaction boundaries explicit. Once claiming is correct there, EF Core can reuse the same SQL shape for worker operations. + +### Do We Share SQL Between EF Core And ADO.NET? + +Not initially. + +Reason: SQL Server already has separate provider-local SQL classes. Mirroring that shape keeps the first PostgreSQL implementation easy to review. If duplication becomes painful after both providers are complete, extract provider-local shared helpers in a later cleanup slice. + +### Do We Use Quoted PascalCase Or Lower Snake Case? + +Recommended initial answer: quoted PascalCase. + +Reason: it matches the existing `TinyOutboxMessage` shape and keeps EF Core and ADO.NET column mapping aligned with SQL Server. We can revisit lower snake case only if PostgreSQL ergonomics become more important than cross-provider symmetry. + +### Do We Add A Shared Provider Test Contract? + +Not at first. + +Reason: shared contract tests can be useful after PostgreSQL exists. Before then, they may force abstraction too early. Start with clear provider-specific tests. Extract shared behavior tests later if the repetition becomes meaningful. + +## Definition Of Done + +PostgreSQL support is ready for an alpha package when: + +- ADO.NET publishing works inside caller-owned transactions. +- EF Core publishing works inside caller-owned `DbContext` saves. +- Worker claiming is atomic with `FOR UPDATE SKIP LOCKED`. +- Mark processed and failed guard on current worker ownership. +- Expired processing claims can be reclaimed. +- Active processing claims are skipped by other workers. +- PostgreSQL schema script is packaged. +- EF Core mapping can create the schema. +- Integration tests pass against real PostgreSQL. +- Docs explain setup, schema, worker semantics, and limitations. +- Package metadata and contents are reviewed. diff --git a/docker-compose.yml b/docker-compose.yml index f10255a..4634be3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,3 +15,18 @@ services: retries: 12 start_period: 20s + postgresql: + image: postgres:16-alpine + container_name: tinyevents-postgresql + environment: + POSTGRES_DB: "tinyevents_samples" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + ports: + - "54323:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d tinyevents_samples"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s diff --git a/docs/README.md b/docs/README.md index c454d0a..a7de787 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,14 +3,21 @@ TinyEvents is an outbox-first event library. The docs are organized around how users adopt it: - [Getting Started](getting-started.md) -- [EF Core Provider](ef-core.md) -- [ADO.NET Provider](ado-net.md) +- [EF Core Providers](ef-core.md) +- [ADO.NET Providers](ado-net.md) +- SQL Server: + - [EF Core](sql-server/ef-core.md) + - [ADO.NET](sql-server/ado-net.md) +- PostgreSQL: + - [EF Core](postgresql/ef-core.md) + - [ADO.NET](postgresql/ado-net.md) - [Workers and Leases](workers.md) - [Schema and Migrations](schema-and-migrations.md) - [The Tiny Suite](tiny-suite.md) - [Source Generator](source-generator.md) - [Architecture](architecture.md) - [Testing](testing.md) +- [Releasing](releasing.md) - [Samples](../samples/README.md) - [Roadmap](roadmap.md) diff --git a/docs/ado-net.md b/docs/ado-net.md index 39dfc25..8d9bf9a 100644 --- a/docs/ado-net.md +++ b/docs/ado-net.md @@ -1,158 +1,20 @@ -# ADO.NET Provider +# ADO.NET Providers -`TinyEvents.SqlServer.AdoNet` stores outbox messages through application-owned ADO.NET transactions. +TinyEvents ADO.NET providers store outbox messages through application-owned ADO.NET transactions. -The current alpha provider emits SQL Server SQL. +TinyEvents joins the transaction supplied by the application. It does not open the publishing connection, begin the transaction, commit, roll back, or dispose the application transaction. -## Install +Choose the provider for your database: -```bash -dotnet add package TinyEvents --version 0.1.0-alpha.1 -dotnet add package TinyEvents.SqlServer.AdoNet --version 0.1.0-alpha.1 -``` - -The ADO.NET provider package is SQL Server-specific. - -## Register - -```csharp -using Microsoft.Data.SqlClient; -using TinyEvents.SqlServer.AdoNet; - -services.UseSqlServerAdoNetOutbox(options => -{ - options.UseCurrentTransaction(sp => - { - var current = sp.GetRequiredService(); - - return new TinyAdoNetTransactionContext( - current.Connection, - current.Transaction); - }); - - options.UseWorkerConnectionFactory(async (_, ct) => - { - var connection = new SqlConnection(connectionString); - await connection.OpenAsync(ct); - return connection; - }); -}); -``` - -This registers TinyEvents core services, applies generated consumer contributions, and configures the ADO.NET outbox writer/store. - -## Publishing Transaction Ownership - -ADO.NET publishing requires an application-owned `DbConnection` and `DbTransaction`. - -TinyEvents: +- [SQL Server ADO.NET](sql-server/ado-net.md) +- [PostgreSQL ADO.NET](postgresql/ado-net.md) -- inserts the outbox row using the supplied transaction -- does not open the application connection -- does not begin the transaction -- does not commit -- does not roll back -- does not dispose the application transaction - -The application owns the persistence boundary. - -## Simple Application Shape - -For small applications without an existing session abstraction, create a scoped transaction object in the composition root: +The application owns the persistence boundary: ```csharp -services.AddScoped(_ => -{ - var connection = new SqlConnection(connectionString); - connection.Open(); - var transaction = connection.BeginTransaction(); - - return new SampleAdoNetTransaction(connection, transaction); -}); -``` - -Then use it from a use case: - -```csharp -public sealed class RegisterUserUseCase -{ - private readonly SampleAdoNetTransaction transaction; - private readonly ITinyEventPublisher events; - - public RegisterUserUseCase( - SampleAdoNetTransaction transaction, - ITinyEventPublisher events) - { - this.transaction = transaction; - this.events = events; - } - - public async ValueTask RegisterAsync(string email, CancellationToken ct) - { - await InsertUserAsync(transaction.Connection, transaction.Transaction, email, ct); - await events.PublishAsync(new UserCreated(Guid.NewGuid(), email), ct); - await transaction.CommitAsync(ct); - } -} -``` - -`SampleAdoNetTransaction` is application infrastructure. It is not a TinyEvents abstraction. - -## Existing Unit Of Work Or Session - -If your application already owns a database session, map it directly: - -```csharp -options.UseCurrentTransaction(sp => -{ - var session = sp.GetRequiredService(); - - return session.CurrentTransaction is null - ? null - : new TinyAdoNetTransactionContext( - session.Connection, - session.CurrentTransaction); -}); -``` - -TinyEvents does not ask your app to adopt a TinyEvents unit of work. - -## Worker Connection Factory - -Worker operations use a separate connection factory: - -```csharp -options.UseWorkerConnectionFactory(async (sp, ct) => -{ - var factory = sp.GetRequiredService(); - return await factory.CreateOpenConnectionAsync(ct); -}); -``` - -Connections returned by `UseWorkerConnectionFactory(...)` are owned by TinyEvents for that worker operation and may be disposed after the operation. - -Contexts returned by `UseCurrentTransaction(...)` are owned by the application and are never disposed by TinyEvents. - -## Schema Script - -ADO.NET applications should apply the TinyEvents SQL Server schema with their migration tool of choice. - -For code-based migration runners: - -```csharp -var sql = TinySqlServerAdoNetSchema.CreateOutboxSql(); -``` - -For custom tables: - -```csharp -var sql = TinySqlServerAdoNetSchema.CreateOutboxSql("app.MyOutbox"); -``` - -The package also includes the default SQL Server script: - -```text -schema/sqlserver/001_CreateTinyOutbox.sql +await InsertUserAsync(connection, transaction, user, ct); +await events.PublishAsync(new UserCreated(user.Id, user.Email), ct); +await transaction.CommitAsync(ct); ``` -TinyEvents owns the schema definition; your application owns when and how the migration runs. +Worker operations use a separate provider-configured connection factory. Connections returned by the worker factory are owned by TinyEvents for that worker operation. diff --git a/docs/architecture.md b/docs/architecture.md index acd792b..0ebd6f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,6 +84,8 @@ src/TinyEvents.SourceGen ```text src/TinyEvents.SqlServer.EntityFrameworkCore src/TinyEvents.SqlServer.AdoNet +src/TinyEvents.PostgreSql.EntityFrameworkCore +src/TinyEvents.PostgreSql.AdoNet src/TinyEvents.Worker ``` @@ -95,8 +97,10 @@ Current database providers: - SQL Server ADO.NET provider - SQL Server EF Core provider +- PostgreSQL ADO.NET provider +- PostgreSQL EF Core provider -Future database providers may target PostgreSQL, MySQL, SQLite, or other engines if they can implement safe atomic claiming for that database. +Future database providers may target MySQL, SQLite, or other engines if they can implement safe atomic claiming for that database. ## Publishing Flow @@ -173,7 +177,7 @@ The contribution system is the bridge between compile-time discovery and runtime 1. The generator emits an `ITinyEventsContribution`. 2. A module initializer adds it to `TinyEventsBootstrap`. -3. `UseTinyEvents`, `UseSqlServerEntityFrameworkCoreOutbox`, or `UseSqlServerAdoNetOutbox` applies contributions. +3. `UseTinyEvents` or a provider registration method applies contributions. 4. Consumers and event type descriptors become normal DI services. Runtime processing does not use a custom consumer registry. It resolves consumers directly from `IServiceProvider`. diff --git a/docs/ef-core.md b/docs/ef-core.md index 2b387b3..eb30cae 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -1,67 +1,15 @@ -# EF Core Provider +# EF Core Providers -`TinyEvents.SqlServer.EntityFrameworkCore` stores outbox messages through a caller-owned `DbContext`. +TinyEvents EF Core providers store outbox messages through a caller-owned `DbContext`. -The current alpha uses SQL Server-specific claiming SQL for worker operations. +Publishing adds an outbox entity to the scoped `DbContext`. TinyEvents does not call `SaveChangesAsync`; the caller owns the save boundary. -## Install +Choose the provider for your database: -```bash -dotnet add package TinyEvents --version 0.1.0-alpha.1 -dotnet add package TinyEvents.SqlServer.EntityFrameworkCore --version 0.1.0-alpha.1 -``` - -The EF Core provider package is SQL Server-specific. - -## Register - -```csharp -using TinyEvents.SqlServer.EntityFrameworkCore; - -services.AddDbContext(options => -{ - options.UseSqlServer(connectionString); -}); - -services.UseSqlServerEntityFrameworkCoreOutbox(); -``` - -This registers TinyEvents core services, applies generated consumer contributions, and configures the EF Core outbox writer/store. - -## Map The Outbox Entity - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder.UseTinyEventsOutbox(); -} -``` - -The default table name is `TinyOutbox`. - -## Custom Table Name - -If you configure a custom table name, configure both the provider and the model mapping: +- [SQL Server EF Core](sql-server/ef-core.md) +- [PostgreSQL EF Core](postgresql/ef-core.md) -```csharp -services.UseSqlServerEntityFrameworkCoreOutbox(options => -{ - options.TableName = "app.MyOutbox"; -}); -``` - -```csharp -protected override void OnModelCreating(ModelBuilder modelBuilder) -{ - modelBuilder.UseTinyEventsOutbox("app.MyOutbox"); -} -``` - -The provider option controls SQL claiming and marking. The model builder extension controls EF mapping and migrations. - -## Transaction Behavior - -Publishing adds an outbox entity to the scoped `DbContext`. TinyEvents does not call `SaveChangesAsync`. +Both providers use the same core publishing model: ```csharp dbContext.Users.Add(user); @@ -69,26 +17,7 @@ await events.PublishAsync(new UserCreated(user.Id, user.Email), ct); await dbContext.SaveChangesAsync(ct); ``` -Business data and outbox messages commit together when the caller saves the context. - -## Worker Processing - -The EF Core store opens the underlying relational connection when needed and executes SQL Server claim/mark statements. - -Claiming is atomic and lease-based: - -- due `Pending` messages can be claimed -- expired `Processing` messages can be reclaimed -- active `Processing` messages are skipped -- processed/failed updates require the current worker id - -## Migrations - -Use normal EF Core migrations: - -```bash -dotnet ef migrations add AddTinyEventsOutbox -dotnet ef database update -``` +Worker claiming is database-specific: -TinyEvents provides mapping. Your application owns migration generation and execution. +- SQL Server uses SQL Server locking hints and atomic update/output SQL. +- PostgreSQL uses `FOR UPDATE SKIP LOCKED` inside an atomic update/returning SQL statement. diff --git a/docs/getting-started.md b/docs/getting-started.md index 8047921..68981c8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,7 +23,10 @@ dotnet add package TinyEvents.SqlServer.EntityFrameworkCore --version 0.1.0-alph dotnet add package TinyEvents.Worker --version 0.1.0-alpha.1 ``` -TinyEvents core is provider-agnostic. The current alpha SQL Server providers ship as provider packages. +TinyEvents core is provider-agnostic. Provider packages are database-specific: + +- SQL Server: `TinyEvents.SqlServer.EntityFrameworkCore` or `TinyEvents.SqlServer.AdoNet` +- PostgreSQL: `TinyEvents.PostgreSql.EntityFrameworkCore` or `TinyEvents.PostgreSql.AdoNet` ## Register Services @@ -49,6 +52,14 @@ services.UseSqlServerEntityFrameworkCoreOutbox(); It also applies generated TinyEvents contributions for the assemblies loaded in the process. Those contributions contain the consumer registrations and event type descriptors emitted by the source generator. +For PostgreSQL EF Core, use the PostgreSQL provider package and registration method: + +```csharp +using TinyEvents.PostgreSql.EntityFrameworkCore; + +services.UsePostgreSqlEntityFrameworkCoreOutbox(); +``` + ## Map The Outbox Call the model builder extension from your `DbContext`: @@ -146,8 +157,10 @@ Workers use lease-based claiming. If a worker crashes, claimed messages become c ## Next - [Run the Samples](../samples/README.md) -- [EF Core Provider](ef-core.md) -- [ADO.NET Provider](ado-net.md) +- [EF Core Providers](ef-core.md) +- [ADO.NET Providers](ado-net.md) +- [SQL Server EF Core](sql-server/ef-core.md) +- [PostgreSQL EF Core](postgresql/ef-core.md) - [Workers and Leases](workers.md) - [Schema and Migrations](schema-and-migrations.md) - [The Tiny Suite](tiny-suite.md) diff --git a/docs/postgresql/ado-net.md b/docs/postgresql/ado-net.md new file mode 100644 index 0000000..9dcc31c --- /dev/null +++ b/docs/postgresql/ado-net.md @@ -0,0 +1,88 @@ +# PostgreSQL ADO.NET + +`TinyEvents.PostgreSql.AdoNet` stores outbox messages through application-owned PostgreSQL transactions. + +## Install + +```bash +dotnet add package TinyEvents --version 0.1.0-alpha.1 +dotnet add package TinyEvents.PostgreSql.AdoNet --version 0.1.0-alpha.1 +``` + +## Register + +```csharp +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; + +services.UsePostgreSqlAdoNetOutbox(options => +{ + options.UseCurrentTransaction(sp => + { + var current = sp.GetRequiredService(); + + return new TinyPostgreSqlAdoNetTransactionContext( + current.Connection, + current.Transaction); + }); + + options.UseWorkerConnectionFactory(async (_, ct) => + { + var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + return connection; + }); +}); +``` + +## Publishing Transaction Ownership + +TinyEvents: + +- inserts the outbox row using the supplied transaction +- does not open the application connection +- does not begin the transaction +- does not commit +- does not roll back +- does not dispose the application transaction + +The application owns the persistence boundary. + +`SampleAdoNetTransaction` is application infrastructure. It is not a TinyEvents abstraction. + +## Existing Unit Of Work Or Session + +If your application already owns a database session, map it directly: + +```csharp +options.UseCurrentTransaction(sp => +{ + var session = sp.GetRequiredService(); + + return session.CurrentTransaction is null + ? null + : new TinyPostgreSqlAdoNetTransactionContext( + session.Connection, + session.CurrentTransaction); +}); +``` + +## Schema Script + +For code-based migration runners: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql(); +``` + +For custom tables: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql("app.MyOutbox"); +``` + +The package also includes the default PostgreSQL script: + +```text +schema/postgresql/001_CreateTinyOutbox.sql +``` diff --git a/docs/postgresql/ef-core.md b/docs/postgresql/ef-core.md new file mode 100644 index 0000000..e14c6b7 --- /dev/null +++ b/docs/postgresql/ef-core.md @@ -0,0 +1,73 @@ +# PostgreSQL EF Core + +`TinyEvents.PostgreSql.EntityFrameworkCore` stores outbox messages through a caller-owned `DbContext`. + +## Install + +```bash +dotnet add package TinyEvents --version 0.1.0-alpha.1 +dotnet add package TinyEvents.PostgreSql.EntityFrameworkCore --version 0.1.0-alpha.1 +``` + +## Register + +```csharp +using TinyEvents.PostgreSql.EntityFrameworkCore; + +services.AddDbContext(options => +{ + options.UseNpgsql(connectionString); +}); + +services.UsePostgreSqlEntityFrameworkCoreOutbox(); +``` + +Provider registration registers TinyEvents core services, applies generated consumer contributions, and configures the EF Core outbox writer/store. + +## Map The Outbox Entity + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.UseTinyEventsOutbox(); +} +``` + +The default table name is `TinyOutbox`. + +## Custom Table Name + +Configure both the provider and the model mapping: + +```csharp +services.UsePostgreSqlEntityFrameworkCoreOutbox(options => +{ + options.TableName = "app.MyOutbox"; +}); +``` + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.UseTinyEventsOutbox("app.MyOutbox"); +} +``` + +The provider option controls SQL claiming and marking. The model builder extension controls EF mapping and migrations. + +## Worker Claiming + +The PostgreSQL EF Core store opens the underlying relational connection when needed and executes PostgreSQL claim/mark statements. + +Claiming is atomic and lease-based. PostgreSQL uses `FOR UPDATE SKIP LOCKED` inside an update/returning statement. + +## Migrations + +Use normal EF Core migrations: + +```bash +dotnet ef migrations add AddTinyEventsOutbox +dotnet ef database update +``` + +TinyEvents provides mapping. Your application owns migration generation and execution. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..062831d --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,88 @@ +# Releasing + +TinyEvents has two release paths. + +Use the full release workflow when every package should move together. Use the package release workflow when one package needs its own version bump. + +## Full Release Train + +The `Release` workflow runs when a tag matching `v*` is pushed. + +Example: + +```text +v0.1.0-alpha.2 +``` + +This workflow: + +- restores the solution +- builds the solution in Release +- runs the full test suite with SQL Server and PostgreSQL integration tests enabled +- packs every NuGet package with the tag version +- verifies every expected package exists +- publishes every package to NuGet + +Use this when the suite should share one version: + +- `TinyEvents` +- `TinyEvents.Worker` +- `TinyEvents.SqlServer.AdoNet` +- `TinyEvents.SqlServer.EntityFrameworkCore` +- `TinyEvents.PostgreSql.AdoNet` +- `TinyEvents.PostgreSql.EntityFrameworkCore` + +## Single Package Release + +The `Release Package` workflow is manual. + +Use it when one package needs a dedicated version without forcing every package to publish the same version. + +Inputs: + +- `package`: the package id to publish +- `version`: the exact NuGet version to publish + +Example: + +```text +package: TinyEvents.PostgreSql.AdoNet +version: 0.1.0-alpha.3 +``` + +This workflow still restores, builds, and tests the whole solution before publishing. It only packs and pushes the selected package. + +Use this for: + +- provider-specific alpha fixes +- package metadata fixes +- documentation or schema-content fixes that affect one package +- small package-specific corrections that do not require a release train + +## Before A Single Package Release + +Check the selected package dependency story before publishing. + +Provider packages reference `TinyEvents` in the repository through project references. During packing, NuGet dependencies are generated from those project references. Make sure the dependency version is the one you intend consumers to use. + +If the provider package requires a newer core package, publish the core package first or use the full release train. + +## Local Checks + +Before using either release path, run: + +```powershell +dotnet restore +dotnet build TinyEvents.sln -c Release --no-restore +dotnet test TinyEvents.sln -c Release --no-build --no-restore +``` + +For database runtime confidence, enable the integration test lanes locally: + +```powershell +$env:TINYEVENTS_RUN_SQLSERVER_TESTS = "true" +$env:TINYEVENTS_RUN_POSTGRESQL_TESTS = "true" +dotnet test TinyEvents.sln -c Release --no-build --no-restore +``` + +The GitHub workflows run the integration lanes before publishing. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8319177..fef260d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,26 +1,28 @@ # Roadmap -TinyEvents is in alpha. The current goal is to keep the core model small while proving the runtime against SQL Server. +TinyEvents is in alpha. The current goal is to keep the core model small while proving the runtime against SQL Server and PostgreSQL. ## Before 1.0 Planned hardening: - polish README and provider docs as APIs settle -- keep SQL Server runtime tests green with Testcontainers -- add more sample documentation +- keep SQL Server and PostgreSQL runtime tests green with Testcontainers +- keep samples split by database family and provider style - review NuGet package boundaries -- publish provider packages after the first alpha -- decide whether SQL Server providers should use explicit package names +- keep provider package publishing explicit and repeatable +- keep provider package names explicit by database family - keep source generator diagnostics focused and useful ## Provider Boundaries -The first alpha prepares these package boundaries: +The current package boundaries are: - `TinyEvents` - `TinyEvents.SqlServer.EntityFrameworkCore` - `TinyEvents.SqlServer.AdoNet` +- `TinyEvents.PostgreSql.EntityFrameworkCore` +- `TinyEvents.PostgreSql.AdoNet` - `TinyEvents.Worker` Other databases should be separate provider packages once they exist. diff --git a/docs/schema-and-migrations.md b/docs/schema-and-migrations.md index dd77264..5eca842 100644 --- a/docs/schema-and-migrations.md +++ b/docs/schema-and-migrations.md @@ -86,6 +86,33 @@ schema/sqlserver/001_CreateTinyOutbox.sql The script creates the default SQL Server shape: `dbo.TinyOutbox`. TinyEvents owns the schema definition; your application owns when and how the migration runs. +## ADO.NET PostgreSQL + +ADO.NET applications create the schema by running the PostgreSQL migration script through their migration tool of choice. + +For the default table: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql(); +``` + +For a custom table: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql("app.TinyOutbox"); +``` + +Run that SQL through DbUp, Flyway, Liquibase, your deployment pipeline, or your existing application migration runner. + +The package also includes the default PostgreSQL script as package content: + +```text +schema/postgresql/001_CreateTinyOutbox.sql +``` + +The script creates the default PostgreSQL shape: `public.TinyOutbox`. +TinyEvents owns the schema definition; your application owns when and how the migration runs. + ## Future Migration Helpers If TinyEvents later offers migration helpers, they should live in separate packages, for example: @@ -94,4 +121,4 @@ If TinyEvents later offers migration helpers, they should live in separate packa TinyEvents.Migrations.DbUp ``` -Core and provider packages should stay free of migration execution dependencies once provider packages are split. +Core and provider packages should stay free of migration execution dependencies. diff --git a/docs/source-generator.md b/docs/source-generator.md index d668c65..39cb52d 100644 --- a/docs/source-generator.md +++ b/docs/source-generator.md @@ -62,20 +62,14 @@ Generated contributions make multi-assembly projects work without runtime scanni Each assembly that contains consumers can contribute registrations. When the host calls a TinyEvents registration method, bootstrap applies the collected contributions once per service collection. -```csharp -services.UseSqlServerEntityFrameworkCoreOutbox(); -``` - -or: +For example: ```csharp -services.UseSqlServerAdoNetOutbox(options => -{ - // provider configuration -}); +services.UseSqlServerEntityFrameworkCoreOutbox(); +services.UsePostgreSqlEntityFrameworkCoreOutbox(); ``` -Both paths register core services and apply generated contributions. +Provider registration methods register core services and apply generated contributions. Calling TinyEvents registration more than once on the same service collection is safe. diff --git a/docs/sql-server/ado-net.md b/docs/sql-server/ado-net.md new file mode 100644 index 0000000..3640490 --- /dev/null +++ b/docs/sql-server/ado-net.md @@ -0,0 +1,88 @@ +# SQL Server ADO.NET + +`TinyEvents.SqlServer.AdoNet` stores outbox messages through application-owned SQL Server transactions. + +## Install + +```bash +dotnet add package TinyEvents --version 0.1.0-alpha.1 +dotnet add package TinyEvents.SqlServer.AdoNet --version 0.1.0-alpha.1 +``` + +## Register + +```csharp +using Microsoft.Data.SqlClient; +using TinyEvents.SqlServer.AdoNet; + +services.UseSqlServerAdoNetOutbox(options => +{ + options.UseCurrentTransaction(sp => + { + var current = sp.GetRequiredService(); + + return new TinyAdoNetTransactionContext( + current.Connection, + current.Transaction); + }); + + options.UseWorkerConnectionFactory(async (_, ct) => + { + var connection = new SqlConnection(connectionString); + await connection.OpenAsync(ct); + return connection; + }); +}); +``` + +## Publishing Transaction Ownership + +TinyEvents: + +- inserts the outbox row using the supplied transaction +- does not open the application connection +- does not begin the transaction +- does not commit +- does not roll back +- does not dispose the application transaction + +The application owns the persistence boundary. + +`SampleAdoNetTransaction` is application infrastructure. It is not a TinyEvents abstraction. + +## Existing Unit Of Work Or Session + +If your application already owns a database session, map it directly: + +```csharp +options.UseCurrentTransaction(sp => +{ + var session = sp.GetRequiredService(); + + return session.CurrentTransaction is null + ? null + : new TinyAdoNetTransactionContext( + session.Connection, + session.CurrentTransaction); +}); +``` + +## Schema Script + +For code-based migration runners: + +```csharp +var sql = TinySqlServerAdoNetSchema.CreateOutboxSql(); +``` + +For custom tables: + +```csharp +var sql = TinySqlServerAdoNetSchema.CreateOutboxSql("app.MyOutbox"); +``` + +The package also includes the default SQL Server script: + +```text +schema/sqlserver/001_CreateTinyOutbox.sql +``` diff --git a/docs/sql-server/ef-core.md b/docs/sql-server/ef-core.md new file mode 100644 index 0000000..84e94af --- /dev/null +++ b/docs/sql-server/ef-core.md @@ -0,0 +1,73 @@ +# SQL Server EF Core + +`TinyEvents.SqlServer.EntityFrameworkCore` stores outbox messages through a caller-owned `DbContext`. + +## Install + +```bash +dotnet add package TinyEvents --version 0.1.0-alpha.1 +dotnet add package TinyEvents.SqlServer.EntityFrameworkCore --version 0.1.0-alpha.1 +``` + +## Register + +```csharp +using TinyEvents.SqlServer.EntityFrameworkCore; + +services.AddDbContext(options => +{ + options.UseSqlServer(connectionString); +}); + +services.UseSqlServerEntityFrameworkCoreOutbox(); +``` + +Provider registration registers TinyEvents core services, applies generated consumer contributions, and configures the EF Core outbox writer/store. + +## Map The Outbox Entity + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.UseTinyEventsOutbox(); +} +``` + +The default table name is `TinyOutbox`. + +## Custom Table Name + +Configure both the provider and the model mapping: + +```csharp +services.UseSqlServerEntityFrameworkCoreOutbox(options => +{ + options.TableName = "app.MyOutbox"; +}); +``` + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.UseTinyEventsOutbox("app.MyOutbox"); +} +``` + +The provider option controls SQL claiming and marking. The model builder extension controls EF mapping and migrations. + +## Worker Claiming + +The SQL Server EF Core store opens the underlying relational connection when needed and executes SQL Server claim/mark statements. + +Claiming is atomic and lease-based. SQL Server uses locking hints and update/output SQL. + +## Migrations + +Use normal EF Core migrations: + +```bash +dotnet ef migrations add AddTinyEventsOutbox +dotnet ef database update +``` + +TinyEvents provides mapping. Your application owns migration generation and execution. diff --git a/docs/testing.md b/docs/testing.md index b762422..6e4911e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,6 +46,29 @@ These tests start an ephemeral SQL Server container and prove behavior fakes can - Active processing leases are not reclaimed. - EF Core SQL Server claim SQL works against the real database. +## PostgreSQL Runtime Suite + +The PostgreSQL runtime tests live in: + +```text +tests/TinyEvents.PostgreSql.Tests +``` + +They use Testcontainers and are skipped unless this environment variable is set: + +```powershell +$env:TINYEVENTS_RUN_POSTGRESQL_TESTS = "true" +dotnet test tests\TinyEvents.PostgreSql.Tests\TinyEvents.PostgreSql.Tests.csproj +``` + +These tests start an ephemeral PostgreSQL container and prove behavior fakes cannot prove: + +- ADO.NET schema creation works against the real database. +- ADO.NET business data and outbox messages commit together. +- ADO.NET business data and outbox messages roll back together. +- ADO.NET worker claim and mark SQL works against the real database. +- EF Core PostgreSQL writer and store SQL works against the real database. + ## Local SQL Server For manual development and app samples, start SQL Server first: diff --git a/docs/workers.md b/docs/workers.md index 1c98140..1bb815f 100644 --- a/docs/workers.md +++ b/docs/workers.md @@ -52,7 +52,12 @@ dotnet add package TinyEvents --version 0.1.0-alpha.1 dotnet add package TinyEvents.Worker --version 0.1.0-alpha.1 ``` -`TinyEvents.Worker` contains the hosted-service integration. You still need one outbox provider package, such as `TinyEvents.SqlServer.EntityFrameworkCore` or `TinyEvents.SqlServer.AdoNet`. +`TinyEvents.Worker` contains the hosted-service integration. You still need one outbox provider package, such as: + +- `TinyEvents.SqlServer.EntityFrameworkCore` +- `TinyEvents.SqlServer.AdoNet` +- `TinyEvents.PostgreSql.EntityFrameworkCore` +- `TinyEvents.PostgreSql.AdoNet` Register: diff --git a/samples/README.md b/samples/README.md index 3908389..bacc470 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,35 +1,42 @@ # TinyEvents Samples -The app samples need SQL Server. Start the Docker SQL Server container first, then run a sample. +The app samples are split by database family and provider style. ## Prerequisites - .NET 8 SDK - Docker Desktop or another Docker engine -## 1. Start SQL Server +## 1. Start Databases ```bash -docker compose up -d sqlserver +docker compose up -d sqlserver postgresql ``` -Default connection string: +Default SQL Server connection string: ```text Server=localhost,14333;Database=TinyEventsSamples;User Id=sa;Password=TinyEvents_2026!;Encrypt=False;TrustServerCertificate=True ``` +Default PostgreSQL connection string: + +```text +Host=localhost;Port=54323;Database=tinyevents_samples;Username=postgres;Password=postgres; +``` + You can pass the connection string as the first command-line argument, or set: ```powershell $env:TINYEVENTS_SAMPLE_SQLSERVER = "Server=localhost,14333;Database=TinyEventsSamples;User Id=sa;Password=TinyEvents_2026!;Encrypt=False;TrustServerCertificate=True" +$env:TINYEVENTS_SAMPLE_POSTGRESQL = "Host=localhost;Port=54323;Database=tinyevents_samples;Username=postgres;Password=postgres;" ``` ## 2. Run An App Sample -### EF Core +### SQL Server EF Core -Runs from local project references and demonstrates EF Core publishing: +Runs from local project references and demonstrates SQL Server EF Core publishing: ```bash dotnet run --project samples/TinyEvents.Sample.EfCore @@ -37,9 +44,9 @@ dotnet run --project samples/TinyEvents.Sample.EfCore The EF Core sample creates its database schema with `Database.EnsureCreatedAsync()` for local demo purposes. -### ADO.NET +### SQL Server ADO.NET -Runs from local project references and demonstrates application-owned ADO.NET transactions: +Runs from local project references and demonstrates SQL Server application-owned ADO.NET transactions: ```bash dotnet run --project samples/TinyEvents.Sample.AdoNet @@ -47,6 +54,26 @@ dotnet run --project samples/TinyEvents.Sample.AdoNet The ADO.NET sample creates the demo `Users` table and `TinyOutbox` table on startup for local demo purposes. Real applications should run the TinyEvents outbox SQL through their normal migration tool. +### PostgreSQL EF Core + +Runs from local project references and demonstrates PostgreSQL EF Core publishing: + +```bash +dotnet run --project samples/TinyEvents.Sample.PostgreSql.EfCore +``` + +The PostgreSQL EF Core sample creates its database schema with `Database.EnsureCreatedAsync()` for local demo purposes. + +### PostgreSQL ADO.NET + +Runs from local project references and demonstrates PostgreSQL application-owned ADO.NET transactions: + +```bash +dotnet run --project samples/TinyEvents.Sample.PostgreSql.AdoNet +``` + +The PostgreSQL ADO.NET sample creates the demo `Users` table and `TinyOutbox` table on startup for local demo purposes. Real applications should run the TinyEvents outbox SQL through their normal migration tool. + ## 3. Try The Endpoints Create a user: diff --git a/samples/TinyEvents.PackageSmoke/README.md b/samples/TinyEvents.PackageSmoke/README.md index c735fdb..4531842 100644 --- a/samples/TinyEvents.PackageSmoke/README.md +++ b/samples/TinyEvents.PackageSmoke/README.md @@ -16,3 +16,5 @@ set TINYEVENTS_PACKAGE_SMOKE_SQLSERVER=Server=localhost,1433;Database=TinyEvents ``` The sample references the public `0.1.0-alpha.1` packages instead of local project references. It verifies that the core package, SQL Server providers, worker package, dependency injection extensions, source-generator consumer registration, publishing, claiming, and processing can be consumed from NuGet. + +PostgreSQL package smoke coverage belongs with the planned sample split by database family. diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Consumers/SendWelcomeEmail.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Consumers/SendWelcomeEmail.cs new file mode 100644 index 0000000..9ffdfd9 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Consumers/SendWelcomeEmail.cs @@ -0,0 +1,21 @@ +using TinyEvents.Sample.PostgreSql.AdoNet.Events; + +namespace TinyEvents.Sample.PostgreSql.AdoNet.Consumers; + +public sealed class SendWelcomeEmail : IEventConsumer +{ + private readonly WelcomeEmailLog log; + + public SendWelcomeEmail(WelcomeEmailLog log) + { + this.log = log; + } + + public ValueTask ConsumeAsync( + UserCreated @event, + CancellationToken cancellationToken) + { + log.Record(@event.Email); + return ValueTask.CompletedTask; + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Consumers/WelcomeEmailLog.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Consumers/WelcomeEmailLog.cs new file mode 100644 index 0000000..3666cd2 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Consumers/WelcomeEmailLog.cs @@ -0,0 +1,18 @@ +namespace TinyEvents.Sample.PostgreSql.AdoNet.Consumers; + +public sealed class WelcomeEmailLog +{ + private readonly List emails = new List(); + + public int Count => emails.Count; + + public IReadOnlyList Snapshot() + { + return emails.ToArray(); + } + + public void Record(string email) + { + emails.Add(email); + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Contracts/RegisterUserRequest.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Contracts/RegisterUserRequest.cs new file mode 100644 index 0000000..8a6d0fb --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Contracts/RegisterUserRequest.cs @@ -0,0 +1,3 @@ +namespace TinyEvents.Sample.PostgreSql.AdoNet.Contracts; + +public sealed record RegisterUserRequest(string Email); diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Contracts/RegisterUserResult.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Contracts/RegisterUserResult.cs new file mode 100644 index 0000000..f459517 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Contracts/RegisterUserResult.cs @@ -0,0 +1,3 @@ +namespace TinyEvents.Sample.PostgreSql.AdoNet.Contracts; + +public sealed record RegisterUserResult(Guid UserId, string Email); diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Events/UserCreated.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Events/UserCreated.cs new file mode 100644 index 0000000..ec156a5 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Events/UserCreated.cs @@ -0,0 +1,3 @@ +namespace TinyEvents.Sample.PostgreSql.AdoNet.Events; + +public sealed record UserCreated(Guid UserId, string Email); diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/DbCommandExtensions.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/DbCommandExtensions.cs new file mode 100644 index 0000000..79cdb50 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/DbCommandExtensions.cs @@ -0,0 +1,17 @@ +using System.Data.Common; + +namespace TinyEvents.Sample.PostgreSql.AdoNet.Infrastructure; + +internal static class DbCommandExtensions +{ + public static void AddParameter( + this DbCommand command, + string name, + object value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/PostgreSqlSchema.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/PostgreSqlSchema.cs new file mode 100644 index 0000000..f93cd76 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/PostgreSqlSchema.cs @@ -0,0 +1,69 @@ +using System.Data.Common; +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; + +namespace TinyEvents.Sample.PostgreSql.AdoNet.Infrastructure; + +internal static class PostgreSqlSchema +{ + public static async ValueTask EnsureCreatedAsync(string connectionString) + { + await EnsureDatabaseCreatedAsync(connectionString); + + await using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(); + await ExecuteAsync(connection, CreateUsersTableSql); + await ExecuteAsync(connection, TinyPostgreSqlAdoNetSchema.CreateOutboxSql()); + } + + private static async ValueTask EnsureDatabaseCreatedAsync(string connectionString) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString); + var databaseName = builder.Database; + + if (string.IsNullOrWhiteSpace(databaseName)) + { + return; + } + + builder.Database = "postgres"; + + await using var connection = new NpgsqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + if (await DatabaseExistsAsync(connection, databaseName)) + { + return; + } + + await ExecuteAsync(connection, $"""CREATE DATABASE "{databaseName.Replace("\"", "\"\"")}";"""); + } + + private static async ValueTask DatabaseExistsAsync( + DbConnection connection, + string databaseName) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1 FROM pg_database WHERE datname = @DatabaseName;"; + command.AddParameter("@DatabaseName", databaseName); + var result = await command.ExecuteScalarAsync(); + return result is not null; + } + + private static async ValueTask ExecuteAsync( + DbConnection connection, + string sql) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(); + } + + private const string CreateUsersTableSql = """ + CREATE TABLE IF NOT EXISTS "Users" + ( + "Id" uuid NOT NULL PRIMARY KEY, + "Email" text NOT NULL + ); + """; +} diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/SampleAdoNetTransaction.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/SampleAdoNetTransaction.cs new file mode 100644 index 0000000..32f5830 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/SampleAdoNetTransaction.cs @@ -0,0 +1,34 @@ +using System.Data.Common; + +namespace TinyEvents.Sample.PostgreSql.AdoNet.Infrastructure; + +public sealed class SampleAdoNetTransaction : IAsyncDisposable +{ + public SampleAdoNetTransaction( + DbConnection connection, + DbTransaction transaction) + { + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + Transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + } + + public DbConnection Connection { get; } + + public DbTransaction Transaction { get; } + + public async ValueTask CommitAsync(CancellationToken cancellationToken = default) + { + await Transaction.CommitAsync(cancellationToken); + } + + public async ValueTask RollbackAsync(CancellationToken cancellationToken = default) + { + await Transaction.RollbackAsync(cancellationToken); + } + + public async ValueTask DisposeAsync() + { + await Transaction.DisposeAsync(); + await Connection.DisposeAsync(); + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/SampleSettings.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/SampleSettings.cs new file mode 100644 index 0000000..4e08fe1 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Infrastructure/SampleSettings.cs @@ -0,0 +1,23 @@ +namespace TinyEvents.Sample.PostgreSql.AdoNet.Infrastructure; + +internal static class SampleSettings +{ + private const string EnvironmentVariable = "TINYEVENTS_SAMPLE_POSTGRESQL"; + + public static string GetConnectionString(string[] args) + { + if (args.Length > 0) + { + return args[0]; + } + + var connectionString = Environment.GetEnvironmentVariable(EnvironmentVariable); + + if (!string.IsNullOrWhiteSpace(connectionString)) + { + return connectionString; + } + + return "Host=localhost;Port=54323;Database=tinyevents_samples;Username=postgres;Password=postgres;"; + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/Program.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Program.cs new file mode 100644 index 0000000..9839231 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/Program.cs @@ -0,0 +1,71 @@ +using Npgsql; +using TinyEvents; +using TinyEvents.PostgreSql.AdoNet; +using TinyEvents.Sample.PostgreSql.AdoNet.Consumers; +using TinyEvents.Sample.PostgreSql.AdoNet.Contracts; +using TinyEvents.Sample.PostgreSql.AdoNet.Infrastructure; +using TinyEvents.Sample.PostgreSql.AdoNet.UseCases; + +var connectionString = SampleSettings.GetConnectionString(args); + +await PostgreSqlSchema.EnsureCreatedAsync(connectionString); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(_ => +{ + var connection = new NpgsqlConnection(connectionString); + connection.Open(); + var transaction = connection.BeginTransaction(); + return new SampleAdoNetTransaction(connection, transaction); +}); +builder.Services.AddScoped(); + +builder.Services.UsePostgreSqlAdoNetOutbox(options => +{ + options.UseCurrentTransaction(provider => + { + // In this sample we create a small scoped transaction object to keep + // the app self-contained. In a real application, map this to your + // existing Unit of Work, DbSession, repository transaction, or directly + // registered DbConnection/DbTransaction. + var current = provider.GetRequiredService(); + return new TinyPostgreSqlAdoNetTransactionContext(current.Connection, current.Transaction); + }); + + // No application connection factory yet? Open NpgsqlConnection directly here. + // If your app already owns a factory, resolve it from provider and call it instead. + options.UseWorkerConnectionFactory(async (_, cancellationToken) => + { + var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + return connection; + }); +}); + +var app = builder.Build(); + +app.MapPost("/users", async ( + RegisterUserRequest request, + RegisterUserUseCase users, + CancellationToken cancellationToken) => +{ + var result = await users.RegisterAsync(request.Email, cancellationToken); + return Results.Created($"/users/{result.UserId}", result); +}); + +app.MapPost("/outbox/process", async ( + ITinyOutboxProcessor processor, + CancellationToken cancellationToken) => +{ + await processor.ProcessPendingAsync(cancellationToken); + return Results.Accepted(); +}); + +app.MapGet("/welcome-emails", (WelcomeEmailLog log) => +{ + return Results.Ok(new { log.Count, Emails = log.Snapshot() }); +}); + +await app.RunAsync(); diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/TinyEvents.Sample.PostgreSql.AdoNet.csproj b/samples/TinyEvents.Sample.PostgreSql.AdoNet/TinyEvents.Sample.PostgreSql.AdoNet.csproj new file mode 100644 index 0000000..bfb99c0 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/TinyEvents.Sample.PostgreSql.AdoNet.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + false + + + + + + + + + + diff --git a/samples/TinyEvents.Sample.PostgreSql.AdoNet/UseCases/RegisterUserUseCase.cs b/samples/TinyEvents.Sample.PostgreSql.AdoNet/UseCases/RegisterUserUseCase.cs new file mode 100644 index 0000000..54a2bd5 --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.AdoNet/UseCases/RegisterUserUseCase.cs @@ -0,0 +1,64 @@ +using System.Data.Common; +using TinyEvents.Sample.PostgreSql.AdoNet.Contracts; +using TinyEvents.Sample.PostgreSql.AdoNet.Events; +using TinyEvents.Sample.PostgreSql.AdoNet.Infrastructure; + +namespace TinyEvents.Sample.PostgreSql.AdoNet.UseCases; + +public sealed class RegisterUserUseCase +{ + private readonly SampleAdoNetTransaction transaction; + private readonly ITinyEventPublisher events; + + public RegisterUserUseCase( + SampleAdoNetTransaction transaction, + ITinyEventPublisher events) + { + this.transaction = transaction; + this.events = events; + } + + public async ValueTask RegisterAsync( + string email, + CancellationToken cancellationToken = default) + { + var userId = Guid.NewGuid(); + + try + { + await InsertUserAsync( + transaction.Connection, + transaction.Transaction, + userId, + email, + cancellationToken); + await events.PublishAsync(new UserCreated(userId, email), cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return new RegisterUserResult(userId, email); + } + + private static async ValueTask InsertUserAsync( + DbConnection connection, + DbTransaction transaction, + Guid userId, + string email, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """ + INSERT INTO "Users" ("Id", "Email") + VALUES (@Id, @Email); + """; + command.AddParameter("@Id", userId); + command.AddParameter("@Email", email); + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.EfCore/Program.cs b/samples/TinyEvents.Sample.PostgreSql.EfCore/Program.cs new file mode 100644 index 0000000..e7d4d0b --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.EfCore/Program.cs @@ -0,0 +1,175 @@ +using Microsoft.EntityFrameworkCore; +using TinyEvents; +using TinyEvents.PostgreSql.EntityFrameworkCore; +using TinyEvents.Sample.PostgreSql.EfCore; + +var connectionString = SampleSettings.GetConnectionString(args); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddDbContext(options => +{ + options.UseNpgsql(connectionString); +}); +builder.Services.UsePostgreSqlEntityFrameworkCoreOutbox(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(); +} + +app.MapPost("/users", async ( + RegisterUserRequest request, + UserRegistrationUseCase users, + CancellationToken cancellationToken) => +{ + var result = await users.RegisterAsync(request.Email, cancellationToken); + return Results.Created($"/users/{result.UserId}", result); +}); + +app.MapPost("/outbox/process", async ( + ITinyOutboxProcessor processor, + CancellationToken cancellationToken) => +{ + await processor.ProcessPendingAsync(cancellationToken); + return Results.Accepted(); +}); + +app.MapGet("/welcome-emails", (WelcomeEmailLog log) => +{ + return Results.Ok(new { log.Count, Emails = log.Snapshot() }); +}); + +await app.RunAsync(); + +namespace TinyEvents.Sample.PostgreSql.EfCore +{ + public sealed class UserRegistrationUseCase + { + private readonly SampleDbContext dbContext; + private readonly ITinyEventPublisher events; + + public UserRegistrationUseCase( + SampleDbContext dbContext, + ITinyEventPublisher events) + { + this.dbContext = dbContext; + this.events = events; + } + + public async ValueTask RegisterAsync( + string email, + CancellationToken cancellationToken = default) + { + var userId = Guid.NewGuid(); + + dbContext.Users.Add(new UserRow + { + Id = userId, + Email = email + }); + + await events.PublishAsync(new UserCreated(userId, email), cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + + return new RegisterUserResult(userId, email); + } + } + + public sealed class SampleDbContext : DbContext + { + public SampleDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(user => user.Id); + entity.Property(user => user.Email).IsRequired().HasMaxLength(320); + }); + + modelBuilder.UseTinyEventsOutbox(); + } + } + + public sealed class SendWelcomeEmail : IEventConsumer + { + private readonly WelcomeEmailLog log; + + public SendWelcomeEmail(WelcomeEmailLog log) + { + this.log = log; + } + + public ValueTask ConsumeAsync( + UserCreated @event, + CancellationToken cancellationToken) + { + log.Record(@event.Email); + return ValueTask.CompletedTask; + } + } + + public sealed class UserRow + { + public Guid Id { get; set; } + + public string Email { get; set; } = string.Empty; + } + + public sealed class WelcomeEmailLog + { + private readonly List emails = new List(); + + public int Count => emails.Count; + + public IReadOnlyList Snapshot() + { + return emails.ToArray(); + } + + public void Record(string email) + { + emails.Add(email); + } + } + + public sealed record RegisterUserRequest(string Email); + + public sealed record RegisterUserResult(Guid UserId, string Email); + + public sealed record UserCreated(Guid UserId, string Email); + + internal static class SampleSettings + { + private const string EnvironmentVariable = "TINYEVENTS_SAMPLE_POSTGRESQL"; + + public static string GetConnectionString(string[] args) + { + if (args.Length > 0) + { + return args[0]; + } + + var connectionString = Environment.GetEnvironmentVariable(EnvironmentVariable); + + if (!string.IsNullOrWhiteSpace(connectionString)) + { + return connectionString; + } + + return "Host=localhost;Port=54323;Database=tinyevents_samples;Username=postgres;Password=postgres;"; + } + } +} diff --git a/samples/TinyEvents.Sample.PostgreSql.EfCore/TinyEvents.Sample.PostgreSql.EfCore.csproj b/samples/TinyEvents.Sample.PostgreSql.EfCore/TinyEvents.Sample.PostgreSql.EfCore.csproj new file mode 100644 index 0000000..e62f26d --- /dev/null +++ b/samples/TinyEvents.Sample.PostgreSql.EfCore/TinyEvents.Sample.PostgreSql.EfCore.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + false + + + + + + + + + + diff --git a/src/TinyEvents.PostgreSql.AdoNet/Connections/ITinyPostgreSqlAdoNetWorkerConnectionFactory.cs b/src/TinyEvents.PostgreSql.AdoNet/Connections/ITinyPostgreSqlAdoNetWorkerConnectionFactory.cs new file mode 100644 index 0000000..e973141 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Connections/ITinyPostgreSqlAdoNetWorkerConnectionFactory.cs @@ -0,0 +1,8 @@ +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +public interface ITinyPostgreSqlAdoNetWorkerConnectionFactory +{ + ValueTask CreateOpenConnectionAsync(CancellationToken cancellationToken); +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/Connections/TinyPostgreSqlAdoNetWorkerConnectionFactory.cs b/src/TinyEvents.PostgreSql.AdoNet/Connections/TinyPostgreSqlAdoNetWorkerConnectionFactory.cs new file mode 100644 index 0000000..521caf6 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Connections/TinyPostgreSqlAdoNetWorkerConnectionFactory.cs @@ -0,0 +1,40 @@ +using System.Data; +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +public sealed class TinyPostgreSqlAdoNetWorkerConnectionFactory : ITinyPostgreSqlAdoNetWorkerConnectionFactory +{ + private readonly TinyEventsPostgreSqlAdoNetOptions options; + private readonly IServiceProvider serviceProvider; + + public TinyPostgreSqlAdoNetWorkerConnectionFactory( + TinyEventsPostgreSqlAdoNetOptions options, + IServiceProvider serviceProvider) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + this.options = options; + this.serviceProvider = serviceProvider; + } + + public async ValueTask CreateOpenConnectionAsync(CancellationToken cancellationToken) + { + var connection = await options.CreateWorkerConnectionAsync(serviceProvider, cancellationToken); + + if (connection.State != ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + return connection; + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/DependencyInjection/TinyEventsPostgreSqlAdoNetServiceCollectionExtensions.cs b/src/TinyEvents.PostgreSql.AdoNet/DependencyInjection/TinyEventsPostgreSqlAdoNetServiceCollectionExtensions.cs new file mode 100644 index 0000000..60e1175 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/DependencyInjection/TinyEventsPostgreSqlAdoNetServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace TinyEvents.PostgreSql.AdoNet; + +public static class TinyEventsPostgreSqlAdoNetServiceCollectionExtensions +{ + public static IServiceCollection UsePostgreSqlAdoNetOutbox( + this IServiceCollection services, + Action configure) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var options = new TinyEventsPostgreSqlAdoNetOptions(); + configure(options); + + services.UseTinyEvents(); + services.TryAddSingleton(options); + services.TryAddScoped(); + services.Replace(ServiceDescriptor.Scoped()); + services.Replace(ServiceDescriptor.Scoped()); + + return services; + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/README.md b/src/TinyEvents.PostgreSql.AdoNet/README.md new file mode 100644 index 0000000..756e125 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/README.md @@ -0,0 +1,74 @@ +# TinyEvents.PostgreSql.AdoNet + +PostgreSQL ADO.NET provider package for TinyEvents outbox storage and worker claiming. + +This package is for applications that own their PostgreSQL connection and transaction. TinyEvents writes the outbox message inside that application-owned transaction, while the worker side uses a separate connection factory to claim and process pending messages. + +The provider follows the same ownership model as the SQL Server ADO.NET provider: + +- applications own publishing connections and transactions +- TinyEvents writes the outbox message inside the supplied transaction +- TinyEvents does not commit, roll back, open, or dispose the application transaction +- worker operations use provider-owned connections from a configured worker connection factory + +PostgreSQL worker claiming uses `FOR UPDATE SKIP LOCKED`. + +## Install + +```bash +dotnet add package TinyEvents --version 0.1.0-alpha.1 +dotnet add package TinyEvents.PostgreSql.AdoNet --version 0.1.0-alpha.1 +``` + +## Register + +```csharp +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; + +services.UsePostgreSqlAdoNetOutbox(options => +{ + options.UseCurrentTransaction(sp => + { + var current = sp.GetRequiredService(); + + return new TinyPostgreSqlAdoNetTransactionContext( + current.Connection, + current.Transaction); + }); + + options.UseWorkerConnectionFactory(async (_, ct) => + { + var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + return connection; + }); +}); +``` + +## Schema + +Apply the PostgreSQL schema with your migration tool of choice: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql(); +``` + +For custom tables: + +```csharp +var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql("app.MyOutbox"); +``` + +The package also includes the default PostgreSQL script: + +```text +schema/postgresql/001_CreateTinyOutbox.sql +``` + +## More Documentation + +- Project documentation: https://github.com/george2006/TinyEvents +- ADO.NET provider guide: https://github.com/george2006/TinyEvents/blob/main/docs/postgresql/ado-net.md +- Worker guide: https://github.com/george2006/TinyEvents/blob/main/docs/workers.md +- Schema and migrations: https://github.com/george2006/TinyEvents/blob/main/docs/schema-and-migrations.md diff --git a/src/TinyEvents.PostgreSql.AdoNet/Schema/PostgreSql/001_CreateTinyOutbox.sql b/src/TinyEvents.PostgreSql.AdoNet/Schema/PostgreSql/001_CreateTinyOutbox.sql new file mode 100644 index 0000000..473b2d4 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Schema/PostgreSql/001_CreateTinyOutbox.sql @@ -0,0 +1,39 @@ +CREATE SCHEMA IF NOT EXISTS "public"; + +CREATE TABLE IF NOT EXISTS "public"."TinyOutbox" +( + "Id" uuid NOT NULL CONSTRAINT "PK_TinyOutbox" PRIMARY KEY, + "EventType" text NOT NULL, + "Payload" text NOT NULL, + "Status" integer NOT NULL, + "AttemptCount" integer NOT NULL, + "ClaimedBy" text NULL, + "ClaimedAtUtc" timestamp with time zone NULL, + "ClaimExpiresAtUtc" timestamp with time zone NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "NextAttemptAtUtc" timestamp with time zone NULL, + "ProcessedAtUtc" timestamp with time zone NULL, + "LastError" text NULL +); + +CREATE INDEX IF NOT EXISTS "IX_TinyOutbox_Pending" +ON "public"."TinyOutbox" +( + "Status", + "NextAttemptAtUtc", + "CreatedAtUtc" +); + +CREATE INDEX IF NOT EXISTS "IX_TinyOutbox_ExpiredProcessing" +ON "public"."TinyOutbox" +( + "Status", + "ClaimExpiresAtUtc" +); + +CREATE INDEX IF NOT EXISTS "IX_TinyOutbox_ClaimedBy" +ON "public"."TinyOutbox" +( + "ClaimedBy", + "Status" +); diff --git a/src/TinyEvents.PostgreSql.AdoNet/Schema/TinyPostgreSqlAdoNetSchema.cs b/src/TinyEvents.PostgreSql.AdoNet/Schema/TinyPostgreSqlAdoNetSchema.cs new file mode 100644 index 0000000..12d38f2 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Schema/TinyPostgreSqlAdoNetSchema.cs @@ -0,0 +1,53 @@ +namespace TinyEvents.PostgreSql.AdoNet; + +public static class TinyPostgreSqlAdoNetSchema +{ + public static string CreateOutboxSql(string tableName = "TinyOutbox") + { + var parsedTableName = TinyPostgreSqlAdoNetTableName.Parse(tableName); + var postgreSqlName = parsedTableName.ToPostgreSqlName("public"); + var schemaName = parsedTableName.ToPostgreSqlSchemaName(); + + return $$""" + CREATE SCHEMA IF NOT EXISTS {{schemaName}}; + + CREATE TABLE IF NOT EXISTS {{postgreSqlName}} + ( + "Id" uuid NOT NULL CONSTRAINT "PK_TinyOutbox" PRIMARY KEY, + "EventType" text NOT NULL, + "Payload" text NOT NULL, + "Status" integer NOT NULL, + "AttemptCount" integer NOT NULL, + "ClaimedBy" text NULL, + "ClaimedAtUtc" timestamp with time zone NULL, + "ClaimExpiresAtUtc" timestamp with time zone NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "NextAttemptAtUtc" timestamp with time zone NULL, + "ProcessedAtUtc" timestamp with time zone NULL, + "LastError" text NULL + ); + + CREATE INDEX IF NOT EXISTS "IX_TinyOutbox_Pending" + ON {{postgreSqlName}} + ( + "Status", + "NextAttemptAtUtc", + "CreatedAtUtc" + ); + + CREATE INDEX IF NOT EXISTS "IX_TinyOutbox_ExpiredProcessing" + ON {{postgreSqlName}} + ( + "Status", + "ClaimExpiresAtUtc" + ); + + CREATE INDEX IF NOT EXISTS "IX_TinyOutbox_ClaimedBy" + ON {{postgreSqlName}} + ( + "ClaimedBy", + "Status" + ); + """; + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/Sql/TinyPostgreSqlAdoNetSql.cs b/src/TinyEvents.PostgreSql.AdoNet/Sql/TinyPostgreSqlAdoNetSql.cs new file mode 100644 index 0000000..1211514 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Sql/TinyPostgreSqlAdoNetSql.cs @@ -0,0 +1,135 @@ +namespace TinyEvents.PostgreSql.AdoNet; + +public static class TinyPostgreSqlAdoNetSql +{ + public static string Insert(TinyPostgreSqlAdoNetTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + INSERT INTO {tableName.ToPostgreSqlName()} + ( + "Id", + "EventType", + "Payload", + "Status", + "AttemptCount", + "ClaimedBy", + "ClaimedAtUtc", + "ClaimExpiresAtUtc", + "CreatedAtUtc", + "NextAttemptAtUtc", + "ProcessedAtUtc", + "LastError" + ) + VALUES + ( + @Id, + @EventType, + @Payload, + @Status, + @AttemptCount, + @ClaimedBy, + @ClaimedAtUtc, + @ClaimExpiresAtUtc, + @CreatedAtUtc, + @NextAttemptAtUtc, + @ProcessedAtUtc, + @LastError + ); + """; + } + + public static string ClaimPending(TinyPostgreSqlAdoNetTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + WITH claimed AS + ( + SELECT "Id" + FROM {tableName.ToPostgreSqlName()} + WHERE + ( + "Status" = @PendingStatus + AND ("NextAttemptAtUtc" IS NULL OR "NextAttemptAtUtc" <= @Now) + ) + OR + ( + "Status" = @ProcessingStatus + AND "ClaimExpiresAtUtc" <= @Now + ) + ORDER BY "CreatedAtUtc" + FOR UPDATE SKIP LOCKED + LIMIT @BatchSize + ) + UPDATE {tableName.ToPostgreSqlName()} AS outbox + SET + "Status" = @ProcessingStatus, + "ClaimedBy" = @WorkerId, + "ClaimedAtUtc" = @Now, + "ClaimExpiresAtUtc" = @ClaimExpiresAtUtc + FROM claimed + WHERE outbox."Id" = claimed."Id" + RETURNING + outbox."Id", + outbox."EventType", + outbox."Payload", + outbox."Status", + outbox."AttemptCount", + outbox."ClaimedBy", + outbox."ClaimedAtUtc", + outbox."ClaimExpiresAtUtc", + outbox."CreatedAtUtc", + outbox."NextAttemptAtUtc", + outbox."ProcessedAtUtc", + outbox."LastError"; + """; + } + + public static string MarkProcessed(TinyPostgreSqlAdoNetTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + UPDATE {tableName.ToPostgreSqlName()} + SET + "Status" = @ProcessedStatus, + "ProcessedAtUtc" = @ProcessedAtUtc + WHERE + "Id" = @Id + AND "ClaimedBy" = @WorkerId + AND "Status" = @ProcessingStatus; + """; + } + + public static string MarkFailed(TinyPostgreSqlAdoNetTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + UPDATE {tableName.ToPostgreSqlName()} + SET + "Status" = @Status, + "AttemptCount" = @AttemptCount, + "NextAttemptAtUtc" = @NextAttemptAtUtc, + "LastError" = @LastError + WHERE + "Id" = @Id + AND "ClaimedBy" = @WorkerId + AND "Status" = @ProcessingStatus; + """; + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/Sql/TinyPostgreSqlAdoNetTableName.cs b/src/TinyEvents.PostgreSql.AdoNet/Sql/TinyPostgreSqlAdoNetTableName.cs new file mode 100644 index 0000000..fc2e7c1 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Sql/TinyPostgreSqlAdoNetTableName.cs @@ -0,0 +1,103 @@ +namespace TinyEvents.PostgreSql.AdoNet; + +public sealed class TinyPostgreSqlAdoNetTableName +{ + private readonly string[] parts; + + private TinyPostgreSqlAdoNetTableName(string value) + { + parts = value.Split('.'); + } + + public static TinyPostgreSqlAdoNetTableName Parse(string tableName) + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException("Table name is required.", nameof(tableName)); + } + + var parsed = new TinyPostgreSqlAdoNetTableName(tableName); + parsed.Validate(); + return parsed; + } + + public string ToPostgreSqlName() + { + return string.Join(".", parts.Select(Quote)); + } + + public string ToPostgreSqlName(string defaultSchema) + { + if (string.IsNullOrWhiteSpace(defaultSchema)) + { + throw new ArgumentException("Default schema is required.", nameof(defaultSchema)); + } + + return parts.Length == 1 + ? $"{Quote(defaultSchema)}.{Quote(parts[0])}" + : ToPostgreSqlName(); + } + + public string ToPostgreSqlSchemaName(string defaultSchema = "public") + { + if (string.IsNullOrWhiteSpace(defaultSchema)) + { + throw new ArgumentException("Default schema is required.", nameof(defaultSchema)); + } + + return parts.Length == 1 + ? Quote(defaultSchema) + : Quote(parts[0]); + } + + public string ToPostgreSqlObjectName(string defaultSchema = "public") + { + if (string.IsNullOrWhiteSpace(defaultSchema)) + { + throw new ArgumentException("Default schema is required.", nameof(defaultSchema)); + } + + return parts.Length == 1 + ? $"{defaultSchema}.{parts[0]}" + : string.Join(".", parts); + } + + private void Validate() + { + if (parts.Length > 2) + { + throw new ArgumentException("Table name can contain only table or schema.table."); + } + + foreach (var part in parts) + { + ValidatePart(part); + } + } + + private static void ValidatePart(string part) + { + if (string.IsNullOrWhiteSpace(part)) + { + throw new ArgumentException("Table name contains an empty segment."); + } + + foreach (var character in part) + { + if (!IsAllowed(character)) + { + throw new ArgumentException("Table name can only contain letters, digits, underscores, and dots."); + } + } + } + + private static bool IsAllowed(char character) + { + return char.IsLetterOrDigit(character) || character == '_'; + } + + private static string Quote(string identifier) + { + return $"\"{identifier}\""; + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/TinyEvents.PostgreSql.AdoNet.csproj b/src/TinyEvents.PostgreSql.AdoNet/TinyEvents.PostgreSql.AdoNet.csproj new file mode 100644 index 0000000..e51651b --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/TinyEvents.PostgreSql.AdoNet.csproj @@ -0,0 +1,39 @@ + + + net8.0 + TinyEvents.PostgreSql.AdoNet + 0.1.0-alpha.1 + Jorge Durban Antunano + PostgreSQL ADO.NET provider for TinyEvents outbox storage and worker claiming. + MIT + https://github.com/george2006/TinyEvents + https://github.com/george2006/TinyEvents + git + tinyevents;outbox;postgresql;postgres;ado.net;dotnet;source-generator + README.md + false + + + + + + + + + + + + + + TinyEvents.PostgreSql.AdoNet 0.1.0-alpha.1 + + Added + - PostgreSQL ADO.NET provider package boundary. + + Notes + - Early alpha / active development. + - PostgreSQL runtime behavior will be added in small tested slices. + - Consumers must be idempotent because delivery is at-least-once. + + + diff --git a/src/TinyEvents.PostgreSql.AdoNet/TinyEventsPostgreSqlAdoNetOptions.cs b/src/TinyEvents.PostgreSql.AdoNet/TinyEventsPostgreSqlAdoNetOptions.cs new file mode 100644 index 0000000..d6bf2b0 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/TinyEventsPostgreSqlAdoNetOptions.cs @@ -0,0 +1,53 @@ +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +public sealed class TinyEventsPostgreSqlAdoNetOptions +{ + private Func? currentTransaction; + private Func>? workerConnectionFactory; + + public string TableName { get; set; } = "TinyOutbox"; + + public void UseCurrentTransaction( + Func currentTransaction) + { + this.currentTransaction = currentTransaction + ?? throw new ArgumentNullException(nameof(currentTransaction)); + } + + public void UseWorkerConnectionFactory( + Func> workerConnectionFactory) + { + this.workerConnectionFactory = workerConnectionFactory + ?? throw new ArgumentNullException(nameof(workerConnectionFactory)); + } + + internal ITinyPostgreSqlAdoNetTransactionContext? GetCurrentTransaction(IServiceProvider serviceProvider) + { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + return currentTransaction?.Invoke(serviceProvider); + } + + internal ValueTask CreateWorkerConnectionAsync( + IServiceProvider serviceProvider, + CancellationToken cancellationToken) + { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + if (workerConnectionFactory is null) + { + throw new InvalidOperationException( + "An ADO.NET worker connection factory is required. Configure UseWorkerConnectionFactory(...) for outbox claiming and marking operations."); + } + + return workerConnectionFactory(serviceProvider, cancellationToken); + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetCommandParameters.cs b/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetCommandParameters.cs new file mode 100644 index 0000000..9945391 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetCommandParameters.cs @@ -0,0 +1,30 @@ +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +internal static class TinyPostgreSqlAdoNetCommandParameters +{ + public static void AddOutboxParameters(DbCommand command, TinyOutboxMessage message) + { + Add(command, "@Id", message.Id); + Add(command, "@EventType", message.EventType); + Add(command, "@Payload", message.Payload); + Add(command, "@Status", (int)message.Status); + Add(command, "@AttemptCount", message.AttemptCount); + Add(command, "@ClaimedBy", message.ClaimedBy); + Add(command, "@ClaimedAtUtc", message.ClaimedAtUtc); + Add(command, "@ClaimExpiresAtUtc", message.ClaimExpiresAtUtc); + Add(command, "@CreatedAtUtc", message.CreatedAtUtc); + Add(command, "@NextAttemptAtUtc", message.NextAttemptAtUtc); + Add(command, "@ProcessedAtUtc", message.ProcessedAtUtc); + Add(command, "@LastError", message.LastError); + } + + public static void Add(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetOutboxStore.cs b/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetOutboxStore.cs new file mode 100644 index 0000000..902d034 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetOutboxStore.cs @@ -0,0 +1,179 @@ +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +public sealed class TinyPostgreSqlAdoNetOutboxStore : ITinyOutboxStore +{ + private readonly ITinyPostgreSqlAdoNetWorkerConnectionFactory connectionFactory; + private readonly TinyPostgreSqlAdoNetTableName tableName; + + public TinyPostgreSqlAdoNetOutboxStore( + TinyEventsPostgreSqlAdoNetOptions options, + ITinyPostgreSqlAdoNetWorkerConnectionFactory connectionFactory) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (connectionFactory is null) + { + throw new ArgumentNullException(nameof(connectionFactory)); + } + + this.connectionFactory = connectionFactory; + tableName = TinyPostgreSqlAdoNetTableName.Parse(options.TableName); + } + + public async ValueTask> ClaimPendingAsync( + int maxCount, + string workerId, + DateTimeOffset now, + TimeSpan claimTimeout, + CancellationToken cancellationToken) + { + if (workerId is null) + { + throw new ArgumentNullException(nameof(workerId)); + } + + await using var connection = await CreateOpenConnectionAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = TinyPostgreSqlAdoNetSql.ClaimPending(tableName); + + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@BatchSize", maxCount); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@WorkerId", workerId); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@Now", now); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@ClaimExpiresAtUtc", now.Add(claimTimeout)); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@PendingStatus", (int)TinyOutboxMessageStatus.Pending); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@ProcessingStatus", (int)TinyOutboxMessageStatus.Processing); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await ReadMessagesAsync(reader, cancellationToken); + } + + public async ValueTask MarkProcessedAsync( + Guid messageId, + string workerId, + DateTimeOffset processedAtUtc, + CancellationToken cancellationToken) + { + if (workerId is null) + { + throw new ArgumentNullException(nameof(workerId)); + } + + await using var connection = await CreateOpenConnectionAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = TinyPostgreSqlAdoNetSql.MarkProcessed(tableName); + + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@Id", messageId); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@WorkerId", workerId); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@ProcessedAtUtc", processedAtUtc); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@ProcessedStatus", (int)TinyOutboxMessageStatus.Processed); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@ProcessingStatus", (int)TinyOutboxMessageStatus.Processing); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask MarkFailedAsync( + Guid messageId, + string workerId, + string error, + int attemptCount, + DateTimeOffset? nextAttemptAtUtc, + CancellationToken cancellationToken) + { + if (workerId is null) + { + throw new ArgumentNullException(nameof(workerId)); + } + + if (error is null) + { + throw new ArgumentNullException(nameof(error)); + } + + await using var connection = await CreateOpenConnectionAsync(cancellationToken); + await using var command = connection.CreateCommand(); + command.CommandText = TinyPostgreSqlAdoNetSql.MarkFailed(tableName); + + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@Id", messageId); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@WorkerId", workerId); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@Status", (int)GetFailedStatus(nextAttemptAtUtc)); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@AttemptCount", attemptCount); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@NextAttemptAtUtc", nextAttemptAtUtc); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@LastError", error); + TinyPostgreSqlAdoNetCommandParameters.Add(command, "@ProcessingStatus", (int)TinyOutboxMessageStatus.Processing); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async ValueTask CreateOpenConnectionAsync(CancellationToken cancellationToken) + { + return await connectionFactory.CreateOpenConnectionAsync(cancellationToken); + } + + private static async ValueTask> ReadMessagesAsync( + DbDataReader reader, + CancellationToken cancellationToken) + { + var messages = new List(); + + while (await reader.ReadAsync(cancellationToken)) + { + messages.Add(ReadMessage(reader)); + } + + return messages; + } + + private static TinyOutboxMessage ReadMessage(DbDataReader reader) + { + return new TinyOutboxMessage + { + Id = reader.GetGuid(0), + EventType = reader.GetString(1), + Payload = reader.GetString(2), + Status = (TinyOutboxMessageStatus)reader.GetInt32(3), + AttemptCount = reader.GetInt32(4), + ClaimedBy = ReadNullableString(reader, 5), + ClaimedAtUtc = ReadNullableDateTimeOffset(reader, 6), + ClaimExpiresAtUtc = ReadNullableDateTimeOffset(reader, 7), + CreatedAtUtc = reader.GetFieldValue(8), + NextAttemptAtUtc = ReadNullableDateTimeOffset(reader, 9), + ProcessedAtUtc = ReadNullableDateTimeOffset(reader, 10), + LastError = ReadNullableString(reader, 11) + }; + } + + private static string? ReadNullableString(DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return reader.GetString(ordinal); + } + + private static DateTimeOffset? ReadNullableDateTimeOffset(DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return reader.GetFieldValue(ordinal); + } + + private static TinyOutboxMessageStatus GetFailedStatus(DateTimeOffset? nextAttemptAtUtc) + { + if (nextAttemptAtUtc is null) + { + return TinyOutboxMessageStatus.Failed; + } + + return TinyOutboxMessageStatus.Pending; + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetOutboxWriter.cs b/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetOutboxWriter.cs new file mode 100644 index 0000000..87c1284 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/TinyPostgreSqlAdoNetOutboxWriter.cs @@ -0,0 +1,68 @@ +namespace TinyEvents.PostgreSql.AdoNet; + +public sealed class TinyPostgreSqlAdoNetOutboxWriter : ITinyOutboxWriter +{ + private readonly TinyEventsPostgreSqlAdoNetOptions options; + private readonly IServiceProvider serviceProvider; + private readonly TinyPostgreSqlAdoNetTableName tableName; + + public TinyPostgreSqlAdoNetOutboxWriter( + TinyEventsPostgreSqlAdoNetOptions options, + IServiceProvider serviceProvider) + { + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + this.options = options; + this.serviceProvider = serviceProvider; + tableName = TinyPostgreSqlAdoNetTableName.Parse(options.TableName); + } + + public async ValueTask AddAsync( + TinyOutboxMessage message, + CancellationToken cancellationToken) + { + if (message is null) + { + throw new ArgumentNullException(nameof(message)); + } + + var context = GetCurrentTransactionContext(); + + await AddUsingTransactionAsync(message, context, cancellationToken); + } + + private async ValueTask AddUsingTransactionAsync( + TinyOutboxMessage message, + ITinyPostgreSqlAdoNetTransactionContext context, + CancellationToken cancellationToken) + { + await using var command = context.Connection.CreateCommand(); + command.Transaction = context.Transaction; + command.CommandText = TinyPostgreSqlAdoNetSql.Insert(tableName); + + TinyPostgreSqlAdoNetCommandParameters.AddOutboxParameters(command, message); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private ITinyPostgreSqlAdoNetTransactionContext GetCurrentTransactionContext() + { + var context = options.GetCurrentTransaction(serviceProvider); + + if (context is not null) + { + return context; + } + + throw new InvalidOperationException( + "PostgreSQL ADO.NET outbox publishing requires an application-owned DbConnection and DbTransaction. Configure UseCurrentTransaction(...) and call PublishAsync inside your application transaction."); + } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/Transactions/ITinyPostgreSqlAdoNetTransactionContext.cs b/src/TinyEvents.PostgreSql.AdoNet/Transactions/ITinyPostgreSqlAdoNetTransactionContext.cs new file mode 100644 index 0000000..cdf7314 --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Transactions/ITinyPostgreSqlAdoNetTransactionContext.cs @@ -0,0 +1,10 @@ +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +public interface ITinyPostgreSqlAdoNetTransactionContext +{ + DbConnection Connection { get; } + + DbTransaction Transaction { get; } +} diff --git a/src/TinyEvents.PostgreSql.AdoNet/Transactions/TinyPostgreSqlAdoNetTransactionContext.cs b/src/TinyEvents.PostgreSql.AdoNet/Transactions/TinyPostgreSqlAdoNetTransactionContext.cs new file mode 100644 index 0000000..d01e74b --- /dev/null +++ b/src/TinyEvents.PostgreSql.AdoNet/Transactions/TinyPostgreSqlAdoNetTransactionContext.cs @@ -0,0 +1,18 @@ +using System.Data.Common; + +namespace TinyEvents.PostgreSql.AdoNet; + +public sealed class TinyPostgreSqlAdoNetTransactionContext : ITinyPostgreSqlAdoNetTransactionContext +{ + public TinyPostgreSqlAdoNetTransactionContext( + DbConnection connection, + DbTransaction transaction) + { + Connection = connection ?? throw new ArgumentNullException(nameof(connection)); + Transaction = transaction ?? throw new ArgumentNullException(nameof(transaction)); + } + + public DbConnection Connection { get; } + + public DbTransaction Transaction { get; } +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/DependencyInjection/TinyEventsPostgreSqlEntityFrameworkCoreServiceCollectionExtensions.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/DependencyInjection/TinyEventsPostgreSqlEntityFrameworkCoreServiceCollectionExtensions.cs new file mode 100644 index 0000000..f70931b --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/DependencyInjection/TinyEventsPostgreSqlEntityFrameworkCoreServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public static class TinyEventsPostgreSqlEntityFrameworkCoreServiceCollectionExtensions +{ + public static IServiceCollection UsePostgreSqlEntityFrameworkCoreOutbox( + this IServiceCollection services, + Action? configure = null) + where TDbContext : DbContext + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + var options = new TinyEventsPostgreSqlEntityFrameworkCoreOptions(); + configure?.Invoke(options); + + services.UseTinyEvents(); + services.TryAddSingleton(options); + services.Replace(ServiceDescriptor.Scoped>()); + services.Replace(ServiceDescriptor.Scoped>()); + + return services; + } +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/README.md b/src/TinyEvents.PostgreSql.EntityFrameworkCore/README.md new file mode 100644 index 0000000..4d45685 --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/README.md @@ -0,0 +1,74 @@ +# TinyEvents.PostgreSql.EntityFrameworkCore + +PostgreSQL Entity Framework Core provider package for TinyEvents outbox storage and worker claiming. + +This package is for applications that use a caller-owned `DbContext`. TinyEvents adds outbox messages to that context, and your application commits business data and outbox messages together with `SaveChangesAsync`. + +The provider follows the same ownership model as the SQL Server EF Core provider: + +- applications own the `DbContext` +- publishing adds an outbox message to the caller-owned `DbContext` +- TinyEvents does not call `SaveChangesAsync` +- business data and outbox messages commit together when the caller saves +- worker claiming uses PostgreSQL-specific SQL + +PostgreSQL worker claiming uses `FOR UPDATE SKIP LOCKED`. + +## Install + +```bash +dotnet add package TinyEvents --version 0.1.0-alpha.1 +dotnet add package TinyEvents.PostgreSql.EntityFrameworkCore --version 0.1.0-alpha.1 +``` + +## Register + +```csharp +using Microsoft.EntityFrameworkCore; +using TinyEvents.PostgreSql.EntityFrameworkCore; + +services.AddDbContext(options => +{ + options.UseNpgsql(connectionString); +}); + +services.UsePostgreSqlEntityFrameworkCoreOutbox(); +``` + +## Map The Outbox Entity + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.UseTinyEventsOutbox(); +} +``` + +The default table is `TinyOutbox`. + +## Custom Table Name + +Configure both the provider and the EF Core mapping when using a custom table: + +```csharp +services.UsePostgreSqlEntityFrameworkCoreOutbox(options => +{ + options.TableName = "app.MyOutbox"; +}); +``` + +```csharp +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.UseTinyEventsOutbox("app.MyOutbox"); +} +``` + +The provider option controls SQL claiming and marking. The model builder extension controls EF mapping and migrations. + +## More Documentation + +- Project documentation: https://github.com/george2006/TinyEvents +- EF Core provider guide: https://github.com/george2006/TinyEvents/blob/main/docs/postgresql/ef-core.md +- Worker guide: https://github.com/george2006/TinyEvents/blob/main/docs/workers.md +- Schema and migrations: https://github.com/george2006/TinyEvents/blob/main/docs/schema-and-migrations.md diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/Sql/TinyPostgreSqlEfCoreSql.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/Sql/TinyPostgreSqlEfCoreSql.cs new file mode 100644 index 0000000..85306eb --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/Sql/TinyPostgreSqlEfCoreSql.cs @@ -0,0 +1,94 @@ +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public static class TinyPostgreSqlEfCoreSql +{ + public static string ClaimPending(TinyPostgreSqlEfCoreTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + WITH claimed AS + ( + SELECT "Id" + FROM {tableName.ToPostgreSqlName()} + WHERE + ( + "Status" = @PendingStatus + AND ("NextAttemptAtUtc" IS NULL OR "NextAttemptAtUtc" <= @Now) + ) + OR + ( + "Status" = @ProcessingStatus + AND "ClaimExpiresAtUtc" <= @Now + ) + ORDER BY "CreatedAtUtc" + FOR UPDATE SKIP LOCKED + LIMIT @BatchSize + ) + UPDATE {tableName.ToPostgreSqlName()} AS outbox + SET + "Status" = @ProcessingStatus, + "ClaimedBy" = @WorkerId, + "ClaimedAtUtc" = @Now, + "ClaimExpiresAtUtc" = @ClaimExpiresAtUtc + FROM claimed + WHERE outbox."Id" = claimed."Id" + RETURNING + outbox."Id", + outbox."EventType", + outbox."Payload", + outbox."Status", + outbox."AttemptCount", + outbox."ClaimedBy", + outbox."ClaimedAtUtc", + outbox."ClaimExpiresAtUtc", + outbox."CreatedAtUtc", + outbox."NextAttemptAtUtc", + outbox."ProcessedAtUtc", + outbox."LastError"; + """; + } + + public static string MarkProcessed(TinyPostgreSqlEfCoreTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + UPDATE {tableName.ToPostgreSqlName()} + SET + "Status" = @ProcessedStatus, + "ProcessedAtUtc" = @ProcessedAtUtc + WHERE + "Id" = @Id + AND "ClaimedBy" = @WorkerId + AND "Status" = @ProcessingStatus; + """; + } + + public static string MarkFailed(TinyPostgreSqlEfCoreTableName tableName) + { + if (tableName is null) + { + throw new ArgumentNullException(nameof(tableName)); + } + + return $""" + UPDATE {tableName.ToPostgreSqlName()} + SET + "Status" = @Status, + "AttemptCount" = @AttemptCount, + "NextAttemptAtUtc" = @NextAttemptAtUtc, + "LastError" = @LastError + WHERE + "Id" = @Id + AND "ClaimedBy" = @WorkerId + AND "Status" = @ProcessingStatus; + """; + } +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/Sql/TinyPostgreSqlEfCoreTableName.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/Sql/TinyPostgreSqlEfCoreTableName.cs new file mode 100644 index 0000000..c484966 --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/Sql/TinyPostgreSqlEfCoreTableName.cs @@ -0,0 +1,82 @@ +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public sealed class TinyPostgreSqlEfCoreTableName +{ + private readonly string[] parts; + + private TinyPostgreSqlEfCoreTableName(string value) + { + parts = value.Split('.'); + } + + public string? Schema + { + get + { + if (parts.Length == 2) + { + return parts[0]; + } + + return null; + } + } + + public string Table => parts[parts.Length - 1]; + + public static TinyPostgreSqlEfCoreTableName Parse(string tableName) + { + if (string.IsNullOrWhiteSpace(tableName)) + { + throw new ArgumentException("Table name is required.", nameof(tableName)); + } + + var parsed = new TinyPostgreSqlEfCoreTableName(tableName); + parsed.Validate(); + return parsed; + } + + public string ToPostgreSqlName() + { + return string.Join(".", parts.Select(Quote)); + } + + private void Validate() + { + if (parts.Length > 2) + { + throw new ArgumentException("Table name can contain only table or schema.table."); + } + + foreach (var part in parts) + { + ValidatePart(part); + } + } + + private static void ValidatePart(string part) + { + if (string.IsNullOrWhiteSpace(part)) + { + throw new ArgumentException("Table name contains an empty segment."); + } + + foreach (var character in part) + { + if (!IsAllowed(character)) + { + throw new ArgumentException("Table name can only contain letters, digits, underscores, and dots."); + } + } + } + + private static bool IsAllowed(char character) + { + return char.IsLetterOrDigit(character) || character == '_'; + } + + private static string Quote(string identifier) + { + return $"\"{identifier}\""; + } +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEvents.PostgreSql.EntityFrameworkCore.csproj b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEvents.PostgreSql.EntityFrameworkCore.csproj new file mode 100644 index 0000000..68ed508 --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEvents.PostgreSql.EntityFrameworkCore.csproj @@ -0,0 +1,38 @@ + + + net8.0 + TinyEvents.PostgreSql.EntityFrameworkCore + 0.1.0-alpha.1 + Jorge Durban Antunano + PostgreSQL Entity Framework Core provider for TinyEvents outbox storage and worker claiming. + MIT + https://github.com/george2006/TinyEvents + https://github.com/george2006/TinyEvents + git + tinyevents;outbox;postgresql;postgres;efcore;dotnet;source-generator + README.md + false + + + + + + + + + + + + + TinyEvents.PostgreSql.EntityFrameworkCore 0.1.0-alpha.1 + + Added + - PostgreSQL EF Core provider package boundary. + + Notes + - Early alpha / active development. + - PostgreSQL runtime behavior will be added in small tested slices. + - Consumers must be idempotent because delivery is at-least-once. + + + diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEventsModelBuilderExtensions.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEventsModelBuilderExtensions.cs new file mode 100644 index 0000000..507f6a0 --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEventsModelBuilderExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public static class TinyEventsModelBuilderExtensions +{ + public static ModelBuilder UseTinyEventsOutbox( + this ModelBuilder modelBuilder, + string tableName = "TinyOutbox") + { + if (modelBuilder is null) + { + throw new ArgumentNullException(nameof(modelBuilder)); + } + + var parsedTableName = TinyPostgreSqlEfCoreTableName.Parse(tableName); + + modelBuilder.Entity(entity => + { + entity.ToTable(parsedTableName.Table, parsedTableName.Schema); + entity.HasKey(message => message.Id); + entity.Property(message => message.EventType).IsRequired(); + entity.Property(message => message.Payload).IsRequired(); + entity.Property(message => message.Status).IsRequired(); + entity.Property(message => message.CreatedAtUtc).IsRequired(); + + entity.HasIndex(message => new + { + message.Status, + message.NextAttemptAtUtc, + message.CreatedAtUtc + }); + + entity.HasIndex(message => new + { + message.Status, + message.ClaimExpiresAtUtc + }); + + entity.HasIndex(message => new + { + message.ClaimedBy, + message.Status + }); + }); + + return modelBuilder; + } +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEventsPostgreSqlEntityFrameworkCoreOptions.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEventsPostgreSqlEntityFrameworkCoreOptions.cs new file mode 100644 index 0000000..11f72a2 --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyEventsPostgreSqlEntityFrameworkCoreOptions.cs @@ -0,0 +1,6 @@ +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public sealed class TinyEventsPostgreSqlEntityFrameworkCoreOptions +{ + public string TableName { get; set; } = "TinyOutbox"; +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyPostgreSqlEfCoreOutboxStore.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyPostgreSqlEfCoreOutboxStore.cs new file mode 100644 index 0000000..c3342dd --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyPostgreSqlEfCoreOutboxStore.cs @@ -0,0 +1,203 @@ +using System.Data; +using System.Data.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public sealed class TinyPostgreSqlEfCoreOutboxStore : ITinyOutboxStore + where TDbContext : DbContext +{ + private readonly TDbContext dbContext; + private readonly TinyPostgreSqlEfCoreTableName tableName; + + public TinyPostgreSqlEfCoreOutboxStore( + TDbContext dbContext, + TinyEventsPostgreSqlEntityFrameworkCoreOptions options) + { + if (dbContext is null) + { + throw new ArgumentNullException(nameof(dbContext)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + this.dbContext = dbContext; + tableName = TinyPostgreSqlEfCoreTableName.Parse(options.TableName); + } + + public async ValueTask> ClaimPendingAsync( + int maxCount, + string workerId, + DateTimeOffset now, + TimeSpan claimTimeout, + CancellationToken cancellationToken) + { + if (workerId is null) + { + throw new ArgumentNullException(nameof(workerId)); + } + + await using var command = await CreateCommandAsync(cancellationToken); + command.CommandText = TinyPostgreSqlEfCoreSql.ClaimPending(tableName); + + AddParameter(command, "@BatchSize", maxCount); + AddParameter(command, "@WorkerId", workerId); + AddParameter(command, "@Now", now); + AddParameter(command, "@ClaimExpiresAtUtc", now.Add(claimTimeout)); + AddParameter(command, "@PendingStatus", (int)TinyOutboxMessageStatus.Pending); + AddParameter(command, "@ProcessingStatus", (int)TinyOutboxMessageStatus.Processing); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + return await ReadMessagesAsync(reader, cancellationToken); + } + + public async ValueTask MarkProcessedAsync( + Guid messageId, + string workerId, + DateTimeOffset processedAtUtc, + CancellationToken cancellationToken) + { + if (workerId is null) + { + throw new ArgumentNullException(nameof(workerId)); + } + + await using var command = await CreateCommandAsync(cancellationToken); + command.CommandText = TinyPostgreSqlEfCoreSql.MarkProcessed(tableName); + + AddParameter(command, "@Id", messageId); + AddParameter(command, "@WorkerId", workerId); + AddParameter(command, "@ProcessedAtUtc", processedAtUtc); + AddParameter(command, "@ProcessedStatus", (int)TinyOutboxMessageStatus.Processed); + AddParameter(command, "@ProcessingStatus", (int)TinyOutboxMessageStatus.Processing); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public async ValueTask MarkFailedAsync( + Guid messageId, + string workerId, + string error, + int attemptCount, + DateTimeOffset? nextAttemptAtUtc, + CancellationToken cancellationToken) + { + if (workerId is null) + { + throw new ArgumentNullException(nameof(workerId)); + } + + if (error is null) + { + throw new ArgumentNullException(nameof(error)); + } + + await using var command = await CreateCommandAsync(cancellationToken); + command.CommandText = TinyPostgreSqlEfCoreSql.MarkFailed(tableName); + + AddParameter(command, "@Id", messageId); + AddParameter(command, "@WorkerId", workerId); + AddParameter(command, "@Status", (int)GetFailedStatus(nextAttemptAtUtc)); + AddParameter(command, "@AttemptCount", attemptCount); + AddParameter(command, "@NextAttemptAtUtc", nextAttemptAtUtc); + AddParameter(command, "@LastError", error); + AddParameter(command, "@ProcessingStatus", (int)TinyOutboxMessageStatus.Processing); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async ValueTask CreateCommandAsync(CancellationToken cancellationToken) + { + var connection = dbContext.Database.GetDbConnection(); + + if (connection.State != ConnectionState.Open) + { + await connection.OpenAsync(cancellationToken); + } + + var command = connection.CreateCommand(); + var currentTransaction = dbContext.Database.CurrentTransaction; + + if (currentTransaction is not null) + { + command.Transaction = currentTransaction.GetDbTransaction(); + } + + return command; + } + + private static void AddParameter(DbCommand command, string name, object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + private static async ValueTask> ReadMessagesAsync( + DbDataReader reader, + CancellationToken cancellationToken) + { + var messages = new List(); + + while (await reader.ReadAsync(cancellationToken)) + { + messages.Add(ReadMessage(reader)); + } + + return messages; + } + + private static TinyOutboxMessage ReadMessage(DbDataReader reader) + { + return new TinyOutboxMessage + { + Id = reader.GetGuid(0), + EventType = reader.GetString(1), + Payload = reader.GetString(2), + Status = (TinyOutboxMessageStatus)reader.GetInt32(3), + AttemptCount = reader.GetInt32(4), + ClaimedBy = ReadNullableString(reader, 5), + ClaimedAtUtc = ReadNullableDateTimeOffset(reader, 6), + ClaimExpiresAtUtc = ReadNullableDateTimeOffset(reader, 7), + CreatedAtUtc = reader.GetFieldValue(8), + NextAttemptAtUtc = ReadNullableDateTimeOffset(reader, 9), + ProcessedAtUtc = ReadNullableDateTimeOffset(reader, 10), + LastError = ReadNullableString(reader, 11) + }; + } + + private static string? ReadNullableString(DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return reader.GetString(ordinal); + } + + private static DateTimeOffset? ReadNullableDateTimeOffset(DbDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + return reader.GetFieldValue(ordinal); + } + + private static TinyOutboxMessageStatus GetFailedStatus(DateTimeOffset? nextAttemptAtUtc) + { + if (nextAttemptAtUtc is null) + { + return TinyOutboxMessageStatus.Failed; + } + + return TinyOutboxMessageStatus.Pending; + } +} diff --git a/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyPostgreSqlEfCoreOutboxWriter.cs b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyPostgreSqlEfCoreOutboxWriter.cs new file mode 100644 index 0000000..5ab591c --- /dev/null +++ b/src/TinyEvents.PostgreSql.EntityFrameworkCore/TinyPostgreSqlEfCoreOutboxWriter.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore; + +public sealed class TinyPostgreSqlEfCoreOutboxWriter : ITinyOutboxWriter + where TDbContext : DbContext +{ + private readonly TDbContext dbContext; + + public TinyPostgreSqlEfCoreOutboxWriter(TDbContext dbContext) + { + if (dbContext is null) + { + throw new ArgumentNullException(nameof(dbContext)); + } + + this.dbContext = dbContext; + } + + public ValueTask AddAsync( + TinyOutboxMessage message, + CancellationToken cancellationToken) + { + if (message is null) + { + throw new ArgumentNullException(nameof(message)); + } + + cancellationToken.ThrowIfCancellationRequested(); + dbContext.Set().Add(message); + return ValueTask.CompletedTask; + } +} diff --git a/src/TinyEvents.SqlServer.AdoNet/README.md b/src/TinyEvents.SqlServer.AdoNet/README.md index cda51ae..f63dc0b 100644 --- a/src/TinyEvents.SqlServer.AdoNet/README.md +++ b/src/TinyEvents.SqlServer.AdoNet/README.md @@ -67,6 +67,6 @@ The default table is `dbo.TinyOutbox`. ## More Documentation -- ADO.NET provider guide: https://github.com/george2006/TinyEvents/blob/main/docs/ado-net.md +- ADO.NET provider guide: https://github.com/george2006/TinyEvents/blob/main/docs/sql-server/ado-net.md - Schema and migrations: https://github.com/george2006/TinyEvents/blob/main/docs/schema-and-migrations.md - Worker guide: https://github.com/george2006/TinyEvents/blob/main/docs/workers.md diff --git a/src/TinyEvents.SqlServer.EntityFrameworkCore/README.md b/src/TinyEvents.SqlServer.EntityFrameworkCore/README.md index 89dd764..242f0f6 100644 --- a/src/TinyEvents.SqlServer.EntityFrameworkCore/README.md +++ b/src/TinyEvents.SqlServer.EntityFrameworkCore/README.md @@ -58,6 +58,6 @@ The provider option controls SQL claiming and marking. The model builder extensi ## More Documentation -- EF Core provider guide: https://github.com/george2006/TinyEvents/blob/main/docs/ef-core.md +- EF Core provider guide: https://github.com/george2006/TinyEvents/blob/main/docs/sql-server/ef-core.md - Schema and migrations: https://github.com/george2006/TinyEvents/blob/main/docs/schema-and-migrations.md - Worker guide: https://github.com/george2006/TinyEvents/blob/main/docs/workers.md diff --git a/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyEvents.PostgreSql.AdoNet.Tests.csproj b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyEvents.PostgreSql.AdoNet.Tests.csproj new file mode 100644 index 0000000..567a42c --- /dev/null +++ b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyEvents.PostgreSql.AdoNet.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + false + + + + + + + + + + + diff --git a/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetOptionsTests.cs b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetOptionsTests.cs new file mode 100644 index 0000000..6f2d173 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetOptionsTests.cs @@ -0,0 +1,211 @@ +using System.Data; +using System.Data.Common; +using Xunit; + +namespace TinyEvents.PostgreSql.AdoNet.Tests; + +public sealed class TinyPostgreSqlAdoNetOptionsTests +{ + [Fact] + public void Options_use_current_transaction_rejects_null_delegate() + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + + Assert.Throws(() => options.UseCurrentTransaction(null!)); + } + + [Fact] + public void Options_use_worker_connection_factory_rejects_null_delegate() + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + + Assert.Throws(() => options.UseWorkerConnectionFactory(null!)); + } + + [Fact] + public void Transaction_context_rejects_null_connection() + { + var transaction = new RecordingTransaction(new RecordingConnection(ConnectionState.Open)); + + Assert.Throws(() => new TinyPostgreSqlAdoNetTransactionContext(null!, transaction)); + } + + [Fact] + public void Transaction_context_rejects_null_transaction() + { + var connection = new RecordingConnection(ConnectionState.Open); + + Assert.Throws(() => new TinyPostgreSqlAdoNetTransactionContext(connection, null!)); + } + + [Fact] + public void Transaction_context_exposes_connection_and_transaction() + { + var connection = new RecordingConnection(ConnectionState.Open); + var transaction = new RecordingTransaction(connection); + + var context = new TinyPostgreSqlAdoNetTransactionContext(connection, transaction); + + Assert.Same(connection, context.Connection); + Assert.Same(transaction, context.Transaction); + } + + [Fact] + public async Task Worker_connection_factory_uses_configured_delegate() + { + var connection = new RecordingConnection(ConnectionState.Open); + var options = new TinyEventsPostgreSqlAdoNetOptions(); + options.UseWorkerConnectionFactory((_, _) => new ValueTask(connection)); + var factory = NewFactory(options); + + var result = await factory.CreateOpenConnectionAsync(CancellationToken.None); + + Assert.Same(connection, result); + } + + [Fact] + public async Task Worker_connection_factory_opens_closed_connection() + { + var connection = new RecordingConnection(ConnectionState.Closed); + var options = new TinyEventsPostgreSqlAdoNetOptions(); + options.UseWorkerConnectionFactory((_, _) => new ValueTask(connection)); + var factory = NewFactory(options); + + await factory.CreateOpenConnectionAsync(CancellationToken.None); + + Assert.Equal(1, connection.OpenCount); + } + + [Fact] + public async Task Worker_connection_factory_does_not_open_already_open_connection() + { + var connection = new RecordingConnection(ConnectionState.Open); + var options = new TinyEventsPostgreSqlAdoNetOptions(); + options.UseWorkerConnectionFactory((_, _) => new ValueTask(connection)); + var factory = NewFactory(options); + + await factory.CreateOpenConnectionAsync(CancellationToken.None); + + Assert.Equal(0, connection.OpenCount); + } + + [Fact] + public async Task Worker_connection_factory_fails_clearly_when_delegate_is_missing() + { + var factory = NewFactory(new TinyEventsPostgreSqlAdoNetOptions()); + + var exception = await Assert.ThrowsAsync( + async () => await factory.CreateOpenConnectionAsync(CancellationToken.None)); + + Assert.Contains("Configure UseWorkerConnectionFactory(...)", exception.Message); + } + + [Fact] + public void Worker_connection_factory_rejects_null_options() + { + var serviceProvider = new RecordingServiceProvider(); + + Assert.Throws(() => new TinyPostgreSqlAdoNetWorkerConnectionFactory(null!, serviceProvider)); + } + + [Fact] + public void Worker_connection_factory_rejects_null_service_provider() + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + + Assert.Throws(() => new TinyPostgreSqlAdoNetWorkerConnectionFactory(options, null!)); + } + + private static TinyPostgreSqlAdoNetWorkerConnectionFactory NewFactory( + TinyEventsPostgreSqlAdoNetOptions options) + { + return new TinyPostgreSqlAdoNetWorkerConnectionFactory( + options, + new RecordingServiceProvider()); + } + +#nullable disable + private sealed class RecordingServiceProvider : IServiceProvider + { + public object GetService(Type serviceType) + { + return null; + } + } + + private sealed class RecordingConnection : DbConnection + { + private ConnectionState state; + + public RecordingConnection(ConnectionState state) + { + this.state = state; + } + + public int OpenCount { get; private set; } + + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => "Test"; + + public override string DataSource => "Test"; + + public override string ServerVersion => "1"; + + public override ConnectionState State => state; + + public override void ChangeDatabase(string databaseName) + { + } + + public override void Close() + { + state = ConnectionState.Closed; + } + + public override void Open() + { + OpenCount++; + state = ConnectionState.Open; + } + + public override Task OpenAsync(CancellationToken cancellationToken) + { + Open(); + return Task.CompletedTask; + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + return new RecordingTransaction(this); + } + + protected override DbCommand CreateDbCommand() + { + throw new NotSupportedException(); + } + } + + private sealed class RecordingTransaction : DbTransaction + { + private readonly DbConnection connection; + + public RecordingTransaction(DbConnection connection) + { + this.connection = connection; + } + + public override IsolationLevel IsolationLevel => IsolationLevel.ReadCommitted; + + protected override DbConnection DbConnection => connection; + + public override void Commit() + { + } + + public override void Rollback() + { + } + } +#nullable restore +} diff --git a/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetPackageTests.cs b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetPackageTests.cs new file mode 100644 index 0000000..cc56bf4 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetPackageTests.cs @@ -0,0 +1,15 @@ +using System.Reflection; +using Xunit; + +namespace TinyEvents.PostgreSql.AdoNet.Tests; + +public sealed class TinyPostgreSqlAdoNetPackageTests +{ + [Fact] + public void Provider_assembly_loads() + { + var assembly = Assembly.Load("TinyEvents.PostgreSql.AdoNet"); + + Assert.Equal("TinyEvents.PostgreSql.AdoNet", assembly.GetName().Name); + } +} diff --git a/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetSchemaTests.cs b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetSchemaTests.cs new file mode 100644 index 0000000..4ea4680 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetSchemaTests.cs @@ -0,0 +1,44 @@ +using Xunit; + +namespace TinyEvents.PostgreSql.AdoNet.Tests; + +public sealed class TinyPostgreSqlAdoNetSchemaTests +{ + [Fact] + public void Schema_helper_creates_default_outbox_sql() + { + var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql(); + + Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"public\"", sql); + Assert.Contains("CREATE TABLE IF NOT EXISTS \"public\".\"TinyOutbox\"", sql); + Assert.Contains("\"Id\" uuid NOT NULL", sql); + Assert.Contains("\"EventType\" text NOT NULL", sql); + Assert.Contains("\"Status\" integer NOT NULL", sql); + Assert.Contains("\"ClaimedAtUtc\" timestamp with time zone NULL", sql); + Assert.Contains("\"IX_TinyOutbox_Pending\"", sql); + Assert.Contains("\"IX_TinyOutbox_ExpiredProcessing\"", sql); + Assert.Contains("\"IX_TinyOutbox_ClaimedBy\"", sql); + } + + [Fact] + public void Schema_helper_creates_custom_table_outbox_sql() + { + var sql = TinyPostgreSqlAdoNetSchema.CreateOutboxSql("app.MyOutbox"); + + Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"app\"", sql); + Assert.Contains("CREATE TABLE IF NOT EXISTS \"app\".\"MyOutbox\"", sql); + Assert.Contains("ON \"app\".\"MyOutbox\"", sql); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("TinyOutbox;DROP TABLE Users")] + [InlineData("public..TinyOutbox")] + [InlineData("public.Tiny-Outbox")] + [InlineData("server.public.TinyOutbox")] + public void Schema_helper_rejects_unsafe_table_names(string tableName) + { + Assert.Throws(() => TinyPostgreSqlAdoNetSchema.CreateOutboxSql(tableName)); + } +} diff --git a/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetTableNameTests.cs b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetTableNameTests.cs new file mode 100644 index 0000000..6a9dca1 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetTableNameTests.cs @@ -0,0 +1,95 @@ +using Xunit; + +namespace TinyEvents.PostgreSql.AdoNet.Tests; + +public sealed class TinyPostgreSqlAdoNetTableNameTests +{ + [Theory] + [InlineData("TinyOutbox", "\"TinyOutbox\"")] + [InlineData("public.TinyOutbox", "\"public\".\"TinyOutbox\"")] + [InlineData("app.MyOutbox", "\"app\".\"MyOutbox\"")] + public void To_postgre_sql_name_quotes_each_identifier(string tableName, string expected) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse(tableName); + + Assert.Equal(expected, parsed.ToPostgreSqlName()); + } + + [Theory] + [InlineData("TinyOutbox", "\"public\".\"TinyOutbox\"")] + [InlineData("app.MyOutbox", "\"app\".\"MyOutbox\"")] + public void To_postgre_sql_name_applies_default_schema_to_unqualified_table(string tableName, string expected) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse(tableName); + + Assert.Equal(expected, parsed.ToPostgreSqlName("public")); + } + + [Theory] + [InlineData("TinyOutbox", "\"public\"")] + [InlineData("app.MyOutbox", "\"app\"")] + public void To_postgre_sql_schema_name_reads_schema_or_default(string tableName, string expected) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse(tableName); + + Assert.Equal(expected, parsed.ToPostgreSqlSchemaName()); + } + + [Theory] + [InlineData("TinyOutbox", "public.TinyOutbox")] + [InlineData("app.MyOutbox", "app.MyOutbox")] + public void To_postgre_sql_object_name_applies_default_schema_to_unqualified_table(string tableName, string expected) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse(tableName); + + Assert.Equal(expected, parsed.ToPostgreSqlObjectName()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".TinyOutbox")] + [InlineData("app.")] + [InlineData("app..TinyOutbox")] + [InlineData("app.my.table")] + [InlineData("app-1.TinyOutbox")] + [InlineData("app.Tiny Outbox")] + public void Parse_rejects_invalid_table_names(string? tableName) + { + Assert.Throws(() => TinyPostgreSqlAdoNetTableName.Parse(tableName!)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void To_postgre_sql_name_rejects_invalid_default_schema(string? defaultSchema) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox"); + + Assert.Throws(() => parsed.ToPostgreSqlName(defaultSchema!)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void To_postgre_sql_object_name_rejects_invalid_default_schema(string? defaultSchema) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox"); + + Assert.Throws(() => parsed.ToPostgreSqlObjectName(defaultSchema!)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void To_postgre_sql_schema_name_rejects_invalid_default_schema(string? defaultSchema) + { + var parsed = TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox"); + + Assert.Throws(() => parsed.ToPostgreSqlSchemaName(defaultSchema!)); + } +} diff --git a/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetWriterTests.cs b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetWriterTests.cs new file mode 100644 index 0000000..622e7e4 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.AdoNet.Tests/TinyPostgreSqlAdoNetWriterTests.cs @@ -0,0 +1,779 @@ +using System.Data; +using System.Data.Common; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace TinyEvents.PostgreSql.AdoNet.Tests; + +public sealed class TinyPostgreSqlAdoNetWriterTests +{ + [Fact] + public void Use_postgre_sql_ado_net_outbox_rejects_null_services() + { + Assert.Throws(() => + TinyEventsPostgreSqlAdoNetServiceCollectionExtensions.UsePostgreSqlAdoNetOutbox(null!, _ => { })); + } + + [Fact] + public void Use_postgre_sql_ado_net_outbox_rejects_null_configure() + { + var services = new ServiceCollection(); + + Assert.Throws(() => services.UsePostgreSqlAdoNetOutbox(null!)); + } + + [Fact] + public void Use_postgre_sql_ado_net_outbox_registers_provider_services() + { + var services = new ServiceCollection(); + + services.UsePostgreSqlAdoNetOutbox(options => + { + options.UseWorkerConnectionFactory((_, _) => new ValueTask(new RecordingConnection())); + }); + + using var provider = services.BuildServiceProvider(); + + Assert.IsType( + provider.GetRequiredService()); + Assert.IsType( + provider.GetRequiredService()); + Assert.IsType( + provider.GetRequiredService()); + } + + [Fact] + public void Use_postgre_sql_ado_net_outbox_does_not_register_unit_of_work() + { + var services = new ServiceCollection(); + + services.UsePostgreSqlAdoNetOutbox(options => + { + options.UseWorkerConnectionFactory((_, _) => new ValueTask(new RecordingConnection())); + }); + + Assert.DoesNotContain( + services, + descriptor => descriptor.ServiceType.Name.Contains("UnitOfWork", StringComparison.Ordinal)); + } + + [Fact] + public void Writer_rejects_null_options() + { + var serviceProvider = new RecordingServiceProvider(); + + Assert.Throws(() => new TinyPostgreSqlAdoNetOutboxWriter(null!, serviceProvider)); + } + + [Fact] + public void Writer_rejects_null_service_provider() + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + + Assert.Throws(() => new TinyPostgreSqlAdoNetOutboxWriter(options, null!)); + } + + [Fact] + public async Task Add_async_rejects_null_message() + { + var writer = NewWriter(new RecordingConnection(), new RecordingTransaction(new RecordingConnection())); + + await Assert.ThrowsAsync( + async () => await writer.AddAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task Add_async_fails_clearly_if_current_transaction_is_not_configured() + { + var writer = new TinyPostgreSqlAdoNetOutboxWriter( + new TinyEventsPostgreSqlAdoNetOptions(), + new RecordingServiceProvider()); + + var exception = await Assert.ThrowsAsync( + async () => await writer.AddAsync(NewMessage(), CancellationToken.None)); + + Assert.Contains("Configure UseCurrentTransaction(...)", exception.Message); + } + + [Fact] + public async Task Add_async_uses_configured_current_transaction_context() + { + var connection = new RecordingConnection(); + var transaction = new RecordingTransaction(connection); + var writer = NewWriter(connection, transaction); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.Same(transaction, connection.LastCommand!.Transaction); + Assert.Contains("INSERT INTO \"TinyOutbox\"", connection.LastCommand.CommandText); + Assert.True(connection.LastCommand.Parameters.Contains("@Payload")); + } + + [Fact] + public async Task Add_async_uses_custom_table_name() + { + var connection = new RecordingConnection(); + var transaction = new RecordingTransaction(connection); + var options = NewOptions(connection, transaction); + options.TableName = "app.MyOutbox"; + var writer = new TinyPostgreSqlAdoNetOutboxWriter(options, new RecordingServiceProvider()); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.Contains("INSERT INTO \"app\".\"MyOutbox\"", connection.LastCommand!.CommandText); + } + + [Fact] + public async Task Add_async_does_not_use_worker_connection_factory() + { + var workerFactoryCalls = 0; + var connection = new RecordingConnection(); + var transaction = new RecordingTransaction(connection); + var options = NewOptions(connection, transaction); + options.UseWorkerConnectionFactory((_, _) => + { + workerFactoryCalls++; + return new ValueTask(new RecordingConnection()); + }); + var writer = new TinyPostgreSqlAdoNetOutboxWriter(options, new RecordingServiceProvider()); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.Equal(0, workerFactoryCalls); + } + + [Fact] + public async Task Add_async_does_not_open_connection() + { + var connection = new RecordingConnection(); + var writer = NewWriter(connection, new RecordingTransaction(connection)); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.Equal(0, connection.OpenCount); + } + + [Fact] + public async Task Add_async_does_not_begin_transaction() + { + var connection = new RecordingConnection(); + var writer = NewWriter(connection, new RecordingTransaction(connection)); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.Equal(0, connection.BeginTransactionCount); + } + + [Fact] + public async Task Add_async_does_not_commit_transaction() + { + var connection = new RecordingConnection(); + var transaction = new RecordingTransaction(connection); + var writer = NewWriter(connection, transaction); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.False(transaction.WasCommitted); + } + + [Fact] + public async Task Add_async_does_not_rollback_transaction() + { + var connection = new RecordingConnection(); + var transaction = new RecordingTransaction(connection); + var writer = NewWriter(connection, transaction); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.False(transaction.WasRolledBack); + } + + [Fact] + public async Task Add_async_does_not_dispose_context_connection() + { + var connection = new RecordingConnection(); + var writer = NewWriter(connection, new RecordingTransaction(connection)); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.False(connection.WasDisposed); + } + + [Fact] + public async Task Add_async_does_not_dispose_context_transaction() + { + var connection = new RecordingConnection(); + var transaction = new RecordingTransaction(connection); + var writer = NewWriter(connection, transaction); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.False(transaction.WasDisposed); + } + + [Fact] + public void Insert_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlAdoNetSql.Insert(null!)); + } + + [Fact] + public void Insert_sql_uses_quoted_postgre_sql_identifiers() + { + var sql = TinyPostgreSqlAdoNetSql.Insert(TinyPostgreSqlAdoNetTableName.Parse("public.TinyOutbox")); + + Assert.Contains("INSERT INTO \"public\".\"TinyOutbox\"", sql); + Assert.Contains("\"EventType\"", sql); + Assert.Contains("@Payload", sql); + } + + [Fact] + public void Claim_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlAdoNetSql.ClaimPending(null!)); + } + + [Fact] + public void Claim_sql_uses_atomic_update_with_postgre_sql_skip_locked() + { + var sql = TinyPostgreSqlAdoNetSql.ClaimPending(TinyPostgreSqlAdoNetTableName.Parse("public.TinyOutbox")); + + Assert.Contains("WITH claimed AS", sql); + Assert.Contains("FOR UPDATE SKIP LOCKED", sql); + Assert.Contains("UPDATE \"public\".\"TinyOutbox\" AS outbox", sql); + Assert.Contains("FROM claimed", sql); + Assert.Contains("RETURNING", sql); + Assert.Contains("@WorkerId", sql); + Assert.Contains("@ClaimExpiresAtUtc", sql); + } + + [Fact] + public void Claim_sql_reclaims_expired_processing_messages() + { + var sql = TinyPostgreSqlAdoNetSql.ClaimPending(TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox")); + + Assert.Contains("\"Status\" = @ProcessingStatus", sql); + Assert.Contains("\"ClaimExpiresAtUtc\" <= @Now", sql); + } + + [Fact] + public void Claim_sql_does_not_claim_future_retry_messages() + { + var sql = TinyPostgreSqlAdoNetSql.ClaimPending(TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox")); + + Assert.Contains("\"NextAttemptAtUtc\" IS NULL OR \"NextAttemptAtUtc\" <= @Now", sql); + } + + [Fact] + public void Mark_processed_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlAdoNetSql.MarkProcessed(null!)); + } + + [Fact] + public void Mark_processed_sql_limits_update_to_current_worker() + { + var sql = TinyPostgreSqlAdoNetSql.MarkProcessed(TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox")); + + Assert.Contains("\"ClaimedBy\" = @WorkerId", sql); + Assert.Contains("\"Status\" = @ProcessingStatus", sql); + Assert.Contains("@ProcessedAtUtc", sql); + } + + [Fact] + public void Mark_failed_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlAdoNetSql.MarkFailed(null!)); + } + + [Fact] + public void Mark_failed_sql_limits_update_to_current_worker() + { + var sql = TinyPostgreSqlAdoNetSql.MarkFailed(TinyPostgreSqlAdoNetTableName.Parse("TinyOutbox")); + + Assert.Contains("\"ClaimedBy\" = @WorkerId", sql); + Assert.Contains("\"Status\" = @ProcessingStatus", sql); + Assert.Contains("@NextAttemptAtUtc", sql); + Assert.Contains("@LastError", sql); + } + + [Fact] + public void Store_rejects_null_options() + { + var factory = new RecordingWorkerConnectionFactory(new RecordingConnection()); + + Assert.Throws(() => new TinyPostgreSqlAdoNetOutboxStore(null!, factory)); + } + + [Fact] + public void Store_rejects_null_connection_factory() + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + + Assert.Throws(() => new TinyPostgreSqlAdoNetOutboxStore(options, null!)); + } + + [Fact] + public async Task Claim_pending_uses_worker_connection_factory() + { + var factory = new RecordingWorkerConnectionFactory(new RecordingConnection()); + var store = NewStore(factory); + + await store.ClaimPendingAsync(1, "worker", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1), CancellationToken.None); + + Assert.Equal(1, factory.CallCount); + } + + [Fact] + public async Task Mark_processed_uses_worker_connection_factory() + { + var factory = new RecordingWorkerConnectionFactory(new RecordingConnection()); + var store = NewStore(factory); + + await store.MarkProcessedAsync(Guid.NewGuid(), "worker", DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.Equal(1, factory.CallCount); + } + + [Fact] + public async Task Mark_failed_uses_worker_connection_factory() + { + var factory = new RecordingWorkerConnectionFactory(new RecordingConnection()); + var store = NewStore(factory); + + await store.MarkFailedAsync(Guid.NewGuid(), "worker", "boom", 1, null, CancellationToken.None); + + Assert.Equal(1, factory.CallCount); + } + + [Fact] + public async Task Worker_connection_is_disposed_after_worker_operation_if_owned_by_factory() + { + var connection = new RecordingConnection(); + var store = NewStore(new RecordingWorkerConnectionFactory(connection)); + + await store.MarkProcessedAsync(Guid.NewGuid(), "worker", DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.True(connection.WasDisposed); + } + + [Fact] + public async Task Claim_pending_rejects_null_worker_id() + { + var store = NewStore(new RecordingWorkerConnectionFactory(new RecordingConnection())); + + await Assert.ThrowsAsync( + async () => await store.ClaimPendingAsync(1, null!, DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1), CancellationToken.None)); + } + + [Fact] + public async Task Mark_processed_rejects_null_worker_id() + { + var store = NewStore(new RecordingWorkerConnectionFactory(new RecordingConnection())); + + await Assert.ThrowsAsync( + async () => await store.MarkProcessedAsync(Guid.NewGuid(), null!, DateTimeOffset.UtcNow, CancellationToken.None)); + } + + [Fact] + public async Task Mark_failed_rejects_null_worker_id() + { + var store = NewStore(new RecordingWorkerConnectionFactory(new RecordingConnection())); + + await Assert.ThrowsAsync( + async () => await store.MarkFailedAsync(Guid.NewGuid(), null!, "boom", 1, null, CancellationToken.None)); + } + + [Fact] + public async Task Mark_failed_rejects_null_error() + { + var store = NewStore(new RecordingWorkerConnectionFactory(new RecordingConnection())); + + await Assert.ThrowsAsync( + async () => await store.MarkFailedAsync(Guid.NewGuid(), "worker", null!, 1, null, CancellationToken.None)); + } + + private static TinyPostgreSqlAdoNetOutboxStore NewStore( + ITinyPostgreSqlAdoNetWorkerConnectionFactory factory) + { + return new TinyPostgreSqlAdoNetOutboxStore( + new TinyEventsPostgreSqlAdoNetOptions(), + factory); + } + + private static TinyPostgreSqlAdoNetOutboxWriter NewWriter( + DbConnection connection, + DbTransaction transaction) + { + return new TinyPostgreSqlAdoNetOutboxWriter( + NewOptions(connection, transaction), + new RecordingServiceProvider()); + } + + private static TinyEventsPostgreSqlAdoNetOptions NewOptions( + DbConnection connection, + DbTransaction transaction) + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + options.UseCurrentTransaction(_ => new TinyPostgreSqlAdoNetTransactionContext(connection, transaction)); + return options; + } + + private static TinyOutboxMessage NewMessage() + { + return new TinyOutboxMessage + { + Id = Guid.NewGuid(), + EventType = "UserCreated", + Payload = "{}", + Status = TinyOutboxMessageStatus.Pending, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + } + +#nullable disable + private sealed class RecordingServiceProvider : IServiceProvider + { + public object GetService(Type serviceType) + { + return null; + } + } + + private sealed class RecordingWorkerConnectionFactory : ITinyPostgreSqlAdoNetWorkerConnectionFactory + { + private readonly RecordingConnection connection; + + public RecordingWorkerConnectionFactory(RecordingConnection connection) + { + this.connection = connection; + } + + public int CallCount { get; private set; } + + public ValueTask CreateOpenConnectionAsync(CancellationToken cancellationToken) + { + CallCount++; + return new ValueTask(connection); + } + } + + private sealed class RecordingConnection : DbConnection + { + private readonly RecordingParameterCollection parameters = new RecordingParameterCollection(); + private ConnectionState state = ConnectionState.Open; + + public RecordingCommand LastCommand { get; private set; } + + public bool WasDisposed { get; private set; } + + public int OpenCount { get; private set; } + + public int BeginTransactionCount { get; private set; } + + public override string ConnectionString { get; set; } = string.Empty; + + public override string Database => "Test"; + + public override string DataSource => "Test"; + + public override string ServerVersion => "1"; + + public override ConnectionState State => state; + + public override void ChangeDatabase(string databaseName) + { + } + + public override void Close() + { + state = ConnectionState.Closed; + } + + public override void Open() + { + OpenCount++; + state = ConnectionState.Open; + } + + protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) + { + BeginTransactionCount++; + return new RecordingTransaction(this); + } + + protected override DbCommand CreateDbCommand() + { + LastCommand = new RecordingCommand(this); + return LastCommand; + } + + protected override void Dispose(bool disposing) + { + WasDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class RecordingTransaction : DbTransaction + { + private readonly DbConnection connection; + + public RecordingTransaction(DbConnection connection) + { + this.connection = connection; + } + + public override IsolationLevel IsolationLevel => IsolationLevel.ReadCommitted; + + protected override DbConnection DbConnection => connection; + + public bool WasCommitted { get; private set; } + + public bool WasRolledBack { get; private set; } + + public bool WasDisposed { get; private set; } + + public override void Commit() + { + WasCommitted = true; + } + + public override void Rollback() + { + WasRolledBack = true; + } + + protected override void Dispose(bool disposing) + { + WasDisposed = true; + base.Dispose(disposing); + } + } + + private sealed class RecordingCommand : DbCommand + { + private readonly RecordingParameterCollection parameters = new RecordingParameterCollection(); + private readonly DbConnection connection; + + public RecordingCommand(DbConnection connection) + { + this.connection = connection; + } + + public override string CommandText { get; set; } = string.Empty; + + public override int CommandTimeout { get; set; } + + public override CommandType CommandType { get; set; } + + public override bool DesignTimeVisible { get; set; } + + public override UpdateRowSource UpdatedRowSource { get; set; } + + protected override DbConnection DbConnection + { + get => connection; + set { } + } + + protected override DbParameterCollection DbParameterCollection => parameters; + + protected override DbTransaction DbTransaction { get; set; } + + public new DbTransaction Transaction + { + get => DbTransaction; + set => DbTransaction = value; + } + + public new RecordingParameterCollection Parameters => parameters; + + public override void Cancel() + { + } + + public override int ExecuteNonQuery() + { + return 1; + } + + public override object ExecuteScalar() + { + return null; + } + + public override void Prepare() + { + } + + protected override DbParameter CreateDbParameter() + { + return new RecordingParameter(); + } + + protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior) + { + return new EmptyDataReader(); + } + + protected override Task ExecuteDbDataReaderAsync( + CommandBehavior behavior, + CancellationToken cancellationToken) + { + return Task.FromResult(new EmptyDataReader()); + } + + public override Task ExecuteNonQueryAsync(CancellationToken cancellationToken) + { + return Task.FromResult(1); + } + } + + private sealed class EmptyDataReader : DbDataReader + { + public override int FieldCount => 0; + + public override bool HasRows => false; + + public override bool IsClosed => false; + + public override int RecordsAffected => 0; + + public override int Depth => 0; + + public override object this[int ordinal] => throw new IndexOutOfRangeException(); + + public override object this[string name] => throw new IndexOutOfRangeException(); + + public override bool Read() + { + return false; + } + + public override Task ReadAsync(CancellationToken cancellationToken) + { + return Task.FromResult(false); + } + + public override bool NextResult() + { + return false; + } + + public override object GetValue(int ordinal) + { + throw new IndexOutOfRangeException(); + } + + public override int GetValues(object[] values) + { + return 0; + } + + public override string GetName(int ordinal) + { + throw new IndexOutOfRangeException(); + } + + public override int GetOrdinal(string name) + { + throw new IndexOutOfRangeException(); + } + + public override string GetDataTypeName(int ordinal) + { + throw new IndexOutOfRangeException(); + } + + public override Type GetFieldType(int ordinal) + { + throw new IndexOutOfRangeException(); + } + + public override bool IsDBNull(int ordinal) + { + throw new IndexOutOfRangeException(); + } + + public override System.Collections.IEnumerator GetEnumerator() + { + return Array.Empty().GetEnumerator(); + } + + public override bool GetBoolean(int ordinal) => throw new IndexOutOfRangeException(); + public override byte GetByte(int ordinal) => throw new IndexOutOfRangeException(); + public override long GetBytes(int ordinal, long dataOffset, byte[] buffer, int bufferOffset, int length) => throw new IndexOutOfRangeException(); + public override char GetChar(int ordinal) => throw new IndexOutOfRangeException(); + public override long GetChars(int ordinal, long dataOffset, char[] buffer, int bufferOffset, int length) => throw new IndexOutOfRangeException(); + public override Guid GetGuid(int ordinal) => throw new IndexOutOfRangeException(); + public override short GetInt16(int ordinal) => throw new IndexOutOfRangeException(); + public override int GetInt32(int ordinal) => throw new IndexOutOfRangeException(); + public override long GetInt64(int ordinal) => throw new IndexOutOfRangeException(); + public override float GetFloat(int ordinal) => throw new IndexOutOfRangeException(); + public override double GetDouble(int ordinal) => throw new IndexOutOfRangeException(); + public override string GetString(int ordinal) => throw new IndexOutOfRangeException(); + public override decimal GetDecimal(int ordinal) => throw new IndexOutOfRangeException(); + public override DateTime GetDateTime(int ordinal) => throw new IndexOutOfRangeException(); + } + + private sealed class RecordingParameter : DbParameter + { + public override DbType DbType { get; set; } + public override ParameterDirection Direction { get; set; } = ParameterDirection.Input; + public override bool IsNullable { get; set; } + public override string ParameterName { get; set; } = string.Empty; + public override string SourceColumn { get; set; } = string.Empty; + public override object Value { get; set; } + public override bool SourceColumnNullMapping { get; set; } + public override int Size { get; set; } + public override void ResetDbType() { } + } + + private sealed class RecordingParameterCollection : DbParameterCollection + { + private readonly List parameters = new List(); + + public override int Count => parameters.Count; + public override object SyncRoot => this; + public override int Add(object value) + { + parameters.Add((DbParameter)value); + return parameters.Count - 1; + } + + public override void AddRange(Array values) + { + foreach (var value in values) + { + Add(value!); + } + } + + public override void Clear() => parameters.Clear(); + public override bool Contains(object value) => parameters.Contains((DbParameter)value); + public override bool Contains(string value) => parameters.Any(parameter => parameter.ParameterName == value); + public override void CopyTo(Array array, int index) => parameters.ToArray().CopyTo(array, index); + public override System.Collections.IEnumerator GetEnumerator() => parameters.GetEnumerator(); + public override int IndexOf(object value) => parameters.IndexOf((DbParameter)value); + public override int IndexOf(string parameterName) => parameters.FindIndex(parameter => parameter.ParameterName == parameterName); + public override void Insert(int index, object value) => parameters.Insert(index, (DbParameter)value); + public override void Remove(object value) => parameters.Remove((DbParameter)value); + public override void RemoveAt(int index) => parameters.RemoveAt(index); + public override void RemoveAt(string parameterName) + { + var index = IndexOf(parameterName); + + if (index >= 0) + { + RemoveAt(index); + } + } + + protected override DbParameter GetParameter(int index) => parameters[index]; + protected override DbParameter GetParameter(string parameterName) => parameters[IndexOf(parameterName)]; + protected override void SetParameter(int index, DbParameter value) => parameters[index] = value; + protected override void SetParameter(string parameterName, DbParameter value) => parameters[IndexOf(parameterName)] = value; + } +#nullable restore +} diff --git a/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests.csproj b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests.csproj new file mode 100644 index 0000000..f39b804 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,15 @@ + + + net8.0 + false + + + + + + + + + + + diff --git a/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreModelBuilderTests.cs b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreModelBuilderTests.cs new file mode 100644 index 0000000..a28d87c --- /dev/null +++ b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreModelBuilderTests.cs @@ -0,0 +1,135 @@ +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore.Tests; + +public sealed class TinyPostgreSqlEfCoreModelBuilderTests +{ + [Fact] + public void Model_builder_extension_rejects_null_model_builder() + { + Assert.Throws(() => + TinyEventsModelBuilderExtensions.UseTinyEventsOutbox(null!)); + } + + [Fact] + public void Model_builder_extension_maps_outbox_message() + { + using var dbContext = NewTestDbContext(); + + var entity = dbContext.Model.FindEntityType(typeof(TinyOutboxMessage)); + + Assert.NotNull(entity); + Assert.NotNull(entity.FindPrimaryKey()); + } + + [Fact] + public void Model_builder_extension_uses_default_table_name() + { + using var dbContext = NewTestDbContext(); + + var entity = dbContext.Model.FindEntityType(typeof(TinyOutboxMessage)); + + Assert.NotNull(entity); + Assert.Equal("TinyOutbox", entity.GetTableName()); + Assert.Null(entity.GetSchema()); + } + + [Fact] + public void Model_builder_extension_uses_custom_table_name() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + using var dbContext = new CustomOutboxDbContext(options); + + var entity = dbContext.Model.FindEntityType(typeof(TinyOutboxMessage)); + + Assert.NotNull(entity); + Assert.Equal("MyOutbox", entity.GetTableName()); + Assert.Equal("app", entity.GetSchema()); + } + + [Fact] + public void Model_builder_extension_adds_pending_claim_lookup_index() + { + using var dbContext = NewTestDbContext(); + + var entity = dbContext.Model.FindEntityType(typeof(TinyOutboxMessage)); + + Assert.NotNull(entity); + Assert.Contains( + entity.GetIndexes(), + index => HasProperties(index, nameof(TinyOutboxMessage.Status), nameof(TinyOutboxMessage.NextAttemptAtUtc), nameof(TinyOutboxMessage.CreatedAtUtc))); + } + + [Fact] + public void Model_builder_extension_adds_expired_processing_claim_lookup_index() + { + using var dbContext = NewTestDbContext(); + + var entity = dbContext.Model.FindEntityType(typeof(TinyOutboxMessage)); + + Assert.NotNull(entity); + Assert.Contains( + entity.GetIndexes(), + index => HasProperties(index, nameof(TinyOutboxMessage.Status), nameof(TinyOutboxMessage.ClaimExpiresAtUtc))); + } + + [Fact] + public void Model_builder_extension_adds_claim_owner_lookup_index() + { + using var dbContext = NewTestDbContext(); + + var entity = dbContext.Model.FindEntityType(typeof(TinyOutboxMessage)); + + Assert.NotNull(entity); + Assert.Contains( + entity.GetIndexes(), + index => HasProperties(index, nameof(TinyOutboxMessage.ClaimedBy), nameof(TinyOutboxMessage.Status))); + } + + private static TestDbContext NewTestDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new TestDbContext(options); + } + + private static bool HasProperties( + Microsoft.EntityFrameworkCore.Metadata.IReadOnlyIndex index, + params string[] propertyNames) + { + return index.Properties + .Select(property => property.Name) + .SequenceEqual(propertyNames); + } + + private sealed class TestDbContext : DbContext + { + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseTinyEventsOutbox(); + } + } + + private sealed class CustomOutboxDbContext : DbContext + { + public CustomOutboxDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseTinyEventsOutbox("app.MyOutbox"); + } + } +} diff --git a/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreOutboxStoreTests.cs b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreOutboxStoreTests.cs new file mode 100644 index 0000000..e29070d --- /dev/null +++ b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreOutboxStoreTests.cs @@ -0,0 +1,215 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore.Tests; + +public sealed class TinyPostgreSqlEfCoreOutboxStoreTests +{ + [Fact] + public void Use_postgre_sql_entity_framework_core_outbox_rejects_null_services() + { + Assert.Throws(() => + TinyEventsPostgreSqlEntityFrameworkCoreServiceCollectionExtensions + .UsePostgreSqlEntityFrameworkCoreOutbox(null!)); + } + + [Fact] + public void Use_postgre_sql_entity_framework_core_outbox_registers_writer_and_store() + { + var services = new ServiceCollection(); + + services.UsePostgreSqlEntityFrameworkCoreOutbox(); + + AssertService>(services); + AssertService>(services); + } + + [Fact] + public void Use_postgre_sql_entity_framework_core_outbox_registers_configured_options() + { + var services = new ServiceCollection(); + + services.UsePostgreSqlEntityFrameworkCoreOutbox(options => + { + options.TableName = "app.MyOutbox"; + }); + + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService(); + Assert.Equal("app.MyOutbox", options.TableName); + } + + [Fact] + public void EF_store_does_not_implement_writer() + { + Assert.False(typeof(ITinyOutboxWriter).IsAssignableFrom(typeof(TinyPostgreSqlEfCoreOutboxStore))); + } + + [Fact] + public void Store_rejects_null_db_context() + { + var options = new TinyEventsPostgreSqlEntityFrameworkCoreOptions(); + + Assert.Throws( + () => new TinyPostgreSqlEfCoreOutboxStore(null!, options)); + } + + [Fact] + public void Store_rejects_null_options() + { + using var dbContext = NewTestDbContext(); + + Assert.Throws( + () => new TinyPostgreSqlEfCoreOutboxStore(dbContext, null!)); + } + + [Fact] + public async Task Claim_pending_rejects_null_worker_id() + { + using var dbContext = NewTestDbContext(); + var store = NewStore(dbContext); + + await Assert.ThrowsAsync( + async () => await store.ClaimPendingAsync(1, null!, DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1), CancellationToken.None)); + } + + [Fact] + public async Task Mark_processed_rejects_null_worker_id() + { + using var dbContext = NewTestDbContext(); + var store = NewStore(dbContext); + + await Assert.ThrowsAsync( + async () => await store.MarkProcessedAsync(Guid.NewGuid(), null!, DateTimeOffset.UtcNow, CancellationToken.None)); + } + + [Fact] + public async Task Mark_failed_rejects_null_worker_id() + { + using var dbContext = NewTestDbContext(); + var store = NewStore(dbContext); + + await Assert.ThrowsAsync( + async () => await store.MarkFailedAsync(Guid.NewGuid(), null!, "boom", 1, null, CancellationToken.None)); + } + + [Fact] + public async Task Mark_failed_rejects_null_error() + { + using var dbContext = NewTestDbContext(); + var store = NewStore(dbContext); + + await Assert.ThrowsAsync( + async () => await store.MarkFailedAsync(Guid.NewGuid(), "worker", null!, 1, null, CancellationToken.None)); + } + + [Fact] + public void Claim_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlEfCoreSql.ClaimPending(null!)); + } + + [Fact] + public void Claim_sql_uses_atomic_update_with_postgre_sql_skip_locked() + { + var sql = TinyPostgreSqlEfCoreSql.ClaimPending(TinyPostgreSqlEfCoreTableName.Parse("public.TinyOutbox")); + + Assert.Contains("WITH claimed AS", sql); + Assert.Contains("FOR UPDATE SKIP LOCKED", sql); + Assert.Contains("UPDATE \"public\".\"TinyOutbox\" AS outbox", sql); + Assert.Contains("FROM claimed", sql); + Assert.Contains("RETURNING", sql); + Assert.Contains("@WorkerId", sql); + Assert.Contains("@ClaimExpiresAtUtc", sql); + } + + [Fact] + public void Claim_sql_reclaims_expired_processing_messages() + { + var sql = TinyPostgreSqlEfCoreSql.ClaimPending(TinyPostgreSqlEfCoreTableName.Parse("TinyOutbox")); + + Assert.Contains("\"Status\" = @ProcessingStatus", sql); + Assert.Contains("\"ClaimExpiresAtUtc\" <= @Now", sql); + } + + [Fact] + public void Claim_sql_does_not_claim_future_retry_messages() + { + var sql = TinyPostgreSqlEfCoreSql.ClaimPending(TinyPostgreSqlEfCoreTableName.Parse("TinyOutbox")); + + Assert.Contains("\"NextAttemptAtUtc\" IS NULL OR \"NextAttemptAtUtc\" <= @Now", sql); + } + + [Fact] + public void Mark_processed_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlEfCoreSql.MarkProcessed(null!)); + } + + [Fact] + public void Mark_processed_sql_limits_update_to_current_worker() + { + var sql = TinyPostgreSqlEfCoreSql.MarkProcessed(TinyPostgreSqlEfCoreTableName.Parse("TinyOutbox")); + + Assert.Contains("\"ClaimedBy\" = @WorkerId", sql); + Assert.Contains("\"Status\" = @ProcessingStatus", sql); + Assert.Contains("@ProcessedAtUtc", sql); + } + + [Fact] + public void Mark_failed_sql_rejects_null_table_name() + { + Assert.Throws(() => TinyPostgreSqlEfCoreSql.MarkFailed(null!)); + } + + [Fact] + public void Mark_failed_sql_limits_update_to_current_worker() + { + var sql = TinyPostgreSqlEfCoreSql.MarkFailed(TinyPostgreSqlEfCoreTableName.Parse("TinyOutbox")); + + Assert.Contains("\"ClaimedBy\" = @WorkerId", sql); + Assert.Contains("\"Status\" = @ProcessingStatus", sql); + Assert.Contains("@NextAttemptAtUtc", sql); + Assert.Contains("@LastError", sql); + } + + private static TinyPostgreSqlEfCoreOutboxStore NewStore(TestDbContext dbContext) + { + return new TinyPostgreSqlEfCoreOutboxStore( + dbContext, + new TinyEventsPostgreSqlEntityFrameworkCoreOptions()); + } + + private static void AssertService(IServiceCollection services) + { + var descriptor = Assert.Single( + services, + descriptor => descriptor.ServiceType == typeof(TService)); + + Assert.Equal(typeof(TImplementation), descriptor.ImplementationType); + } + + private static TestDbContext NewTestDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new TestDbContext(options); + } + + private sealed class TestDbContext : DbContext + { + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseTinyEventsOutbox(); + } + } +} diff --git a/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreOutboxWriterTests.cs b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreOutboxWriterTests.cs new file mode 100644 index 0000000..fb529e6 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreOutboxWriterTests.cs @@ -0,0 +1,107 @@ +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore.Tests; + +public sealed class TinyPostgreSqlEfCoreOutboxWriterTests +{ + [Fact] + public void Writer_rejects_null_db_context() + { + Assert.Throws( + () => new TinyPostgreSqlEfCoreOutboxWriter(null!)); + } + + [Fact] + public async Task Writer_rejects_null_message() + { + await using var dbContext = NewTestDbContext(); + var writer = new TinyPostgreSqlEfCoreOutboxWriter(dbContext); + + await Assert.ThrowsAsync( + async () => await writer.AddAsync(null!, CancellationToken.None)); + } + + [Fact] + public async Task Writer_observes_cancellation_before_tracking_message() + { + await using var dbContext = NewTestDbContext(); + var writer = new TinyPostgreSqlEfCoreOutboxWriter(dbContext); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAsync( + async () => await writer.AddAsync(NewMessage(), cancellation.Token)); + + Assert.Empty(dbContext.ChangeTracker.Entries()); + } + + [Fact] + public async Task Writer_adds_outbox_message_without_saving_changes() + { + await using var dbContext = NewTestDbContext(); + var writer = new TinyPostgreSqlEfCoreOutboxWriter(dbContext); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + + Assert.Single(dbContext.ChangeTracker.Entries()); + Assert.Empty(await dbContext.Set().ToListAsync()); + } + + [Fact] + public async Task Writer_message_is_persisted_by_caller_save_changes() + { + var databaseName = Guid.NewGuid().ToString(); + var options = NewOptions(databaseName); + + await using (var dbContext = new TestDbContext(options)) + { + var writer = new TinyPostgreSqlEfCoreOutboxWriter(dbContext); + + await writer.AddAsync(NewMessage(), CancellationToken.None); + await dbContext.SaveChangesAsync(); + } + + await using (var dbContext = new TestDbContext(options)) + { + Assert.Single(await dbContext.Set().ToListAsync()); + } + } + + private static TestDbContext NewTestDbContext() + { + return new TestDbContext(NewOptions(Guid.NewGuid().ToString())); + } + + private static DbContextOptions NewOptions(string databaseName) + { + return new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName) + .Options; + } + + private static TinyOutboxMessage NewMessage() + { + return new TinyOutboxMessage + { + Id = Guid.NewGuid(), + EventType = "UserCreated", + Payload = "{}", + Status = TinyOutboxMessageStatus.Pending, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + } + + private sealed class TestDbContext : DbContext + { + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseTinyEventsOutbox(); + } + } +} diff --git a/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreTableNameTests.cs b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreTableNameTests.cs new file mode 100644 index 0000000..c9b70cf --- /dev/null +++ b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEfCoreTableNameTests.cs @@ -0,0 +1,44 @@ +using Xunit; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore.Tests; + +public sealed class TinyPostgreSqlEfCoreTableNameTests +{ + [Theory] + [InlineData("TinyOutbox", null, "TinyOutbox")] + [InlineData("public.TinyOutbox", "public", "TinyOutbox")] + [InlineData("app.MyOutbox", "app", "MyOutbox")] + public void Parse_reads_schema_and_table(string tableName, string? expectedSchema, string expectedTable) + { + var parsed = TinyPostgreSqlEfCoreTableName.Parse(tableName); + + Assert.Equal(expectedSchema, parsed.Schema); + Assert.Equal(expectedTable, parsed.Table); + } + + [Theory] + [InlineData("TinyOutbox", "\"TinyOutbox\"")] + [InlineData("public.TinyOutbox", "\"public\".\"TinyOutbox\"")] + [InlineData("app.MyOutbox", "\"app\".\"MyOutbox\"")] + public void To_postgre_sql_name_quotes_each_identifier(string tableName, string expected) + { + var parsed = TinyPostgreSqlEfCoreTableName.Parse(tableName); + + Assert.Equal(expected, parsed.ToPostgreSqlName()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".TinyOutbox")] + [InlineData("app.")] + [InlineData("app..TinyOutbox")] + [InlineData("app.my.table")] + [InlineData("app-1.TinyOutbox")] + [InlineData("app.Tiny Outbox")] + public void Parse_rejects_invalid_table_names(string? tableName) + { + Assert.Throws(() => TinyPostgreSqlEfCoreTableName.Parse(tableName!)); + } +} diff --git a/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEntityFrameworkCorePackageTests.cs b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEntityFrameworkCorePackageTests.cs new file mode 100644 index 0000000..73299bc --- /dev/null +++ b/tests/TinyEvents.PostgreSql.EntityFrameworkCore.Tests/TinyPostgreSqlEntityFrameworkCorePackageTests.cs @@ -0,0 +1,15 @@ +using System.Reflection; +using Xunit; + +namespace TinyEvents.PostgreSql.EntityFrameworkCore.Tests; + +public sealed class TinyPostgreSqlEntityFrameworkCorePackageTests +{ + [Fact] + public void Provider_assembly_loads() + { + var assembly = Assembly.Load("TinyEvents.PostgreSql.EntityFrameworkCore"); + + Assert.Equal("TinyEvents.PostgreSql.EntityFrameworkCore", assembly.GetName().Name); + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlEndToEndRuntimeTests.cs b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlEndToEndRuntimeTests.cs new file mode 100644 index 0000000..dde4255 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlEndToEndRuntimeTests.cs @@ -0,0 +1,137 @@ +using System.Data.Common; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class AdoNetPostgreSqlEndToEndRuntimeTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public AdoNetPostgreSqlEndToEndRuntimeTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Processor_publishes_consumes_and_marks_message_processed() + { + RecordingConsumer.Consumed.Clear(); + await fixture.ResetSchemaAsync(); + using var provider = BuildServices(); + using var scope = provider.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + var publisher = scope.ServiceProvider.GetRequiredService(); + var processor = scope.ServiceProvider.GetRequiredService(); + var userId = Guid.NewGuid(); + + await session.ExecuteInTransactionAsync(async (_, _, cancellationToken) => + { + await publisher.PublishAsync(new UserCreated(userId), cancellationToken); + }); + await processor.ProcessPendingAsync(); + + var consumed = Assert.Single(RecordingConsumer.Consumed); + Assert.Equal(userId, consumed.UserId); + Assert.Equal(TinyOutboxMessageStatus.Processed, await ReadStatusAsync()); + } + + private ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + services.AddScoped(_ => new TestApplicationDbSession(fixture.ConnectionString)); + services.UsePostgreSqlAdoNetOutbox(options => + { + options.UseCurrentTransaction(serviceProvider => + { + var session = serviceProvider.GetRequiredService(); + + return session.CurrentTransaction is null + ? null + : new TinyPostgreSqlAdoNetTransactionContext( + session.Connection, + session.CurrentTransaction); + }); + options.UseWorkerConnectionFactory(async (_, cancellationToken) => + { + var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(cancellationToken); + return connection; + }); + }); + services.AddSingleton( + new TinyEventTypeDescriptor(typeof(UserCreated).FullName!, typeof(UserCreated))); + services.AddScoped, RecordingConsumer>(); + + return services.BuildServiceProvider(); + } + + private async Task ReadStatusAsync() + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """SELECT "Status" FROM "TinyOutbox";"""; + var result = await command.ExecuteScalarAsync(); + return (TinyOutboxMessageStatus)Convert.ToInt32(result); + } + + private sealed class TestApplicationDbSession + { + private readonly string connectionString; + private DbConnection? connection; + + public TestApplicationDbSession(string connectionString) + { + this.connectionString = connectionString; + } + + public DbConnection Connection => + connection ?? throw new InvalidOperationException("The application session has no active connection."); + + public DbTransaction? CurrentTransaction { get; private set; } + + public async ValueTask ExecuteInTransactionAsync( + Func work, + CancellationToken cancellationToken = default) + { + await using var openedConnection = new NpgsqlConnection(connectionString); + await openedConnection.OpenAsync(cancellationToken); + await using var transaction = await openedConnection.BeginTransactionAsync(cancellationToken); + connection = openedConnection; + CurrentTransaction = transaction; + + try + { + await work(connection, transaction, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + finally + { + CurrentTransaction = null; + connection = null; + } + } + } + + private sealed record UserCreated(Guid UserId); + + private sealed class RecordingConsumer : IEventConsumer + { + public static List Consumed { get; } = new List(); + + public ValueTask ConsumeAsync(UserCreated @event, CancellationToken cancellationToken) + { + Consumed.Add(@event); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlSchemaRuntimeTests.cs b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlSchemaRuntimeTests.cs new file mode 100644 index 0000000..a1837b2 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlSchemaRuntimeTests.cs @@ -0,0 +1,61 @@ +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class AdoNetPostgreSqlSchemaRuntimeTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public AdoNetPostgreSqlSchemaRuntimeTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Schema_helper_creates_outbox_table_and_indexes() + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + + await using var createCommand = connection.CreateCommand(); + createCommand.CommandText = TinyPostgreSqlAdoNetSchema.CreateOutboxSql(); + await createCommand.ExecuteNonQueryAsync(); + + var tableExists = await ReadScalarAsync( + connection, + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'TinyOutbox'; + """); + + var indexCount = await ReadScalarAsync( + connection, + """ + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'TinyOutbox' + AND indexname IN + ( + 'IX_TinyOutbox_Pending', + 'IX_TinyOutbox_ExpiredProcessing', + 'IX_TinyOutbox_ClaimedBy' + ); + """); + + Assert.Equal(1, tableExists); + Assert.Equal(3, indexCount); + } + + private static async Task ReadScalarAsync(NpgsqlConnection connection, string sql) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + var value = await command.ExecuteScalarAsync(); + return Assert.IsType(value); + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlStoreRuntimeTests.cs b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlStoreRuntimeTests.cs new file mode 100644 index 0000000..126b8f3 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlStoreRuntimeTests.cs @@ -0,0 +1,279 @@ +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class AdoNetPostgreSqlStoreRuntimeTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public AdoNetPostgreSqlStoreRuntimeTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Store_claims_due_pending_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + await InsertOutboxMessageAsync(messageId); + var store = NewStore(); + var now = DateTimeOffset.UtcNow; + + var claimed = await store.ClaimPendingAsync(1, "worker-1", now, TimeSpan.FromMinutes(5), CancellationToken.None); + + var message = Assert.Single(claimed); + Assert.Equal(messageId, message.Id); + Assert.Equal("worker-1", message.ClaimedBy); + Assert.Equal(TinyOutboxMessageStatus.Processing, message.Status); + } + + [PostgreSqlIntegrationFact] + public async Task Store_does_not_claim_future_retry_message() + { + await fixture.ResetSchemaAsync(); + await InsertOutboxMessageAsync( + Guid.NewGuid(), + nextAttemptAtUtc: DateTimeOffset.UtcNow.AddMinutes(5)); + var store = NewStore(); + + var claimed = await store.ClaimPendingAsync(1, "worker-1", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), CancellationToken.None); + + Assert.Empty(claimed); + } + + [PostgreSqlIntegrationFact] + public async Task Store_reclaims_expired_processing_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + await InsertOutboxMessageAsync( + messageId, + TinyOutboxMessageStatus.Processing, + workerId: "dead-worker", + claimedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-10), + claimExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1)); + var store = NewStore(); + + var claimed = await store.ClaimPendingAsync(1, "worker-2", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), CancellationToken.None); + + var message = Assert.Single(claimed); + Assert.Equal(messageId, message.Id); + Assert.Equal("worker-2", message.ClaimedBy); + Assert.Equal(TinyOutboxMessageStatus.Processing, message.Status); + } + + [PostgreSqlIntegrationFact] + public async Task Store_does_not_claim_active_processing_message() + { + await fixture.ResetSchemaAsync(); + await InsertOutboxMessageAsync( + Guid.NewGuid(), + TinyOutboxMessageStatus.Processing, + workerId: "worker-1", + claimedAtUtc: DateTimeOffset.UtcNow, + claimExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(5)); + var store = NewStore(); + + var claimed = await store.ClaimPendingAsync(1, "worker-2", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), CancellationToken.None); + + Assert.Empty(claimed); + } + + [PostgreSqlIntegrationFact] + public async Task Competing_workers_claim_message_only_once() + { + await fixture.ResetSchemaAsync(); + await InsertOutboxMessageAsync(Guid.NewGuid()); + var store = NewStore(); + var now = DateTimeOffset.UtcNow; + + var first = store.ClaimPendingAsync(1, "worker-1", now, TimeSpan.FromMinutes(5), CancellationToken.None).AsTask(); + var second = store.ClaimPendingAsync(1, "worker-2", now, TimeSpan.FromMinutes(5), CancellationToken.None).AsTask(); + + var results = await Task.WhenAll(first, second); + var totalClaimed = results.Sum(result => result.Count); + + Assert.Equal(1, totalClaimed); + } + + [PostgreSqlIntegrationFact] + public async Task Mark_processed_updates_only_message_owned_by_worker() + { + await fixture.ResetSchemaAsync(); + var ownedId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + await InsertOutboxMessageAsync(ownedId, TinyOutboxMessageStatus.Processing, workerId: "worker-1"); + await InsertOutboxMessageAsync(otherId, TinyOutboxMessageStatus.Processing, workerId: "worker-2"); + var store = NewStore(); + + await store.MarkProcessedAsync(ownedId, "worker-1", DateTimeOffset.UtcNow, CancellationToken.None); + await store.MarkProcessedAsync(otherId, "worker-1", DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.Equal(TinyOutboxMessageStatus.Processed, await ReadStatusAsync(ownedId)); + Assert.Equal(TinyOutboxMessageStatus.Processing, await ReadStatusAsync(otherId)); + } + + [PostgreSqlIntegrationFact] + public async Task Mark_failed_without_retry_marks_failed_for_owned_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + await InsertOutboxMessageAsync(messageId, TinyOutboxMessageStatus.Processing, workerId: "worker-1"); + var store = NewStore(); + + await store.MarkFailedAsync(messageId, "worker-1", "boom", 2, null, CancellationToken.None); + + var row = await ReadFailureAsync(messageId); + Assert.Equal(TinyOutboxMessageStatus.Failed, row.Status); + Assert.Equal(2, row.AttemptCount); + Assert.Equal("boom", row.LastError); + } + + [PostgreSqlIntegrationFact] + public async Task Mark_failed_with_retry_marks_pending_for_owned_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + var nextAttemptAtUtc = DateTimeOffset.UtcNow.AddMinutes(1); + await InsertOutboxMessageAsync(messageId, TinyOutboxMessageStatus.Processing, workerId: "worker-1"); + var store = NewStore(); + + await store.MarkFailedAsync(messageId, "worker-1", "boom", 3, nextAttemptAtUtc, CancellationToken.None); + + var row = await ReadFailureAsync(messageId); + Assert.Equal(TinyOutboxMessageStatus.Pending, row.Status); + Assert.Equal(3, row.AttemptCount); + Assert.Equal("boom", row.LastError); + Assert.NotNull(row.NextAttemptAtUtc); + } + + private TinyPostgreSqlAdoNetOutboxStore NewStore() + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + options.UseWorkerConnectionFactory(async (_, cancellationToken) => + { + var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(cancellationToken); + return connection; + }); + + return new TinyPostgreSqlAdoNetOutboxStore( + options, + new TinyPostgreSqlAdoNetWorkerConnectionFactory(options, new EmptyServiceProvider())); + } + + private async Task InsertOutboxMessageAsync( + Guid messageId, + TinyOutboxMessageStatus status = TinyOutboxMessageStatus.Pending, + string? workerId = null, + DateTimeOffset? claimedAtUtc = null, + DateTimeOffset? claimExpiresAtUtc = null, + DateTimeOffset? nextAttemptAtUtc = null) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO "TinyOutbox" + ( + "Id", + "EventType", + "Payload", + "Status", + "AttemptCount", + "ClaimedBy", + "ClaimedAtUtc", + "ClaimExpiresAtUtc", + "CreatedAtUtc", + "NextAttemptAtUtc" + ) + VALUES + ( + @Id, + @EventType, + @Payload, + @Status, + @AttemptCount, + @ClaimedBy, + @ClaimedAtUtc, + @ClaimExpiresAtUtc, + @CreatedAtUtc, + @NextAttemptAtUtc + ); + """; + AddParameter(command, "@Id", messageId); + AddParameter(command, "@EventType", typeof(UserCreated).FullName!); + AddParameter(command, "@Payload", "{}"); + AddParameter(command, "@Status", (int)status); + AddParameter(command, "@AttemptCount", 0); + AddParameter(command, "@ClaimedBy", workerId); + AddParameter(command, "@ClaimedAtUtc", claimedAtUtc); + AddParameter(command, "@ClaimExpiresAtUtc", claimExpiresAtUtc); + AddParameter(command, "@CreatedAtUtc", DateTimeOffset.UtcNow); + AddParameter(command, "@NextAttemptAtUtc", nextAttemptAtUtc); + await command.ExecuteNonQueryAsync(); + } + + private async Task ReadStatusAsync(Guid messageId) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """SELECT "Status" FROM "TinyOutbox" WHERE "Id" = @Id;"""; + AddParameter(command, "@Id", messageId); + var result = await command.ExecuteScalarAsync(); + return (TinyOutboxMessageStatus)Convert.ToInt32(result); + } + + private async Task ReadFailureAsync(Guid messageId) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT "Status", "AttemptCount", "LastError", "NextAttemptAtUtc" + FROM "TinyOutbox" + WHERE "Id" = @Id; + """; + AddParameter(command, "@Id", messageId); + + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return new FailureRow( + (TinyOutboxMessageStatus)reader.GetInt32(0), + reader.GetInt32(1), + reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetFieldValue(3)); + } + + private static void AddParameter( + NpgsqlCommand command, + string name, + object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + private sealed record UserCreated(Guid UserId); + + private sealed record FailureRow( + TinyOutboxMessageStatus Status, + int AttemptCount, + string LastError, + DateTimeOffset? NextAttemptAtUtc); + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) + { + return null; + } + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlWriterRuntimeTests.cs b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlWriterRuntimeTests.cs new file mode 100644 index 0000000..26dd808 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/AdoNetPostgreSqlWriterRuntimeTests.cs @@ -0,0 +1,170 @@ +using System.Data.Common; +using Npgsql; +using TinyEvents.PostgreSql.AdoNet; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class AdoNetPostgreSqlWriterRuntimeTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public AdoNetPostgreSqlWriterRuntimeTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Writer_commits_business_data_and_outbox_message_together() + { + await fixture.ResetSchemaAsync(); + var session = new ApplicationDbSession(fixture.ConnectionString); + var writer = NewWriter(session); + var userId = Guid.NewGuid(); + + await session.ExecuteInTransactionAsync(async (connection, transaction, ct) => + { + await InsertUserAsync(connection, transaction, userId, "user@example.com", ct); + await writer.AddAsync(NewMessage(userId), ct); + }); + + Assert.Equal(1, await CountAsync("\"Users\"")); + Assert.Equal(1, await CountAsync("\"TinyOutbox\"")); + } + + [PostgreSqlIntegrationFact] + public async Task Writer_rolls_back_business_data_and_outbox_message_together() + { + await fixture.ResetSchemaAsync(); + var session = new ApplicationDbSession(fixture.ConnectionString); + var writer = NewWriter(session); + var userId = Guid.NewGuid(); + + await Assert.ThrowsAsync( + async () => await session.ExecuteInTransactionAsync(async (connection, transaction, ct) => + { + await InsertUserAsync(connection, transaction, userId, "user@example.com", ct); + await writer.AddAsync(NewMessage(userId), ct); + throw new InvalidOperationException("rollback"); + })); + + Assert.Equal(0, await CountAsync("\"Users\"")); + Assert.Equal(0, await CountAsync("\"TinyOutbox\"")); + } + + private TinyPostgreSqlAdoNetOutboxWriter NewWriter(ApplicationDbSession session) + { + var options = new TinyEventsPostgreSqlAdoNetOptions(); + options.UseCurrentTransaction(_ => + { + return session.CurrentTransaction is null + ? null + : new TinyPostgreSqlAdoNetTransactionContext( + session.Connection, + session.CurrentTransaction); + }); + + return new TinyPostgreSqlAdoNetOutboxWriter(options, new EmptyServiceProvider()); + } + + private static TinyOutboxMessage NewMessage(Guid userId) + { + return new TinyOutboxMessage + { + Id = Guid.NewGuid(), + EventType = typeof(UserCreated).FullName!, + Payload = $$"""{"userId":"{{userId}}"}""", + Status = TinyOutboxMessageStatus.Pending, + CreatedAtUtc = DateTimeOffset.UtcNow + }; + } + + private static async Task InsertUserAsync( + DbConnection connection, + DbTransaction transaction, + Guid userId, + string email, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = """INSERT INTO "Users" ("Id", "Email") VALUES (@Id, @Email);"""; + AddParameter(command, "@Id", userId); + AddParameter(command, "@Email", email); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task CountAsync(string tableName) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {tableName};"; + var result = await command.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + + private static void AddParameter( + DbCommand command, + string name, + object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + private sealed record UserCreated(Guid UserId); + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) + { + return null; + } + } + + private sealed class ApplicationDbSession + { + private readonly string connectionString; + private DbConnection? connection; + + public ApplicationDbSession(string connectionString) + { + this.connectionString = connectionString; + } + + public DbConnection Connection => + connection ?? throw new InvalidOperationException("The application session has no active connection."); + + public DbTransaction? CurrentTransaction { get; private set; } + + public async ValueTask ExecuteInTransactionAsync( + Func work, + CancellationToken cancellationToken = default) + { + await using var openedConnection = new NpgsqlConnection(connectionString); + await openedConnection.OpenAsync(cancellationToken); + await using var transaction = await openedConnection.BeginTransactionAsync(cancellationToken); + connection = openedConnection; + CurrentTransaction = transaction; + + try + { + await work(connection, transaction, cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + finally + { + CurrentTransaction = null; + connection = null; + } + } + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/EfCorePostgreSqlStoreRuntimeTests.cs b/tests/TinyEvents.PostgreSql.Tests/EfCorePostgreSqlStoreRuntimeTests.cs new file mode 100644 index 0000000..0e7ddf7 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/EfCorePostgreSqlStoreRuntimeTests.cs @@ -0,0 +1,321 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using TinyEvents.PostgreSql.EntityFrameworkCore; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class EfCorePostgreSqlStoreRuntimeTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public EfCorePostgreSqlStoreRuntimeTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Processor_publishes_consumes_and_marks_message_processed() + { + RecordingConsumer.Consumed.Clear(); + await fixture.ResetSchemaAsync(); + using var provider = BuildServices(); + using var scope = provider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var publisher = scope.ServiceProvider.GetRequiredService(); + var processor = scope.ServiceProvider.GetRequiredService(); + var userId = Guid.NewGuid(); + + await publisher.PublishAsync(new UserCreated(userId)); + await dbContext.SaveChangesAsync(); + await processor.ProcessPendingAsync(); + + var consumed = Assert.Single(RecordingConsumer.Consumed); + Assert.Equal(userId, consumed.UserId); + Assert.Equal(TinyOutboxMessageStatus.Processed, await ReadStatusAsync()); + } + + [PostgreSqlIntegrationFact] + public async Task Store_claims_due_pending_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + await InsertOutboxMessageAsync(messageId); + await using var dbContext = NewDbContext(); + var store = NewStore(dbContext); + var now = DateTimeOffset.UtcNow; + + var claimed = await store.ClaimPendingAsync(1, "worker-1", now, TimeSpan.FromMinutes(5), CancellationToken.None); + + var message = Assert.Single(claimed); + Assert.Equal(messageId, message.Id); + Assert.Equal("worker-1", message.ClaimedBy); + Assert.Equal(TinyOutboxMessageStatus.Processing, message.Status); + } + + [PostgreSqlIntegrationFact] + public async Task Store_does_not_claim_future_retry_message() + { + await fixture.ResetSchemaAsync(); + await InsertOutboxMessageAsync( + Guid.NewGuid(), + nextAttemptAtUtc: DateTimeOffset.UtcNow.AddMinutes(5)); + await using var dbContext = NewDbContext(); + var store = NewStore(dbContext); + + var claimed = await store.ClaimPendingAsync(1, "worker-1", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), CancellationToken.None); + + Assert.Empty(claimed); + } + + [PostgreSqlIntegrationFact] + public async Task Store_reclaims_expired_processing_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + await InsertOutboxMessageAsync( + messageId, + TinyOutboxMessageStatus.Processing, + workerId: "dead-worker", + claimedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-10), + claimExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1)); + await using var dbContext = NewDbContext(); + var store = NewStore(dbContext); + + var claimed = await store.ClaimPendingAsync(1, "worker-2", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), CancellationToken.None); + + var message = Assert.Single(claimed); + Assert.Equal(messageId, message.Id); + Assert.Equal("worker-2", message.ClaimedBy); + Assert.Equal(TinyOutboxMessageStatus.Processing, message.Status); + } + + [PostgreSqlIntegrationFact] + public async Task Store_does_not_claim_active_processing_message() + { + await fixture.ResetSchemaAsync(); + await InsertOutboxMessageAsync( + Guid.NewGuid(), + TinyOutboxMessageStatus.Processing, + workerId: "worker-1", + claimedAtUtc: DateTimeOffset.UtcNow, + claimExpiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(5)); + await using var dbContext = NewDbContext(); + var store = NewStore(dbContext); + + var claimed = await store.ClaimPendingAsync(1, "worker-2", DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), CancellationToken.None); + + Assert.Empty(claimed); + } + + [PostgreSqlIntegrationFact] + public async Task Mark_processed_updates_only_message_owned_by_worker() + { + await fixture.ResetSchemaAsync(); + var ownedId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + await InsertOutboxMessageAsync(ownedId, TinyOutboxMessageStatus.Processing, workerId: "worker-1"); + await InsertOutboxMessageAsync(otherId, TinyOutboxMessageStatus.Processing, workerId: "worker-2"); + await using var dbContext = NewDbContext(); + var store = NewStore(dbContext); + + await store.MarkProcessedAsync(ownedId, "worker-1", DateTimeOffset.UtcNow, CancellationToken.None); + await store.MarkProcessedAsync(otherId, "worker-1", DateTimeOffset.UtcNow, CancellationToken.None); + + Assert.Equal(TinyOutboxMessageStatus.Processed, await ReadStatusAsync(ownedId)); + Assert.Equal(TinyOutboxMessageStatus.Processing, await ReadStatusAsync(otherId)); + } + + [PostgreSqlIntegrationFact] + public async Task Mark_failed_with_retry_marks_pending_for_owned_message() + { + await fixture.ResetSchemaAsync(); + var messageId = Guid.NewGuid(); + var nextAttemptAtUtc = DateTimeOffset.UtcNow.AddMinutes(1); + await InsertOutboxMessageAsync(messageId, TinyOutboxMessageStatus.Processing, workerId: "worker-1"); + await using var dbContext = NewDbContext(); + var store = NewStore(dbContext); + + await store.MarkFailedAsync(messageId, "worker-1", "boom", 3, nextAttemptAtUtc, CancellationToken.None); + + var row = await ReadFailureAsync(messageId); + Assert.Equal(TinyOutboxMessageStatus.Pending, row.Status); + Assert.Equal(3, row.AttemptCount); + Assert.Equal("boom", row.LastError); + Assert.NotNull(row.NextAttemptAtUtc); + } + + private ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + services.AddDbContext(options => + { + options.UseNpgsql(fixture.ConnectionString); + }); + services.UsePostgreSqlEntityFrameworkCoreOutbox(options => + { + options.TableName = "TinyOutbox"; + }); + services.AddSingleton( + new TinyEventTypeDescriptor(typeof(UserCreated).FullName!, typeof(UserCreated))); + services.AddScoped, RecordingConsumer>(); + + return services.BuildServiceProvider(); + } + + private TestDbContext NewDbContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(fixture.ConnectionString) + .Options; + + return new TestDbContext(options); + } + + private static TinyPostgreSqlEfCoreOutboxStore NewStore(TestDbContext dbContext) + { + return new TinyPostgreSqlEfCoreOutboxStore( + dbContext, + new TinyEventsPostgreSqlEntityFrameworkCoreOptions()); + } + + private async Task InsertOutboxMessageAsync( + Guid messageId, + TinyOutboxMessageStatus status = TinyOutboxMessageStatus.Pending, + string? workerId = null, + DateTimeOffset? claimedAtUtc = null, + DateTimeOffset? claimExpiresAtUtc = null, + DateTimeOffset? nextAttemptAtUtc = null) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO "TinyOutbox" + ( + "Id", + "EventType", + "Payload", + "Status", + "AttemptCount", + "ClaimedBy", + "ClaimedAtUtc", + "ClaimExpiresAtUtc", + "CreatedAtUtc", + "NextAttemptAtUtc" + ) + VALUES + ( + @Id, + @EventType, + @Payload, + @Status, + @AttemptCount, + @ClaimedBy, + @ClaimedAtUtc, + @ClaimExpiresAtUtc, + @CreatedAtUtc, + @NextAttemptAtUtc + ); + """; + AddParameter(command, "@Id", messageId); + AddParameter(command, "@EventType", typeof(UserCreated).FullName!); + AddParameter(command, "@Payload", "{}"); + AddParameter(command, "@Status", (int)status); + AddParameter(command, "@AttemptCount", 0); + AddParameter(command, "@ClaimedBy", workerId); + AddParameter(command, "@ClaimedAtUtc", claimedAtUtc); + AddParameter(command, "@ClaimExpiresAtUtc", claimExpiresAtUtc); + AddParameter(command, "@CreatedAtUtc", DateTimeOffset.UtcNow); + AddParameter(command, "@NextAttemptAtUtc", nextAttemptAtUtc); + await command.ExecuteNonQueryAsync(); + } + + private async Task ReadStatusAsync(Guid messageId) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """SELECT "Status" FROM "TinyOutbox" WHERE "Id" = @Id;"""; + AddParameter(command, "@Id", messageId); + var result = await command.ExecuteScalarAsync(); + return (TinyOutboxMessageStatus)Convert.ToInt32(result); + } + + private async Task ReadStatusAsync() + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """SELECT "Status" FROM "TinyOutbox";"""; + var result = await command.ExecuteScalarAsync(); + return (TinyOutboxMessageStatus)Convert.ToInt32(result); + } + + private async Task ReadFailureAsync(Guid messageId) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT "Status", "AttemptCount", "LastError", "NextAttemptAtUtc" + FROM "TinyOutbox" + WHERE "Id" = @Id; + """; + AddParameter(command, "@Id", messageId); + + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return new FailureRow( + (TinyOutboxMessageStatus)reader.GetInt32(0), + reader.GetInt32(1), + reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetFieldValue(3)); + } + + private static void AddParameter( + NpgsqlCommand command, + string name, + object? value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.Value = value ?? DBNull.Value; + command.Parameters.Add(parameter); + } + + private sealed class TestDbContext : DbContext + { + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseTinyEventsOutbox(); + } + } + + private sealed record UserCreated(Guid UserId); + + private sealed class RecordingConsumer : IEventConsumer + { + public static List Consumed { get; } = new List(); + + public ValueTask ConsumeAsync(UserCreated @event, CancellationToken cancellationToken) + { + Consumed.Add(@event); + return ValueTask.CompletedTask; + } + } + + private sealed record FailureRow( + TinyOutboxMessageStatus Status, + int AttemptCount, + string LastError, + DateTimeOffset? NextAttemptAtUtc); +} diff --git a/tests/TinyEvents.PostgreSql.Tests/EfCorePostgreSqlWriterRuntimeTests.cs b/tests/TinyEvents.PostgreSql.Tests/EfCorePostgreSqlWriterRuntimeTests.cs new file mode 100644 index 0000000..ee9d731 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/EfCorePostgreSqlWriterRuntimeTests.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using TinyEvents.PostgreSql.EntityFrameworkCore; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class EfCorePostgreSqlWriterRuntimeTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public EfCorePostgreSqlWriterRuntimeTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Writer_commits_business_data_and_outbox_message_with_save_changes() + { + await fixture.ResetSchemaAsync(); + var services = BuildServices(); + using var scope = services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var events = scope.ServiceProvider.GetRequiredService(); + var userId = Guid.NewGuid(); + + dbContext.Users.Add(new UserRow + { + Id = userId, + Email = "user@example.com" + }); + + await events.PublishAsync(new UserCreated(userId, "user@example.com")); + await dbContext.SaveChangesAsync(); + + Assert.Equal(1, await dbContext.Users.CountAsync()); + Assert.Equal(1, await dbContext.Set().CountAsync()); + } + + [PostgreSqlIntegrationFact] + public async Task Writer_rolls_back_business_data_and_outbox_message_with_db_context_transaction() + { + await fixture.ResetSchemaAsync(); + var services = BuildServices(); + using var scope = services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var events = scope.ServiceProvider.GetRequiredService(); + var userId = Guid.NewGuid(); + + await using (var transaction = await dbContext.Database.BeginTransactionAsync()) + { + dbContext.Users.Add(new UserRow + { + Id = userId, + Email = "rollback@example.com" + }); + + await events.PublishAsync(new UserCreated(userId, "rollback@example.com")); + await dbContext.SaveChangesAsync(); + await transaction.RollbackAsync(); + } + + Assert.Equal(0, await CountAsync("\"Users\"")); + Assert.Equal(0, await CountAsync("\"TinyOutbox\"")); + } + + private ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + + services.AddDbContext(options => + { + options.UseNpgsql(fixture.ConnectionString); + }); + services.UsePostgreSqlEntityFrameworkCoreOutbox(); + + return services.BuildServiceProvider(); + } + + private async Task CountAsync(string tableName) + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {tableName};"; + var result = await command.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + + private sealed class TestDbContext : DbContext + { + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Users"); + entity.HasKey(user => user.Id); + entity.Property(user => user.Email).IsRequired(); + }); + + modelBuilder.UseTinyEventsOutbox(); + } + } + + private sealed class UserRow + { + public Guid Id { get; set; } + + public string Email { get; set; } = string.Empty; + } + + private sealed record UserCreated(Guid UserId, string Email); +} diff --git a/tests/TinyEvents.PostgreSql.Tests/PostgreSqlContainerTests.cs b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlContainerTests.cs new file mode 100644 index 0000000..56fac1d --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlContainerTests.cs @@ -0,0 +1,28 @@ +using Npgsql; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class PostgreSqlContainerTests : IClassFixture +{ + private readonly PostgreSqlFixture fixture; + + public PostgreSqlContainerTests(PostgreSqlFixture fixture) + { + this.fixture = fixture; + } + + [PostgreSqlIntegrationFact] + public async Task Container_starts_and_accepts_connection() + { + await using var connection = new NpgsqlConnection(fixture.ConnectionString); + await connection.OpenAsync(); + + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1;"; + + var result = await command.ExecuteScalarAsync(); + + Assert.Equal(1, result); + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/PostgreSqlFixture.cs b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlFixture.cs new file mode 100644 index 0000000..e76ec45 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlFixture.cs @@ -0,0 +1,52 @@ +using Npgsql; +using Testcontainers.PostgreSql; +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class PostgreSqlFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("tinyevents") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + public string ConnectionString + { + get + { + return container.GetConnectionString(); + } + } + + public async Task InitializeAsync() + { + if (!PostgreSqlIntegrationSettings.Enabled) + { + return; + } + + await container.StartAsync(); + await ResetSchemaAsync(); + } + + public async Task DisposeAsync() + { + if (!PostgreSqlIntegrationSettings.Enabled) + { + return; + } + + await container.DisposeAsync(); + } + + public async Task ResetSchemaAsync() + { + await using var connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = PostgreSqlSchema.CreateSchemaSql; + await command.ExecuteNonQueryAsync(); + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/PostgreSqlIntegrationFactAttribute.cs b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlIntegrationFactAttribute.cs new file mode 100644 index 0000000..0b2de82 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlIntegrationFactAttribute.cs @@ -0,0 +1,14 @@ +using Xunit; + +namespace TinyEvents.PostgreSql.Tests; + +public sealed class PostgreSqlIntegrationFactAttribute : FactAttribute +{ + public PostgreSqlIntegrationFactAttribute() + { + if (!PostgreSqlIntegrationSettings.Enabled) + { + Skip = "Set TINYEVENTS_RUN_POSTGRESQL_TESTS=true to run PostgreSQL integration tests."; + } + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/PostgreSqlIntegrationSettings.cs b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlIntegrationSettings.cs new file mode 100644 index 0000000..10060ca --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlIntegrationSettings.cs @@ -0,0 +1,13 @@ +namespace TinyEvents.PostgreSql.Tests; + +public static class PostgreSqlIntegrationSettings +{ + public static bool Enabled + { + get + { + var value = Environment.GetEnvironmentVariable("TINYEVENTS_RUN_POSTGRESQL_TESTS"); + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/tests/TinyEvents.PostgreSql.Tests/PostgreSqlSchema.cs b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlSchema.cs new file mode 100644 index 0000000..ba831d2 --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/PostgreSqlSchema.cs @@ -0,0 +1,31 @@ +namespace TinyEvents.PostgreSql.Tests; + +public static class PostgreSqlSchema +{ + public const string CreateSchemaSql = """ + DROP TABLE IF EXISTS "Users"; + DROP TABLE IF EXISTS "TinyOutbox"; + + CREATE TABLE "Users" + ( + "Id" uuid NOT NULL PRIMARY KEY, + "Email" text NOT NULL + ); + + CREATE TABLE "TinyOutbox" + ( + "Id" uuid NOT NULL PRIMARY KEY, + "EventType" text NOT NULL, + "Payload" text NOT NULL, + "Status" integer NOT NULL, + "AttemptCount" integer NOT NULL, + "ClaimedBy" text NULL, + "ClaimedAtUtc" timestamp with time zone NULL, + "ClaimExpiresAtUtc" timestamp with time zone NULL, + "CreatedAtUtc" timestamp with time zone NOT NULL, + "NextAttemptAtUtc" timestamp with time zone NULL, + "ProcessedAtUtc" timestamp with time zone NULL, + "LastError" text NULL + ); + """; +} diff --git a/tests/TinyEvents.PostgreSql.Tests/TinyEvents.PostgreSql.Tests.csproj b/tests/TinyEvents.PostgreSql.Tests/TinyEvents.PostgreSql.Tests.csproj new file mode 100644 index 0000000..9026f6a --- /dev/null +++ b/tests/TinyEvents.PostgreSql.Tests/TinyEvents.PostgreSql.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + false + + + + + + + + + + + + + +