PostgreSQL repository and outbox plugins for entity change tracking. Uses Npgsql for database access and supports PostgreSQL NOTIFY/LISTEN for low-latency change notification.
- Outbox -- writes entity changes to an outbox table for reliable, at-least-once delivery
- Source Repository -- manages source tables that store entity key data with auto-migration
- NOTIFY/LISTEN -- instant change notification via PostgreSQL async triggers with automatic fallback polling
- Auto-migration -- creates outbox tables, source tables, indexes, and schema columns on initialization
- Entity property columns -- stores selected entity state directly in the outbox row for immediate access
The project is in src/RayTree.Plugins.PostgreSQL/. Add it to your solution or reference via:
<ProjectReference Include="..\RayTree.Plugins.PostgreSQL\RayTree.Plugins.PostgreSQL.csproj" />Requires the Npgsql package (version managed in Directory.Packages.props).
services.UsePostgreSqlOutbox<Order>(options =>
{
options.ConnectionString = "Host=...;Database=...";
options.OutboxTableName = "orders_outbox";
});Or through the change tracking builder:
builder.UsePostgreSqlOutbox(entityType =>
{
if (entityType == typeof(Order))
return new PostgreSqlOutboxOptions
{
ConnectionString = "Host=...",
OutboxTableName = "orders_outbox"
};
// ...
});builder.UsePostgreSqlRepository<Order>(options =>
{
options.ConnectionString = "Host=...;Database=...";
options.TableName = "orders_source";
});This creates a source table with infrastructure columns (id, created_at, updated_at, version) plus the entity's
[Key]-annotated properties. Columns are automatically added on startup; type changes and orphan columns emit warnings
instead of making destructive changes.
Enable instant push-based change delivery:
services.UsePostgreSqlOutbox<Order>(options =>
{
options.ConnectionString = "Host=...;Database=...";
})
.UseNotificationChannel("order_changes");This generates a pg_notify trigger on the outbox table. At startup, the NotificationBasedPublisher connects via
LISTEN and publishes changes as they arrive. A fallback polling loop drains any records missed while the listener was
unhealthy, and also processes pre-existing unpublished records on first start.
Configure concurrency and fallback interval:
services.UsePostgreSqlOutbox<Order>(options => ...)
.UseNotificationChannel("order_changes")
.WithFallbackPolling(TimeSpan.FromSeconds(15));The NotificationBasedPublisher can be started/stopped via StartAsync / StopAsync. It integrates with
EntityChangeTracker.Publisher for serialization, compression, and queue publishing.
The plugin observes two distinct Postgres connection surfaces, with different recovery shapes:
postgres.notification — LISTEN connection (NotificationBasedPublisher.ListenLoopAsync)
A single long-lived NpgsqlConnection. When WaitAsync throws an exception classified by the
internal PostgresFault helper (transient NpgsqlException, SocketException / IOException
inner, 08xxx / 57P0x SQL state, or ObjectDisposedException), the loop:
- Logs a
Warningonce per fault cycle. - Runs an inline exponential-backoff loop bounded by
NotificationBasedPublisherOptions.ConnectionRecovery(aPostgresConnectionRecoveryOptions) — disposes the broken connection, opens a fresh one, re-attaches theNotificationhandler, re-issuesLISTEN. - On success logs
Information(with reconnect duration), flips_listenerHealthy = trueon the nextWaitAsyncwake. - On
MaxAttemptsexhaustion logsErrorand exits the LISTEN loop (fallback polling continues).
The fallback polling loop continues running throughout, providing best-effort delivery during the
reconnect window. TryClaimForPublishingAsync prevents double-publish races between the two paths.
postgres.outbox — pooled outbox connections (OutboxPublisherService.ProcessBatchAsync, NotificationBasedPublisher.FallbackPollingLoopAsync)
Short-lived per-call NpgsqlConnection from the pool. No reconnect code — Npgsql's pool
handles transient TCP errors, and the polling cadence is the retry. Instead, the consumers
classify each batch failure via IOutbox.IsConnectionFault (which PostgreSqlOutbox overrides
to delegate to PostgresFault) and:
- Demote the per-batch
Errorlog toWarningso a transient Postgres blip looks like one fault cycle. - On the first subsequent successful batch, log an
Information"outbox connection recovered" entry (with duration).
Non-connection-fault exceptions (e.g. unique-violation 23505, syntax errors) bypass this path
entirely — they still log at Error and surface to operators as application bugs.
Write paths (WriteAsync, PostgreSqlRepository) are unchanged: exceptions propagate to the
caller. Outbox writes typically run inside the caller's EF Core transaction; the library cannot
retry just the outbox row without breaking transaction atomicity. The caller's unit-of-work owns
write-side retry.
| Option | Default | Description |
|---|---|---|
PostgreSqlOutboxOptions.ConnectionString |
"" |
PostgreSQL connection string |
PostgreSqlOutboxOptions.OutboxTableName |
"{entity}_outbox" |
Outbox table name |
PostgreSqlOutboxOptions.UseNotificationChannel |
false |
Enable NOTIFY trigger |
PostgreSqlOutboxOptions.NotificationChannel |
null |
Channel name for NOTIFY |
PostgreSqlOutboxOptions.FallbackPollingInterval |
null |
Polling interval when LISTEN is down |
NotificationBasedPublisherOptions.ConnectionRecovery |
new PostgresConnectionRecoveryOptions() (Enabled, 1s→30s, ×2, ±20% jitter, unlimited) |
Tunes LISTEN reconnect backoff. Set Enabled = false to skip reconnect and rely on fallback polling only. |
PostgreSqlOutboxOptions.CleanupBatchSize |
1000 |
Rows per cleanup batch |
PostgreSqlRepositoryOptions.ConnectionString |
"" |
PostgreSQL connection string |
PostgreSqlRepositoryOptions.TableName |
"{entity}_source" |
Source table name |
| Column | Type | Notes |
|---|---|---|
id |
BIGSERIAL |
Primary key |
entity_id |
TEXT |
Logical entity identifier |
change_type |
VARCHAR(10) |
Created / Updated / Deleted |
timestamp |
TIMESTAMPTZ |
Default: NOW() |
published |
BOOLEAN |
Default: FALSE |
version |
INTEGER |
Entity version |
correlation_id |
UUID |
Default: gen_random_uuid() |
entity_type |
TEXT |
Fully-qualified type name |
state_* |
varies | One column per entity property |
Indexes are created for unpublished changes, cleanup, and entity-type queries.
Integration tests require Docker:
dotnet test tests/RayTree.Plugins.PostgreSQL.Tests
Outbox/
PostgreSqlOutbox.cs — IOutbox implementation
PostgreSqlOutboxOptions.cs — Outbox configuration
EntityColumnMapper.cs — Entity-to-column mapping
Schema/
OutboxTableSchema.cs — Outbox table model
OutboxSchemaGenerator.cs — DDL generation
OutboxColumn.cs / OutboxIndex.cs / EntityPropertyColumn.cs
Notification/
NotificationBasedPublisher.cs — LISTEN/NOTIFY publisher
NotificationBasedPublisherOptions.cs — Publisher configuration
NotificationPayload.cs — Trigger payload DTO
Repository/
PostgreSqlRepository.cs — IRepository implementation
PostgreSqlRepositoryOptions.cs — Repository configuration
Schema/
SourceTableDdlGenerator.cs — Source table DDL generation
SourceTableSchema.cs / SourceTableColumn.cs / SourceTableIndex.cs
Schema/
SchemaInspector.cs — information_schema introspection
SchemaMigrator.cs — Column diff & migration
IndexMigrator.cs — Index diff & migration
PostgreSqlTypeNormalizer.cs — PostgreSQL type normalization
Extensions/
BuilderExtensions.cs — IServiceCollection / IChangeTrackingBuilder extensions
RepositoryExtensions.cs — Convenience extension for outbox + repository setup