The Processor¶
The Processor is the Go (a programming language) binary, pg_relay, that you run as a system service alongside your PostgreSQL database. It connects to the database as the pgrelay role, runs the poll loop, and records every outcome.
Command-line flags¶
pg_relay [--verbose] [--debug] [--workers N] [--mode M] [--startup_timeout S] [--stop_timeout S]
pg_relay --test-notify [VAR_NAME] [--recovery]
| Flag | Effect |
|---|---|
| (none) | Log level: warn. Only warnings and errors appear. A healthy Processor is silent. |
--verbose |
Log level: info. Lifecycle messages (started, stopped, reconnected) and per-event invalid events appear. |
--debug |
Log level: debug. Every event outcome (ok, expired, skipped) is logged. --debug takes precedence over --verbose. |
--workers N |
Run N concurrent worker "goroutines" (lightweight units of concurrent work in Go), each on its own database connection. Range: 1–9, default 1. Values outside this range are clamped silently. |
--mode M |
Cluster posture: single (default), multi-node, or leader. See the Multi-Master Deployment book. |
--startup_timeout S |
Seconds the database must be unreachable at startup before the outage alert fires. Default 120 — long enough for a rebooted server's PostgreSQL to come up before anyone is paged. Effective only when PG_RELAY_OUTAGE_NOTIFY is set. |
--stop_timeout S |
Seconds an established connection must stay lost mid-run before the outage alert fires. Default 10. Effective only when PG_RELAY_OUTAGE_NOTIFY is set. |
--test-notify [VAR_NAME] |
Do not run the Processor: parse a lifecycle-notification contract, send its message (outage_message for an outage contract), print the result, and exit (0 on a delivered send). With a name it tests that variable's contract exactly as the daemon will fire it; bare, it sends the complete test contract in PG_RELAY_TEST_NOTIFY — a fourth variable the daemon never reads, an administrator's connection test. No database required. See Lifecycle Notifications. |
--recovery |
With --test-notify: send an outage contract's recovery_message instead. |
Connection configuration¶
The Processor reads connection settings from standard libpq environment variables (libpq is PostgreSQL's own client connection library, and these variable names are shared by every standard PostgreSQL tool). There is no separate configuration file, and no command-line argument for connection details.
| Variable | Purpose |
|---|---|
PGHOST |
Hostname or Unix socket directory |
PGPORT |
Port (default 5432) |
PGDATABASE |
Database name |
PGUSER |
Role name (pgrelay) |
PGPASSFILE |
Path to a .pgpass file — always use this, not PGPASSWORD |
PGSSLMODE |
SSL mode (prefer, require, verify-full, etc.) |
PGSSLCERT |
Client certificate path |
PGSSLKEY |
Client key path |
PGSSLROOTCERT |
CA (certificate authority) certificate path |
The Processor re-reads these environment variables on every reconnect, so rotating a credential (for example, an AWS IAM token referenced via PGPASSFILE) takes effect on the next reconnect, with no restart needed.
Other environment variables¶
Beyond the libpq set, the Processor reads only these — each a deliberate, feature-gated exception to "connection settings only". None is ever read from the database.
| Variable | Purpose |
|---|---|
PG_RELAY_OUTAGE_NOTIFY, PG_RELAY_START_NOTIFY, PG_RELAY_STOP_NOTIFY |
Out-of-band lifecycle notifications; PG_RELAY_TEST_NOTIFY is read only by --test-notify |
PG_RELAY_SPOOL_DIR |
Base spool directory for pg_relay.file_spool channels; a channel without its own directory writes to <base>/<channel>/ |
PG_RELAY_SPOOL_<CHANNEL>_DIR |
Spool directory for one channel, overriding the base (<CHANNEL> = the channel name upper-cased, non-alphanumerics → _) |
PG_RELAY_SPOOL_MAX_BYTES, PG_RELAY_SPOOL_<CHANNEL>_MAX_BYTES |
Cap on the total size of files in a channel's spool directory (global, per-channel override); writes are withheld at the cap |
PG_RELAY_SPOOL_MAX_FILE_BYTES |
Cap on one spooled payload (default 16 MiB); a larger payload fails permanently |
HTTPS_PROXY, NO_PROXY |
Honoured by Go's HTTP client for every HTTP-based transport (standard proxy variables, not pg_relay's own) |
A PG_RELAY_* variable that is present but invalid — a malformed notification contract, a spool size that is not an integer — refuses startup (notify_config_invalid / spool_config_invalid); an unset one simply turns that feature off.
Startup sequence¶
- Parse flags; set the log level; clamp
--workersto the range 1–9. - Parse the three lifecycle-notification variables (
PG_RELAY_OUTAGE_NOTIFY,PG_RELAY_START_NOTIFY,PG_RELAY_STOP_NOTIFY) that are set — a present-but-invalid contract refuses startup immediately (notify_config_invalid), so a broken alerting configuration is caught while a human is watching, not during an outage. From here on, any deliberate exit sends the stop notification if one is configured. See Lifecycle Notifications. - Open N database connections (one per worker) using the connection factory. If the database is unreachable, the Processor does not exit — it waits in-process, retrying with the reconnection backoff below, so the outage alert can fire after
--startup_timeoutand a rebooted server whose PostgreSQL is still coming up simply gets waited for. (A startup failure with the database reachable — a preflight error, a missing registry — still exits non-zero as before.) - Claim an instance slot (1–64) on the first connection using a session-level advisory lock (a PostgreSQL locking mechanism explained in the Glossary) via
pg_try_advisory_lock. The slot number appears in every log line as"instance": N. pg_relay's advisory lock namespace (itsclassid) is19680305. - Read the application registry via
pgrelay.list_applications(). This call is mandatory — if it fails, the extension almost certainly predates the binary, and the Processor refuses to start, with a message directing you to runALTER EXTENSION pg_relay UPDATE. This registry tells the Processor which companion applications (such as pg_relay_notifier) are installed. - Run
pgrelay.preflight()to verify thepgrelayrole has every required grant. The Processor refuses to start if any check returnserror. (notifier:*checks are only warnings — a broken companion application never blocks ordinary SQL event processing.) - Run one
pgrelay.queue_probe()to seed its in-memory reload token and pause state. - Log
pg_relay startedwith the worker count, and send the start notification ifPG_RELAY_START_NOTIFYis configured (one best-effort attempt; a failure warns and never blocks startup). - Enter the poll loop.
Reconnection¶
When any connection error occurs, the Processor:
- Closes every connection in its pool.
- Waits for a backoff period: 1s → 2s → 5s → 10s → 20s → 30s (capped there).
- Re-opens N connections, re-reads the
libpqenvironment, and re-acquires an instance slot (the slot number may differ from before). - Logs
pg_relay reconnected, re-runs its state-loading startup steps (application registry, preflight, probe seed), and resumes polling.
The poll loop does not try to "finish" in-flight events before reconnecting — an event either committed or rolled back as one atomic unit, so there is nothing left half-done to recover.
If the outage lasts longer than --stop_timeout and PG_RELAY_OUTAGE_NOTIFY is configured, the reconnect loop also fires the out-of-band outage alert — the one notification pg_relay cannot deliver through its own queue, because the queue is what just became unreachable.
On SIGTERM or SIGINT (the standard Unix signals for "please stop" and "stop now", sent by systemctl stop or Ctrl-C), the Processor logs pg_relay stopping, closes all connections, logs pg_relay stopped, and exits cleanly. If PG_RELAY_STOP_NOTIFY is configured, it also sends the stop notification — that fires on any deliberate exit, error exits included.
Fleet control (pause, resume, reload)¶
Every Processor's per-second check is pgrelay.queue_probe(), which returns three things in one round trip: whether eligible work exists, the current reload token, and the effective pause state. The single-row table behind it, pgrelay.processor_control, is declarative: it holds what the whole fleet of Processors should be doing right now, and every Processor — including one that starts up mid-pause — brings itself into line within a second. The last write always wins.
Pausing. SELECT pgrelay.stop(); pauses every Processor indefinitely: each one finishes whatever it is already in the middle of dispatching, then keeps checking in but claims no new work (logging pg_relay paused once). SELECT pgrelay.start(); resumes them (pg_relay resumed). For a pause with a time limit, use pgrelay.pause_for(300) (seconds) or pgrelay.pause_to('2026-08-01 03:00+00') — the expiry is worked out inside the database, so a time-limited pause lifts itself even if everyone forgets about it. Changing your mind is just another call: start() cuts a pause short, another pause_for() restarts the clock, and stop() makes it indefinite again. The columns updated_at/updated_by on the control row record who last changed the fleet's state. These functions all require the privileges granted by pgrelay.grant_user().
Reloading. SELECT pgrelay.request_reload(); replaces the control row's reload token — an opaque UUID, so any change triggers a reload and there is no ordering to worry about. Each Processor notices the changed token on its next check-in and performs a full reload before fetching any more work. A reload closes and reopens all its connections (re-reading the libpq environment, so rotated credentials apply), re-acquires an instance slot, re-reads the application registry, and re-runs preflight. It then adopts the token as the one it has acted on, so rapid, repeated reload requests collapse into a single reload. If preflight now reports an error, the Processor logs reload_failed and exits with a non-zero status — this makes systemd's restart policy surface the broken environment loudly, rather than the Processor limping along in a degraded state.
A reload can also be requested through the event queue itself, using a reserved action type:
SELECT pgrelay.register('processor_reload', '', p_action_type := 'pg_relay.reload');
SELECT pgrelay.notify('processor_reload', ''); -- reload now
SELECT pgrelay.notify('processor_reload', '', p_run_at := '2026-08-01 03:00+00'); -- scheduled
Exactly one Processor instance claims this event. Its entire "action" is calling request_reload() and writing an audit row to pgrelay.log; the token change then reaches every instance — including the one that claimed the event — through the normal probe. The pg_relay.* action-type prefix is reserved for Processor commands like this one; register_action_type() refuses to let you register a type with that prefix yourself. Note that a fully paused fleet claims no queue events at all, so to reload a paused fleet, call pgrelay.request_reload() directly — paused Processors still keep checking in, even though they are not doing any work.
What a reload cannot change: command-line flags (--workers, the log level) are set when the process starts. Changing those still needs an actual restart.
pg_relay_notifier compatibility (v1.1)¶
pg_relay works with pg_relay_notifier, the companion extension for sending notifications out of the database. Five transports are supported: SMTP; Microsoft 365 via the Graph API; Azure Communication Services via its REST API (Microsoft's purpose-built service for high-volume transactional email); Gmail / Google Workspace via the Gmail REST API, for sending through an existing Google mailbox; and a generic webhook — any JSON-based HTTP API, such as SendGrid, with no extra Processor code needed per provider. The notifier extension owns all the notification data, recipients, and provider configuration. The Processor's job is purely delivery: it routes action_type 'notify' events through a small interface (pgrelay_notifier.fetch / set_status / debug_log / interface_version, plus an optional classify_webhook_response used only by the webhook transport). Per-transport configuration is covered in its own chapter of Notifications.
This is deliberately not a "send and hope" integration. It carries the same durability guarantees as every other pg_relay event, plus a set of controls built specifically for production email and notification traffic:
- One held transaction per event. The queue row is claimed, the send is attempted, and the result is recorded — all inside a single transaction on one connection. The claim lock is therefore held for the entire external send, so no other Processor instance can pick up the same event mid-flight. This is what lets
concurrency_modeand at-least-once delivery apply to notification channels exactly as they do to SQL channels. - Savepoint isolation around every notifier call.
fetch(),set_status(), and (for webhooks)classify_webhook_response()each run under their own savepoint (a "checkpoint" inside a transaction that lets just that part be rolled back without losing everything else). A notifier function that raises an error, or is simply missing, never aborts the whole surrounding transaction — the event still resolves to a clear final status and a clean audit row, instead of looping forever or leaving a queue row stuck. - Deterministic, provider-aware failure classification. SMTP 5xx errors, Graph API and Azure Communication Services 4xx errors (except 429, which means "you're sending too fast", classified identically by both), and a webhook provider's own
classify_webhook_responseverdict are never retried when retrying could not possibly help. SMTP 4xx, Graph/ACS 429/5xx, and network or timeout failures use the samemax_retriesbackoff chain as any other pg_relay event (see Retry Policy). - Recipient identification without leaking recipient data. If an SMTP send is rejected at the "who is this going to" step, the audit row and
set_statusdetail identify the recipient by category and position — for exampleto[2/3]— never by the actual address. Message content, including every recipient address, subject, and body, is never written topgrelay.logor the Processor's own logs at any point; the position recorded is enough to look the real address up in pg_relay_notifier's own record of the message. - Duplicate-resistant retries. Every SMTP send carries a Message-ID worked out deterministically from the notification's own primary key, rather than a randomly generated one. Suppose a send genuinely succeeds at the provider but the Processor never finds out — a crash between the provider accepting the message and this transaction committing. The retried send then carries the exact same Message-ID, so a receiving mail server or client that de-duplicates by Message-ID recognises it as the message it already delivered, instead of showing the recipient a duplicate. This is a mitigation, not an ironclad guarantee. pg_relay's own delivery model is at-least-once by design: refusing to retry after an ambiguous failure would trade a rare duplicate for a much worse outcome — a message that silently never sends at all.
- Secrets never touch the database. Any string value in a notifier profile written as
_env:VAR_NAME— SMTP passwords, OAuth client secrets, webhook API keys — is read from the Processor host's own environment immediately before use. It is never cached, and never stored in pg_relay's or pg_relay_notifier's tables. Rotating a secret still requires updating the environment source and restarting the Processor; a running process's environment cannot change on its own. See Keeping Secrets Out of the Database for the full picture. - Preflight visibility.
pgrelay.preflight()reports the health of the notifier integration on every Processor startup and reload — whether the schema exists, whether all the required (and optional, such as the webhook classifier) interface functions are present, and whether thepgrelayrole can call them. These checks are always warnings, never blocking — a broken or partially-upgraded notifier degrades gracefully instead of taking the whole Processor down.
Continue to Workers to see how the Processor handles several events at the same time.