Skip to content

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]
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

  1. Parse flags; set the log level; clamp --workers to the range 1–9.
  2. Open N database connections (one per worker) using the connection factory.
  3. 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 (its classid) is 19680305.
  4. 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 run ALTER EXTENSION pg_relay UPDATE. This registry tells the Processor which companion applications (such as pg_relay_notifier) are installed.
  5. Run pgrelay.preflight() to verify the pgrelay role has every required grant. The Processor refuses to start if any check returns error. (notifier:* checks are only warnings — a broken companion application never blocks ordinary SQL event processing.)
  6. Run one pgrelay.queue_probe() to seed its in-memory reload token and pause state.
  7. Log pg_relay started with the worker count.
  8. Enter the poll loop.

Reconnection

When any connection error occurs, the Processor:

  1. Closes every connection in its pool.
  2. Waits for a backoff period: 1s → 2s → 5s → 10s → 20s → 30s (capped there).
  3. Re-opens N connections, re-reads the libpq environment, and re-acquires an instance slot (the slot number may differ from before).
  4. 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_mode and 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_response verdict are never retried when retrying could not possibly help. SMTP 4xx, Graph 429/5xx, and network or timeout failures use the same max_retries backoff 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_status detail identify the recipient by category and position — for example to[2/3] — never by the actual address. Message content, including every recipient address, subject, and body, is never written to pgrelay.log or 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 the pgrelay role 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.