Documentation
¶
Overview ¶
Package postgres implements Warren's persistence, outbox and inbox ports against Postgres.
It provides a UnitOfWork whose Do commits aggregate state and the outbox rows for the events those aggregates raised in ONE transaction, resolves that transaction onto repositories through the context, ships a durable outbox store with LISTEN/NOTIFY wake-up, an advisory-lock leader elector, a durable inbox dedupe store, and a ping health check.
Repositories are plain SQL over a small driver-free query handle. There is no ORM, by design, and no pgx type in any signature here except Raw's.
It never runs a migration ¶
Schema is a deploy step: see Schema and Migrate. Migrating from a lifecycle hook races every replica of a rolling deploy, applies DDL the still-serving old replicas were not written against, and turns one bad file into a simultaneous crash-loop. There is no option to enable it.
Index ¶
- Constants
- Variables
- func Migrate(ctx context.Context, dsn string, fsys fs.FS) error
- func Module(opts ...Option) warren.Module
- func RequireTx(ctx context.Context, op string) error
- type CacheMode
- type DB
- type InboxOption
- type LockOption
- type Option
- func ApplicationName(name string) Option
- func Configure(fn func(*pgxpool.Config) error) Option
- func ConnectTimeout(d time.Duration) Option
- func DSN(dsn string) Option
- func HealthTimeout(d time.Duration) Option
- func MaxConnIdleTime(d time.Duration) Option
- func MaxConnLifetime(d time.Duration) Option
- func MaxConns(n int32) Option
- func MinConns(n int32) Option
- func Raw(fn func(context.Context, *pgxpool.Pool) error) Option
- func StatementCacheMode(m CacheMode) Option
- func StatementTimeout(d time.Duration) Option
- func WithAdvisoryLock(opts ...LockOption) Option
- func WithInbox(opts ...InboxOption) Option
- func WithOutbox(opts ...OutboxOption) Option
- type OutboxOption
- type Queryer
- type Row
- type Rows
- type UnitOfWork
Constants ¶
const ModuleName = "warren/persistence/postgres"
ModuleName is the name of the module Module returns — the scope name that appears in boot diagnostics.
Variables ¶
var ErrNoRows = pgx.ErrNoRows
ErrNoRows is returned by Row.Scan when the query matched nothing. A repository maps it to a semantic error rather than leaking it upward:
if errors.Is(err, postgres.ErrNoRows) {
return nil, errors.NotFound("user", id)
}
var Schema fs.FS = mustSub(schemaFS, "schema")
Schema is the DDL for the tables this package owns — warren_outbox and warren_inbox — as numbered SQL files in goose's file format, so a project already running goose, atlas or dbmate applies them with one line.
Warren never applies them for you. See Migrate.
Functions ¶
func Migrate ¶
Migrate applies every unapplied file in fsys, in name order, each in its own transaction, under a Postgres advisory lock so concurrent runners serialise. Applied versions are recorded in warren_schema_migrations.
It is called by your deploy job — and by `warren migrate` once the CLI grows that command. It is NEVER called from a lifecycle hook, and there is no option to make it one. Under a rolling deploy, migrating at boot races N replicas: N−1 block and get killed for missing their readiness deadline, the winner applies DDL that the still serving old replicas were not written against, and one bad file puts every replica into a crash loop at the same moment. Schema is a deploy step.
There are no down migrations. A migration that must be undone is a migration you write forwards, because that is the one you can test.
func Module ¶
Module returns the warren.Module providing the connection pool, the UnitOfWork, the query-handle resolver repositories inject, and a ping health check.
The constructor PARSES: a malformed DSN or pool setting fails at wiring, before any hook runs, because pgxpool.ParseConfig does no I/O. The lifecycle hook CONNECTS: the pool is created and pinged in OnStart under ConnectTimeout, so an unreachable database rolls the boot back instead of leaving a live pool and open sockets behind a boot that failed later for an unrelated reason.
Because everything else here depends on the pool, its hook is appended first and reverse-order teardown closes it last — after the outbox relay's final flush.
It provides, into the graph:
*UnitOfWork the concrete unit of work, for OnCommit persistence.UnitOfWork the port handlers take DB the resolver a repository injects
func RequireTx ¶
RequireTx returns a diagnostic unless a Postgres transaction is in scope on ctx. It is the aggregate-free half of the rule: Store.Append and any write that carries no root call it directly.
A repository Save or Delete does NOT call it. Those go through persistence.Write, which makes this same check AND enlists the aggregate — the two were separate statements until a field test deleted the second one and the row committed while the event evaporated.
Reads outside UnitOfWork.Do are fine and go to the pool — the persistence contract suite depends on it. Writes are not: the row would autocommit while persistence.Track stayed a no-op, so the aggregate's events would sit pending on an object about to go out of scope, and vanish. Silently, with no error anywhere. That is the exact loss the outbox exists to prevent, so it is refused rather than permitted quietly.
Types ¶
type CacheMode ¶
type CacheMode string
CacheMode is how the driver caches prepared statements.
const ( // StatementCachePrepare is the default: named prepared statements. It is // the fastest, and it is incompatible with a pgbouncer in transaction // pooling mode, which does not keep a session for the statement to live in. StatementCachePrepare CacheMode = "prepare" // StatementCacheDescribe uses unnamed statements: slower, and what a // transaction-pooling pgbouncer requires. StatementCacheDescribe CacheMode = "describe" )
type DB ¶
DB resolves the handle a repository uses for one call: the transaction ctx carries if UnitOfWork.Do put one there, and the pool otherwise.
It is the only piece of framework machinery in a repository, and it does one thing. Hold it in a field; never store the context:
type UserRepository struct{ db postgres.DB }
row := r.db(ctx).QueryRow(ctx, `SELECT … WHERE id = $1`, id)
Both handles are boxed once — once per transaction, and once per process for the pool — so this allocates nothing per call.
type InboxOption ¶
type InboxOption struct {
// contains filtered or unexported fields
}
InboxOption configures the inbox store.
func InboxRetention ¶
func InboxRetention(d time.Duration) InboxOption
InboxRetention is how long a seen key is remembered. The default is 24h: it must exceed the longest redelivery window the broker can produce, or a late duplicate is processed twice.
type LockOption ¶
type LockOption struct {
// contains filtered or unexported fields
}
LockOption configures the advisory-lock elector.
func LockKey ¶
func LockKey(name string) LockOption
LockKey names the lock. The default is "warren/outbox". Two services sharing one database AND one key will contend for a single leadership, and one of them will never drain — give each its own.
func RetryInterval ¶
func RetryInterval(d time.Duration) LockOption
RetryInterval is how often a follower retries acquisition. The default is 5s: the window between a leader dying and the outbox draining again.
type Option ¶
type Option struct {
// contains filtered or unexported fields
}
Option configures the Postgres module.
func ApplicationName ¶
ApplicationName is what this service calls itself to Postgres. It defaults to the name of the running binary.
It is not decoration. An operator looking at pg_stat_activity or pg_locks on a shared database sees one row per connection, and without this every row is anonymous: which service holds the outbox's advisory lock, which one is running the query that will not finish, which one to restart. A multi-replica outbox has exactly one leader by design, and finding it started with a blank column.
A DSN that already sets application_name WINS — an explicit choice in the connection string is not overridden by a default derived from argv.
func Configure ¶
Configure runs fn against the parsed pool configuration — after pgxpool.ParseConfig and BEFORE the pool is created, which is the only moment ConnConfig.Tracer, a custom type registration, or a dial function can still be set. An error from fn fails the boot as a WIRING failure, because ParseConfig does no I/O and nothing has been dialled yet.
It is the second place a pgx type appears in this package's exported surface (AGENT.md invariant 3), and it exists because query spans cannot be wired any other way: the tracer seam is a pgx interface, and warren/observability may not import this module (invariant 4). So database instrumentation is one explicit line in main rather than something this package can do for you:
postgres.Configure(func(c *pgxpool.Config) error {
c.ConnConfig.Tracer = otelpgx.NewTracer()
return nil
})
func ConnectTimeout ¶
ConnectTimeout bounds one connection attempt, including the ping in OnStart. The default is 5s: a boot that hangs on an unreachable database is worse than a boot that fails.
func DSN ¶
DSN sets the Postgres connection string. Required: omitting it fails the boot. The password never appears in a log line, an error, or a diagnostic — see redact.
func HealthTimeout ¶
HealthTimeout bounds one readiness ping. The default is 2s. The check gates readiness: a service whose database is unreachable should leave the load balancer's rotation.
func MaxConnIdleTime ¶
MaxConnIdleTime closes a connection idle for d. The default is 30m.
func MaxConnLifetime ¶
MaxConnLifetime retires a connection after d regardless of health, which is how a pool behind a failing-over primary or a rotating credential eventually moves. The default is 1h.
func MaxConns ¶
MaxConns caps the pool. The default is 10. It is the concurrency limit of every handler that touches the database, so size it against the request budget rather than against the server's max_connections.
func MinConns ¶
MinConns keeps n connections warm, so the first request after an idle period does not pay a handshake. The default is 0.
func Raw ¶
Raw runs fn against the raw pgx pool during OnStart, after the pool is live and the ping succeeded, and before any dependent hook starts. An error from fn fails the boot.
It is an explicit opt-out: run a warm-up query, inspect the pool's stats, reach a pgx API this package does not model. It CANNOT install a query tracer — ConnConfig.Tracer is read when the pool is constructed, which has already happened by the time this runs. Use Configure for that.
func StatementCacheMode ¶
StatementCacheMode selects how prepared statements are cached. Use StatementCacheDescribe behind a pgbouncer in transaction pooling mode.
func StatementTimeout ¶
StatementTimeout bounds a single statement, on both sides of the wire. The default is 30 seconds; 0 disables it.
It is set as the connection's server-side statement_timeout AND applied as a context deadline by the query wrapper, and it needs both. The server-side half is what cancels a genuinely slow query. The client-side half is what survives a server that cannot answer at all: a paused, partitioned or swapping Postgres enforces no timeout of its own, because enforcing one is work it has to do — so without a deadline here a request goroutine waits for ever, and the pool empties behind it.
A caller's own deadline always wins; this only bounds a context that has none. A statement that exceeds it is UNAVAILABLE, which is what warren.md §3.3 promises for a database that cannot serve and what makes app.Retrying re-run the transaction.
func WithAdvisoryLock ¶
func WithAdvisoryLock(opts ...LockOption) Option
WithAdvisoryLock enables an outbox.Elector backed by a Postgres session-level advisory lock, so exactly one replica drains the outbox.
It holds one connection outside the pool's rotation for as long as it leads, and cancels the context it handed the relay the moment that connection dies. A lock lost is leadership lost, immediately — the alternative is two replicas publishing the same rows.
postgres.Module(postgres.DSN(url), postgres.WithOutbox(), postgres.WithAdvisoryLock())
func WithInbox ¶
func WithInbox(opts ...InboxOption) Option
WithInbox enables a durable inbox.Store over the warren_inbox table.
What durability buys is REACH, not atomicity. The seen set survives a restart, a rolling deploy and a partition rebalance, and every replica consults the same one — so a redelivery landing on a different process, or after this one restarted, is still suppressed. The memory store forgets all of that at exit, which is the whole difference. Re-marking refreshes the window.
The row is NOT written in the handler's transaction, and nothing here makes it so. This comment used to claim it was, and a field test measured the two rows landing at different xmins. broker.Deduplicate is a consumer-ring stage: it calls MarkSeen after the handler has returned, by which time app.Transactional has already committed and the transaction is off the context. Handler success and mark-seen are two commits, and a crash between them redelivers the message. That is at-least-once — warren.md §5.6's guarantee for every store, this one included — and handlers must be idempotent regardless.
Its OnStart verifies the table exists and fails the boot naming the fix. It does not create it: see Migrate.
func WithOutbox ¶
func WithOutbox(opts ...OutboxOption) Option
WithOutbox enables a durable outbox.Store over the warren_outbox table, and registers outbox.Sink on the UnitOfWork so events drained at commit become rows in the same transaction.
The store implements outbox.Waiter with LISTEN/NOTIFY. The notify is issued INSIDE the business transaction, so Postgres delivers it exactly when the rows become visible — a missed signal costs the relay's poll interval and never loses a record.
Its OnStart VERIFIES the table exists and fails the boot naming the fix if it does not. It does not create it: see Migrate.
It is an option rather than a sibling module because it needs the pool, and a sibling module cannot see another module's providers.
type OutboxOption ¶
type OutboxOption struct {
// contains filtered or unexported fields
}
OutboxOption configures the outbox store.
func Encoder ¶
func Encoder(e outbox.Encoder) OutboxOption
Encoder sets how a domain event becomes an outbox record. The default is outbox.JSONEncoder().
func Retention ¶
func Retention(d time.Duration) OutboxOption
Retention deletes published rows older than d on a sweep, so the table does not grow without bound. The default is 24h; zero keeps rows forever, which is right only when something else archives them.
func Table ¶
func Table(name string) OutboxOption
Table renames the outbox table. The default is "warren_outbox". Changing it after rows exist is a migration you write.
type Queryer ¶
type Queryer interface {
// Query runs a query returning many rows. Close the Rows.
Query(ctx context.Context, sql string, args ...any) (Rows, error)
// QueryRow runs a query returning at most one row. Its error surfaces
// from Scan, and a missing row is ErrNoRows.
QueryRow(ctx context.Context, sql string, args ...any) Row
// Exec runs a statement and returns the number of rows it affected.
Exec(ctx context.Context, sql string, args ...any) (int64, error)
}
Queryer is the driver-free query handle. Its method set is deliberately three methods wide: it is the surface every non-pgx implementation of the same idea would have to provide. CopyFrom and SendBatch are reachable through Raw.
type Row ¶
Row is one row, or the error that prevented it. It is byte-identical to pgx.Row, so a pgx row satisfies it with no wrapping and no allocation.
type Rows ¶
Rows is a result set. It is the subset of pgx.Rows a repository needs, so a pgx result satisfies it unwrapped.
type UnitOfWork ¶
type UnitOfWork struct {
// contains filtered or unexported fields
}
UnitOfWork runs a function inside one Postgres transaction and makes the state it wrote and the events its aggregates raised commit together.
It is provided by Module; construct it only in a test.
func (*UnitOfWork) Do ¶
func (u *UnitOfWork) Do(ctx context.Context, fn func(context.Context) error, opts ...persistence.Option) error
Do begins a transaction, puts it on the context, runs fn, drains the events of every aggregate that enlisted through persistence.Track, runs the commit sinks inside that transaction, and commits. fn returning an error, a sink failing, or a panic rolls everything back; a panic is re-raised.
A nested Do JOINS the transaction in scope: it opens nothing, commits nothing, drains nothing, and returns fn's error — so an inner failure rolls back the outer transaction too. A caller who handles the inner error and returns nil from the outer fn therefore commits state the inner call may have left inconsistent; that is the cost of join semantics, and it is the same on every driver.
Passing an Option to a nested Do is an error, not a silent no-op: Postgres sets isolation and read-only on a transaction's FIRST statement, so a nested Isolation(Serializable) is unimplementable — and ignoring it would mean a handler asking for Serializable and quietly getting Read Committed.
func (*UnitOfWork) OnCommit ¶
OnCommit registers a sink for the events drained at commit — how the outbox writer attaches, and how a test asserts on what was published.
Sinks run INSIDE the transaction, on its context, so a sink's own writes join it and a failing sink fails the commit. That atomicity is the entire pattern: rows and events land together or not at all.
uow.OnCommit(outbox.Sink(store, outbox.JSONEncoder()))
Outbox wires this for you.