Tyto.Transports.Postgres
0.1.0-alpha.4
See the version list below for details.
dotnet add package Tyto.Transports.Postgres --version 0.1.0-alpha.4
NuGet\Install-Package Tyto.Transports.Postgres -Version 0.1.0-alpha.4
<PackageReference Include="Tyto.Transports.Postgres" Version="0.1.0-alpha.4" />
<PackageVersion Include="Tyto.Transports.Postgres" Version="0.1.0-alpha.4" />
<PackageReference Include="Tyto.Transports.Postgres" />
paket add Tyto.Transports.Postgres --version 0.1.0-alpha.4
#r "nuget: Tyto.Transports.Postgres, 0.1.0-alpha.4"
#:package Tyto.Transports.Postgres@0.1.0-alpha.4
#addin nuget:?package=Tyto.Transports.Postgres&version=0.1.0-alpha.4&prerelease
#tool nuget:?package=Tyto.Transports.Postgres&version=0.1.0-alpha.4&prerelease
Tyto.Transports.Postgres
PostgreSQL-backed transport for Tyto. Designed for teams that want a real queue (ack/nack, retry, dead-letter, competing consumers) without operating Kafka or RabbitMQ — when you already run Postgres, this reuses it as the broker.
This is not a Kafka replacement. It targets thousands-of-messages/second workloads where operational simplicity and transactional integrity matter more than raw broker throughput.
How it works
A single table acts as the queue:
| column | purpose |
|---|---|
id |
identity PK |
queue |
endpoint address (logical queue) |
payload / headers / message_* |
the MessageEnvelope |
state |
0=Ready, 1=InFlight, 2=DeadLetter |
attempt |
delivery count, drives retry/dead-letter |
visible_at |
delayed-visibility gate |
locked_until |
crash-recovery lease guard |
- Send →
INSERTaReadyrow, thenpg_notifyto wake consumers. - Receive →
SELECT ... FOR UPDATE SKIP LOCKEDleases a batch and marks itInFlightwith alocked_untillease. Consumers alsoLISTENfor instant wake-ups and fall back to polling (PollingInterval). - Settle →
Completedeletes the row;Abandon/retry bumpsattemptand delaysvisible_at; exceedingMaxRetryCountsetsstate = DeadLetter. - Crash safety → an
InFlightrow whoselocked_untilhas passed is re-leased automatically (at-least-once delivery).
The table, schema and index are created idempotently at startup by
PostgresTopologyManager (set AutoCreateSchema = false to run DDL-free under
least-privilege credentials).
Priority queues
Each message carries a priority (default PostgresOptions.DefaultPriority, or a
per-message Tyto-Priority header). Higher values are leased first — the lease
order is priority DESC, visible_at, id, backed by a matching index.
// per-message
envelope.Headers["Tyto-Priority"] = "10";
// or a transport-wide default
pg.Configure(o => o.DefaultPriority = 0);
Retries, backoff & poison messages
A failed message is settled by policy, keyed off the DB attempt column
(crash-safe — no reliance on headers):
- Backoff:
RetryStrategyisExponentialby default — the delay isRetryDelayMilliseconds * 2^(attempt-1), capped atMaxRetryDelayMilliseconds, with ±RetryJitterFactorrandom jitter to avoid retry stampedes. SetRetryStrategy = Fixedfor a constant delay. - Give up: once
attempt >= MaxRetryCountthe message is dead-lettered. - Poison messages: exceptions that can never succeed on retry (validation,
deserialization) can be dead-lettered immediately via
NonRetryableExceptionsor a customShouldRetrypredicate — no wasted retries, no queue head-of-line blocking. The dead-letter reason records whether it was exhaustion or a non-retryable exception.
pg.Consumers(c => {
c.MaxRetryCount = 5;
c.RetryDelayMilliseconds = 1000; // base delay
c.RetryStrategy = PostgresRetryStrategy.Exponential;
c.MaxRetryDelayMilliseconds = 30_000; // cap
c.RetryJitterFactor = 0.2; // ±20%
// Poison-message protection: never retry these.
c.NonRetryableExceptions.Add(typeof(ValidationException));
c.NonRetryableExceptions.Add(typeof(JsonException));
// or a predicate:
c.ShouldRetry = ex => ex is not ArgumentException;
});
With the defaults above and base 1s: retries land at ~1s, ~2s, ~4s, ~8s … (each
±20%), until MaxRetryCount, then dead-letter.
TTL / expiry & retention
- Messages with
ExpiresAtin the past are never leased and are reclaimed by the maintenance worker. - Completed messages are deleted on
Complete, so they never accumulate. - Dead-lettered rows are purged after
Maintenance.DeadLetterRetention(default 7 days;TimeSpan.Zerokeeps them forever for inspection).
PostgresMaintenanceWorker runs these sweeps on Maintenance.Interval (default
5 min), keeping table bloat — and the resulting autovacuum pressure — in check.
pg.Configure(o => {
o.Maintenance.Interval = TimeSpan.FromMinutes(2);
o.Maintenance.DeadLetterRetention = TimeSpan.FromDays(3);
o.Maintenance.DeleteExpiredMessages = true;
});
Observability (OpenTelemetry)
Meter and ActivitySource are both named Tyto.Transports.Postgres.
- Traces:
postgres.send(producer),postgres.receive(consumer). - Metrics:
enqueued,leased,completed,abandoned,deadlettered,expired,retention.deletedcounters and aprocessing.durationhistogram (all undertyto.transports.postgres.*).
tracing.AddSource("Tyto.Transports.Postgres");
metrics.AddMeter("Tyto.Transports.Postgres");
Usage
services.AddTyto(tyto => {
tyto.Transports(t => {
t.AddPostgres("Postgres_Main", pg => {
pg.Connection("Host=localhost;Database=app;Username=app;Password=secret");
pg.Schema("public"); // optional, default "public"
pg.Table("tyto_messages"); // optional
pg.Consumers(c => {
c.DefaultConcurrencyLimit = 4;
c.MaxRetryCount = 5;
c.RetryDelayMilliseconds = 2000;
c.PerQueueSettings["orders"] = new() { ConcurrencyLimit = 8 };
});
});
});
// ... endpoints route to "Postgres_Main" like any other transport
});
Transactional outbox seam
IPostgresMessageStore.EnqueueAsync accepts an optional caller-owned
NpgsqlConnection / NpgsqlTransaction. When supplied, the message is inserted
in the same transaction as the caller's business data — the foundation for a
true transactional outbox (message and state commit atomically, or not at all).
The current "publish now" path (PostgresSendingTransport) passes null, opening
its own connection. When the outbox module is wired into Tyto's send pipeline, it
can call the store directly with its transaction — no transport changes required.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Npgsql (>= 9.0.3)
- Tyto.DependencyInjection (>= 0.1.0-alpha.4)
- Tyto.Outbox.Abstractions (>= 0.1.0-alpha.4)
- Wiaoj.Primitives (>= 0.1.0-alpha.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-alpha.5 | 0 | 9/21/2026 |
| 0.1.0-alpha.4 | 33 | 9/20/2026 |
| 0.1.0-alpha.3 | 33 | 9/20/2026 |
| 0.1.0-alpha.2 | 41 | 9/20/2026 |
| 0.1.0-alpha.1 | 36 | 9/20/2026 |
| 0.0.1-alpha.106 | 39 | 9/15/2026 |
| 0.0.1-alpha.105 | 69 | 9/14/2026 |
| 0.0.1-alpha.104 | 55 | 9/10/2026 |
| 0.0.1-alpha.103 | 57 | 9/4/2026 |
| 0.0.1-alpha.102 | 54 | 9/1/2026 |
| 0.0.1-alpha.101 | 51 | 9/1/2026 |
| 0.0.1-alpha.100 | 62 | 8/24/2026 |
| 0.0.1-alpha.99 | 62 | 8/20/2026 |
| 0.0.1-alpha.98 | 62 | 8/18/2026 |
| 0.0.1-alpha.97 | 60 | 8/18/2026 |
| 0.0.1-alpha.96 | 96 | 8/18/2026 |
| 0.0.1-alpha.95 | 68 | 8/17/2026 |
| 0.0.1-alpha.94 | 66 | 7/21/2026 |
| 0.0.1-alpha.93 | 58 | 7/20/2026 |
| 0.0.1-alpha.92 | 59 | 7/20/2026 |