Spock Node Monitoring: Design of the spock_monitor subsystem
Date: 2026-09-23
Status: Implemented, regression tested
Component: src/spock_monitor.c, include/spock_monitor.h
PostgreSQL: 15 to 19
A Word version of this document is kept next to it as
spock-monitor-design.docx.
1. Summary
Operators of a Spock cluster have had to read the PostgreSQL server log to answer routine questions: is every subscription replicating, did a worker restart overnight, why was a subscription disabled, how much WAL is a slot holding back, did the extension get upgraded. The catalog and the existing statistics views describe configuration and cumulative counters but not state changes or failures.
The monitoring subsystem records state changes as events and keeps
per-subscription activity counters in shared memory, and exposes them
together with worker, slot and replication progress through eleven views in
the spock schema. A query against spock.node_status or
spock.subscription_status gives the complete picture of a node;
spock.events gives the recent history. The module is also the single home
of all monitoring C code, absorbing the channel counters, the apply group
progress function and the slot confirmation wait.
2. Goals and non-goals
Goals
- Replace log reading for day to day operation: every notable state change is queryable.
- One row per node, per subscription, per worker and per slot, with column names that need no legend.
- Cheap on the apply hot path: a spinlock and a few integer adds per message, no catalog writes, no WAL.
- Safe in error paths: recording an error must never allocate memory, block or raise.
- Same source on every supported PostgreSQL version; version specific data such as the PG18 conflict counters degrade to NULL.
- One module for all monitoring code, with PostgreSQL naming and layout conventions.
Non-goals
- Durability across a postmaster restart. Events and counters live in shared memory only; the catalog and pgstat remain the durable stores.
- Transactional semantics. An event is recorded when the reporting code runs, whether or not its transaction commits.
- Cluster wide aggregation. Each node reports about itself; a fleet view is left to external tooling that reads the views on every node.
3. Architecture
The subsystem has three layers: shared memory structures written by spock
processes, C functions that read them and return rows, and SQL views that
join those rows with the catalogs and with core views such as
pg_stat_replication and pg_stat_activity.
| Layer | Elements | Owner |
|---|---|---|
| Shared memory | Event ring (SpockMonitorShmem), per-subscription counters (SpockMonitorStatsHash) |
spock_monitor.c, allocated by spock_shmem.c |
| Producers | Worker attach and detach, apply loop, sync worker, output plugin, node and subscription management functions, exception handler, extension manager, emit_log_hook |
One call at each site |
| SQL functions | get_worker_status, get_events, get_apply_stats, get_slot_groups, get_monitor_summary, reset_events, reset_subscription_stats, get_channel_stats, reset_channel_stats, apply_group_progress, wait_slot_confirm_lsn |
spock_monitor.c |
| Views | system_status, node_status, subscription_status, subscription_stats, worker_status, slot_status, peer_status, replication_set_status, settings, table_sync_status, events |
sql/spock--6.0.0.sql and the 5.x upgrade script |
Producers never format SQL or touch the catalog. They call one of a handful
of functions declared in spock_monitor.h, which copy fixed size data into
shared memory under a lock. Readers take a consistent copy of each entry
and build tuples outside any spinlock.
4. Shared memory
4.1 Event ring
Events are stored in a fixed size ring inside one ShmemInitStruct
allocation named spock monitor. The capacity is the
spock.event_history_size GUC (default 1024, PGC_POSTMASTER).
typedef struct SpockMonitorEvent
{
uint64 event_id; /* increases by one, never reused */
TimestampTz event_time;
SpockMonitorEventType event_type;
Oid dboid; /* database of the reporting process */
Oid subid; /* subscription, or InvalidOid */
int pid;
int sqlerrcode; /* SQLSTATE of an error event, else 0 */
XLogRecPtr lsn; /* origin LSN, if any */
char detail[512];
} SpockMonitorEvent;
The header holds the LWLock, the time shared memory was initialized
(reported as spock_started_at), the next event id, and head and count
indexes. Appending when full advances the head, so the oldest event is
dropped. Event ids keep increasing across resets, which lets a poller detect
both new events and gaps. The ring is cluster wide; the views filter on the
current database and keep events without a database, such as those of the
supervisor.
Each event type has a fixed name and severity in a table indexed by the
enum, so adding a type means adding one enum member and one table row.
Severities are info, warning and error; warning covers state changes
an operator would want to know about even when they were requested, such as
a subscription being disabled, repair mode being switched on or a
transaction being skipped.
4.2 Per-subscription counters
Counters live in a fixed size shared hash keyed by (dboid, subid), sized
at max(64, 4 * max_worker_processes) entries. The multiple leaves room for
disabled subscriptions whose history should survive while they are down.
typedef struct SpockMonitorStatsEntry
{
SpockMonitorStatsKey key;
int64 counter[SPOCK_MONITOR_NUM_COUNTERS];
TimestampTz counter_time[SPOCK_MONITOR_NUM_COUNTERS];
XLogRecPtr last_error_lsn;
int last_error_sqlerrcode;
int last_error_pid;
char last_error_message[512];
TimestampTz stats_reset;
slock_t mutex;
} SpockMonitorStatsEntry;
Every counter carries the time of its last increment. That one rule gives
the view its last_worker_start, last_provider_connect,
last_xact_applied and last_apply_error columns without separate fields,
and keeps the reader generic: two small tables in spock_get_apply_stats()
map counters to count columns and to timestamp columns, checked at compile
time against the column enum.
| Counter | Incremented when |
|---|---|
worker_starts |
An apply worker attaches to its shared memory slot. |
worker_failures |
An apply worker detaches after an error exit. |
sync_worker_starts, sync_worker_failures |
The same for sync workers, so a resync is told apart from an apply worker restart. |
provider_connects |
START_REPLICATION succeeded in the apply worker. |
provider_disconnects |
apply_work() classified an error as connection loss. A sync worker's failed connection is recorded as an event and as the last error but not counted here, so the two counters pair. |
idle_timeouts |
spock.apply_idle_timeout forced a reconnect. |
messages_received, bytes_received |
A data message was read from the stream. |
xacts_applied |
A remote transaction committed locally. |
xacts_skipped |
A transaction was skipped through sub_skip_lsn. |
xacts_discarded |
A transaction was discarded under transdiscard. |
apply_errors, sync_errors |
An ERROR was raised in an apply or sync worker (section 6). |
deadlocks, lock_timeouts, constraint_violations, resource_errors |
The same errors classified by SQLSTATE. |
tables_synced |
A sync worker finished a table without failure. |
A worker caches a pointer to its entry in MySpockMonitorStats when it
attaches. Entries in a HASH_FIXED_SIZE shared hash are never moved, so the
pointer stays valid for the life of the process. Counting is then a
spinlock, an add and a timestamp. Backends that only record events do not
touch the hash.
Subscription ids come from a cycling sequence, so an id can be reused. Each entry counts the workers attached to it. Dropping a subscription removes the entry at once when no worker is attached; otherwise the entry is only marked dropped and the last worker to detach removes it, so a worker never writes through a pointer into a freed element. A worker that attaches to an entry marked dropped, because the id was reused, resets it.
4.3 Locking
- One LWLock in the tranche
spock_monitorprotects the ring and the hash structure. Individual entries have a spinlock for their counters. - The monitor lock is a leaf: no other lock is taken while holding it. It
may be acquired while
SpockCtx->lockis held, never the other way round. - The error reporting path uses
LWLockConditionalAcquireand skips the event if the lock is busy, so error reporting can never block. - Readers hold the monitor lock only while copying the ring or the entries into local memory, so that error reporting, which only tries the lock, is not held off while a result is formatted.
5. Instrumentation points
Each producer adds one call at the place where the state change is already logged, so the event and the log line always agree.
| Event type | Call site |
|---|---|
node_created, node_dropped |
spock_create_node(), spock_drop_node() after the catalog change |
subscription_created, subscription_dropped |
spock_create_subscription(), spock_drop_subscription() |
subscription_enabled, subscription_disabled |
spock_alter_subscription_enable(), spock_alter_subscription_disable(), spock_disable_subscription() in the exception handler |
subscription_altered |
Interface change, replication set add and remove, skip_lsn, options, synchronize and resynchronize table |
worker_started, worker_stopped, worker_failed |
spock_worker_attach() and spock_worker_detach(); the failure detail carries the last recorded error message |
worker_error |
emit_log_hook, for any ERROR or FATAL in a spock worker |
provider_connected, provider_disconnected |
spock_start_replication(); the connection loss branch of apply_work() |
transaction_skipped, transaction_discarded |
stop_skipping_changes(); the transdiscard branch of handle_commit() |
sync_started, sync_finished, sync_failed |
spock_sync_main() and spock_sync_worker_finish() |
slot_created, stream_started, stream_stopped |
pg_decode_startup() with and without is_init; pg_decode_shutdown() |
apply_paused, apply_resumed |
spock_pause_apply_workers(), spock_resume_apply_workers() |
repair_mode_enabled, repair_mode_disabled |
spock_repair_mode() |
extension_upgraded |
spock_manage_extension() after ALTER EXTENSION UPDATE |
Worker start and stop are recorded in the generic attach and detach code,
so the manager, every apply worker and every sync worker are covered by two
call sites. The detach hook runs before SpockCtx->lock is taken, which
keeps the lock order simple.
6. Error capture
Rather than instrumenting each error site, the module hooks the existing
emit_log_hook installed by spock.c. spock_monitor_report_error() is
called for every report and applies these rules:
- Ignore unless the process is a spock worker (
MySpockWorkeris set) or a walsender running the spock output plugin, and shared memory is attached. - Ignore levels below ERROR and at or above PANIC; a PANIC path must not take locks.
- Treat connection class errors (connection failure, admin shutdown,
cannot connect now and the related codes) as a lost peer, never as an
apply error. In a worker the hook records them as
provider_disconnectedwith the message kept as the last error, which covers failures at connect or START_REPLICATION time that no handler catches.apply_work()uses the same predicate for its connection-loss branch and reports through the same function before rethrowing; it flags the rethrow so the hook does not count it again. In a walsender such an error is the subscriber going away and nothing is recorded. - In a walsender, record a
stream_errorevent with the slot name, the SQLSTATE and the message. The output plugin marks the process at startup and clears the mark at shutdown. Only a walsender is marked: a regular backend decoding a slot through the SQL functions gets no shutdown call when decoding fails, and its later errors must not be reported as stream errors. - For apply and sync workers, bump
apply_errorsorsync_errorsand the matching class counter (deadlock, lock timeout, constraint violation, resource error), and store the message, SQLSTATE, pid and the current origin LSN as the last error. - Record a
worker_errorevent with the message and SQLSTATE, using a conditional lock acquire.
The message stored is the error message followed by the DETAIL and CONTEXT
lines, so a constraint violation carries the failing row and the apply
context. The function is guarded against re-entry and does no allocation:
the text is formatted with snprintf into fixed buffers.
An ERROR caught by a PG_CATCH is never emitted: PostgreSQL longjmps
before EmitErrorReport(), and the apply worker's handler copies and
flushes the error. The hook therefore only sees errors that end the worker.
The two paths of apply_work() that swallow an error, the first exception
that enters replay and the sub_disable path, call
spock_monitor_report_error() on the copied ErrorData themselves; the
paths that rethrow leave it to the hook so that an error is counted once.
Under sub_disable the replay ends with a synthetic error requesting the
disable; the worker remembers that the transaction's first error was
already reported and does not report that one. Row level errors during a
discard replay are not counted again, so apply_errors counts failed
transactions rather than failed rows.
7. SQL surface
7.1 Functions
Functions return raw shared memory rows and are the tooling interface. Set
returning functions use InitMaterializedSRF and OUT parameters; the C
code checks the column count against its enum with an assertion.
| Function | Returns |
|---|---|
spock.get_worker_status() |
Every worker slot in the cluster plus the supervisor, with database_id |
spock.get_events() |
Every event in the ring, oldest first, with database_id |
spock.get_apply_stats() |
Counters of every subscription of the current database, with PG18 conflict counters |
spock.get_slot_groups() |
Slot groups with attached walsenders |
spock.get_monitor_summary() |
One row: start time, supervisor pid, slot and capacity usage |
spock.get_system_info() |
One row: host name, operating system, CPU, memory, load averages, statvfs() of the data directory and of pg_wal, postmaster start, PostgreSQL and spock versions |
spock.get_pending_exceptions() |
Exception log slots with a recorded error: the transactions being retried, with failed_action_number, the position of the failing change in its transaction |
spock.report_section(title, query, expanded) |
The rows of a query as text, values in PostgreSQL text form |
spock.node_report(recent) |
The whole node as a text report, one section per view |
spock.reset_events() |
Empties the ring; ids keep increasing |
spock.reset_subscription_stats(subid) |
Resets activity and, on PG18, conflict counters of one or all subscriptions |
spock.redact_dsn(text) |
Connection string with the password hidden, keyword and URI forms |
7.2 Views
Views are named by what they show: *_status for state, *_stats for
counters. Columns use full words, never abbreviations, and the same word for
the same thing everywhere (subscription_name, provider_node,
worker_pid, replication_lag_bytes).
| View | Rows | Built from |
|---|---|---|
spock.system_status |
Exactly one | get_system_info(), pg_stat_activity, pg_prepared_xacts, pg_stat_replication, pg_database, pg_stat_database, pg_stat_archiver, spock.wal_size_bytes() |
spock.node_status |
Exactly one | get_monitor_summary(), local_node, pg_extension, aggregates over the other views, spock.wal_size_bytes() |
spock.subscription_status |
One per subscription | subscription, node, node_interface, local_sync_status, get_worker_status() (lateral, running worker preferred), pg_stat_activity, progress, replication_origin_status(), subscription_stats |
spock.subscription_stats |
One per subscription | get_apply_stats() and channel_summary_stats, counters coalesced to zero |
spock.worker_status |
One per worker | get_worker_status(), pg_stat_activity, pg_blocking_pids() for the session holding a lock the worker waits for |
spock.slot_status |
One per spock slot | pg_replication_slots, pg_stat_replication, get_slot_groups(), current or replay WAL position |
spock.table_sync_status |
One per sync row | local_sync_status with readable kind and status |
spock.peer_status |
One per node and subscription | node, node_interface, subscription_status |
spock.replication_set_status |
One per replication set | replication_set with counts over its tables, sequences and subscriptions |
spock.settings |
One per parameter | pg_settings, spock parameters and the core replication parameters, DSNs redacted |
spock.events |
One per event | get_events(), subscription and node for the provider name |
The status text of a subscription is computed in SQL with the same rule as
spock.sub_show_status(): replicating, initializing or unknown while
a worker runs, otherwise disabled or down. Computing it in the view
keeps node_status usable before a local node exists, where the function
would raise an error.
The slot view uses only columns present in pg_replication_slots since
PostgreSQL 15, because one SQL script serves every version. It falls back
to pg_last_wal_replay_lsn() on a standby so it never raises during
recovery.
7.3 The report
spock.node_report() is a plpgsql function that calls
spock.report_section() once per view. The formatter runs the query twice:
once through row_to_json to learn the column names in query order, then
with every column cast to text so that values appear as PostgreSQL prints
them rather than as JSON. Each section's query runs inside its own
exception block, so a failing view produces one error line instead of
aborting the report. Passwords never appear: the views already redact
connection strings, and spock.settings redacts the DSN parameters.
The exception handler keeps one shared slot per subscription with the
commit LSN of the transaction in flight and the first error raised for it.
spock.get_pending_exceptions() reports only slots with an error, because
the LSN alone is set for every transaction.
8. Compatibility and upgrade
- The same SQL section is appended to
spock--6.0.0.sqland tospock--5.0.13--6.0.0.sql. Every catalog column the views reference exists since 5.0.6, so an upgraded node ends with the same objects as a fresh install. spock.get_apply_worker_status()is removed; nothing in the repository used it.spock.get_worker_status()andspock.worker_statusreplace it.spock_monitoring.cis removed.spock.wait_slot_confirm_lsn(),spock.get_channel_stats(),spock.reset_channel_stats()andspock.apply_group_progress()keep their SQL definitions and C symbols and now live inspock_monitor.c.spock.reset_subscription_stats()keeps its SQL definition and C symbol, so the existing upgrade script needs no change, but it now resets both counter sets and no longer raises before PostgreSQL 18.- Conflict counters are fetched through the pgstat entry under
PG_VERSION_NUM >= 180000and are NULL otherwise. - Shared memory grows by the ring plus the hash.
SpockMonitorStatsEntryis 800 bytes, dominated by its 512-bytelast_error_messageand the two per-counter arrays, so the hash costs roughly that plus dynahash overhead for each subscription tracked, bounded bymax(64, 4 * max_worker_processes)entries.SpockMonitorEventis 560 bytes, dominated by its 512-bytedetail, so the ring costsspock.event_history_size * 560bytes: 560 kB at the default 1024, and a megabyte or more if the setting is raised. Size shared memory from that figure rather than from the event count alone.
9. Testing
The regression test tests/regress/sql/monitor.sql runs after basic and
checks, on the provider, node_status, worker_status, slot_status and
the provider side events, and on the subscriber, subscription_status,
worker_status, table_sync_status, subscription_stats after real
traffic, the events of subscription creation and worker start, both reset
functions, and that disabling and enabling a subscription produces the
expected events and a worker restart that is visible in the counters.
Values that vary between runs are compared, not printed. The full suite of
41 tests passes on PostgreSQL 18.
10. Limitations and future work
- The views and the functions they call (
get_system_info(),get_events(),get_apply_stats(),get_pending_exceptions(),replication_origin_status(), which stands in for the superuser-onlypg_replication_origin_status) are granted topg_monitor; a superuser or a member of that role reads them.wal_size_bytes()stays callable by any role, as the total WAL size is not sensitive. The reset functions are superuser only. - The subscription stats hash is bounded at
max(64, 4 * max_worker_processes)entries. Past that,spock_monitor_attach_stats()returns NULL and logs it; the worker carries on and the subscription operates normally, but its counters are not recorded:spock.subscription_statsstill has its row, with every counter read as 0 and every timestamp as NULL, so a subscription past the bound looks idle rather than unrecorded.spock.node_status.channel_stats_fullreports the same condition for the channel counter hash. - The ring is cluster wide, so a database with heavy activity can age out the events of a quiet one. A per-database ring would remove this at the cost of more shared memory.
- Events and counters are lost at postmaster restart. If history across restarts is wanted, a background worker could periodically append events to a spock catalog table.
pg_replication_slotscolumns added in PostgreSQL 17 (failover,invalidation_reason,inactive_since) are not shown; a version conditional view would be needed.- Replication set changes on the provider (table added or removed) are not events; they are visible in the catalog and could be added with two call sites.