Documentation
¶
Overview ¶
Package sqlbus extends the in-process eventbus across application nodes by using a shared SQL database (PostgreSQL or SQLite) as the transport. It is the distributed sibling of the outbox package: where the outbox guarantees that a locally subscribed listener eventually processes an event, sqlbus guarantees that an event published on one node reaches listeners attached on any node of the cluster.
An event is stored as one message row, written inside the caller's business transaction when a unit of work is active. Listeners attach through a Bridge under one of two delivery modes: competing listeners process each event exactly once cluster-wide (arbitrated by a guarded claim in the database), while broadcast listeners process each event once per node, which suits node-local concerns such as cache invalidation. Every node runs a Dispatcher that materializes delivery rows for the listeners it hosts, claims them, delivers through the local in-memory bus, and settles the outcome.
Delivery is at-least-once: a crash between delivering an event and settling its delivery leads to a redelivery after the claim lease expires, so listeners must be idempotent. Polling is the correctness mechanism; wake signals (for example PostgreSQL LISTEN/NOTIFY wired by the caller) are strictly latency hints. The nodes must keep reasonably synchronized clocks: the materialization grace absorbs clock skew, like commit latency, only up to its configured size.
The package is decoupled from the eventbus core: eventbus keeps its zero-dependency guarantee, and applications that do not need cross-node delivery never import sqlbus. An event type must be routed either through the outbox or through sqlbus, never both, or its listeners process it twice.
Index ¶
- Constants
- Variables
- func AttachBroadcastListener[T any](ctx context.Context, bridge *Bridge, id eventbus.ListenerID, ...) error
- func AttachCompetingListener[T any](ctx context.Context, bridge *Bridge, id eventbus.ListenerID, ...) error
- func RegisterEventType[T any](serializer *JSONSerializer, name string) error
- func Schema() fs.FS
- type AttachOption
- type AttachmentRegistration
- type Bridge
- func (b *Bridge) Attach(ctx context.Context, registration AttachmentRegistration) error
- func (b *Bridge) Detach(ctx context.Context, id eventbus.ListenerID) error
- func (b *Bridge) FindExhausted(ctx context.Context, limit int) ([]DueDelivery, error)
- func (b *Bridge) Node() NodeID
- func (b *Bridge) Publish(ctx context.Context, querier Querier, event any) (Message, []DeliveryKey, error)
- func (b *Bridge) Resubmit(ctx context.Context, key DeliveryKey) (bool, error)
- type BridgeOption
- type Broker
- func (b *Broker) FindExhausted(ctx context.Context, limit int) ([]eventbus.DeadLetter, error)
- func (b *Broker) Publish(ctx context.Context, event any) error
- func (b *Broker) Resubmit(ctx context.Context, reference string) (bool, error)
- func (b *Broker) Subscribe(ctx context.Context, registration eventbus.ConsumerRegistration) error
- type Consumer
- type ConsumerKey
- type Delivery
- type DeliveryKey
- type DeliveryMode
- type Dialect
- type Dispatcher
- type DispatcherOption
- func WithBatchSize(size int) DispatcherOption
- func WithConsumerExpiry(expiry time.Duration) DispatcherOption
- func WithMaintenanceInterval(interval time.Duration) DispatcherOption
- func WithMaximumMessageAge(age time.Duration) DispatcherOption
- func WithPollInterval(interval time.Duration) DispatcherOption
- func WithSettledRetention(retention time.Duration) DispatcherOption
- func WithWakeSignal(wake <-chan struct{}) DispatcherOption
- type DueDelivery
- type EventBus
- type JSONSerializer
- type Message
- type Migrator
- type MigratorFunc
- type NativeMigrator
- type NodeID
- type Option
- type Ordering
- type PostgresStore
- func (s PostgresStore) AdvanceFrontier(ctx context.Context, key ConsumerKey, frontier time.Time) error
- func (s PostgresStore) ClaimDelivery(ctx context.Context, key DeliveryKey, token string, now time.Time, ...) (bool, error)
- func (s PostgresStore) ClaimDeliveryInOrder(ctx context.Context, key DeliveryKey, token string, now time.Time, ...) (bool, error)
- func (s PostgresStore) CompleteDelivery(ctx context.Context, key DeliveryKey, token string, completionDate time.Time) (bool, error)
- func (s PostgresStore) CreateDeliveries(ctx context.Context, querier Querier, keys []DeliveryKey) error
- func (s PostgresStore) CreateMessage(ctx context.Context, querier Querier, message Message) error
- func (s PostgresStore) DeleteMessagesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
- func (s PostgresStore) DeleteOrphanDeliveries(ctx context.Context) (int64, error)
- func (s PostgresStore) DeleteSettledMessages(ctx context.Context, olderThan time.Time) (int64, error)
- func (s PostgresStore) ExpireBroadcastConsumers(ctx context.Context, cutoff time.Time) (int64, error)
- func (s PostgresStore) FailDelivery(ctx context.Context, key DeliveryKey, token string, cause string, ...) (bool, error)
- func (s PostgresStore) FindDueDeliveries(ctx context.Context, key ConsumerKey, ordering Ordering, now time.Time, ...) ([]DueDelivery, error)
- func (s PostgresStore) FindExhaustedDeliveries(ctx context.Context, limit int) ([]DueDelivery, error)
- func (s PostgresStore) Heartbeat(ctx context.Context, key ConsumerKey, at time.Time) (bool, error)
- func (s PostgresStore) Initialize(ctx context.Context) error
- func (s PostgresStore) MaterializeDeliveries(ctx context.Context, key ConsumerKey) (int64, error)
- func (s PostgresStore) RegisterConsumer(ctx context.Context, consumer Consumer) error
- func (s PostgresStore) RegisterListenerMode(ctx context.Context, id eventbus.ListenerID, mode DeliveryMode, ...) (DeliveryMode, Ordering, error)
- func (s PostgresStore) RemoveListener(ctx context.Context, id eventbus.ListenerID) error
- func (s PostgresStore) ResubmitDelivery(ctx context.Context, key DeliveryKey) (bool, error)
- type Publisher
- type Querier
- type QuerierSource
- type SQLiteStore
- func (s SQLiteStore) AdvanceFrontier(ctx context.Context, key ConsumerKey, frontier time.Time) error
- func (s SQLiteStore) ClaimDelivery(ctx context.Context, key DeliveryKey, token string, now time.Time, ...) (bool, error)
- func (s SQLiteStore) ClaimDeliveryInOrder(ctx context.Context, key DeliveryKey, token string, now time.Time, ...) (bool, error)
- func (s SQLiteStore) CompleteDelivery(ctx context.Context, key DeliveryKey, token string, completionDate time.Time) (bool, error)
- func (s SQLiteStore) CreateDeliveries(ctx context.Context, querier Querier, keys []DeliveryKey) error
- func (s SQLiteStore) CreateMessage(ctx context.Context, querier Querier, message Message) error
- func (s SQLiteStore) DeleteMessagesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
- func (s SQLiteStore) DeleteOrphanDeliveries(ctx context.Context) (int64, error)
- func (s SQLiteStore) DeleteSettledMessages(ctx context.Context, olderThan time.Time) (int64, error)
- func (s SQLiteStore) ExpireBroadcastConsumers(ctx context.Context, cutoff time.Time) (int64, error)
- func (s SQLiteStore) FailDelivery(ctx context.Context, key DeliveryKey, token string, cause string, ...) (bool, error)
- func (s SQLiteStore) FindDueDeliveries(ctx context.Context, key ConsumerKey, ordering Ordering, now time.Time, ...) ([]DueDelivery, error)
- func (s SQLiteStore) FindExhaustedDeliveries(ctx context.Context, limit int) ([]DueDelivery, error)
- func (s SQLiteStore) Heartbeat(ctx context.Context, key ConsumerKey, at time.Time) (bool, error)
- func (s SQLiteStore) Initialize(ctx context.Context) error
- func (s SQLiteStore) MaterializeDeliveries(ctx context.Context, key ConsumerKey) (int64, error)
- func (s SQLiteStore) RegisterConsumer(ctx context.Context, consumer Consumer) error
- func (s SQLiteStore) RegisterListenerMode(ctx context.Context, id eventbus.ListenerID, mode DeliveryMode, ...) (DeliveryMode, Ordering, error)
- func (s SQLiteStore) RemoveListener(ctx context.Context, id eventbus.ListenerID) error
- func (s SQLiteStore) ResubmitDelivery(ctx context.Context, key DeliveryKey) (bool, error)
- type Serializer
- type Status
- type Store
Constants ¶
const SchemaHistoryTable = "event_message_schema_history"
SchemaHistoryTable is the name of the schema-history table that engine-backed Migrator adapters use to record applied migrations. It is exported so an adapter (such as the goway adapter) writes to the same table across upgrades, preserving the on-disk migration-history contract for databases that were previously migrated by that engine.
Variables ¶
var ( // ErrUnknownEventType reports a serialization or deserialization of an // event type that was never registered. ErrUnknownEventType = errors.New("event type is not registered") // ErrConflictingRegistration reports a second registration that binds // an already used name or type to something different. ErrConflictingRegistration = errors.New("conflicting event type registration") )
var ( // ErrConflictingDeliveryMode reports an attachment whose delivery mode // disagrees with the mode another node already registered for the same // listener. Without this arbitration two nodes could silently run one // listener as competing and broadcast at the same time, processing every // event twice. ErrConflictingDeliveryMode = errors.New("listener is already registered under a different delivery mode") // ErrConflictingOrdering reports an attachment whose ordering disagrees // with the ordering another node already registered for the same // listener. Without this arbitration an unordered node would claim // deliveries out of order behind the back of the FIFO nodes. ErrConflictingOrdering = errors.New("listener is already registered under a different ordering") )
Functions ¶
func AttachBroadcastListener ¶
func AttachBroadcastListener[T any]( ctx context.Context, bridge *Bridge, id eventbus.ListenerID, handle func(ctx context.Context, event T) error, options ...AttachOption, ) error
AttachBroadcastListener subscribes the listener on the local bus and registers this node as one broadcast consumer of every event of type T: each event is processed once per hosting node, which suits node-local concerns such as cache invalidation. T must already be registered on the serializer.
func AttachCompetingListener ¶
func AttachCompetingListener[T any]( ctx context.Context, bridge *Bridge, id eventbus.ListenerID, handle func(ctx context.Context, event T) error, options ...AttachOption, ) error
AttachCompetingListener subscribes the listener on the local bus and registers it as the competing consumer of every event of type T: each event is delivered exactly once cluster-wide, by whichever hosting node claims it first. T must already be registered on the serializer.
A freshly attached listener starts at the attachment time minus the materialization grace, so publications still in flight are never missed; it does not replay older history.
func RegisterEventType ¶
func RegisterEventType[T any](serializer *JSONSerializer, name string) error
RegisterEventType maps the event type T to a stable persistent name. Registering the same pair again is allowed; binding a name or a type that is already bound differently is rejected, because it would silently deserialize stored messages into the wrong type.
func Schema ¶
Schema returns the embedded sqlbus migration scripts as a read-only file system rooted at the migration directory, so its entries are "V1__create_event_message_tables.sql" and any successors, not "migration/V1__...". Both the native Migrator and any external adapter read these exact scripts, so the schema has a single source.
The returned value is an immutable view over the package embedded files; callers cannot mutate them.
Types ¶
type AttachOption ¶
type AttachOption func(*attachmentConfiguration)
AttachOption customizes one listener at attachment time.
func WithListenerMaximumAttempts ¶
func WithListenerMaximumAttempts(attempts int) AttachOption
WithListenerMaximumAttempts overrides, for this listener, how many delivery attempts a delivery may consume before it becomes exhausted. Configure the same value on every node hosting the listener.
func WithListenerRetryDelay ¶
func WithListenerRetryDelay(delay func(attempt int) time.Duration) AttachOption
WithListenerRetryDelay overrides, for this listener, the backoff schedule of failed deliveries. Configure the same schedule on every node hosting the listener.
func WithOrderedDelivery ¶
func WithOrderedDelivery() AttachOption
WithOrderedDelivery attaches the listener as a FIFO consumer: its events are processed strictly in publication order, one at a time, cluster-wide for a competing listener and per node for a broadcast one. Ordered deliveries wait below the materialization frontier, so their latency is at least the materialization grace; every node must attach the listener with the same ordering.
type AttachmentRegistration ¶
type AttachmentRegistration struct {
// ID identifies the listener; it must be unique within the bus.
ID eventbus.ListenerID
// Matches decides which events the listener receives on the local bus.
Matches func(event any) bool
// Probe carries the zero value of the consumed event type, so the bridge
// resolves the persistent event type name through its serializer without
// invoking the handler.
Probe any
// Handle processes one delivered event.
Handle eventbus.Handler
// Mode selects competing or broadcast consumption.
Mode DeliveryMode
// Options carry the per-listener delivery behavior.
Options []AttachOption
}
AttachmentRegistration describes one listener to Attach: the untyped seam the typed attachment functions and the Broker adapter both go through.
type Bridge ¶
type Bridge struct {
// contains filtered or unexported fields
}
Bridge connects the local in-process bus to the shared database: it stores published events as messages, pre-creates the delivery rows of the listeners attached on this node, and runs the claim, deliver, and settle state machine both for the Publisher's after-commit path and for the Dispatcher's polling path.
Attach every listener and register every event type before publishing or starting a Dispatcher. A Bridge is safe for concurrent use.
func NewBridge ¶
func NewBridge(store Store, bus EventBus, serializer Serializer, options ...BridgeOption) *Bridge
NewBridge constructs a Bridge over the store, the local bus, and the serializer. Without WithNodeIdentifier the node identity is generated per process, so a restarted node joins as a fresh broadcast consumer while its previous identity expires through the heartbeat.
func (*Bridge) Attach ¶
func (b *Bridge) Attach(ctx context.Context, registration AttachmentRegistration) error
Attach subscribes the listener on the local bus and registers it durably under its delivery mode and ordering. It is the untyped seam the typed attachment functions and the Broker adapter go through.
func (*Bridge) Detach ¶
Detach removes the durable registrations of the listener, unpinning its messages from retention. It is the decommissioning step for a listener whose code was removed: call it after the last node hosting the listener stopped, because a node that still hosts it — including this one, when its Dispatcher is still running — re-registers the consumer on its next heartbeat. The subscription on the local in-process bus remains until the process restarts.
func (*Bridge) FindExhausted ¶
FindExhausted returns the dead letters — deliveries that consumed their attempt budget — oldest first, up to the limit. Each carries its message and the last failure cause, so an operator can inspect them and revive one with Resubmit.
func (*Bridge) Publish ¶
func (b *Bridge) Publish(ctx context.Context, querier Querier, event any) (Message, []DeliveryKey, error)
Publish stores the event as one message, together with the pending delivery rows of the listeners attached on this node, through the provided querier — so the rows join the caller's transaction. Deliveries of listeners hosted on other nodes are materialized there by their dispatchers.
Publish is the low-level seam for callers that manage their own transactions; the stored deliveries are picked up by the dispatchers once the transaction commits. Most callers use Publisher instead, which resolves the querier from the active unit of work and dispatches the local deliveries immediately after commit.
type BridgeOption ¶
type BridgeOption func(*Bridge)
BridgeOption customizes a Bridge at construction time.
func WithLeaseDuration ¶
func WithLeaseDuration(lease time.Duration) BridgeOption
WithLeaseDuration overrides how long a claim protects a processing delivery before another dispatcher may steal it (default 5 minutes). It must exceed the slowest listener, or a slow delivery is repeated on another node. Configure the same value on every node.
func WithMaterializationGrace ¶
func WithMaterializationGrace(grace time.Duration) BridgeOption
WithMaterializationGrace overrides the overlap window that protects materialization against publications whose transaction commits after later events became visible (default 10 minutes). It must exceed the longest business transaction that publishes events; a freshly attached listener may receive events published up to this long before its attachment. Configure the same value on every node.
func WithMaximumAttempts ¶
func WithMaximumAttempts(attempts int) BridgeOption
WithMaximumAttempts overrides how many delivery attempts a delivery may consume before it becomes exhausted (default 5). Configure the same value on every node: the budget is evaluated against the shared attempt counter.
func WithNodeIdentifier ¶
func WithNodeIdentifier(node NodeID) BridgeOption
WithNodeIdentifier overrides the generated per-process node identifier. A stable identifier makes a restarted node resume its broadcast consumers instead of starting fresh ones; it must be unique per running process.
func WithRetryDelay ¶
func WithRetryDelay(delay func(attempt int) time.Duration) BridgeOption
WithRetryDelay overrides the backoff schedule of failed deliveries: the function receives the attempt count just spent (1 for the first attempt) and returns how long to wait before the next one. The default doubles from 5 seconds up to 5 minutes.
type Broker ¶
type Broker struct {
// contains filtered or unexported fields
}
Broker adapts the sqlbus machinery to the eventbus.Broker contract: the same consumer-facing interface as the in-memory engine, with persistence and cross-node consumption added underneath. Publications go through the transactional Publisher, so they join the caller's unit of work; consumers attach durably through the Bridge, competing cluster-wide by default and broadcast per node on request.
Delivery settles exactly once per consumer, but a crash or an expired claim lease re-executes the handler, so handlers must be idempotent — the documented cost of durability. The Workers consumer option is not interpreted: the concurrency of this engine is governed by the Dispatcher batch size and by how many nodes host the listener.
func NewBroker ¶
NewBroker constructs a Broker over the bridge and the publisher. The caller keeps running a Dispatcher per node, exactly as with the lower-level API.
func (*Broker) FindExhausted ¶
FindExhausted returns the dead letters, oldest first, up to the limit. The event of a dead letter is restored from its serialized form when the type is registered on this node's serializer.
func (*Broker) Publish ¶
Publish stores the event through the current unit of work and schedules its deliveries. It reports only serialization and persistence failures; handler outcomes settle asynchronously and surface as dead letters.
type Consumer ¶
type Consumer struct {
ListenerID eventbus.ListenerID
Instance string
EventType string
DeliveryMode DeliveryMode
StartBoundary time.Time
Frontier time.Time
RegistrationDate time.Time
HeartbeatDate time.Time
}
Consumer is one durable registration: the declaration that a listener, under a delivery mode, consumes one event type starting at a boundary. Instance follows the DeliveryKey convention. Frontier is the advancing publication-date floor below which every visible message already has its delivery row, which bounds the materialization scan; StartBoundary is the fixed registration-time floor used to decide whether the consumer covers a message at all.
func (Consumer) Key ¶
func (c Consumer) Key() ConsumerKey
Key returns the identifying part of the consumer.
type ConsumerKey ¶
type ConsumerKey struct {
ListenerID eventbus.ListenerID
Instance string
EventType string
}
ConsumerKey identifies one durable consumer registration.
type Delivery ¶
type Delivery struct {
Key DeliveryKey
Status Status
Attempts int
ClaimToken string
ClaimDate *time.Time
NextAttemptDate *time.Time
CompletionDate *time.Time
LastError string
}
Delivery is one delivery row: the unit the claim and settlement state machine runs on.
type DeliveryKey ¶
type DeliveryKey struct {
MessageID uuid.UUID
ListenerID eventbus.ListenerID
Instance string
}
DeliveryKey identifies one delivery: the processing of one message by one consumer of one listener. Instance is empty for a competing listener, whose single cluster-wide consumer is the listener itself, and holds the NodeID of the consuming node for a broadcast listener.
type DeliveryMode ¶
type DeliveryMode string
DeliveryMode selects how the cluster shares the work of one listener.
const ( // DeliveryModeCompeting delivers each event to exactly one node hosting // the listener, so homogeneous replicas share the work instead of // repeating it. It is the safe default for scaled deployments. DeliveryModeCompeting DeliveryMode = "COMPETING" // DeliveryModeBroadcast delivers each event to every node hosting the // listener, once per node, for node-local concerns such as invalidating // an in-memory cache. DeliveryModeBroadcast DeliveryMode = "BROADCAST" )
type Dialect ¶
type Dialect string
Dialect identifies the target database without leaking any third-party type into the sqlbus core. It is a closed string enum: the core only ever produces DialectSQLite and DialectPostgres, set by the respective store constructors. A Migrator adapter switches on it to choose its own dialect representation.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher is the per-node delivery loop: it materializes the delivery rows of the listeners attached on its bridge, claims the due ones, delivers them through the local in-process bus, and settles the outcome. It also runs the shared maintenance duties — consumer heartbeats, broadcast expiry, retention, and orphan cleanup — on a slower cadence, so no separately managed component guards the tables against unbounded growth.
Every node that hosts listeners must run one Dispatcher. Polling is the correctness mechanism; a wake signal only shortens the latency of a pass.
func NewDispatcher ¶
func NewDispatcher(bridge *Bridge, options ...DispatcherOption) *Dispatcher
NewDispatcher constructs a Dispatcher over the bridge.
func (*Dispatcher) Start ¶
func (d *Dispatcher) Start() (stop func())
Start launches the background loops: one delivery pass immediately, which picks up the backlog of a previous run, then one pass per jittered interval or wake signal, with the consumer heartbeats on their own cadence so a long delivery pass cannot starve liveness. The returned stop function cancels the loops and waits for an in-flight pass to finish; a delivery in flight at that moment still settles its outcome.
type DispatcherOption ¶
type DispatcherOption func(*Dispatcher)
DispatcherOption customizes a Dispatcher at construction time.
func WithBatchSize ¶
func WithBatchSize(size int) DispatcherOption
WithBatchSize overrides how many due deliveries one pass claims per attached listener before looking again (default 100).
func WithConsumerExpiry ¶
func WithConsumerExpiry(expiry time.Duration) DispatcherOption
WithConsumerExpiry overrides how long a broadcast consumer may miss its heartbeats before it is considered gone and reaped (default 15 minutes). It must dwarf the worst scheduling or database stall a live node can suffer, or a paused node is wrongly reaped and re-registers with a fresh boundary. Configure the same value on every node.
func WithMaintenanceInterval ¶
func WithMaintenanceInterval(interval time.Duration) DispatcherOption
WithMaintenanceInterval overrides how often the maintenance duties run (default 1 minute).
func WithMaximumMessageAge ¶
func WithMaximumMessageAge(age time.Duration) DispatcherOption
WithMaximumMessageAge overrides the hard age cap after which a message is removed even when unsettled deliveries still pin it (default 30 days). The cap bounds table growth when a dead letter or an abandoned consumer would otherwise pin messages forever; every forced removal is reported loudly. Configure the same value on every node.
func WithPollInterval ¶
func WithPollInterval(interval time.Duration) DispatcherOption
WithPollInterval overrides how often the dispatcher runs a delivery pass (default 1 second). The interval is jittered by ±10 percent so the nodes of a cluster spread their load instead of polling in step.
func WithSettledRetention ¶
func WithSettledRetention(retention time.Duration) DispatcherOption
WithSettledRetention overrides how long a fully settled message is kept before retention removes it (default 72 hours). Configure the same value on every node.
func WithWakeSignal ¶
func WithWakeSignal(wake <-chan struct{}) DispatcherOption
WithWakeSignal makes a receive on the channel trigger an immediate pass, so a caller can wire a latency hint such as PostgreSQL LISTEN/NOTIFY. The signal is strictly best-effort: a lost wake-up costs at most one poll interval, never an event.
type DueDelivery ¶
DueDelivery pairs a claimable delivery with the message it delivers, so a dispatcher restores the event without a second read.
type EventBus ¶
type EventBus interface {
Subscribe(id eventbus.ListenerID, matches func(event any) bool, handle eventbus.Handler) error
Deliver(ctx context.Context, id eventbus.ListenerID, event any) error
}
EventBus is the slice of the in-process bus the bridge relies on. It is satisfied by *eventbus.Bus.
type JSONSerializer ¶
type JSONSerializer struct {
// contains filtered or unexported fields
}
JSONSerializer is a Serializer based on encoding/json. Event types must be registered with RegisterEventType under a stable name, which decouples the persisted representation from Go type names across refactorings.
func NewJSONSerializer ¶
func NewJSONSerializer() *JSONSerializer
NewJSONSerializer constructs a JSONSerializer with no registered types.
func (*JSONSerializer) Deserialize ¶
func (s *JSONSerializer) Deserialize(eventType string, payload string) (any, error)
Deserialize reconstructs the event registered under the given name.
type Message ¶
type Message struct {
ID uuid.UUID
EventType string
SerializedEvent string
PublisherNode NodeID
PublicationDate time.Time
}
Message is one published event as stored in the shared database: the payload every delivery of that event refers to.
type Migrator ¶
type Migrator interface {
// Migrate applies schema to db for the given dialect.
Migrate(ctx context.Context, db *sql.DB, dialect Dialect, schema fs.FS) error
}
Migrator brings the sqlbus schema up to date against an open database. It is the single seam the store uses for schema initialization, so the choice of migration engine stays out of the query code. The store calls Migrate once, from Store.Initialize.
Implementations receive the open *sql.DB (a concrete handle, because some engines require one rather than the narrower Querier), the target Dialect, and the embedded schema scripts as an fs.FS rooted at the directory that holds the SQL files, so an adapter never duplicates the SQL.
Implementations must be idempotent: Initialize may run on every start, so calling Migrate against an already-migrated database must be a no-op.
The core ships a native database/sql implementation (NativeMigrator) as the zero-configuration default, so the common case needs no Migrator and the core go.mod carries no migration-engine dependency. A goway-backed implementation lives in its own module (github.com/cgardev/gokeel/sqlbus/gowaymigrator) so that only clients who opt in pull goway into their build.
type MigratorFunc ¶
MigratorFunc adapts an ordinary function to the Migrator interface, so a caller can supply a one-off migration strategy without declaring a type.
type NativeMigrator ¶
type NativeMigrator struct{}
NativeMigrator applies the sqlbus schema using only database/sql. It is the default Migrator wired by the store constructors, so the common case pulls in no third-party migration engine.
It executes each embedded *.sql script in ascending file-name order, recording the applied script names so a later run skips them. A database migrated before the record table existed re-runs the historical scripts, whose IF NOT EXISTS DDL is a no-op, and an ALTER TABLE ADD COLUMN that finds its column already present is tolerated, so every upgrade path converges. The dialect is accepted for interface symmetry but is not needed here, because the DDL is portable across SQLite and PostgreSQL.
type NodeID ¶
type NodeID string
NodeID identifies one application process in the cluster. It is the consumer instance of every broadcast delivery the node handles.
type Option ¶
type Option func(*sqlStore)
Option customizes a store at construction time.
func WithMigrator ¶
WithMigrator overrides the default native Migrator the store uses in Initialize. The default keeps the sqlbus core free of any migration-engine dependency; pass gowaymigrator.New() from github.com/cgardev/gokeel/sqlbus/gowaymigrator to opt in to goway.
type Ordering ¶
type Ordering string
Ordering selects how the deliveries of one listener are worked through.
const ( // OrderingUnordered processes the deliveries of the listener as they // become due, with no ordering guarantee: a failing delivery retries on // its own schedule without delaying the others. OrderingUnordered Ordering = "UNORDERED" // OrderingFIFO processes the deliveries of the listener strictly in // publication order, one at a time per consumer, cluster-wide for a // competing listener and per node for a broadcast one. A failing delivery // blocks its successors while it retries; once it exhausts its attempt // budget it parks as a dead letter and the queue continues. Ordered // deliveries wait below the materialization frontier, so their latency is // at least the materialization grace. OrderingFIFO Ordering = "FIFO" )
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
PostgresStore is a Store backed by a PostgreSQL database.
func NewPostgresStore ¶
func NewPostgresStore(database *sql.DB, options ...Option) *PostgresStore
NewPostgresStore constructs a PostgresStore on top of an open PostgreSQL database. The schema and queries are the same as the SQLite store; only the rendering dialect and the migration dialect differ.
func (PostgresStore) AdvanceFrontier ¶
func (s PostgresStore) AdvanceFrontier(ctx context.Context, key ConsumerKey, frontier time.Time) error
AdvanceFrontier raises the materialization floor of the consumer. The monotonic guard makes concurrent advances from several nodes converge on the highest value.
func (PostgresStore) ClaimDelivery ¶
func (s PostgresStore) ClaimDelivery( ctx context.Context, key DeliveryKey, token string, now time.Time, leaseCutoff time.Time, attempts int, ) (bool, error)
ClaimDelivery atomically transitions the delivery into processing under the given token, counting the attempt. The eligibility and attempt guards are re-evaluated by the update itself, so exactly one of several concurrent claimants wins; the losers observe zero affected rows.
func (PostgresStore) ClaimDeliveryInOrder ¶
func (s PostgresStore) ClaimDeliveryInOrder( ctx context.Context, key DeliveryKey, token string, now time.Time, leaseCutoff time.Time, attempts int, publicationDate time.Time, ) (bool, error)
ClaimDeliveryInOrder claims like ClaimDelivery but re-verifies inside the update that no earlier incomplete delivery of the same consumer exists — between the find and the claim a predecessor may have been resubmitted, and its revived delivery must run first — and that no other delivery of the consumer is processing under a live lease, so FIFO execution stays serial even when a revived predecessor and its running successor race. The claimed row never excludes itself, because nothing is earlier than itself in the total order.
func (PostgresStore) CompleteDelivery ¶
func (s PostgresStore) CompleteDelivery( ctx context.Context, key DeliveryKey, token string, completionDate time.Time, ) (bool, error)
CompleteDelivery settles a successful delivery; it reports whether this caller's settlement won.
func (PostgresStore) CreateDeliveries ¶
func (s PostgresStore) CreateDeliveries(ctx context.Context, querier Querier, keys []DeliveryKey) error
CreateDeliveries writes one pending delivery row per key through the provided querier. The conflict-tolerant insert lets a publisher-created row and a dispatcher-materialized row for the same key converge instead of failing.
func (PostgresStore) CreateMessage ¶
CreateMessage writes the message through the provided querier, so it joins the transaction of the business change that produced the event.
func (PostgresStore) DeleteMessagesOlderThan ¶
func (s PostgresStore) DeleteMessagesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
DeleteMessagesOlderThan unconditionally removes messages older than the cutoff. It is the hard age cap that bounds table growth when unsettled deliveries would otherwise pin messages forever; the caller reports every non-zero count loudly.
func (PostgresStore) DeleteOrphanDeliveries ¶
DeleteOrphanDeliveries removes deliveries whose message was deleted and deliveries whose consumer registration no longer exists. It must run after message deletion, never before: completed delivery rows are the memory of the materialization anti-join, so removing them while their message survives would resurrect the message as a fresh delivery.
func (PostgresStore) DeleteSettledMessages ¶
func (s PostgresStore) DeleteSettledMessages(ctx context.Context, olderThan time.Time) (int64, error)
DeleteSettledMessages removes messages older than the reference that every covering registered consumer has completed. Exhausted deliveries do not count as settled: a dead letter pins its message, and so its payload, until Bridge.Resubmit revives it or the hard age cap removes it loudly.
func (PostgresStore) ExpireBroadcastConsumers ¶
func (s PostgresStore) ExpireBroadcastConsumers(ctx context.Context, cutoff time.Time) (int64, error)
ExpireBroadcastConsumers removes broadcast registrations whose heartbeat is older than the cutoff, so consumers of nodes that left the cluster stop pinning messages. Competing registrations are durable and never expire.
func (PostgresStore) FailDelivery ¶
func (s PostgresStore) FailDelivery( ctx context.Context, key DeliveryKey, token string, cause string, nextAttemptDate time.Time, attempts int, maximumAttempts int, ) (bool, error)
FailDelivery settles a failed delivery: it records the cause and the next attempt date, and moves the delivery to failed, or to exhausted once the attempt budget is spent. The token fence guarantees the attempts value set by this dispatcher's claim is still current, so the exhaustion decision is computed from it without re-reading the row.
func (PostgresStore) FindDueDeliveries ¶
func (s PostgresStore) FindDueDeliveries( ctx context.Context, key ConsumerKey, ordering Ordering, now time.Time, leaseCutoff time.Time, limit int, ) ([]DueDelivery, error)
FindDueDeliveries returns the claimable deliveries of the consumer in the total order (publication_date, message_id). For a FIFO consumer only the head of the queue is claimable, and only below the materialization frontier: below that watermark every visible message already has its delivery row, so no late-committing publication can slot in front of the head.
func (PostgresStore) FindExhaustedDeliveries ¶
func (s PostgresStore) FindExhaustedDeliveries(ctx context.Context, limit int) ([]DueDelivery, error)
FindExhaustedDeliveries returns the dead letters, oldest publication first, up to the limit.
func (PostgresStore) Heartbeat ¶
Heartbeat refreshes the liveness timestamp of the consumer. It reports false when the registration row no longer exists (for example, after a broadcast expiry reaped it), so the caller can re-register with a freshly computed boundary instead of resurrecting a stale one.
func (PostgresStore) Initialize ¶
Initialize brings the database schema up to date by applying the embedded migration scripts through the configured Migrator. The default native Migrator uses database/sql only; a goway-backed Migrator can be supplied with WithMigrator.
func (PostgresStore) MaterializeDeliveries ¶
func (s PostgresStore) MaterializeDeliveries(ctx context.Context, key ConsumerKey) (int64, error)
MaterializeDeliveries inserts one pending delivery row for every message the consumer covers but has no row for yet, scanning from the consumer's durable frontier. The frontier is read from the consumer row inside the statement, so the scan floor is always the durable one; a missing consumer row (for example, one reaped by broadcast expiry) yields a NULL frontier, whose comparison matches no rows. The composite primary key makes the insert idempotent, so concurrent materializations converge.
func (PostgresStore) RegisterConsumer ¶
RegisterConsumer records the durable consumer registration. Registering an existing consumer again keeps the stored boundary and frontier, so a durable group resumes where it left off, and refreshes only the heartbeat: a node re-attaching under a stable identity must rejoin with fresh liveness, not with the staleness it accumulated while it was down.
func (PostgresStore) RegisterListenerMode ¶
func (s PostgresStore) RegisterListenerMode( ctx context.Context, id eventbus.ListenerID, mode DeliveryMode, ordering Ordering, ) (DeliveryMode, Ordering, error)
RegisterListenerMode records the delivery mode and the ordering of the listener with first-registration-wins semantics and returns the pair that won, so the caller can detect a conflicting registration made by another node.
func (PostgresStore) RemoveListener ¶
func (s PostgresStore) RemoveListener(ctx context.Context, id eventbus.ListenerID) error
RemoveListener deletes every consumer registration and the delivery-mode row of the listener, unpinning its messages from retention.
func (PostgresStore) ResubmitDelivery ¶
func (s PostgresStore) ResubmitDelivery(ctx context.Context, key DeliveryKey) (bool, error)
ResubmitDelivery gives a failed or exhausted delivery a fresh attempt budget, clearing the backoff of the failed attempts so the fresh budget starts immediately. It reports false when the delivery is not in a resubmittable state.
type Publisher ¶
type Publisher struct {
// contains filtered or unexported fields
}
Publisher is the bridge between a business write and the shared bus: it stores the message inside the current unit of work and delivers it to the locally attached listeners only after that unit commits. Listeners on other nodes receive the event through their own Dispatcher.
When no unit of work is active, the originating write has already auto-committed, so the local deliveries are dispatched immediately instead. A Publisher is immutable after construction and safe for concurrent use.
func NewPublisher ¶
func NewPublisher(bridge *Bridge, querier QuerierSource) *Publisher
NewPublisher constructs a Publisher that writes through the querier source and dispatches the local deliveries synchronously after commit.
func (*Publisher) Publish ¶
Publish stores the event as one message through the current querier, pre-creates the delivery rows of the listeners attached on this node, and schedules their delivery for after the outermost transaction commits. When no unit of work is active the rows are already durable, so local delivery happens at once. Delivery failures do not fail the call: the affected deliveries stay incomplete and are recovered by a Dispatcher.
The message is stored even when no listener is attached locally, because listeners on other nodes are unknown at publish time; a message no registered consumer covers is removed by retention.
func (*Publisher) WithAsynchronousDispatch ¶
WithAsynchronousDispatch returns a Publisher that hands committed local deliveries to a background goroutine, so callers in a request path do not wait for slow listeners. The at-least-once guarantee is unchanged: deliveries settle only after their listener succeeds, and incomplete ones are recovered by a Dispatcher.
type Querier ¶
type Querier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
Querier is the minimal execution surface the store runs its statements against. It is satisfied by *sql.DB, *sql.Tx, and *sql.Conn, and so by the querier transaction resolves from the context.
type QuerierSource ¶
type QuerierSource interface {
Querier(ctx context.Context) transaction.Querier
}
QuerierSource resolves the querier the message rows are written through. It is satisfied by *transaction.Manager, whose Querier returns the active transaction when a unit of work is in progress.
type SQLiteStore ¶
type SQLiteStore struct {
// contains filtered or unexported fields
}
SQLiteStore is a Store backed by a SQLite database.
func NewSQLiteStore ¶
func NewSQLiteStore(database *sql.DB, options ...Option) *SQLiteStore
NewSQLiteStore constructs a SQLiteStore on top of an open SQLite database.
func (SQLiteStore) AdvanceFrontier ¶
func (s SQLiteStore) AdvanceFrontier(ctx context.Context, key ConsumerKey, frontier time.Time) error
AdvanceFrontier raises the materialization floor of the consumer. The monotonic guard makes concurrent advances from several nodes converge on the highest value.
func (SQLiteStore) ClaimDelivery ¶
func (s SQLiteStore) ClaimDelivery( ctx context.Context, key DeliveryKey, token string, now time.Time, leaseCutoff time.Time, attempts int, ) (bool, error)
ClaimDelivery atomically transitions the delivery into processing under the given token, counting the attempt. The eligibility and attempt guards are re-evaluated by the update itself, so exactly one of several concurrent claimants wins; the losers observe zero affected rows.
func (SQLiteStore) ClaimDeliveryInOrder ¶
func (s SQLiteStore) ClaimDeliveryInOrder( ctx context.Context, key DeliveryKey, token string, now time.Time, leaseCutoff time.Time, attempts int, publicationDate time.Time, ) (bool, error)
ClaimDeliveryInOrder claims like ClaimDelivery but re-verifies inside the update that no earlier incomplete delivery of the same consumer exists — between the find and the claim a predecessor may have been resubmitted, and its revived delivery must run first — and that no other delivery of the consumer is processing under a live lease, so FIFO execution stays serial even when a revived predecessor and its running successor race. The claimed row never excludes itself, because nothing is earlier than itself in the total order.
func (SQLiteStore) CompleteDelivery ¶
func (s SQLiteStore) CompleteDelivery( ctx context.Context, key DeliveryKey, token string, completionDate time.Time, ) (bool, error)
CompleteDelivery settles a successful delivery; it reports whether this caller's settlement won.
func (SQLiteStore) CreateDeliveries ¶
func (s SQLiteStore) CreateDeliveries(ctx context.Context, querier Querier, keys []DeliveryKey) error
CreateDeliveries writes one pending delivery row per key through the provided querier. The conflict-tolerant insert lets a publisher-created row and a dispatcher-materialized row for the same key converge instead of failing.
func (SQLiteStore) CreateMessage ¶
CreateMessage writes the message through the provided querier, so it joins the transaction of the business change that produced the event.
func (SQLiteStore) DeleteMessagesOlderThan ¶
DeleteMessagesOlderThan unconditionally removes messages older than the cutoff. It is the hard age cap that bounds table growth when unsettled deliveries would otherwise pin messages forever; the caller reports every non-zero count loudly.
func (SQLiteStore) DeleteOrphanDeliveries ¶
DeleteOrphanDeliveries removes deliveries whose message was deleted and deliveries whose consumer registration no longer exists. It must run after message deletion, never before: completed delivery rows are the memory of the materialization anti-join, so removing them while their message survives would resurrect the message as a fresh delivery.
func (SQLiteStore) DeleteSettledMessages ¶
DeleteSettledMessages removes messages older than the reference that every covering registered consumer has completed. Exhausted deliveries do not count as settled: a dead letter pins its message, and so its payload, until Bridge.Resubmit revives it or the hard age cap removes it loudly.
func (SQLiteStore) ExpireBroadcastConsumers ¶
ExpireBroadcastConsumers removes broadcast registrations whose heartbeat is older than the cutoff, so consumers of nodes that left the cluster stop pinning messages. Competing registrations are durable and never expire.
func (SQLiteStore) FailDelivery ¶
func (s SQLiteStore) FailDelivery( ctx context.Context, key DeliveryKey, token string, cause string, nextAttemptDate time.Time, attempts int, maximumAttempts int, ) (bool, error)
FailDelivery settles a failed delivery: it records the cause and the next attempt date, and moves the delivery to failed, or to exhausted once the attempt budget is spent. The token fence guarantees the attempts value set by this dispatcher's claim is still current, so the exhaustion decision is computed from it without re-reading the row.
func (SQLiteStore) FindDueDeliveries ¶
func (s SQLiteStore) FindDueDeliveries( ctx context.Context, key ConsumerKey, ordering Ordering, now time.Time, leaseCutoff time.Time, limit int, ) ([]DueDelivery, error)
FindDueDeliveries returns the claimable deliveries of the consumer in the total order (publication_date, message_id). For a FIFO consumer only the head of the queue is claimable, and only below the materialization frontier: below that watermark every visible message already has its delivery row, so no late-committing publication can slot in front of the head.
func (SQLiteStore) FindExhaustedDeliveries ¶
func (s SQLiteStore) FindExhaustedDeliveries(ctx context.Context, limit int) ([]DueDelivery, error)
FindExhaustedDeliveries returns the dead letters, oldest publication first, up to the limit.
func (SQLiteStore) Heartbeat ¶
Heartbeat refreshes the liveness timestamp of the consumer. It reports false when the registration row no longer exists (for example, after a broadcast expiry reaped it), so the caller can re-register with a freshly computed boundary instead of resurrecting a stale one.
func (SQLiteStore) Initialize ¶
Initialize brings the database schema up to date by applying the embedded migration scripts through the configured Migrator. The default native Migrator uses database/sql only; a goway-backed Migrator can be supplied with WithMigrator.
func (SQLiteStore) MaterializeDeliveries ¶
func (s SQLiteStore) MaterializeDeliveries(ctx context.Context, key ConsumerKey) (int64, error)
MaterializeDeliveries inserts one pending delivery row for every message the consumer covers but has no row for yet, scanning from the consumer's durable frontier. The frontier is read from the consumer row inside the statement, so the scan floor is always the durable one; a missing consumer row (for example, one reaped by broadcast expiry) yields a NULL frontier, whose comparison matches no rows. The composite primary key makes the insert idempotent, so concurrent materializations converge.
func (SQLiteStore) RegisterConsumer ¶
RegisterConsumer records the durable consumer registration. Registering an existing consumer again keeps the stored boundary and frontier, so a durable group resumes where it left off, and refreshes only the heartbeat: a node re-attaching under a stable identity must rejoin with fresh liveness, not with the staleness it accumulated while it was down.
func (SQLiteStore) RegisterListenerMode ¶
func (s SQLiteStore) RegisterListenerMode( ctx context.Context, id eventbus.ListenerID, mode DeliveryMode, ordering Ordering, ) (DeliveryMode, Ordering, error)
RegisterListenerMode records the delivery mode and the ordering of the listener with first-registration-wins semantics and returns the pair that won, so the caller can detect a conflicting registration made by another node.
func (SQLiteStore) RemoveListener ¶
func (s SQLiteStore) RemoveListener(ctx context.Context, id eventbus.ListenerID) error
RemoveListener deletes every consumer registration and the delivery-mode row of the listener, unpinning its messages from retention.
func (SQLiteStore) ResubmitDelivery ¶
func (s SQLiteStore) ResubmitDelivery(ctx context.Context, key DeliveryKey) (bool, error)
ResubmitDelivery gives a failed or exhausted delivery a fresh attempt budget, clearing the backoff of the failed attempts so the fresh budget starts immediately. It reports false when the delivery is not in a resubmittable state.
type Serializer ¶
type Serializer interface {
Serialize(event any) (eventType string, payload string, err error)
Deserialize(eventType string, payload string) (event any, err error)
}
Serializer converts events to and from their persistent representation. The interface is structurally identical to the outbox Serializer, so one serializer instance can serve both modules when an application uses them side by side. The implementation is deliberately duplicated instead of imported: the modules are versioned independently and neither may depend on the other for its core contract.
type Status ¶
type Status string
Status models the lifecycle of one delivery.
const ( // StatusPending marks a delivery that awaits its first claim. StatusPending Status = "PENDING" // StatusProcessing marks a delivery claimed by a dispatcher. StatusProcessing Status = "PROCESSING" // StatusCompleted marks a delivery whose listener succeeded. StatusCompleted Status = "COMPLETED" // StatusFailed marks a delivery whose listener failed and that awaits // its next attempt after a backoff delay. StatusFailed Status = "FAILED" // StatusExhausted marks a delivery that consumed every configured // attempt. It is terminal until Bridge.Resubmit gives it a fresh budget. StatusExhausted Status = "EXHAUSTED" )
type Store ¶
type Store interface {
Initialize(ctx context.Context) error
CreateMessage(ctx context.Context, querier Querier, message Message) error
CreateDeliveries(ctx context.Context, querier Querier, keys []DeliveryKey) error
RegisterListenerMode(ctx context.Context, id eventbus.ListenerID,
mode DeliveryMode, ordering Ordering) (DeliveryMode, Ordering, error)
RegisterConsumer(ctx context.Context, consumer Consumer) error
Heartbeat(ctx context.Context, key ConsumerKey, at time.Time) (bool, error)
RemoveListener(ctx context.Context, id eventbus.ListenerID) error
MaterializeDeliveries(ctx context.Context, key ConsumerKey) (int64, error)
AdvanceFrontier(ctx context.Context, key ConsumerKey, frontier time.Time) error
// FindDueDeliveries returns the claimable deliveries of the consumer,
// oldest publication first. For a FIFO consumer only the head of the
// queue is claimable, and only below the materialization frontier, where
// the publication order is known to be complete; the total order is
// (publication_date, message_id), deterministic across the cluster.
FindDueDeliveries(ctx context.Context, key ConsumerKey, ordering Ordering,
now time.Time, leaseCutoff time.Time, limit int) ([]DueDelivery, error)
FindExhaustedDeliveries(ctx context.Context, limit int) ([]DueDelivery, error)
// ClaimDelivery atomically transitions a due delivery into processing
// under the token, counting the attempt. The attempts value the claimant
// observed is re-checked by the update, so it doubles as a fencing
// generation: a claim based on a stale read affects zero rows.
ClaimDelivery(ctx context.Context, key DeliveryKey, token string,
now time.Time, leaseCutoff time.Time, attempts int) (bool, error)
// ClaimDeliveryInOrder claims like ClaimDelivery but re-verifies inside
// the update that no earlier incomplete delivery of the same consumer
// exists, so a claim races neither a resubmitted predecessor nor another
// node claiming ahead; publicationDate is the position of the claimed
// message in the total order.
ClaimDeliveryInOrder(ctx context.Context, key DeliveryKey, token string,
now time.Time, leaseCutoff time.Time, attempts int, publicationDate time.Time) (bool, error)
CompleteDelivery(ctx context.Context, key DeliveryKey, token string, completionDate time.Time) (bool, error)
// FailDelivery settles a failed delivery under the token fence; attempts
// is the attempt count this dispatcher's claim recorded, from which the
// exhaustion decision is computed against maximumAttempts.
FailDelivery(ctx context.Context, key DeliveryKey, token string, cause string,
nextAttemptDate time.Time, attempts int, maximumAttempts int) (bool, error)
ResubmitDelivery(ctx context.Context, key DeliveryKey) (bool, error)
ExpireBroadcastConsumers(ctx context.Context, cutoff time.Time) (int64, error)
DeleteSettledMessages(ctx context.Context, olderThan time.Time) (int64, error)
DeleteMessagesOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
DeleteOrphanDeliveries(ctx context.Context) (int64, error)
}
Store defines the outbound port for persisting messages, consumer registrations, and deliveries.
CreateMessage and CreateDeliveries receive the querier of the caller, so the rows are written inside the same transaction as the business change that produced the event. Every other method runs on its own connection, because claims, settlements, and maintenance must be settled independently of any business transaction.
ClaimDelivery reports whether the caller obtained the delivery: it returns false when another dispatcher already holds or settled it, which is the arbitration that keeps competing consumption at exactly one node. The settlement methods are fenced by the claim token, so a dispatcher whose claim lease expired and was stolen affects zero rows.