One source
This page is the project's CHANGELOG.md, included as-is. The newest release is first; the [Unreleased] section lists what has landed on main since it. For how pg_relay began, see Where pg_relay Started.
Changelog¶
Full documentation: https://pg-relay.pebbleit.com.au/latest/
All notable changes to pg_relay are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
pg_relay 1.6.0 adds the file spool: a Processor-native action type, pg_relay.file_spool, that writes each queued event's payload to a file in a local spool directory, atomically, so any process watching that directory can pick it up — log shippers, ETL pick-up jobs, batch loaders, agents on the same host, air-gapped exports. The hand-off is transactional (the file appears only if the enqueuing transaction commits) and the directory is environment, not data: the channel registration stores nothing site-specific, the Processor learns where to write from its own environment, and a restored or cloned database needs no data change. The extension changes ship as the upgrade script sql/pg_relay--1.5--1.6.sql — existing installs run ALTER EXTENSION pg_relay UPDATE;, fresh installs chain the 1.0 script and all six deltas automatically. Documented in its own book on the docs site, File Spool.
Added¶
- Reserved action type
pg_relay.file_spool— seeded by the 1.6 delta (run_order 1, alongside'sql','pg_relay.exec', and'pg_relay.otlp'). Likepg_relay.otlp, a file_spool channel is an ordinary registration (never reserved):pgrelay.register('exports', '', p_action_type := 'pg_relay.file_spool'). Theactioncolumn is optional — empty means every default — or carries the file options as JSON:filename(template, default{channel}-{event_id}.json; tokens{channel},{event_id},{queued_at}as20260907T041200Z, and{payload.<key>}for a top-level string or number of a JSON payload),extension_tmp(default.tmp),mode(default0640, POSIX only), andreplace(defaultfalse). The options describe the file, never the place. - Environment variables (hardcoded names, the lifecycle-notification precedent; read fresh on every event, never stored):
PG_RELAY_SPOOL_DIR(base; a channel without its own directory writes to<base>/<channel>/),PG_RELAY_SPOOL_<CHANNEL>_DIR(per-channel override;<CHANNEL>= the name upper-cased with non-alphanumerics →_),PG_RELAY_SPOOL_MAX_BYTES/PG_RELAY_SPOOL_<CHANNEL>_MAX_BYTES(cap on the total size of files in a directory),PG_RELAY_SPOOL_MAX_FILE_BYTES(cap on one payload, default 16 MiB). The Processor never creates a directory; a size variable that is present but not an integer refuses startup (spool_config_invalid), and a configured directory that does not exist is reported at start (spool_dir_unavailable). - The atomic write: payload bytes verbatim (no parsing, no trailing newline) to
<dir>/<name><extension_tmp>(created exclusively; a stale temporary file from a crashed attempt is removed first, a symlink under either name is never followed), fsync, mode applied, close, rename to<dir>/<name>, directory fsync — a consumer never observes a partial file under the final name.os.RenameisMoveFileExwith replace semantics andFile.SyncisFlushFileBufferson Windows; the directory fsync andmodeare POSIX-only. - Idempotency and
replace: an event whose file already exists is reported as success with a note inpgrelay.log.error(spool_existslogged) — the crash-between-rename-and-commit / at-least-once redelivery case, which the deterministic default template makes a safety net rather than a design dependency (SKIP LOCKED already gives one Processor per event). Withreplace: trueon the channel the rename atomically overwrites the old file instead (spool_replaced); a JSON-object payload can override the channel default per event with a top-level boolean"replace"member (a non-boolean is a permanent error; a non-JSON payload is never inspected). - Failure semantics, mapped onto what the Processor already does: a directory that is not configured, missing, or unwritable, or that would be at or over its
MAX_BYTEScap after the write, withholds the event — the claim rolls back and the same row stays pending, re-offered every tick until the environment is fixed or the consumer drains the directory (theexec_withheldprecedent: nothing lost, no retry burned, no audit row;spool_unavailable/spool_availablelogged once per channel transition,spool_withheldper event at debug). An oversized payload, an unsafe or empty rendered filename (only[A-Za-z0-9._-], no.., under 200 bytes), or a bad channel option is permanent (oneerroraudit row, filesystem never touched). A disk error during the write or rename follows the ordinary retry chain (_queue_insert_retryup to the channel'smax_retries), the temporary file removed if possible. - Go: the
pg_relay.file_spoolevent handler (internal/processor/spool.go) — routed in the dispatch worker like every other Processor-native type, using the same held-transaction claim ceremony: claim → options via the existingpgrelay._fetch_action()→ environment → write →_write_log→_queue_mark_done(+_queue_insert_retryon a disk error) → COMMIT.{queued_at}is recovered from the queue id's snowflake timestamp (every file_spool row postdates the 1.1 delta). Payload content and the rendered filename never reach stdout at any level; the directory path (the operator's own environment) does, in thespool_*lines. - Go suite
internal/processor/spool_test.go— option parsing and validation, every filename token and the full unsafe-name matrix (traversal, separators in payload values,.., length, unknown tokens) with the error text proven never to echo payload content, environment resolution (base fallback, per-channel override, case-insensitive channel keys, invalid caps named), the startup check, and fullprocessSpoolEventdrives against the held-txfakeConnand a real temp directory: exactly one complete file with the bytes unchanged and mode0640; a high-frequency poller never observing a partial file under the final name during an 8 MiB write; redelivery as a logged no-op;replaceon the channel and overridden either way by the payload;MAX_BYTESwithholding (rollback, no audit, onespool_unavailable) then draining once files are removed; the same database conversation under two differentPG_RELAY_SPOOL_DIRvalues with output following the environment; a missing directory reported at start and withheld until created; unconfigured and unwritable directories withheld; unsafe templates permanent with the filesystem untouched; oversized payloads permanent; a disk error scheduling a retry then terminal when exhausted; stale and symlinked temporary names replaced without being followed; expired/invalid preconditions; and dispatch routing. - v1.6 SQL test suite
test/pg_relay_test--1.6.sql— replaces the 1.5 suite as the suite CI runs (make test-sql). Adds the 1.5→1.6 leg of the upgrade-path test and six new tests (129–134) covering what SQL owns: the seed row and the still-reservedpg_relay.*prefix, a file_spool channel as an ordinary registration with an empty or optional-JSON action (never a path),notify()storing an opaque payload verbatim (trailing newline and all,NULLaccepted), the transactional hand-off proven from a separate dblink session (a rolled-backnotify()leaves nothing, a committed one exactly one pending row),queue_pending_ids()offering(id, 'pg_relay.file_spool')and_queue_claim()handing the row to one session only under a concurrent dblink claim, and old-binary degradation tounsupported_action_type. - File Spool — a new five-page book on the documentation site: an overview (when to choose a file over a webhook, the write sequence, what the action never does); setting up the spool directory (shared group,
0770/0640, theReadWritePaths=line the hardened unit needs, every environment variable, why the path is not in the database with the clone-and-restore example, Windows and container notes); filenames and replacing files (the template tokens, the safe-name rule,replace: truefor keep-current files with the per-event payload override and a redelivery/retry matrix); reading the spool (the consumer's three rules,inotifywait, polling, and PowerShell consumers, consuming areplacechannel, backpressure); and a configuration and failure reference (options, variables, the write sequence, the full outcome table, log lines, monitoring, grants, platform summary). Cross-linked from Common Uses, Managing Channels, the Database Tables reference, and the Glossary. The variables join the Technical Guide's Processor page (a new "Other environment variables" reference), the log-line reference, bothdeploy/*.env.examplefiles (commented out), and the Security Guide'sProtectSystem=strictnote.
Changed¶
- CI: native Windows verification on
wtags. A newtest-windowsjob runs the Go unit suite,go vet, a native build ofpg_relay-windows-x86_64.exe, and a-hsmoke run on GitLab.com's hosted Windows Server 2022 runner (saas-windows-medium-amd64). It is triggered only by a tag matchingw{major}.{minor}.{patch}(w1.6.0), a separate family from release tags, so a Windows check can be pushed at will without cutting a release and a release never waits on a Windows runner. Documented in RELEASING.md. - CI: the test stage is now three jobs with three separate triggers. The full 14-job Linux matrix (
test) runs only on ab{major}.{minor}.{patch}tag (b1.6.0); a new one-job smoke check (test-quick, PostgreSQL 18 on Ubuntu 26.04) runs on any push that changes the Go or SQL sources,go.mod/go.sum, theMakefile, or the control file;test-windowsstays onwtags. Avrelease tag runs no tests — only build, publish, and the security scans — so run abtag (and awtag if wanted) on the release commit first; RELEASING.md documents the order. Both Linux jobs share one hidden body (.test-linux). GitLab evaluatesrules: changesas true on every tag pipeline, sotest-quickcarries awhen: neverguard for tags. The Go toolchain version every job installs is a single top-levelGO_VERSIONvariable. - The 1.5 → 1.6 delta is deliberately tiny — the
pg_relay.file_spoolseed row and the version bump are the entire script. No new SQL function, no new grant, nopreflight()change, and no reserved channel: the claim → options-read → audit → done sequence reuses_queue_claim,_fetch_action,_write_log,_queue_mark_done, and_queue_insert_retryexactly as they already stand. A Processor already running pg_relay ≥ 1.1 picks up the feature with zero operator action beyond settingPG_RELAY_SPOOL_DIR. - Running a pre-1.6 binary against 1.6 SQL degrades loudly and safely: a file_spool row routes to
process_one(), which resolves it asunsupported_action_type— one audit row per occurrence, never a retry loop. Upgrade the binary to start writing. - Release tarball is now
pg_relay--1.6.sql.tar.gz, containingpg_relay.control,sql/pg_relay--1.0.sql, and the six upgrade deltas (plus theschedule/companion files as before).
[1.5.0] - 2026-09-05¶
pg_relay 1.5.0 adds OTLP metrics: a Processor-native action type, pg_relay.otlp, that sends a gauge metric point to any OpenTelemetry Protocol (OTLP) HTTP/JSON metrics receiver — Grafana Alloy's OTLP intake is the driving use case, but the wire format is the OTLP spec itself, not anything Alloy-specific. A metric measured inside the database (a connection count, a replication lag, a queue depth, a business figure) becomes one pgrelay.notify() call on an otlp channel, and the durable queue, retry chain, and audit log carry it to the receiver exactly as they carry every other event. The extension changes ship as the upgrade script sql/pg_relay--1.4--1.5.sql — existing installs run ALTER EXTENSION pg_relay UPDATE;, fresh installs chain the 1.0 script and all five deltas automatically. Documented in its own book on the docs site, OTLP Metrics (overview, a worked Grafana Alloy setup, and the full reference).
Added¶
- Reserved action type
pg_relay.otlp— seeded by the 1.5 delta (run_order 1, alongside'sql'and'pg_relay.exec': ordinary work, never ahead of the run_order-0 control types). It follows the Processor-nativepg_relay.reload/health/execpattern — no companion extension — with one deliberate difference: those three carry an empty action because their payload is all they need, whereas a metric point has to be sent somewhere, and different channels may target different receivers. So apg_relay.otlpchannel is an ordinary, unreserved registration whoseactioncolumn carries the receiver configuration as a JSON object instead of SQL:pgrelay.register('otel_metrics', '{"endpoint": "http://localhost:4318/v1/metrics"}', p_action_type := 'pg_relay.otlp'). An operator registers as many otlp channels as there are receivers;register/update/get/listtreat them like any other channel, andpgrelay.queue_stats()reports them like any other action type. - Channel config (the
actionJSON):endpoint(required — the URL the export request is POSTed to),service_name(optional, defaultpg_relay— sent as theservice.nameresource attribute),auth(optional — the webhook transport's exactauth.stylevocabulary,bearer_header|custom_header|basic_auth, applied only when the key is present, since a local OTLP receiver is commonly unauthenticated), andtimeout_seconds(default 30, cap 120, via the shared send timeout). Any_env:VAR_NAMEstring anywhere in the config is resolved from the Processor host's environment immediately before use — the same rule'notify'profiles follow — never cached, never stored, never logged.host.nameis never configurable: every point carries this Processor instance's own OS hostname, aspg_relay.healthanswers already report theirhost. - The metric payload — the
notify()payload is one gauge data point:{"name": "...", "value": <number>, "timestamp": "<RFC 3339>", "attributes": {...}}.name,value, andtimestampare all required: the timestamp is the moment the measurement was taken, supplied by the producer, and deliberately never a default-to-now fallback — a row can sit in the queue before dispatch, and a send-time stamp would silently report the wrong moment.valuemaps to OTLP'sasInt(whole number) orasDouble(fractional part).attributesis optional and typed from the JSON value itself — string →stringValue, boolean →boolValue, whole number →intValue, fractional →doubleValue; a nested object or array is rejected, naming the key, since OTLP attribute values are not shape-blind the way the webhook transport'sbody_mergeis. Every event maps to exactly one standard OTLPresourceMetricsexport request — pg_relay never batches queue rows into one request, so each stays independently retryable. - Go: the
pg_relay.otlpevent handler (internal/processor/otlp.go) — routed likesql/notify/pg_relay.reload/health/execin the dispatch worker, using the same held-transaction claim ceremony: claim → parse the payload → read the channel config via the existingpgrelay._fetch_action()(the very callprocess_onemakes for a'sql'channel's action text, here holding JSON) → resolve_env:references → build the OTLP envelope → POST → audit → done, with a transient failure scheduling a retry through the ordinary chain. Classification is the acs/gmail split, not the webhook transport'sclassify_webhook_responsedelegation (OTLP has a fixed, well-known response shape; there is no companion to ask): 2xx is success; a malformed payload or channel config (including a missingendpoint) is permanent with no request sent — configuration cannot improve by retrying; a 3xx redirect (never followed, mirroring the webhook transport) or any other 4xx is permanent; 429, 5xx, and a network/DNS/TLS/timeout failure are transient. The payload and channel config are never logged to stdout at any level — only channel, queue id, and log id reach the Processor's own log lines — and a dial failure's error text has the endpoint URL stripped before it can reachpgrelay.log.error, since a signed push URL is itself a credential. - Go suite
internal/processor/otlp_test.go— the payload's required-field matrix and attribute typing, config parsing with_env:resolution end to end (an unset variable named as a permanent error), the exact wire envelope, the full status-classification matrix against realhttptestreceivers (including a redirect resolved without being followed and a genuine dial failure classified transient), and a fullprocessOTLPEventdrive asserting the held-transaction statement order, the metric name absent from every log line, permanent resolution with no retry for bad input, and a transient 503 scheduling one. - v1.5 SQL test suite
test/pg_relay_test--1.5.sql— replaces the 1.4 suite as the suite CI runs (make test-sql). Adds the 1.4→1.5 leg of the upgrade-path test and five new tests (124–128) covering what SQL owns: the seed row and the still-reservedpg_relay.*prefix, an otlp channel as an ordinary registration carrying real config (never reserved, never empty),notify()enqueuing with no special-casing,queue_pending_ids()offering a due otlp row, and old-binary degradation. The send and classification logic is Go-tested. - Resource identity on
pg_relay.otlpchannels — the channel config gains two first-class keys alongsideservice_name:service_namespace(→ theservice.namespaceresource attribute) anddeployment_environment(→deployment.environment.name, the current OpenTelemetry semantic-convention key; the deprecateddeployment.environmentis never emitted), plus a free-formresource_attributesobject for any other resource attribute (service.version,service.instance.id,cloud.region, …), typed from each JSON value exactly like data-point attributes and sorted by key. These are what convention-aware backends group by — Grafana derives itsjoblabel as<service.namespace>/<service.name>and maps services to environments bydeployment.environment.name; without them pg_relay's metrics appear ungrouped. Because they live in the channel config,_env:VAR_NAMEreferences resolve in all of them (an environment name can come from the deployment's own environment), and the four keys with dedicated handling —service.name,service.namespace,deployment.environment.name, and the never-configurablehost.name— are rejected insideresource_attributesso a free-form entry can never shadow them. A non-objectresource_attributesor a nested value inside it is a permanent error with no request sent, the existing malformed-config rule. Go-only; no SQL delta, no new grant. Documented in the OTLP Metrics book's new Building a Metric Document chapter — a step-by-step guide for developers assembling the JSON a producer sends — and the reference. - Security Guide — a new book on the documentation site covering where the Processor should run and how to lock it down: the rule that co-locating the Processor with PostgreSQL is for sandboxes and development only (every other deployment runs it on its own host in a restricted, no-inbound zone with allowlisted egress); the cloud-managed equivalents (private networking, identity-based database credentials, no long-lived password); an honest blast-radius assessment of a fully compromised Processor (what the
pgrelayrole can and cannot do, whyGRANT … TO pgrelayis the one knob that sets it, detection, and a containment runbook); the hardened Linux service (a dedicatednologinuser, the full systemd sandbox —NoNewPrivileges,ProtectHome,PrivateTmp,ProtectSystem=strict,ProtectProc=invisible, and the rest —hidepid=2on/proc, core dumps disabled at the unit,systemd-coredump, and kernel layers, a root-only environment file the Processor's own user cannot read, and credential rotation); database-side controls (pg_hba.confper-hosthostssl+ SCRAM,verify-fullTLS, thepgrelayrole's shape,PUBLICprivilege review); and a per-tier checklist. No Processor code change. Ships with two new deploy files —deploy/pg_relay-hardened.service(the production unit: dedicatednologinuser, the full sandbox,LimitCORE=0, annotated prerequisites) anddeploy/pg_relay-hardened.env.example(TCP,verify-full, proxy and secret placeholders; installed root-only since systemd reads it before dropping privileges) — whiledeploy/pg_relay.serviceandpg_relay.env.exampleremain the sandbox-tier examples (User=postgres, Unix socket) and now say so in their headers.
Changed¶
- The 1.4 → 1.5 delta is deliberately tiny — the
pg_relay.otlpseed row and the version bump are the entire script. No new SQL function, no new grant, nopreflight()change, and no reserved channel: the claim → config-read → audit → done sequence reuses_queue_claim,_fetch_action,_write_log,_queue_mark_done, and_queue_insert_retryexactly as they already stand, all already granted to thepgrelayrole and already checked. A Processor already running picks the whole feature up on upgrade with zero operator action — not even a reload;grant_user()is untouched, so unlike the 1.1–1.4 upgrades there is nothing to re-grant. sanitizeURLError(internal/processor/default_notifications.go) takes an operation label so the otlp handler shares the lifecycle-webhook path's URL-stripping rule; its existing callers now label their errorsnotify sendexplicitly (message text unchanged).- Running a pre-1.5 binary against 1.5 SQL degrades loudly and safely: an otlp row routes to
process_one(), which resolves it asunsupported_action_type— one audit row per occurrence, never a retry loop. Upgrade the binary to start sending. - Release tarball is now
pg_relay--1.5.sql.tar.gz, containingpg_relay.control,sql/pg_relay--1.0.sql, and the five upgrade deltas (plus theschedule/companion files as before). - Go toolchain bumped from
go1.26.5togo1.26.7, pgx fromv5.7.5tov5.10.0, and the minimum Go version raised from 1.23 to 1.25. pgx 5.8.0 through 5.10.0 bring a substantial hardening pass against a malicious or compromised server (bounded binary decoders, capped SCRAM iteration counts, cancel requests sent over TLS when the main connection used TLS, the newrequire_auth/PGREQUIREAUTHsetting to refuse authentication downgrades undersslmode=prefer), the 5.9.2 fix for placeholder confusion inside dollar-quoted strings (GHSA-j88v-2chj-qfwx — reachable only through the simple protocol with a caller-supplied argument, which the Processor never does), SCRAM-SHA-256-PLUS channel binding, and fixes to connect-time context handling (a data race on cancellation during connect, a goroutine leak in the context watcher, a fresh context for thesslmode=preferfallback). The wire protocol still defaults to 3.0, so connections to PostgreSQL 15–17 and to poolers are unchanged.golang.org/x/cryptoleaves the dependency graph entirely (pgx 5.8.0 dropped it);golang.org/x/textmoves tov0.41.0. pgx 5.9.0 requires Go 1.25, so thegodirective moves from1.23.0to1.25.0— the minimum to build from source — while the pinned toolchain does the actual compiling and is fetched automatically by any Go 1.25 installation.govulncheckreports no known vulnerabilities; no Processor source changed and all Go and SQL tests pass unmodified. The CI test and build stages now install Go 1.26.7 directly instead of relying on the toolchain auto-switch from an end-of-life 1.23. No SQL files are affected. - Documentation site restructured for readability. The thirteen top-level books are regrouped under five tabs — Start Here, Deploy, Features, Reference, About — so a newcomer sees the User Guide first and the optional books (multi-master, OTLP, the schedule type, which is now an appendix of Scheduled Jobs) in their place. The home page is cut to a one-paragraph explanation, the flow diagram, and a "where to start" table; the User Guide is the single full explanation of what pg_relay is, and the other books link to it instead of re-explaining. Every feature book opens with a "Who needs this book" note. Three oversized pages are split at their headings (Function Reference into three; Lifecycle Notifications' message variables and the Security Guide's host settings into their own chapters), and the longest sentences across the Technical Guide, Scheduled Jobs, and Security Guide are broken up. Acronyms get hover definitions site-wide from one shared abbreviations file, replacing inline glosses. The site is now the only user-facing documentation: the root-level
USER_GUIDE.md,TECHNICAL_GUIDE.md,CLOUD_SETUP.md,EXAMPLE_USAGE.md, andMULTI_MASTER_DEPLOYMENT.mdduplicates are removed, the site's Release Notes page includes thisCHANGELOG.mddirectly instead of paraphrasing it, andREADME.mdis a short pitch plus links (the public repository's README is now this file as-is, no longer a renamedUSER_GUIDE.md). Version numbers are no longer hand-maintained on the home page or README — the Release Notes are the one source.
Deferred¶
- Only the gauge metric point type ships in 1.5. Sum and histogram points are deliberately not inferred from the payload's shape — each needs an explicit payload-contract decision first (aggregation temporality for sums, bucket boundaries for histograms) and will arrive in a future delta with one.
[1.4.0]¶
pg_relay 1.4.0 adds scheduled jobs: a pg_cron replacement built directly on the durable queue, with second-level scheduling pg_cron cannot do. A scheduled job is a perpetual queue row — its run_at always holds the next occurrence, so the Processor's existing 1-second poll detects every instance with zero changes to the dispatch queries and zero changes to the Go binary. The call shape is pg_cron's own: pgrelay.schedule_job('nightly', '0 3 * * *', 'CALL run_report()') is a complete job — the SQL payload runs on the seeded, reserved pg_relay_adhoc channel — while naming a channel (schedule_job('nightly', '0 3 * * *', 'summary', 'report_chan')) fires that channel with the payload as its ordinary $1. Because a scheduled job is an ordinary queue row on an ordinary channel, every existing capability composes for free — any action type (a recurring Processor health snapshot is one call), concurrency_mode, node-restricted channels, multi-node ownership and adoption, pause/reload, and the audit log. The extension changes ship as the upgrade script sql/pg_relay--1.3--1.4.sql — existing installs run ALTER EXTENSION pg_relay UPDATE;, fresh installs chain the 1.0 script and all four deltas automatically.
Added¶
- Perpetual schedule rows — six new
pgrelay.queuecolumns:schedule(the recurrence spec; NULL = ordinary one-shot event, completely unchanged),schedule_name(the job's case-insensitive identity, unique among live schedules via a partial index), the job-record statsschedule_updated_at,schedule_first_run_at,schedule_run_count, and the pause bookkeepingschedule_paused_until. A schedule row is never consumed:dispatched_atstays NULL for its whole life, and existing column semantics extend naturally —expire_atis the schedule's end date (the expired branch deletes it),cancel(id)stops it,pgrelay.log WHERE queue_id = <id>is its complete run history under one stable id, andpurge_queue()never touches a live schedule. - Schedule syntax (
pgrelay._next_run(spec, after)— the one calculator every path uses, doubling as the registration-time validator): a PostgreSQL interval ('30 seconds','2 hours'), traditional 5-field cron ('*/5 * * * *'— min hour dom month dow, with lists, ranges, steps, and the standard dom/dow OR rule; dow 0 and 7 both Sunday), 6-field cron whose first field is seconds ('20 * * * * *'— second-granularity calendar scheduling, one better than pg_cron), or apgrelay.schedulecompanion value supplied as its composite literal (e.g.pgrelay.daily('04:30')::text) — complex calendar arrangements (nth weekday, month-end, per-day times) delegated to the optionalschedule/companion's ownpgrelay.next_run()through guarded dynamic SQL, so the extension keeps no static reference to it and a companion-literal spec without the companion installed raises a clear error at registration. Cron evaluates in the session timezone; missed occurrences are skipped — a late row (Processor down, fleet paused) fires once and the next occurrence is computed strictly after now. - The claim-time advance —
_queue_claim()moves a scheduled row'srun_atto the next occurrence at claim, before the payload runs, inside the claim's own transaction: fixed cadence (computed from launch time, not completion time), crash-safe (a crash mid-payload rolls the advance back and the job re-fires — the same at-least-once contract every event has), and outcome-independent (a payload error is contained byprocess_one()'s savepoint, so the schedule marches on, each run audited). The same UPDATE maintainsschedule_run_countandschedule_first_run_at, so they count committed launches. Because every action type begins with_queue_claim(), scheduling works identically forsql,notify,pg_relay.reload, andpg_relay.healthevents — with no Go changes at all. A spec that stops being evaluable (a companion literal after the companion is uninstalled) is treated as a poison event: the schedule stops loudly, once — anerroraudit row naming the cause — never an error-per-tick loop. - The overrun bump —
_queue_mark_done()leaves a scheduled row pending, and if a run outlasted the occurrence the claim computed, re-advancesrun_atpast now() — re-applying skip-missed instead of firing back-to-back to catch up. Ordinary rows are consumed exactly as before; retries of a failed scheduled run are ordinary one-shot rows (the schedule is never copied). pgrelay.schedule_job(p_name, p_schedule, p_payload DEFAULT NULL, p_channel DEFAULT 'pg_relay_adhoc', p_first_run_at DEFAULT NULL, p_expire_at DEFAULT NULL, p_run_as DEFAULT NULL, p_timeout_seconds DEFAULT NULL, p_detached DEFAULT false)— registers (or re-registers) a named job, returning its stable queue row id. The parameter order deliberately mirrorscron.schedule(jobname, schedule, command): with only a payload, the job runs it as SQL on the reserved adhoc channel; with a channel, the payload is that channel's ordinary$1; an exec channel with no payload raises (which is also the neither-supplied error), and the three exec-only parameters raise on any other channel. Validates likenotify()(unregistered/inactive channel raises; the spec is validated at the call site even whenp_first_run_atis supplied). Upserts by name: an existing live schedule keeps its id, history, and launch stats while channel/payload/spec/first-run/end-date update in place.p_first_run_atoverrides the computed first occurrence verbatim (a past value fires on the next tick); recurrence follows the spec either way. Namedschedule_job— notschedule— becausepgrelay.scheduleis the companion's recurrence domain.- The
pg_relay.execaction type — ad-hoc SQL outside a transaction. The Processor gains a fourth event handler (internal/processor/exec.go): the job's payload is executed as SQL on a dedicated autocommit connection, top-level, soVACUUM,CREATE INDEX CONCURRENTLY, andREINDEX CONCURRENTLYcan be scheduled — the pg_cron capability an in-transaction action could never offer. The payload is sent as one query string (psql -csemantics: a multi-statement payload runs in the protocol's implicit transaction — atomic, but a non-transactional command must then be the payload's only statement). Two lanes per job:- Inline (default): the notify-style held claim, payload under a timeout (
p_timeout_seconds, default 300s, cap 43200s), at-least-once (a crash re-fires the occurrence). An inline payload occupies its worker — and this instance's dispatch barrier — for its duration; other Processor instances keep draining viaSKIP LOCKED(run ≥2 instances if you use long inline timeouts). - Detached (
p_detached := true) — the maintenance lane: the claim commits immediately (worker freed, the whole instance keeps its 1-second cadence), the payload runs in its own goroutine on its own connection, a session advisory lock on the job id guarantees occurrences never overlap even across instances (a still-running prior occurrence resolves the new one as anoverlap_skippedaudit row), concurrency is capped by thepgrelay.optionskeyexec_max_detached(default 5; at the cap the row simply stays pending until a slot frees), no default timeout (cap 43200s when set), at-most-once (a crash loses that occurrence; the next proceeds). p_run_as— least-privilege execution, pg_cron's rule: the payload runs underSET ROLE <run_as>, where the scheduler must hold the role (pg_has_role, checked atschedule_job()time — you cannot schedule privileges you don't have) and the DBA must have opted the role in withGRANT <role> TO pgrelay(checked too, with the remedy in the error text). The shared inline connection runsDISCARD ALLbetween payloads, so no job's role or session state leaks into the next. Within the management tier this is least-privilege hygiene, not a hard boundary — documented as such.
- Inline (default): the notify-style held claim, payload under a timeout (
- The reserved
pg_relay_adhocchannel +pgrelay.actions.reserved— the seeded default channel (action_typepg_relay.exec, empty action — the Processor does the work). A channel whose payload is executable SQL inverts the queue's core safety property (payloads are inert data), so it is protected by a row-levelreservedflag, not a name string:notify()— the one PUBLIC entry point — refuses reserved channels (schedule_job()andrun_sql(), both management-granted, are the only ways onto them, the same trust level as pg_cron's schema grant);unregister()refuses to drop them (channels have no rename path, so the identity is pinned for good);update()refuses to repoint their action while every tuning knob (max_retries,concurrency_mode, notes) still works;enable()/disable()remain available as a deliberate suspend-all-adhoc-jobs lever; andregister()never exposes the flag, so user channels are always unreserved. pgrelay.run_sql(p_sql, p_run_at DEFAULT now(), p_expire_at DEFAULT NULL, p_run_as DEFAULT NULL, p_timeout_seconds DEFAULT NULL, p_detached DEFAULT false)—schedule_job()'s one-shot sibling: submits a single ad-hoc statement, with no name and no recurrence spec, as an ordinary (schedule-NULL) queue row on the reservedpg_relay_adhocchannel. The statement runs exactly as a scheduled ad-hoc job's would — by the Processor, outside a transaction, with the samep_run_as/p_timeout_seconds/p_detachedexecution options — on the next 1-second poll, or deferred viap_run_at("run thisVACUUMat 02:00, once"), withp_expire_atdiscarding it if it has not run by then. Returns the queue row id:cancel(id)cancels it while pending, andpgrelay.log.queue_idkeys its audit row after. Failures are terminal (no retries) — the audit row records them for inspection and resubmission. Completes the submission triangle:notify()= one-shot event on your channel (PUBLIC, payload inert),run_sql()= one-shot SQL on pg_relay's channel (management),schedule_job()= recurring either. Zero Go changes — a schedule-NULL exec row is the shape the handler already processes.pgrelay.unschedule_job(p_name)— stops a job through thecancel()machinery ('cancelled'outcome and audit row; the name is immediately reusable). An unknown or concurrently-locked name is'skipped'— never raises, never blocks.pgrelay.pause_job(p_name, p_until DEFAULT NULL)/pgrelay.resume_job(p_name)— suspend one job without cancelling it (the fleet-widestop()/pause_*()still pause everything). Implemented by repositioningrun_atitself —'infinity'for an indefinite pause, the first occurrence at/afterp_untilfor a bounded one — so the dispatch queries need no new logic and a bounded pause self-resumes database-side, exactly like the fleet'spause_until. The job keeps its id, history, and stats;resume_job()recomputes the next occurrence from now and is idempotent (it never postpones a due run); re-registering withschedule_job()also lifts a pause.pgrelay.queue.schedule_paused_untilrecords the pause for the listing, which now carries effectivepaused+paused_untilcolumns.pgrelay.run_job_now(p_name)— fires an immediate extra occurrence (something pg_cron cannot do): the row becomes due now, the next 1-second poll launches it through the ordinary claim — so it can never overlap an executing run, is audited under the job's stable id, and counts inrun_count— and the claim-time advance then restores the normal rhythm. Raises on a paused job (resume first) rather than silently lifting the pause.pgrelay.list_scheduled_jobs()— thecron.jobanalogue: id (the log-history key), name, channel, spec, payload,next_run,expire_at,created_at,updated_at,first_run_at, andrun_count— the last two maintained on the row itself, so they survive log purges.pgrelay.scheduled_job_runs(p_name DEFAULT NULL, p_limit DEFAULT 100, p_within DEFAULT NULL, p_status DEFAULT NULL)— thecron.job_run_detailsanalogue: per-run history frompgrelay.logvia the job's stable queue_id, newest first — status (ok/error/retry_scheduled/invalid/…), error text,elapsed_ms, and when it ran. Three freely-combinable optional filters: one job by name,p_within(an interval — only completions inside the trailing window, e.g.p_within := '1 hour'for the last hour's runs), andp_status(one outcome, case-insensitive —p_status := 'error'for just the failures).-
v1.4 SQL test suite
test/pg_relay_test--1.4.sql— replaces the 1.3 suite as the suite CI runs (make test-sql). Adds the 1.3→1.4 leg of the upgrade-path test and fourteen new tests (109–122) covering the calculator's grammars and validation, registration/upsert/unschedule/listing/run-history, the claim-time advance (including its rollback with the claim transaction and the poison-spec stop), the full perpetual-row lifecycle throughprocess_one(), the overrun bump, dispatch composition with node restriction, privileges, pause/resume (infinity parking, bounded self-resume, idempotency, the stale-marker tidy-up),run_job_now(), the reserved-channel guards, the pg_cron-simple SQL surface (exec-typed dispatch, run_as/timeout/detached validation and storage, safe old-binary degradation), andrun_sql()(validation, the one-shot row shape, cancel by id, purge cleanup, the disabled-channel refusal). Ad-hoc execution itself — both lanes, timeouts, SET ROLE, the advisory-lock overlap skip, capacity withholding — is covered by the new Go suiteinternal/processor/exec_test.go. -
pgrelay.queue_pending(p_limit DEFAULT 99)— the pending (undispatched) event rows, deferred ones included, in dispatch order: id, channel, payload, queued/run/expire times, retry_count, parent_id, and the node columns. The management complement tocancel(): the returned id is exactly whatcancel()takes, so a management role can now find the pending row it wants to withdraw. Excludes perpetual schedule rows (list_scheduled_jobs()is their view) and takes no locks — a passive read, not the Processor's dispatch feed. pgrelay.log_report(p_limit DEFAULT 99, p_within DEFAULT NULL, p_status DEFAULT NULL, p_channel DEFAULT NULL)— the audit-log read surface (thehealth_report()pattern applied topgrelay.log): recent completions of every queued item, newest first, with combinable case-insensitive filters for a trailing window, one status, and one channel. Until now the log was readable only by superusers or through the schedule-scopedscheduled_job_runs().- psql shortcuts (
deploy/pg_relay_shortcuts.sql) —\setquery macros to reference from~/.psqlrc::schedules(every live job) and:schedules5m/15m/1h/4h/8h/1d(jobs due within the coming period);:jobs(the last 99 completions, newest first, every status) and:jobs5m…:jobs1d(completions within the trailing period);:jobs_err(the last 99 not-okcompletions) and:jobs5m_err…:jobs1d_err(the same, windowed);:queue(the pending event rows, in dispatch order) and:queue_done(the last 99 completed queue items of any kind). Plain macros over the management read functions — they need only thegrant_user()grant, nothing installed in the database.
Changed¶
pgrelay.grant_user()additionally grantsschedule_job(),run_sql(),unschedule_job(),pause_job(),resume_job(),run_job_now(),list_scheduled_jobs(),scheduled_job_runs(),queue_pending(),log_report(), and_next_run()(the last as an operator spec validator / next-occurrence previewer). If you are upgrading: re-runpgrelay.grant_user(role)for each management role, exactly as the 1.1, 1.2, and 1.3 upgrade notes asked.grant_relay()andpreflight()are untouched — the Processor drives scheduled rows through functions it can already execute, so a running Processor picks the feature up with no operator action (a reload or restart is not even required)._queue_claim()and_queue_mark_done()are recreated (CREATE OR REPLACE, ACLs preserved) with the schedule-aware behaviour above; their signatures and every dispatch predicate are unchanged.pgrelay.notify(),pgrelay.unregister(), andpgrelay.update()are recreated (CREATE OR REPLACE, ACLs preserved —notify()stays PUBLIC) with the reserved-channel refusals described above; behaviour on every ordinary channel is byte-identical.grant_relay()is recreated to additionally grant_exec_options()(the exec handler's per-job settings read) andget_option()(theexec_max_detachedread), both also granted to thepgrelayrole directly by the delta — a running Processor executes ad-hoc jobs with no operator action. Running a pre-1.4 binary against 1.4 SQL degrades loudly and safely: each exec occurrence logsunsupported_action_typeand the schedule itself is preserved.- Release tarball is now
pg_relay--1.4.sql.tar.gz, containingpg_relay.control,sql/pg_relay--1.0.sql, and the four upgrade deltas (plus theschedule/companion files as before).
[1.3.0]¶
pg_relay 1.3.0 adds Processor health snapshots: an on-demand way to ask a running Processor instance, through the database, to report on the host it runs on — CPU utilisation, memory utilisation, and disk space (os_metrics), the host's process table, optionally filtered by a search string (processes), or the Linux database-host audits: kernel and tuning posture (os_config), path-to-device storage resolution (storage), and network interfaces (network) — collected with the Go standard library only (no agents, no new dependencies) and answered into a durable table. It also adds lifecycle notifications: the Processor speaking for itself, out of band — an outage alert when the database becomes unreachable (the one failure it can never report through its own queue) with a recovery message when it returns, plus start/stop announcements — each an environment-variable contract over any transport (webhook, SMTP, M365, ACS, Gmail), with template variables in the messages and a no-database --test-notify mode. The extension changes ship as the upgrade script sql/pg_relay--1.2--1.3.sql — existing installs run ALTER EXTENSION pg_relay UPDATE;, fresh installs chain the 1.0 script and all three deltas automatically. Documented on its own page: the Technical Guide's "Processor Health Snapshots" chapter.
Added¶
- Reserved action type
pg_relay.health— seeded by the 1.3 delta (run_order 0, so health requests surface with reload requests ahead of ordinary work). Follows thepg_relay.reloadpattern exactly: register a channel with this action type (empty action) and enqueue requests through the ordinarypgrelay.notify(). The payload is the request — empty for all defaults, a bare string as shorthand for just the calling reference, or a JSON object with the optional keysreference(a caller-chosen correlation string echoed onto the answer row, NULL when omitted),request_type(defaultos_metrics; an unrecognised value resolves the event as a permanent error naming the supported types),disk(os_metricsonly: an OS-specific path qualifying the space calculation —/var/lib,D:\— defaulting to the Processor's working directory), andsearch(processesonly: a case-insensitive substring filter over process name and command line; absent/empty = every process). Because a request is an ordinary queue event,p_run_at,p_expire_at,p_deduplicate, andcancel()all apply unchanged. Exactly one instance claims each request (normalSKIP LOCKEDsemantics); a fleet survey means one request per instance. pgrelay.processor_health— the private answer table: responder identity in real columns (instance,node,host,os), request correlation (queue_id,request_type,reference,collected_at), and the results as a single jsonbdatadocument — deliberately never hard-coded metric columns, so future request types (process lists, installed versions, …) land without schema changes. Snowflakeid(likequeue/log), so the table is safe to replicate. Foros_metricsthe document carriescpu_percent(Linux and Windows, from two counter samples 250 ms apart; not reachable on macOS without cgo),load1(Linux and macOS; Windows has no load-average concept),mem_total_bytes/mem_available_bytes(all platforms; macOS approximates available as free pages × page size),disk_path/disk_total_bytes/disk_available_bytes(all platforms). Collection is best-effort per metric: anything unavailable is omitted, with the reason under the document'serrorsobject; the metrics describe the Processor host, which is not necessarily the database host.pgrelay._write_health(p_queue_id, p_request_type, p_reference, p_instance, p_host, p_os, p_data)— the Processor's answer path, called inside the same held transaction as the claim (thepg_relay.reloadceremony). Stampsnodefrompg_relay.node_id, normalises an empty reference to NULL, and returns the answer row's id. Joins the Processor operating set: granted bygrant_relay(), checked bypreflight()as a new requiredgrant:_write_healthrow, and granted to thepgrelayrole directly by the delta so existing installs pass with no operator action.pgrelay.health_report(p_limit, p_reference, p_request_type)— the read surface: answers newest first, optionally filtered by exact reference and/or request type. Granted bygrant_user().- The
processesrequest type — the Processor host's process table into the samedatadocument: the echoedsearch(when given),process_count(the full matched total),truncated, andprocesses— one object per matching process, worst-resident-memory-first, capped at the 800 largest by RSS (each command line capped at 2048 bytes,cmdline_truncatedflagging a cut). Per process, as the platform and privilege allow:pid/ppid/namealways;cmdline(on PostgreSQL backends this is the live status line —postgres: alice appdb … idle in transaction),username,state(D= stuck in I/O,Z= zombie),exe(on Linux(deleted)reveals a process running a since-replaced binary),rss_bytes,vsz_bytes,swap_bytes,threads,nice,cpu_seconds,cpu_percent(Linux: two samples 250 ms apart),started_at/elapsed_seconds,read_bytes/write_bytes,open_fds. Linux is first-class via a/procwalk; Windows (Toolhelp32) has no command line, sosearchmatches the executable name only; macOS collects via/bin/ps— the one supported doorway to a kernel ABI the standard library cannot express. Caution: command lines can carry secrets passed as arguments —processor_healthis private by design; do not relay process documents onward wholesale. - The
os_configrequest type (Linux) — kernel release and OS name, glibc version (executinglibc.so.6itself — the one documented Linux exec exception, matching macOS's/bin/ps; collation-breaking glibc upgrades are the most notorious PostgreSQL silent killer, and comparing this across a primary and its standbys before an OS upgrade is the early warning), OS locale, transparent-huge-pages enabled/defrag modes (the other classic), the database-hostvm.*/net.*sysctls (swappiness, dirty ratios, overcommit, somaxconn, TCP keepalives), cgroup version, SELinux/AppArmor mode, NUMA node count,auto_patchingconfig-presence indicators (presence only — patch history belongs to patch-management tooling), anddb_clock_offset_ms— the Processor's clock measured against the database'sclock_timestamp()on the request's own connection, a relative-drift signal for audit-timestamp correlation (handler-injected; collectors themselves stay database-free). - The
storagerequest type (Linux) — the request'spaths(default: the Processor's working directory) each resolved to mount point, filesystem type, full mount options, device, and the whole disk beneath (device-mapper stacks followed through their slaves), withrotational, the active I/O scheduler, andluks(dm-crypt anywhere in the chain). Pass the database'sdata_directoryand WAL paths to answer the shared-versus-dedicated-device and wrong-scheduler questions directly; nonexistent paths carry per-path errors, network filesystems report mount facts and omit device facts. - The
networkrequest type (Linux) — per non-loopback interface: MTU, link state, speed where reported, and bonding mode/slaves from/proc/net/bonding. processesenrichment — each Linux process now also reports its effective resource limits (limits:nofile/nprocsoft and hard from/proc/<pid>/limits— what the process actually got, the questionlimits.conf-versus-systemd-override comparisons are really asking) and itscpus_allowed/mems_allowedpinning.pgrelay.purge_health(p_hours)— trims answers older thanp_hours(default 168 = 7 days), returning the count; thepurge_queue()of the health table. Granted bygrant_user().-
Go: the
pg_relay.healthevent handler (internal/processor/health.goplus per-OS collectorshealth_linux.go/health_darwin.go/health_windows.go) — routed likesql/notify/pg_relay.reloadin the dispatch worker; claim → collect →_write_health→ audit → done in one held transaction; a malformed payload or unknownrequest_typeresolves permanently (audit row, no retries burned). Collectors are stdlib only: procfs andstatfson Linux,sysctlandstatfson macOS,kernel32(GetSystemTimes/GlobalMemoryStatusEx/GetDiskFreeSpaceExW) viasyscall.NewLazyDLLon Windows. Success logshealth_reported: <channel>, id: <queue id>at info; the payload — and therefore the reference — is never logged, per the standing rule. -
PG_RELAY_START_NOTIFYandPG_RELAY_STOP_NOTIFY— start/stop lifecycle contracts joining the outage dead-man's switch (PG_RELAY_OUTAGE_NOTIFY— its rework from v1.2's prototype is under Changed below), same shape with a singlemessageblock, each independently choosing its transport. The start message is sent once, right afterpg_relay started(one best-effort attempt; failure warns, never blocks). The stop message is sent on any deliberate exit — cleanSIGTERM/SIGINTshutdown and fatal error exits (preflight failure, failed reload) alike — as one bounded attempt on the way out; a crash orkill -9inherently sends nothing. New log lines:start_notify_sent/start_notify_failed,stop_notify_sent/stop_notify_failed. - Template variables in lifecycle-notification messages — message-block strings may embed
{{variable}}tokens, expanded at send time with Processor-local facts that never require a database query (the outage message fires precisely when the database is gone):{{time}}/{{time_local}},{{host}},{{os}},{{pid}},{{instance}},{{event}},{{database}}/{{dbhost}}, and — in outage contracts only —{{outage_started}},{{outage_seconds}},{{outage_duration}}(1m 34s). Timestamp variables accept a PostgreSQLto_char()-style format after a colon ({{time:YYYY-MM-DD HH24:MI:SS}}; supported subset documented, literal letters double-quoted exactly as into_char,Month/Dayunpadded,OFalways with minutes). Everything validates at startup: an unknown variable, a format on a non-timestamp variable, an outage-only variable in a start/stop contract, or an unrecognized to_char token all refuse startup naming the offender — a typo can never render garbage into a 3 a.m. page. Non-matching brace shapes pass through verbatim, so bodies aimed at systems with their own templating are never mangled;--test-notifyprints the fully rendered message and so doubles as a template previewer. - Literal-brace escape variables
{{open}}/{{close}}in lifecycle-notification messages — two new registry variables rendering the literal characters{{and}}, for message bodies aimed at a downstream system whose own templating collides with the token shape:{{open}}incident_url}}(or the symmetric{{open}}incident_url{{close}}) lands on the wire as the literal{{incident_url}}. Expansion is a single pass, so the braces they emit are never re-expanded —{{open}}time}}reliably delivers{{time}}, not a timestamp. A bare trailing}}already passed through verbatim, making{{close}}purely cosmetic. Built into the binary like every other variable — nothing to configure or register. --test-notify [VAR]+PG_RELAY_TEST_NOTIFY— the one notification tester (replacing--test-env). With a variable name (pg_relay --test-notify PG_RELAY_STOP_NOTIFY) it sends that production contract exactly as the daemon will fire it: an outage contract sendsoutage_message(orrecovery_messagewith--recovery), a start/stop contract itsmessage; email transports report the provider's reference in place of an HTTP status. Bare, it sends the complete, self-contained contract inPG_RELAY_TEST_NOTIFY— an administrator's connection test: transport + profile + a message with every key the transport requires, template variables included, a harmless message of your own before the production variables depend on the connection. The daemon never readsPG_RELAY_TEST_NOTIFY, so a broken test contract cannot affect a running Processor; validation and delivery are exactly the daemon's own; exit 0 only on a delivered send — a config linter and template previewer in one.
Changed¶
- The GitLab CI test matrix additionally exercises PostgreSQL 18 on Ubuntu 26.04 (PGDG's
resolute-pgdgdist) — the newest Ubuntu LTS, newest-PG-only for now, alongside the existing Fedora forward-visibility job. -
pgrelay.purge()gainedp_processor_health boolean DEFAULT true— the one scheduled purge call now applies its retention rule (age withp_hours, keep-the-newest-N withp_keep_quantity) topgrelay.processor_healthas well aspgrelay.log, returning the combined count. Passfalseto leave health answers alone;purge_health()remains the targeted trim with its own retention. If you are upgrading: the parameter list changes, so the old two-argumentpurge(integer, integer)is dropped and recreated — any per-role grants made directly on it are reset; re-runningpgrelay.grant_user(role)(already required by this release, below) restores them on the new signature. -
BREAKING: outage alerting is now "lifecycle notifications", and
PG_RELAY_OUTAGE_WEBHOOKis renamed toPG_RELAY_OUTAGE_NOTIFYwith a new contract shape. The old variable is no longer read and the old top-levelurl/auth/outage_bodylayout no longer parses — the contract is now{"transport", "profile", "outage_message", "recovery_message"}, with the connection settings nested underprofileand the messages renamed. The--test-envflag is likewise replaced by--test-notify [VAR]. (Accepted as a clean break: the prior shape and flag had no known deployments.) - Every lifecycle contract can now use any medium, not just a webhook.
transportselectswebhook(the default — JSON POST, hard-coded 2xx success, redirects never followed; the notifier webhook transport'sbody_mergeis honoured with identical semantics — profile-owned keys,_env:-resolved, overlaid onto the message at send time and overwriting any same-named key, so a body-authenticating credential like PagerDuty'srouting_keylives in an environment variable instead of literally in the contract; a non-objectbody_merge, or a non-object message while one is configured, refuses startup) orsmtp/m365/acs/gmail, whoseprofileand message blocks use the identical schemas as their pg_relay_notifier profiles and route through the very same transport senders — which work with the database down, since none of the email senders touch it (OAuth token endpoints are external HTTPS). A local SMTP relay withauth: "none"is the one medium with no external dependency at all. Email messages are validated at parse time (recipients, a body), so a typo refuses startup rather than warning per send; email Message-IDs are seeded deterministically from the outage episode's start (retries of one episode's message share an id for receiving-side dedup; recovery and start/stop messages get their own). - The contract's
profileresolves_env:VAR_NAMEreferences exactly like a notifier profile, so a credential lives in its own environment variable — typically the very one the notifier profile for the same provider already references — and rotation never edits the contract (a Processor restart is still required: the process environment is fixed at exec). The message blocks are sent as written — an_env:string inside one stays literal, mirroring the transports' "_env:resolves in profile, never in message" rule. An_env:reference to an unset variable refuses startup (notify_config_invalid, previouslyoutage_config_invalid) or fails--test-notify, never surfacing mid-outage.
Upgrade notes¶
-
If you configured the pre-release
PG_RELAY_OUTAGE_WEBHOOK, rewrite it as aPG_RELAY_OUTAGE_NOTIFYcontract (new shape — see the Technical Guide's "Lifecycle Notifications" chapter) and change--test-envinvocations to--test-notify. -
grant_user()is recreated to additionally granthealth_report()andpurge_health(). Re-runpgrelay.grant_user(role)for each management role after upgrading, exactly as the 1.1 and 1.2 upgrade notes asked for their own additions. (grant_relay()is also recreated, but the delta grants the new_write_health()to thepgrelayrole itself — no operator action needed for the Processor.)
[1.2.0]¶
pg_relay 1.2.0 pairs one focused extension change with a substantial Processor release.
On the SQL side: cancelling a queued event before the Processor selects it, and a way to find out which event to cancel. The extension changes are delivered as the upgrade script sql/pg_relay--1.1--1.2.sql — existing installs run ALTER EXTENSION pg_relay UPDATE;, fresh installs chain the 1.0 script and both deltas automatically.
On the Processor side: four additions to the 'notify' transports — the first two driven by Microsoft steering bulk/transactional email off Exchange Online toward Azure Communication Services (ACS) and deprecating Exchange Online's own SMTP AUTH in favor of OAuth2, the second two extending the same machinery to Google (sending through an existing Gmail / Google Workspace mailbox, over either SMTP or the Gmail REST API; design: AZURE_COMMUNICATION_SERVICES_DESIGN.md) — plus two webhook-transport profile fields (body_style, body_merge), out-of-band outage alerting for the one failure the queue itself can never report (the database being unreachable), and a Processor that waits instead of exiting when the database is unreachable at startup. The transport additions require paired pg_relay_notifier SQL support (the 'acs'/'gmail' transport values, auth = 'oauth2' profile validation, and the body_merge profile field) — separate work in that project and a hard prerequisite for them to be reachable in practice; until it ships, events targeting them resolve as clear permanent errors rather than being silently dropped.
Added¶
pgrelay.cancel(p_id bigint)— cancels queue rowp_idif it has not yet been dispatched. Locks the row withFOR UPDATE SKIP LOCKED, so it never blocks: if the row is already dispatched (normally, or by an earlier cancel) or is locked right now by a concurrent claim or a concurrent cancel, it returnsoutcome = 'skipped'and changes nothing — the same conventionprocess_one()already uses for an already-claimed row. On success it sets the newpgrelay.queue.cancelled_atcolumn anddispatched_atto the same moment, writes a'cancelled'pgrelay.logrow, and returnsoutcome = 'cancelled'. Reusingdispatched_at(rather than inventing a status column) means every existing dispatch query —queue_has_work(),queue_probe(),queue_pending_ids(),_queue_claim()— excludes a cancelled row with no changes at all, andpurge_queue()cleans one up exactly like a normally-processed row. Deliberately not scoped by the node-restriction/leadership dispatch predicate: a node-restricted or non-leader-owned pending row is still cancellable, because cancelling is not the same question as "who is allowed to dispatch this." Not part ofgrant_relay()or anypreflight()check — the Processor itself never calls it; granted to producer/management roles viagrant_user().pgrelay.queue.cancelled_at— nullable timestamp, set only bycancel().transport = 'acs'— a new first-class transport for Azure Communication Services' RESTemails:sendAPI (internal/processor/acs.go), structurally mirroring the existingm365transport: client-credentials OAuth2 (tenant_id/client_id/client_secret/endpoint/frominprofile) against thehttps://communication.azure.com/.defaultscope. ACS's response is asynchronous (202 Acceptedwith an operation id in the body, not a final delivery result) — treated as terminalsent, matching the M365sendMailfire-and-forget precedent already in place; the Processor does not pollOperation-Location.provider_refis the operationid. Unlike Graph (HTML wins when both are given), bothbody_text/body_htmlare sent simultaneously when present. Classification matches the existing SMTP/M365 taxonomy: 429/5xx/network transient, other 4xx permanent.auth = 'oauth2'on the existingtransport = 'smtp'(internal/processor/smtp.go) — a generalization, not an ACS-specific addition: a hand-rolled XOAUTH2 SASL mechanism (xoauth2Auth; no new dependency) authenticates using an Entra client-credentials token instead of a static password, read from a nestedoauth2profile block (username/tenant_id/client_id/client_secret/scope). Becausescopeis a profile field rather than hardcoded, the identical mechanism also serves as Exchange Online's own documented replacement for its SMTP AUTH deprecation — not just ACS's SMTP relay. An AUTH rejection has no HTTP-401-style refresh point the way M365/ACS do, so a failed send now explicitly invalidates that cache entry (invalidateOAuth2Token) rather than relying on natural expiry, bounding the blast radius of an out-of-band secret rotation to one failed send instead of up to an hour of them.internal/processor/oauth2token.go— the M365 transport's token-cache code, generalized and extracted soacsandsmtp's new oauth2 mode share it:getOAuth2Token(ctx, tokenURL, tenantID, clientID, secret, scope, force), cached on(tenant_id, client_id, scope)— scope joins the cache key so a tenant+client pair reused with different scopes across transports cannot collide. M365's own behavior is unchanged (same expiry slack, same refresh-on-401 flow).transport = 'gmail'— a new first-class transport for the Gmail REST API'smessages.send(internal/processor/gmail.go), for sending through an existing Gmail / Google Workspace mailbox (a shared team inbox, a small-scale internal notifier — Google has no ACS-style bulk product, and daily mailbox quotas apply). Authentication is the OAuth2 refresh-token grant — a one-time per-mailbox consent (token_url/client_id/client_secret/refresh_tokenin a nestedoauth2profile block), never a service-account JWT flow. The request wraps the same MIME message the SMTP transport builds (buildMIMEMessage, deterministic Message-ID included), base64url-encoded into{"raw": ...}and sent asusers/me— the token's own mailbox;frommust be that mailbox or one of its Send As aliases.bccrecipients ride as aBccheader (the API has no SMTP envelope). Success is a synchronous 200 whose body carries the sent message's id asprovider_ref— a real final result, unlike ACS's async 202. Classification: 429 and 403 (Gmail signals quota/rate exhaustion as 403) plus 5xx/network transient; other 4xx permanent; one automatic refresh-and-retry on 401.grant_typeselector on the SMTPoauth2block (internal/processor/smtp.go) — the block's grant defaults toclient_credentials(the existing ACS/Exchange shape, unchanged, no migration) and now also acceptsrefresh_tokenfor Gmail / Google Workspace SMTP (smtp.gmail.com,smtp-relay.gmail.com):username/token_url/client_id/client_secret/refresh_token, withtoken_urla profile field because refresh-token providers use flat endpoints with no tenant segment. Google's SMTP servers accept the identical XOAUTH2 SASL string, so everything downstream — MIME building, STARTTLS, Message-ID — is untouched. An SMTP AUTH rejection invalidates whichever grant's cache entry the profile used (invalidateOAuth2RefreshTokenjoinsinvalidateOAuth2Token).-
getOAuth2RefreshToken(internal/processor/oauth2token.go) — the refresh-token sibling ofgetOAuth2Token: same form-encoded POST, response shape, cache, and expiry slack, cached on(client_id, refresh_token)since one refresh token exists per consenting mailbox. Shared by thegmailtransport and the SMTPrefresh_tokengrant. -
Out-of-band outage alerting (dead-man's switch) — the one failure pg_relay could never report through its own queue is the database being unreachable; now the Processor can. A webhook contract held in the (hardcoded)
PG_RELAY_OUTAGE_WEBHOOKenvironment variable (url, the webhook transport'sauthblock,outage_body, optionalrecovery_body) is fired directly by the binary — no queue, no notifier, no SQL — after the database has been unreachable for--startup_timeout(default 120s, a Processor that has never connected: a patched server whose PostgreSQL should be coming back) or--stop_timeout(default 10s, an established connection lost mid-run). Once per outage episode (delivery retried until one 2xx lands, then latched); the recovery message fires only if the outage one was delivered, and resets the episode. Bodies are sent verbatim and never logged; delivery errors are stripped of the endpoint URL before logging (URL-keyed endpoints carry their credential in the URL). Newpg_relay --test-env VAR [--recovery]mode sends a nominated contract through the identical code path with no database, printing the provider's response and exiting 0 only on a 2xx — a DBA smoke test and config linter in one. (internal/processor/outage.go; documented in the Technical Guide's "Outage Alerting" chapter.) body_mergeon the webhook transport — an optional profile object whose top-level keys the Processor overlays ontomessageimmediately before encoding, unconditionally overwriting any producer-supplied value for the same key. It exists for APIs that authenticate inside the request body (PagerDuty's Events APIrouting_key; Vonage's legacy SMSapi_key/api_secret): because the block lives in the profile, its_env:references resolve through the existing profile secret pass — the credential stays in the environment, never in the notifier's message data — and a producer has no way to name an environment variable or occupy the merged key. The overlay is shape-blind (top-level assignment only), works identically under bothbody_styles, and a non-objectbody_mergeis a permanent failure with no request sent, mirroring the malformed-authcontract. Because the merged key always overwrites, the field doubles as pure enforcement for non-secret routing fields — e.g. pinning a Slack profile to a single channel regardless of what the producer supplies. The docs' former "any API that authenticates in the request body is unusable" rule is retired accordingly.body_styleon the webhook transport — an optional profile field selecting the wire encoding ofmessage:"json"(the default; unchanged verbatimjson.Marshal) or"form", which re-encodes a flat message asapplication/x-www-form-urlencodedfor providers that reject JSON (Twilio's and Telesign's SMS APIs). Strings pass verbatim, numbers/booleans render in canonical JSON text,nullis omitted, and an array of scalars repeats its key (Twilio'sMediaUrlconvention); a nested value or unknown style is a permanent failure with no request sent, mirroring the malformed-authcontract.Content-Typedefaults to urlencoded underform, still overridable viaprofile.headers.
Changed¶
- A database that is unreachable at Processor startup no longer exits the binary. The Processor now waits in-process, retrying with the normal reconnection backoff (so the outage alert above can fire and its once-per-episode latch survives the whole wait), and proceeds into normal startup when the database appears. Startup failures with the database reachable — a preflight error, a registry that predates the binary — still exit non-zero exactly as before. Operators who monitored for a non-zero exit as their "cannot connect at boot" signal should use the outage alert (or the
reconnectinglog lines) instead. - The
'notify'transports are now routed through atransportSendersregistry innotify.go(uniformsendFuncsignature) rather than a switch;case "acs"andcase "gmail"became registry entries, and the unsupported-transport error text derives its list (acs, gmail, m365, smtp, webhook) from the registry keys. oauth2ErrorSummary(internal/processor/oauth2token.go) now reads all three error envelopes seen across the OAuth2 transports: the{"error":{"code","message"}}object shape with a string code (Graph, ACS) or a numeric code (Gmail), and the flat RFC 6749 token-endpoint shape{"error":"<code>","error_description":"..."}that both Microsoft's and Google's token endpoints answer with — the last of which the previous parser reported asunrecognised error responsewhenever the flaterrorstring was present, hiding e.g.invalid_client/invalid_grantcodes from the audit trail.pgrelay.notify()now returnsbigintinstead ofvoid— the id of thepgrelay.queuerow that now represents the event, so a caller has a way to name what it just sent for a latercancel()call. Previously nothing gave a caller that id; only a role with raw access to the privatepgrelay.queuetable could discover one. Every existing call site keeps working unchanged (PERFORMdiscards a return value of any type; a bare top-levelSELECTjust shows an extra column). Underp_deduplicate, if an identical pending row already existed and the insert was suppressed,notify()returns that row's id instead ofNULL— the return value always names something a caller can pass straight tocancel(). PostgreSQL does not allow changing a function's return type viaCREATE OR REPLACE, so the upgrade script drops and recreatesnotify(); unlike every management function this release also touches, its PUBLICEXECUTEgrant is restored automatically by the script itself — no operator action needed fornotify()specifically.pgrelay.grant_user()was extended to also grantcancel(); if you are upgrading, re-runpgrelay.grant_user(role)for each producer/management role to pick it up.- v1.2 SQL test suite
test/pg_relay_test--1.2.sql— replaces the 1.1 suite as the suite CI runs (make test-sql). Adds the 1.1→1.2 leg of the upgrade-path test and nine new tests (91–99) coveringcancel()'s outcomes, its interaction withpurge_queue(), a genuine cross-sessionFOR UPDATE SKIP LOCKEDproof viadblink, its deliberate absence from the Processor's own grant/preflight surface, andnotify()'s new return value including the dedup-suppressed case. - Release tarball is now
pg_relay--1.2.sql.tar.gz, containingpg_relay.control,sql/pg_relay--1.0.sql,sql/pg_relay--1.0--1.1.sql, andsql/pg_relay--1.1--1.2.sql.
[1.1.0]¶
Five features: per-channel dispatch concurrency, Processor fleet control (reload and pause), external notification support via the companion pg_relay_notifier extension, a generic private options store (pgrelay.options) for DBA-configurable settings, and a shared recurrence type (pgrelay.schedule) for pg_relay and its related applications. SQL changes to the extension itself are delivered as the extension upgrade script sql/pg_relay--1.0--1.1.sql — existing installs run ALTER EXTENSION pg_relay UPDATE;, fresh installs chain the 1.0 script and the delta automatically. The Processor binary gains the fleet-control behaviours and the notification transports. pgrelay.schedule is the exception to the upgrade-script story — see its entry below.
Added¶
- pg_relay_notifier compatibility — pg_relay v1.1 works with pg_relay_notifier 1.0, which caters for SMTP email and Microsoft 365 email via the Graph API. The Processor routes action_type
'notify'events through the interface specified inNOTIFIER_INTERFACE.md: the queue payload is a primary key into the notifier's tables; the Processor fetches the message throughpgrelay_notifier.fetch(), delivers it (SMTP with the full TLS/auth matrix, HTML/plain/multipart bodies, attachments, CC/BCC/Reply-To; or GraphsendMailwith client-credentials OAuth and token caching), and writes per-attempt status back throughpgrelay_notifier.set_status(). The whole claim→fetch→send→status sequence runs in one held transaction, so delivery is at-least-once andconcurrency_modeapplies to notification channels exactly as to SQL channels. Transient failures (SMTP 4xx, Graph 429/5xx, timeouts) use the existing retry chain; permanent failures (SMTP 5xx, Graph 4xx, bad configuration) resolve immediately. Send timeout: profile-controlled, default 30s, hard cap 120s. Secrets are read from Processor-host environment variables at send time — never stored in the database. Message content is never logged by the Processor. Per-notification debug tracing writes live step rows throughpgrelay_notifier.debug_log()on a separate autocommit connection. pgrelay.processor_control— one-row declarative fleet state:reload_token(uuid; edge-triggered reload signal) anddesired_state/pause_until(level-triggered pause). Mutated only through functions; observed by every Processor each second.pgrelay.queue_probe()— the combined per-tick probe returning(has_work, reload_token, paused)in one round trip (~1µs overqueue_has_work(), which remains for monitoring).pgrelay.request_reload()— replaces the reload token; every Processor performs a full reload (reconnect, re-read application registry, re-run preflight) at its next probe. Also invoked by the new reservedpg_relay.reloadaction type: register a channel with it andpgrelay.notify()a reload — schedulable viap_run_at, audited inpgrelay.log. Thepg_relay.*action_type prefix is reserved (register_action_typenow rejects it).pgrelay.start()/pgrelay.stop()/pgrelay.pause_for(seconds)/pgrelay.pause_to(timestamptz)— fleet pause control. Paused Processors finish their in-flight batch, keep probing, and claim nothing; a bounded pause self-expires database-side; last write wins;updated_by/updated_ataudit every change.- Application-registry startup gate — the Processor now calls
pgrelay.list_applications()at startup (and on every reload) and refuses to start if the call fails, catching binary/extension version mismatches immediately with an actionable message. - Registry-scoped preflight checks — when
pg_relay_applicationscontains apg_relay_notifierrow,preflight()verifies the notifier schema, the four interface functions, and thepgrelayrole's EXECUTE on them (notifier:*checks, WARN class). Two new ERROR-class grant checks coverqueue_probe/request_reload. -
NOTIFIER_INTERFACE.md— the complete interface specification pg_relay_notifier 1.0 is built against. -
pgrelay.actions.concurrency_mode— per-channel dispatch concurrency control:'concurrent'(default; no restriction — identical to 1.0 behaviour),'channel'(at most one in-flight event per channel, strictly in queued order), and'channel_payload'(at most one in-flight event per (channel, payload) pair; different payloads still run concurrently). Enforced inqueue_pending_ids(): each poll offers at most one candidate per exclusivity group — always the oldest eligible row — so the guarantee holds across any number of workers and Processor instances. A withheld event is silent: no log row, no retry_count change; it is simply offered on a later poll. NULL payloads group per channel (never across channels); channel grouping is case-insensitive, payload grouping exact. p_concurrency_modeparameter onpgrelay.register()(default'concurrent') andpgrelay.update()(default NULL = unchanged), validated against the three modes.get()/list()surface the new column.pgrelay.queue_stats(p_minutes int DEFAULT 1, p_order_by text DEFAULT 'action')— new monitoring function returning one row per(action_type, channel)pair with either pending queue rows or log activity in the lastp_minutesminutes. Columns:action_type,channel,pending(eligible now, excluding expired),future(deferred),pending_restricted(undispatched node-pinned rows, counted regardless ofrun_at/expire_at— the alerting hook for rows stuck behind a dead/decommissioned node),oldest_pending(key alerting interval),processed(oklog rows),expired,errors(error+invalid+unsupported_action_typelog rows).p_order_by='action'(default) sorts byaction_type, channel;'count'sorts bypending DESC. EXECUTE revoked from PUBLIC; grant explicitly to monitoring roles.- Node-restricted channels — a per-channel
node_restrictedflag (register(p_node_restricted := true)/update()) for actions with node-local side effects (e.g. a materialised view refresh, whose heap is not logically replicated — every node must run its own copy). Queue rows for a flagged channel are stamped with the creating node'spg_relay.node_id(newpgrelay.queue.run_in_nodecolumn, populated by aBEFORE INSERTtrigger so direct queue inserts and retry rows are covered too — never caller-supplied, origin-only so replicated rows keep their pin) and are dispatched only by that node's Processor in every--mode: never handed to the leader, never adopted or reassigned by the multi-node watchers.p_deduplicatesuppresses per node on a restricted channel (one pending copy per node) and stays cluster-wide on unrestricted ones; retries inherit the pin;concurrency_modeserializes per node for restricted channels. Inleadermode a non-leader Processor no longer idles — it dispatches its own node-restricted rows every tick (leadership is published per connection as thepg_relay.is_leaderGUC and enforced by the SQL dispatch predicate inqueue_pending_ids()/_queue_claim()); only unrestricted rows remain leader-only. A pinned row on a permanently dead node never runs and never expires — decommissioning requires the manual cleanup documented in the Multi-Master Deployment book on the documentation site §9. Single-node deployments are unaffected. Design reference: the Node-Restricted Channels chapter of the Multi-Master book. pgrelay.options— a generic, private key/value settings table for pg_relay itself and for companion applications/developer code to persist DBA-configurable options, pluspgrelay.set_option(p_option_name, p_option_value)(creates the option on first call, updates it on every subsequent call — upsert semantics) andpgrelay.get_option(p_option_name)(returns the current value, orNULLif the option has never been set — never raises). Neither the table nor either function is granted to PUBLIC;grant_user()covers both. Purely additive infrastructure — v1.1 does not read or write any option itself.pgrelay.schedule— a validating domain (pgrelay.scheduleover the compositepgrelay.schedule_type) describing a recurring schedule: every matching weekday, the nth weekday of the month, every N weeks, specific days of the month, or (new) independent per-day times (day_times, e.g.'mon=05:00;tue=17:30,20:00;wed=12:00') — each mode carrying one or more UTC times exceptday_times, where each group carries its own. Ships inschedule/as a separate, manually-installed companion, versioned independently inschedule/CHANGELOG.md— like the options store, pg_relay itself never reads or writes apgrelay.schedulevalue, and installing it changes nothing about pg_relay's own behaviour; unlike the other v1.1 additions, it is not part ofsql/pg_relay--1.0--1.1.sqland does not install viaCREATE EXTENSION/ALTER EXTENSION UPDATE— runschedule/install.sqlyourself after the extension is installed, and it's always safe to re-run (it detects what's installed and upgrades in place — no more unconditionalDROP ... CASCADE, which was a real, reproduced hazard against a database with consumer tables using this type;schedule/uninstall.sqllikewise now refuses to cascade into consumer data by default). Constructors (pgrelay.weekdays(),pgrelay.nth_dow(),pgrelay.fortnightly(),pgrelay.on_dom(),pgrelay.on_day_times(),pgrelay.month_end(), and friends) build and validate a schedule at the call site; evaluators (pgrelay.fires_on(),pgrelay.occurrences(),pgrelay.next_run(),pgrelay.matches(),pgrelay.describe()) answer "does/when/is this due," each also available as apgrelay.schedule[]overload for combining several schedules (including mixed modes) into one answer. Intended as the watermark for anext_run_atcolumn in your own scheduled-job table — seeschedule/README.mdfor the full pattern, including how to connect a due watermark to pg_relay's actual queue viapgrelay.notify(). Verified against PostgreSQL 14–18, under three session timezones with byte-identical output, and against 6.4M+ evaluations of an independent Python differential test — seeschedule/VERIFICATION.mdfor the full report. Documented for DBAs and developers in thedocs/site under Schedule Type.- v1.1 SQL test suite
test/pg_relay_test--1.1.sql— replaces the 1.0 suite as the suite CI runs (make test-sql). Adds the 1.0→1.1 upgrade-path test and twelve dispatch-concurrency tests, including genuine cross-session lock tests viadblink(test-time dependency only).pgrelay.schedulehas its own separate test suite —schedule/run_tests.sh— since it is not part of the extension.
Changed¶
pgrelay.register()andpgrelay.update()signatures gained thep_concurrency_modeandp_node_restrictedparameters. Upgrade note: the old signatures are dropped and recreated by the upgrade script, which discards per-role EXECUTE grants on those two functions — re-runpgrelay.grant_user(role)for each management role after upgrading.pgrelay.queue_stats()is likewise dropped and recreated (newpending_restrictedoutput column) — re-grant it to monitoring roles.pgrelay.notify()recreated with the duplicate-suppression key extended from(channel, payload)to(channel, payload, run_in_node)— a no-op for unrestricted channels (all rows compare NULL = NULL), per-node dedup for node-restricted ones. The PUBLIC grant is preserved.notify()also stampsrun_in_nodeexplicitly in its INSERTs: the stamping trigger is origin-only and therefore does not fire whennotify()is called during logical apply (a companion'sENABLE ALWAYStrigger enqueuing local work while a replicated row is applied — e.g. pg_auto_mv's TRUNCATE-signal handler); without the explicit stamp those rows would be silently unrestricted and run on the wrong node under--mode=leader. On origin the trigger recomputes the identical value, so the two stamps can never disagree.- The Processor refuses to start in
--mode=multi-node/leaderwhenpg_relay.node_idresolves to0(unset) — two nodes both defaulting to 0 would own (and pin) each other's rows. Set a distinct id per node:ALTER SYSTEM SET pg_relay.node_id = '<n>'; SELECT pg_reload_conf(); pgrelay.grant_relay()additionally grantsqueue_probe()andrequest_reload()(the extended Processor operating set);pgrelay.grant_user()additionally grants the fleet-control functions (request_reload,start,stop,pause_for,pause_to).- The Go
dbConnsurface gainsExec(BEGIN/COMMIT/SAVEPOINT for the held notify/reload transactions); the per-tick probe switches fromqueue_has_work()toqueue_probe()with named-column selection. - The pg_relay application-registry record (
pgrelay.pg_relay_applications) is updated tolatest_version = '1.1.0'by the upgrade script. - Release tarball is now
pg_relay--1.1.sql.tar.gz, containingpg_relay.control,sql/pg_relay--1.0.sql, andsql/pg_relay--1.0--1.1.sql. - Multi-master support via a new
--modeparameter (single|multi-node|leader; defaultsingle).singleis the v1.0 posture plus follow-the-primary HA (idle on a read-only standby, dispatch on promotion).multi-nodeis owner-partitioned active-active — each node dispatches its own rows in parallel and a conservative per-tick watcher adopts a dead node's rows to the lowest live master.leaderruns a single cluster-wide dispatcher (the lowest live write-master processes every unrestricted row; node-restricted rows — see above — always stay on their own node). Supporting changes:pgrelay.queue.idandpgrelay.log.iddefault to a snowflakebigint(pgrelay._next_id()) so both tables are safe to replicate (cluster-wide audit); a newowner_nodecolumn and a mode-aware ownership filter inqueue_pending_ids()/_queue_claim(); per-mode SQL gate functions (should_dispatch_single,is_lowest_live_master,multi_master_watch,_startup_queue_check) that the binary calls opaquely, so all recovery/Spock logic lives in SQL; and removal of the old preflight replication warning (snowflake ids make replication safe). Adoption is conservative — a peer is dead only once its replication slot isactive=false(afterwal_receiver_timeout), never on lag — with the guarantee that every eligible row runs at least once eventually or expires. Split-brain under a sustained partition is documented-and-accepted (no self-fencing). Full operator reference: the Multi-Master Deployment book on the documentation site. Single-node (--mode=single, the default) is entirely unaffected —pg_relay.node_idunset ⇒ node0, every filter a no-op, identical to before. This is in-corepgrelaySQL + the Processor; there is no separate extension. - Go toolchain bumped from
go1.24.4togo1.26.5. The 1.24.x branch reached end-of-life when Go 1.26 released (its final patch was1.24.13); the pinnedgo1.24.4was nine security-relevant patch releases behind within that now-dead branch, including fixes tocrypto/tlsandnet/textproto— both exercised directly by the SMTP transport (internal/processor/smtp.go) — as well asnet/httpandnet/url, used by the webhook and M365 transports. Thegodirective (minimum language version,1.23.0) is unchanged; only thetoolchainpin moved, so this is a pure security/currency update with no source-level breaking changes. No SQL files are affected. - GitLab CI test matrix adds
fedora:44, scoped toPG_VERSION: ['18']only rather than the full version sweep. Fedora tracks PGDG's yum-family packages fastest — a new PostgreSQL major version's-develpackage often lands there before EL (Rocky/RHEL) catches up, which is exactly the forward-visibility purpose this covers. Fedora is not a supported production platform (its ~13-month release support window rules that out; see Platform and Version Support) — this is CI coverage only, not a new deployment target. Required a dedicatedbefore_scriptbranch: Fedora uses PGDG'spgdg-fedora-repopackage andF-<ver>path scheme (notpgdg-redhat-repo/EL-<ver>), and needs neither EPEL nor CRB — both RHEL-family-only concepts, since whatever CRB supplies there (e.g.perl(IPC::Run)forpostgresql-devel) already ships in Fedora's own repos.
[1.0.0]¶
Initial release. pg_relay is a durable event processor for PostgreSQL: producers enqueue events with pgrelay.notify(), and a companion binary — the Processor — polls the durable queue once per second and runs each event's registered action. There is no LISTEN/NOTIFY in the dispatch path; the Processor finds work by polling.
Extension (pgrelay schema)¶
- Action type registry (
pgrelay.action_types) — extensibility foundation. Seeded with'sql'(run_order 1).list_action_types()is PUBLIC (read-only).register_action_type/update_action_type/delete_action_typemanagement functions require explicit grants.delete_action_typeis blocked if any channel references the type. Action types carry arun_orderthat controls dispatch ordering within a batch (NULL sorts last). - Channel registry (
pgrelay.actions) with case-insensitive channel names, per-channelmax_retries(0–99), per-channelaction_type(FK toaction_types, default'sql'), andregister/update/enable/disable/unregister/get/listmanagement functions.registerandupdateaccept ap_action_typeparameter and validate it againstaction_types. - Durable event queue (
pgrelay.queue) written bypgrelay.notify()inside the producer's transaction. Supports deferred events (p_run_at), expiry (p_expire_at), deduplication (p_deduplicate), and automatic retries with backoff (3 s, 5 s, 10 s) viaparent_id/retry_count.notify()validates the channel before enqueuing and raises on an unregistered channel. - Per-event processing:
queue_has_work()(cheap probe),queue_pending_ids()(returnsTABLE(id bigint, action_type text)withFOR UPDATE OF queue SKIP LOCKED, ordered byrun_order NULLS LAST, run_at, queued_at), andprocess_one(id)— which claims one row, runs its action in a savepoint, writes the audit row, and returns(channel, outcome, log_id). Outcomes:ok,error,retry_scheduled,expired,invalid,unsupported_action_type,skipped. - Audit log (
pgrelay.log) with one row per processed event, carrying the originatingqueue_idand the status. - Maintenance:
purge(log) andpurge_queue(dispatched rows only). - Security model: private tables (all privileges revoked from PUBLIC — now four tables including
action_types),SECURITY DEFINERhelpers with a fixedsearch_path,SECURITY INVOKERprocess_one(action SQL runs as thepgrelayrole, never the owner), and EXECUTE hardening — every function revoked from PUBLIC exceptnotify()andlist_action_types().grant_relay()grants the Processor operating set;grant_user()grants the management set (channel and action type management);preflight()self-checks grants and replication configuration at startup.
Processor (Go binary)¶
- Poll-only design: a 1-second ticker that probes the queue and processes each eligible event individually. No LISTEN, no channel set, no reload.
queue_pending_ids()now returns(id, action_type)pairs. The Processor routes each event byaction_type(type switch);'sql'goes toprocessSQL; unknown types delegate toprocess_onewhich returnsunsupported_action_type. This is the extensibility hook for future non-SQL action handlers.- Concurrent dispatch:
--workers N(1–9, default 1) opens N database connections and fans pending events across them. Multiple Processors run against one database and partition work viaFOR UPDATE SKIP LOCKED; each claims an instance slot (1–64) and stamps every log line with"instance": N. - Structured JSON logging at
warnby default (--verbose→ info,--debug→ debug). The payload is never logged; only channel, queue id, and log id appear. - Automatic reconnect with backoff; credentials re-read from libpq env vars on every connect, so rotated credentials take effect without a restart. Clean SIGTERM/SIGINT shutdown.
- Cross-platform builds (Linux/macOS/Windows, x86_64/arm64) and GitLab CI.
Copyright © 2026 Pebble IT Solutions Pty Ltd, Australia. Licensed under the MIT Licence.
