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¶
| 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. |
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.
Startup sequence¶
- Parse flags; set the log level; clamp
--workersto the range 1–9. - Open N database connections (one per worker) using the connection factory.
- 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. - 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.
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.
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 — Universally Unique Identifier, a long randomly generated value used so no two of them will ever clash; any change triggers a reload, 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: it 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, re-runs preflight, and adopts the token as the one it has now acted on (so rapid, repeated reload requests are collapsed 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() (plus writing an audit row to pgrelay.log), and 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 v1.1 is compatible with pg_relay_notifier, the companion extension for sending notifications out of the database over SMTP (the standard protocol for sending email), Microsoft 365 (via the Graph API — Microsoft's programmatic interface to its 365 services), or 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, routing action_type 'notify' events through a small interface (pgrelay_notifier.fetch / set_status / debug_log / interface_version, plus an optional classify_webhook_response function used only by the webhook transport).
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. This is what lets
concurrency_modeand at-least-once delivery apply to notification channels exactly as they do to SQL channels — the claim lock is held for the entire external send, so no other Processor instance in a multi-Processor fleet can pick up the same event mid-flight. - 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 4xx errors (except 429, which means "you're sending too fast"), and a webhook provider's own
classify_webhook_responseverdict are never retried when retrying could not possibly help. SMTP 4xx, Graph 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. If a send genuinely succeeds at the provider but the Processor never finds out (say, a crash between the provider accepting the message and this transaction committing), the retried send 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, because refusing to retry after an ambiguous failure trades 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 variables immediately before use — never cached, never stored in pg_relay's or pg_relay_notifier's tables. Rotating a secret takes effect on the very next send; no restart required. - 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.