Articles · Projections & Read Models ·033
Persistent Named Subscriptions
Runnable sample on GitHub
Sample:
samples/033-persistent-named-subscriptions— a durable, named position over the event log that resumes across restarts. Run its tests withdotnet test(needs Docker for PostgreSQL).Prerequisites: 005 — Event Sourcing Basics, 007 — Projections.
Overview
A projection (article 007) is a catch-up reader: it walks the global event log forward, updates a read model, and remembers how far it got so a restart resumes instead of reprocessing two years of history. That “how far it got” is a checkpoint — a durable position on the log. The projection runner owns it and you never touch it directly.
But projections are not the only thing that wants to read the log forward and remember its place. You
might ship events to an external system, feed an analytics pipeline, drive a webhook integration, or
build a bespoke reader the framework’s projection machinery doesn’t model. All of those need the same
primitive the projection runner uses internally: a durable position, identified by name, that only
moves forward. That primitive is ISubscriptionStore, and a persistent named subscription is one
named position recorded in it.
This article wires the PostgreSQL-backed store (EfCoreSubscriptionStore) and proves the property that
makes it worth having: a named consumer’s position survives the process. A fresh process against the
same database reads the stored position and resumes from exactly where the last one stopped.
Why this exists
A catch-up consumer that doesn’t persist its position has only bad options on restart. Replay from the start every time — correct but ruinously expensive once the log is large, and it re-delivers every event the consumer ever saw. Start from “now” and silently drop everything that arrived while it was down — fast, but it loses events, which is usually unacceptable for an integration feed. The only good answer is to record the position durably and resume from it: process the backlog exactly once on restart, then continue.
Projection checkpoints already do this, but they are sealed inside the projection runner. The moment you
have a consumer that isn’t a Relay projection — an exporter, a custom feed, a bridge to another bus —
you’d be tempted to hand-roll a last_processed_id column, and hand-rolled position tracking is where
the subtle bugs live: positions that regress on a retry, races between two writers, or a position keyed
on the wrong column so a late-committing transaction gets skipped forever. ISubscriptionStore gives you
the framework’s correct, gap-safe, monotonic position-keeping for arbitrary named consumers, backed by
the database you already run.
When to use this
- An external feed or integration. You read events forward and push them somewhere — a partner API, another message bus, a data warehouse — and must resume cleanly after a deploy or crash.
- A custom catch-up reader the projection runner doesn’t model. Anything that consumes the global log on its own cadence and needs a durable place to stand.
- Multiple independent consumers of one log. Each gets its own named position, advancing at its own pace, with no coupling between them.
When not to use this
- You’re building a read model. Use a projection (article 007). The projection runner already owns its checkpoint, gives you ordered delivery, failure handling, dead-lettering, and rebuilds — a named subscription is the raw position primitive underneath that, not a replacement for it. Reach here only when your consumer genuinely isn’t a projection.
- You only ever need current state. Then you don’t need to read the log forward at all; query a projection or a table.
- Per-request work. A subscription position is for a long-running forward reader, not for handling a single command.
Concepts
Named subscription. A consumer identified by a string name ("orders-export",
"analytics-bridge"). The name is the primary key in relay_subscriptions — one row per consumer — so
distinct consumers never collide and each resumes independently.
Stored position. The position is an EventCursor — a (TxId, Position) pair, not a bare offset.
Position is the database-assigned global_position; TxId is the inserting transaction id. Both are
needed because global_position is allocated at INSERT but made visible at COMMIT, and commit order is
not allocation order. Ordering and checkpointing on (tx_id, position) is gap-safe: a
later-committing transaction always has a strictly greater TxId, so a catch-up reader capped at the
visibility horizon can never skip it. An unknown subscription reads back EventCursor.Start ((0, 0))
— “before the first event,” i.e. a full replay.
Monotonic advance. AdvanceAsync(name, cursor) only ever moves the stored position forward (by
the (TxId, Position) order). A backward or equal ack — a retry that re-acknowledges an earlier place, a
duplicate, an out-of-order callback — is ignored. The store serializes concurrent advances for a name
with a transaction-scoped PostgreSQL advisory lock, so two writers can’t race the position backward.
Resume. On startup a consumer calls GetPositionAsync(name), gets back the last acknowledged cursor
(or Start), and reads the log forward from there. Because the position is a row in your database, it
outlives the process that wrote it — that is the whole point of “persistent.”
Architecture
flowchart TD
subgraph Process A (before restart)
CA[Consumer 'orders-export'] -->|read forward from cursor| LOG[(relay_events)]
CA -->|AdvanceAsync name, cursor| STORE[(relay_subscriptions<br/>one row per name)]
end
CA -.process exits / crashes / redeploys.-> X[ ]
subgraph Process B (after restart)
CB[Consumer 'orders-export' again] -->|GetPositionAsync name| STORE
STORE -->|stored cursor 42,4200| CB
CB -->|resume read forward from 42,4200| LOG
end
Process B is a brand-new process with nothing shared in memory with A. It learns where to resume only
from the durable row in relay_subscriptions — so the position is the seam that makes the restart
invisible.
Building it step by step
The full sample is in
samples/033-persistent-named-subscriptions.
1. Map the relay tables onto your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyRelayEventStore(); // relay_events — the log a subscription reads forward over
modelBuilder.ApplyRelaySubscriptions(); // relay_subscriptions — the durable per-name position
}
2. Register the store
// The store opens its own connection per call, so register a context factory:
services.AddDbContextFactory<FeedDbContext>(o => o.UseNpgsql(connectionString));
services.AddRelaySubscriptionStorePostgres<FeedDbContext>(); // ISubscriptionStore (singleton)
3. Use it from a consumer — read where you left off, work, acknowledge
public sealed class OrdersExportConsumer(ISubscriptionStore subscriptions, IEventStore events)
{
private const string Name = "orders-export";
public async Task PumpAsync(CancellationToken ct)
{
var cursor = await subscriptions.GetPositionAsync(Name, ct); // EventCursor.Start on first run
// ... read events forward from `cursor`, ship each one to the external system (idempotently) ...
var lastProcessed = new EventCursor(txId, position); // the last event you acknowledged
await subscriptions.AdvanceAsync(Name, lastProcessed, ct); // forward-only; safe to re-call
}
}
AdvanceAsync is monotonic, so re-acknowledging after a partial failure never rewinds the feed, and two
instances acking concurrently can’t move it backward.
Complete source code
| File | Shows | DB? |
|---|---|---|
FeedDbContext.cs |
ApplyRelayEventStore() + ApplyRelaySubscriptions() |
— |
FeedFixture.cs |
Testcontainers Postgres + AddRelaySubscriptionStorePostgres; builds a fresh provider to simulate a restart |
Yes |
PersistentSubscriptionTests.cs |
starts at 0, advance persists, monotonic, resume-across-restart | Yes |
Running the example
dotnet test samples/033-persistent-named-subscriptions/Feed.Subscriptions.Tests
This needs Docker — the fixture starts a real postgres:16 via Testcontainers, applies the schema
with EnsureCreatedAsync(), and exercises the store against it. The restart test does the only honest
thing: it disposes the first ServiceProvider entirely (every connection, cache, and the store
singleton) and builds a second, independent provider against the same connection string, proving the
position came from the database and not from in-process state. In production you replace
EnsureCreatedAsync() with EF migrations (or the baseline SQL in
libraries/nuvora-nexus-relay/docs/migrations/) and add the AddRelaySchema() readiness check.
Testing
The split mirrors the rest of Relay: the irreducible database interaction lives in one focused
integration test. There is nothing pure to extract here — the entire value of a persistent position is
that it survives a process, which is a database-level fact. So the test proves exactly that, against real
PostgreSQL: an unknown name reads EventCursor.Start; AdvanceAsync persists; a backward or equal ack
is ignored (monotonic); and a fresh provider against the same database resumes from the stored cursor.
Mocking the store would test the mock, not the guarantee.
// Resume across restart — the property that justifies "persistent".
var processOne = FeedFixture.BuildProvider(connectionString);
await using (processOne)
await processOne.GetRequiredService<ISubscriptionStore>()
.AdvanceAsync("orders-export", new EventCursor(42, 4200)); // process #1 makes progress, then exits
var processTwo = FeedFixture.BuildProvider(connectionString); // brand-new process, same database
await using (processTwo)
(await processTwo.GetRequiredService<ISubscriptionStore>().GetPositionAsync("orders-export"))
.Should().Be(new EventCursor(42, 4200)); // resumes where #1 stopped
Production considerations
- One owner per subscription. A named subscription is a single logical consumer’s position. If two instances advance the same name concurrently, the store keeps the position monotonic but they’re competing — they’ll process overlapping ranges. For a singleton consumer, run it under leader election (article 023); to parallelize, give each worker its own partition and its own subscription name (article 023’s partitioner), not one shared name.
- Delivery is at-least-once; consumers must be idempotent. You acknowledge after doing the work, so a crash between “did the work” and “advanced the position” replays the last range on restart. That is the safe direction (no event is lost), but it means the downstream effect can happen more than once — design the consumer so reprocessing an event is harmless (upsert by event id, dedupe at the sink).
- Acknowledge after the side effect, never before. Advancing first and then doing the work turns a crash into lost events. Advance last so a crash only re-delivers.
- Advance in sensible batches. Advancing after every single event is correct but chatty; advancing after a batch bounds the replay window to that batch on restart. Pick a batch size that trades replay cost against write traffic.
- Schema is a deploy-time concern. Map
relay_subscriptions(ApplyRelaySubscriptions()), ship it with EF migrations or the baseline script, and wireAddRelaySchema()so a missing table fails readiness rather than the first read.
Common mistakes
- Acknowledging before the work is durable. The classic data-loss bug: advance the position, then the process dies before the event reaches the sink. Always advance after the effect.
- Sharing one name across instances expecting partitioned parallelism. One name is one position; multiple advancers contend, they don’t shard. Use distinct names (per partition) or a single leader.
- Assuming exactly-once delivery. It’s at-least-once. A consumer that isn’t idempotent will double-emit on every restart that replays a range. Dedupe at the sink.
- Rolling your own
last_idcolumn “because it’s just one integer”. A bare offset checkpoints onpositionalone and will skip a late-committing transaction (see theEventCursornote above). The store checkpoints on(tx_id, position)for exactly this reason. - Treating it as a queue. A subscription is a position over a log you keep, not a queue that deletes consumed messages. The events stay; the position moves.
Tradeoffs
Benefits. Durable resume for any catch-up consumer, not just framework projections; gap-safe, monotonic position-keeping you don’t have to get right yourself; one named row per consumer with no cross-consumer coupling; and no new infrastructure — it rides the PostgreSQL you already run.
Costs. It is a low-level primitive: it stores a position and nothing else. Unlike the projection runner, it gives you no ordered delivery loop, no failure handling, no dead-lettering, no rebuild tooling — you write the read-forward loop and the idempotency. At-least-once delivery pushes idempotency onto every consumer. And it is single-position-per-name: parallel consumption needs partitioning you wire yourself.
Alternatives
- Relay projections (article 007). If your consumer is a read model, use projections — the runner owns the checkpoint and gives you the delivery loop, failure handling, and rebuilds for free. Named subscriptions are the primitive beneath; don’t reimplement projections on top of them.
- Kafka consumer offsets. Kafka commits a per-partition offset per consumer group — the same idea at the broker layer, with built-in partitioned parallelism and rebalancing. The right tool if your events already flow through Kafka; a named subscription is the equivalent for the PostgreSQL log Relay already keeps, with no extra system to run.
- EventStoreDB persistent subscriptions. EventStoreDB offers server-managed persistent subscriptions
with competing consumers, per-message acks/nacks, and retry/park semantics — richer than a single
monotonic position. If you run EventStoreDB, use them; if you’re on Relay’s Postgres event store,
ISubscriptionStoreis the position-keeping half, and you supply the delivery loop.
Lessons from production systems
- The integration that “replays from the beginning on every deploy” is a perennial incident: it works in staging with a small log and falls over in production where the log is enormous. A durable position fixes it the same afternoon.
- “Start from now on restart” looks fine in a demo and quietly drops every event that arrived during the deploy. The teams that sleep well persist the position and accept at-least-once with idempotent consumers.
- The position-regression bug — a retry that re-acks an earlier place and rewinds the feed — is brutal to diagnose because it only triggers under failure. Monotonic advance in the store eliminates the whole class.
Should you use this?
| If you… | Then… |
|---|---|
| build a read model | no — use a projection (article 007); it owns the checkpoint |
| run a custom catch-up consumer / external feed that must resume | yes — a named subscription |
| ship events to another system or bus | yes — durable position + idempotent sink |
| need partitioned parallel consumption | yes, but one name per partition (article 023), not one shared name |
| only ever query current state | no — you don’t read the log forward at all |
Next steps
- 007 — Projections: the framework-owned catch-up reader this primitive sits beneath — ordered delivery, failure handling, dead-lettering, and rebuilds over the same gap-safe cursor.
- 023 — Distributed Coordination: run a singleton consumer under leader election, or partition a keyed feed so each worker owns its own subscription.
- 021 — Observability: track how far each named subscription lags the head of the log so a stalled feed is visible before it’s a backlog.