Skip to content

Commit be9c02f

Browse files
authored
Merge pull request #22 from PandaTechAM/development
v5.0.0 - SQLite support + broader version compatibility.
2 parents 0017714 + 7f16061 commit be9c02f

80 files changed

Lines changed: 2213 additions & 370 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/main.yml

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,70 @@
1-
name: Deploy NuGet Package
1+
name: Publish MassTransit Outbox Packages To NuGet
22

33
env:
4-
PROJECT_PATH: './src/MassTransit.PostgresOutbox/MassTransit.PostgresOutbox.csproj'
5-
OUTPUT_DIR: 'nupkgs'
6-
NUGET_SOURCE: 'https://api.nuget.org/v3/index.json'
7-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
4+
NUGET_SOURCE: https://api.nuget.org/v3/index.json
5+
OUTPUT_DIR: nupkgs
86

97
on:
108
push:
11-
branches:
12-
- main
9+
branches: [ main ]
10+
11+
permissions:
12+
contents: read
13+
1314
jobs:
14-
deploy:
15+
publish:
1516
runs-on: ubuntu-latest
17+
environment: Environment Settings
1618

1719
steps:
1820
- name: Checkout
1921
uses: actions/checkout@v6
2022

21-
- name: Setup .NET Core
23+
- name: Setup .NET
2224
uses: actions/setup-dotnet@v5
2325
with:
2426
global-json-file: global.json
2527

28+
- name: Restore
29+
run: dotnet restore
30+
2631
- name: Build
27-
run: dotnet build ${{ env.PROJECT_PATH }}
32+
run: dotnet build --no-restore --configuration Release
33+
34+
- name: Test
35+
run: dotnet test --no-build --configuration Release --verbosity normal
36+
37+
- name: Pack PostgresOutbox
38+
run: dotnet pack src/MassTransit.PostgresOutbox/MassTransit.PostgresOutbox.csproj --no-build --configuration Release --output ${{ env.OUTPUT_DIR }}
39+
40+
- name: Pack SqliteOutbox
41+
run: dotnet pack src/MassTransit.SqliteOutbox/MassTransit.SqliteOutbox.csproj --no-build --configuration Release --output ${{ env.OUTPUT_DIR }}
42+
43+
- name: Publish packages
44+
env:
45+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
46+
shell: bash
47+
run: |
48+
set -euo pipefail
49+
50+
if [ -z "${NUGET_API_KEY:-}" ]; then
51+
echo "NUGET_API_KEY is missing. If you stored it as an Environment secret, set jobs.publish.environment to that Environment name."
52+
exit 1
53+
fi
54+
55+
shopt -s nullglob
56+
57+
nupkgs=( "${{ env.OUTPUT_DIR }}"/*.nupkg )
58+
snupkgs=( "${{ env.OUTPUT_DIR }}"/*.snupkg )
59+
60+
if [ ${#nupkgs[@]} -eq 0 ]; then
61+
echo "No .nupkg files found in ${{ env.OUTPUT_DIR }}"
62+
ls -la "${{ env.OUTPUT_DIR }}" || true
63+
exit 1
64+
fi
2865
29-
- name: Pack
30-
run: dotnet pack ${{ env.PROJECT_PATH }} --output ${{ env.OUTPUT_DIR }}
66+
dotnet nuget push "${nupkgs[@]}" --api-key "$NUGET_API_KEY" --source "${{ env.NUGET_SOURCE }}" --skip-duplicate
3167
32-
- name: Publish
33-
run: dotnet nuget push ${{ env.OUTPUT_DIR }}/*.nupkg -k ${{ env.NUGET_API_KEY }} -s ${{ env.NUGET_SOURCE }}
68+
if [ ${#snupkgs[@]} -gt 0 ]; then
69+
dotnet nuget push "${snupkgs[@]}" --api-key "$NUGET_API_KEY" --source "${{ env.NUGET_SOURCE }}" --skip-duplicate
70+
fi

Directory.Build.props

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<Project>
2+
<PropertyGroup>
3+
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
4+
<Nullable>enable</Nullable>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
7+
<IsPackable>true</IsPackable>
8+
9+
<Authors>Pandatech</Authors>
10+
<PackageLicenseExpression>MIT</PackageLicenseExpression>
11+
<RepositoryType>git</RepositoryType>
12+
<RepositoryUrl>https://github.com/PandaTechAM/be-lib-masstransit-outbox</RepositoryUrl>
13+
<PackageProjectUrl>https://github.com/PandaTechAM/be-lib-masstransit-outbox</PackageProjectUrl>
14+
<PublishRepositoryUrl>true</PublishRepositoryUrl>
15+
16+
<PackageIcon>pandatech.png</PackageIcon>
17+
<PackageReadmeFile>README.md</PackageReadmeFile>
18+
19+
<Version>5.0.0</Version>
20+
<PackageReleaseNotes>Multi-TFM support (net9/10), zero-alloc logging, SQLite outbox package, improved CI</PackageReleaseNotes>
21+
22+
<!-- Build quality -->
23+
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
24+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
25+
26+
<!-- Source/symbols -->
27+
<IncludeSymbols>true</IncludeSymbols>
28+
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
29+
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
30+
31+
<!-- Analyzer behavior -->
32+
<RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild>
33+
<RunAnalyzersDuringLiveAnalysis>true</RunAnalyzersDuringLiveAnalysis>
34+
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
35+
</PropertyGroup>
36+
37+
<ItemGroup>
38+
<PackageReference Include="Pandatech.Analyzers" Version="[2.1.0]" PrivateAssets="all"/>
39+
<PackageReference Include="SonarAnalyzer.CSharp" Version="[10.19.0.132793]">
40+
<PrivateAssets>all</PrivateAssets>
41+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
42+
</PackageReference>
43+
</ItemGroup>
44+
45+
<!-- Pack shared assets for all packable projects -->
46+
<ItemGroup Condition="'$(IsPackable)' == 'true'">
47+
<None Include="$(MSBuildThisFileDirectory)pandatech.png" Pack="true" PackagePath="\"/>
48+
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\"/>
49+
</ItemGroup>
50+
</Project>

LICENSE.txt renamed to LICENSE

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2023 PandaTech
3+
Copyright (c) 2026 <Pandatech LLC>
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21-
SOFTWARE.
21+
SOFTWARE.
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,23 @@
33
<File Path=".editorconfig"/>
44
<File Path=".github/workflows/main.yml"/>
55
<File Path=".gitignore"/>
6+
<File Path="Directory.Build.props"/>
67
<File Path="global.json"/>
8+
<File Path="LICENSE"/>
79
<File Path="Readme.md"/>
810
</Folder>
911
<Folder Name="/src/">
1012
<Project Path="src/MassTransit.PostgresOutbox/MassTransit.PostgresOutbox.csproj"/>
13+
<Project Path="src\MassTransit.SQLiteOutbox\MassTransit.SQLiteOutbox.csproj"/>
1114
</Folder>
1215
<Folder Name="/test/">
16+
<File Path="test\Directory.Build.props"/>
1317
<Project Path="test/MassTransit.PostgresOutbox.Demo.Consumer/MassTransit.PostgresOutbox.Demo.Consumer.csproj"/>
14-
<Project Path="test/MassTransit.PostgresOutbox.Demo.Shared/MassTransit.PostgresOutbox.Demo.Shared.csproj"/>
1518
<Project
1619
Path="test/MassTransit.PostgresOutbox.Demo.Publisher/MassTransit.PostgresOutbox.Demo.Publisher.csproj"/>
1720
<Project Path="test/MassTransit.PostgresOutbox.Tests/MassTransit.PostgresOutbox.Tests.csproj"/>
21+
<Project Path="test/MassTransit.SQLiteOutbox.Demo.Consumer/MassTransit.SQLiteOutbox.Demo.Consumer.csproj"/>
22+
<Project Path="test/MassTransit.SQLiteOutbox.Demo.Publisher/MassTransit.SQLiteOutbox.Demo.Publisher.csproj"/>
23+
<Project Path="test\MassTransit.Demo.Shared\MassTransit.Demo.Shared.csproj"/>
1824
</Folder>
1925
</Solution>

README.md

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Pandatech MassTransit Outbox
2+
3+
Outbox and inbox pattern implementation for [MassTransit](https://masstransit-project.com/) with **multiple DbContext
4+
support**.
5+
6+
MassTransit's built-in outbox only supports a single `DbContext`. These packages let you reliably publish and consume
7+
messages across many modules, each with its own `DbContext` — designed for modular monolith and microservice
8+
architectures.
9+
10+
| Package | Provider | Concurrency strategy |
11+
|----------------------------------------|------------|-----------------------------|
12+
| `Pandatech.MassTransit.PostgresOutbox` | PostgreSQL | `FOR UPDATE SKIP LOCKED` |
13+
| `Pandatech.MassTransit.SqliteOutbox` | SQLite | Lease-based (`LeasedUntil`) |
14+
15+
Both packages are **wire-compatible** — a service using PostgreSQL for its outbox can publish to a service using SQLite
16+
for its inbox, and vice versa.
17+
18+
## Features
19+
20+
- **Multiple DbContext support** — each module gets its own `DbContext`, outbox, and inbox
21+
- **Outbox pattern** — messages are persisted atomically with your domain changes, then published by a background
22+
service
23+
- **Inbox pattern** — idempotent message consumption prevents duplicate processing
24+
- **Background cleanup** — processed messages are automatically removed after a configurable retention period
25+
- **Zero-allocation logging** — uses `[LoggerMessage]` source generators throughout
26+
- **Multi-TFM** — targets `net9.0`, and `net10.0`
27+
28+
## Installation
29+
30+
```bash
31+
# PostgreSQL
32+
dotnet add package Pandatech.MassTransit.PostgresOutbox
33+
34+
# SQLite
35+
dotnet add package Pandatech.MassTransit.SqliteOutbox
36+
```
37+
38+
## Quick start
39+
40+
The API surface is identical for both providers. Examples below use the PostgreSQL package — replace the namespace with
41+
`MassTransit.SQLiteOutbox` for SQLite.
42+
43+
### 1. Configure your DbContext
44+
45+
Implement `IOutboxDbContext`, `IInboxDbContext`, or both, and call `ConfigureInboxOutboxEntities` in `OnModelCreating`:
46+
47+
```csharp
48+
using MassTransit.PostgresOutbox.Abstractions;
49+
using MassTransit.PostgresOutbox.Extensions;
50+
51+
public class OrdersDbContext : DbContext, IOutboxDbContext, IInboxDbContext
52+
{
53+
public DbSet<OutboxMessage> OutboxMessages { get; set; }
54+
public DbSet<InboxMessage> InboxMessages { get; set; }
55+
56+
protected override void OnModelCreating(ModelBuilder modelBuilder)
57+
{
58+
modelBuilder.ConfigureInboxOutboxEntities();
59+
}
60+
}
61+
```
62+
63+
**PostgreSQL only** — enable `UseQueryLocks()` for the `FOR UPDATE SKIP LOCKED` feature:
64+
65+
```csharp
66+
builder.Services.AddDbContextPool<OrdersDbContext>(options =>
67+
options.UseNpgsql(connectionString)
68+
.UseQueryLocks());
69+
```
70+
71+
### 2. Register services
72+
73+
```csharp
74+
using MassTransit.PostgresOutbox.Extensions;
75+
76+
// Registers outbox publisher + outbox cleanup + inbox cleanup background services
77+
services.AddOutboxInboxServices<OrdersDbContext>();
78+
```
79+
80+
To customize behavior, pass a `Settings` object:
81+
82+
```csharp
83+
services.AddOutboxInboxServices<OrdersDbContext>(new Settings
84+
{
85+
PublisherTimerPeriod = TimeSpan.FromSeconds(2),
86+
PublisherBatchCount = 50,
87+
OutboxRemovalBeforeInDays = 7,
88+
InboxRemovalBeforeInDays = 7
89+
});
90+
```
91+
92+
You can also register services individually:
93+
94+
```csharp
95+
services.AddOutboxPublisherJob<OrdersDbContext>();
96+
services.AddOutboxRemovalJob<OrdersDbContext>();
97+
services.AddInboxRemovalJob<OrdersDbContext>();
98+
```
99+
100+
> **SQLite only**`Settings` has an additional `LeaseDuration` property (default: 5 minutes) that controls how long a
101+
> message is leased before becoming available for reprocessing after a crash.
102+
103+
### 3. Publish messages (outbox)
104+
105+
Add your message to the outbox within the same `SaveChangesAsync` call as your domain changes:
106+
107+
```csharp
108+
dbContext.Orders.Add(new Order
109+
{
110+
Amount = 555,
111+
CreatedAt = DateTime.UtcNow
112+
});
113+
114+
dbContext.AddToOutbox(new OrderCreatedEvent { OrderId = orderId });
115+
116+
await dbContext.SaveChangesAsync();
117+
```
118+
119+
To add multiple messages at once:
120+
121+
```csharp
122+
dbContext.AddToOutboxRange(event1, event2, event3);
123+
await dbContext.SaveChangesAsync();
124+
```
125+
126+
Both methods return the generated outbox message ID(s) for correlation if needed.
127+
128+
The background publisher picks up new messages, publishes them via MassTransit, and marks them as done.
129+
130+
### 4. Consume messages (inbox)
131+
132+
Create a consumer that inherits from `InboxConsumer<TMessage, TDbContext>`:
133+
134+
```csharp
135+
using MassTransit.PostgresOutbox.Abstractions;
136+
using Microsoft.EntityFrameworkCore.Storage;
137+
138+
public class OrderCreatedConsumer(IServiceProvider sp)
139+
: InboxConsumer<OrderCreatedEvent, OrdersDbContext>(sp)
140+
{
141+
protected override async Task ConsumeAsync(
142+
OrderCreatedEvent message,
143+
IDbContextTransaction transaction,
144+
CancellationToken ct)
145+
{
146+
// Your idempotent processing logic here.
147+
// The transaction is managed by InboxConsumer — just do your work.
148+
}
149+
}
150+
```
151+
152+
The base class handles deduplication (by `MessageId` + `ConsumerId`) and concurrency. In PostgreSQL this uses
153+
`FOR UPDATE SKIP LOCKED`; in SQLite it uses atomic lease acquisition.
154+
155+
## How it works
156+
157+
### Outbox flow
158+
159+
Your code calls `AddToOutbox()` + `SaveChangesAsync()` → the message is persisted in the `OutboxMessages` table
160+
atomically with your domain changes → a background `HostedService` polls for new messages, publishes them via
161+
MassTransit, and marks them as done → a cleanup service deletes old processed messages.
162+
163+
### Inbox flow
164+
165+
MassTransit delivers a message to your `InboxConsumer` → the base class inserts or finds the `InboxMessage` row →
166+
acquires an exclusive lock (PostgreSQL) or lease (SQLite) → calls your `ConsumeAsync` method → marks the message as done
167+
and commits → if your code throws, the transaction rolls back and the message is retried.
168+
169+
## Cross-provider compatibility
170+
171+
Both packages serialize messages identically (`System.Text.Json`, same MassTransit header convention), so they are fully
172+
wire-compatible. A modular monolith can have some modules using PostgreSQL and others using SQLite — messages flow
173+
seamlessly between them via the shared message broker.
174+
175+
## Settings reference
176+
177+
| Property | Default | Description |
178+
|---------------------------------|-----------|-------------------------------------------------------|
179+
| `PublisherTimerPeriod` | 1 second | How often the publisher polls for new outbox messages |
180+
| `PublisherBatchCount` | 100 | Max messages published per tick |
181+
| `OutboxRemovalBeforeInDays` | 5 | Days to retain processed outbox messages |
182+
| `OutboxRemovalTimerPeriod` | 1 day | How often outbox cleanup runs |
183+
| `InboxRemovalBeforeInDays` | 5 | Days to retain processed inbox messages |
184+
| `InboxRemovalTimerPeriod` | 1 day | How often inbox cleanup runs |
185+
| `LeaseDuration` *(SQLite only)* | 5 minutes | How long a message lease is held |
186+
187+
## License
188+
189+
MIT

0 commit comments

Comments
 (0)