postgres.EventBus¶
A PostgreSQL-backed implementation of EventBus. Subscribers poll the events table and persist their position in event_subscriptions, so subscriptions survive restarts and are safe to run on multiple instances at once. A LISTEN/NOTIFY connection wakes subscribers immediately after a commit, with the poll interval as fallback.
Package¶
github.com/terraskye/eventsourcing/eventbus/postgres
Schema¶
Apply eventbus/postgres/schema.sql (in addition to the event store schema — the bus reads the same events table):
CREATE TABLE IF NOT EXISTS event_subscriptions (
name VARCHAR PRIMARY KEY,
position BIGINT NOT NULL DEFAULT 0
);
It also installs an AFTER INSERT trigger on events that sends pg_notify('eventsourcing_events_inserted', ''), which is what wakes subscribers without waiting for the next poll.
Constructor¶
func NewEventBus(pool *pgxpool.Pool, pollInterval time.Duration) *EventBus
| Parameter | Description |
|---|---|
pool |
A pgxpool.Pool connected to a database containing both tables. |
pollInterval |
Fallback polling frequency per subscriber. LISTEN/NOTIFY delivers events immediately in normal operation, so a few seconds is reasonable. |
bus := postgres.NewEventBus(pool, 3*time.Second)
defer bus.Close()
There is no Dispatch — events become visible to subscribers as soon as they are committed to the events table by the event store.
WithFilterEvents¶
func WithFilterEvents(types []string) cqrs.SubscriberOption
Limits a subscriber to specific event types (matched against the event_type column):
bus.Subscribe(ctx, "my-handler", handler,
postgres.WithFilterEvents([]string{"TaskCreated", "TaskCompleted"}),
)
WithStartFrom¶
func WithStartFrom(pos int64) cqrs.SubscriberOption
Sets the global position a new subscription starts from. Only applies when no record exists in event_subscriptions yet; existing subscriptions resume from their stored position.
bus.Subscribe(ctx, "new-projection", handler, postgres.WithStartFrom(0))
Behaviour¶
- Subscriber names are the identity: two processes subscribing with the same name share one position and one of them processes each batch. Each poll locks the subscription row with
FOR UPDATE SKIP LOCKED, so at most one instance handles events for a given subscription at a time — this gives you competing-consumer semantics across instances for free. - Events are delivered in global order (
id ASC), at-least-once. The position is only committed after the batch is handled, so a crash mid-batch redelivers — handlers should be idempotent. - A handler error aborts the poll and the position is not advanced; the batch is retried on the next cycle. Returning
ErrSkippedEventskips the event and continues. - Polls only see rows committed before the oldest in-progress transaction (
xmincheck), so a slow concurrent writer cannot cause events to be skipped. UseregistersEventHandlerMiddlewareapplied to handlers atSubscribetime — call it before subscribing.- Errors are sent to
Errors(); if that channel is full, the error is dropped. - The LISTEN connection reconnects automatically every 5 seconds if it drops. It is an optimization only — polling keeps working without it.
- Locking transactions run on a small dedicated pool (max 4 connections) copied from
pool, so long-running handlers that need their own connections can't deadlock the bus. - Cancelling the
Subscribecontext stops the subscriber (its position remains in the table).Closestops all subscribers and waits for them to finish; it does not close the pool you passed in.
Limitations¶
- Delivery latency is bounded by
pollIntervalif LISTEN/NOTIFY is unavailable (e.g. connection poolers that don't support it, such as PgBouncer in transaction mode). - Batches are capped at 100 events per poll cycle.
- Subscription records are never deleted automatically; remove rows from
event_subscriptionsby hand when retiring a subscriber.