Sizing Postgres Resources for Spock
Spock replication consumes three separate, independently-configured Postgres resources: background worker slots, walsender slots, and replication slots/origins. Each is governed by a different parameter, and each is sized from a different property of your cluster.
Many of the values shown in the tutorials are a starting point for a small test cluster, not a recommendation for production. This page explains what Spock actually launches so you can size each parameter deliberately.
Warning
Every parameter on this page backed by an explicit recommendation requires
a server restart to change. Under-sizing them is not a soft failure:
Spock raises an ERROR when a worker cannot be registered and the
supervisor retries in a loop, so replication does not start at all. Formulas
include a small amount of headroom partially to prevent this.
What Spock Runs on Each Node
| Process | How many | Counts against |
|---|---|---|
| Supervisor | 1 per instance, always running | max_worker_processes |
| Slot sync worker | 1 per instance | max_worker_processes |
| Manager | 1 per connectable database in the instance | max_worker_processes |
| Apply worker | 1 per enabled subscription | max_worker_processes |
| Table sync worker | at most 1 concurrently per subscription | max_worker_processes |
| Walsender | 1 per subscription streaming from this node, plus 1 more per subscription during table sync | max_wal_senders |
Four of these counts are commonly misjudged.
A slot sync worker exists on every node, not just standbys. These workers copy logical replication slots to each physical standby so that failovers do not force a full resync of every subscriber. These workers remain idle on primary nodes but still consume a worker slot, so are included in the formula. See Slot Synchronization below for more information.
Managers are launched for every connectable database, not just the ones
using Spock. The supervisor cannot determine whether a database contains a
Spock node without connecting to it, and a background worker cannot switch
databases. So it launches a manager for every database in pg_database with
datallowconn = true; managers that find no Spock node exit immediately.
On a default cluster that means postgres and template1 each get a
short-lived manager in addition to your replicated database. template0 is
excluded because it does not allow connections.
Walsenders are not background workers. They are governed by
max_wal_senders and counted separately in MaxBackends. Raising
max_worker_processes does nothing for walsender exhaustion, and vice versa.
Subscriptions are per database, not per node. A Spock node is created inside a single database, and each subscription connects one database on this instance to the corresponding database on one other instance. An instance that replicates several databases therefore hosts one Spock node per replicated database, and each of those nodes carries its own full set of subscriptions. Everything downstream of the subscription count such as apply workers, table sync workers, walsenders, slots, and origins, all scale with node count multiplied by replicated database count, not node count alone.
Slot Synchronization
Slot synchronization is not optional for a cluster with a physical standby: a promotion without it forces every subscriber to recreate its slot and re-sync its tables. What changes between PostgreSQL versions is only who supplies the worker.
| PostgreSQL | Supplied by | Charged to max_worker_processes |
|---|---|---|
| 15, 16 | Spock's spock_failover_slots background worker |
Yes |
| 17 | Either, depending on sync_replication_slots |
Yes - Spock registers its worker regardless, and it stands down when the native one is enabled |
| 18+ | PostgreSQL's native slotsync worker | No - it is a dedicated postmaster process, counted separately in MaxBackends |
Budget a worker for it on every node and every version. Where PostgreSQL supplies the worker the budgeted slot simply becomes headroom, which costs nothing beyond a little shared memory and spares you a version-dependent formula. Logical Slot Failover covers the configuration each mechanism needs.
Sizing max_worker_processes
For one node, at peak:
spock_workers = 1 # supervisor
+ 1 # slot sync worker
+ D # connectable databases in this instance
+ S # apply workers: one per enabled subscription
+ S # table sync workers: worst case, one per subscription
max_worker_processes = spock_workers
+ max_parallel_workers # shared pool - see below
+ other_extension_workers # pg_cron, pg_squeeze, ...
+ 2 # headroom
Where:
Nis the number of Spock nodes in the cluster.Dis the number of databases in the instance withdatallowconn = true, includingpostgresandtemplate1— not just the databases participating in replication.Ris the number of databases in this instance that actually participate in Spock replication (R <= D). It is1in most deployments.Sis the total number of enabled subscriptions across all databases in the instance. In a fully-meshed cluster ofNnodes each replicating the sameRdatabases,S = R * (N - 1). With a single replicated database that reduces to the familiarS = N - 1.
Warning
max_worker_processes is a shared pool. Parallel query workers
(max_parallel_workers, default 8), native logical replication workers
(max_logical_replication_workers), and background workers belonging to
other extensions all draw from the same allocation. A parallel-heavy
workload can starve replication of worker slots. Autovacuum workers are
the exception — they have their own autovacuum_max_workers allocation.
Recommended Minimums
Assuming a single replicated database alongside postgres and template1
(D = 3, R = 1), the default max_parallel_workers = 8, and no other
worker-using extensions:
| Cluster | Spock Workers | Total Workers |
|---|---|---|
| 2-node | 7 | 20 |
| 3-node | 9 | 20 |
| 4-node | 11 | 24 |
| 5-node | 13 | 24 |
| 8-node | 19 | 32 |
The totals are rounded up to leave room for growth; there is no meaningful
cost to over-provisioning max_worker_processes beyond a small amount of
shared memory, and the parameter cannot be changed without a restart.
Add to these numbers if your instance hosts more databases, replicates more
than one database, or runs other extensions that register background workers.
Each additional replicated database costs 1 manager (if the database is
not already counted in D) plus 2 * (N - 1) workers for its apply and sync
workers, so a 3-node cluster replicating three databases needs 3 * 2 * 2 = 12
apply and sync workers per instance rather than 4.
Sizing max_replication_slots and max_wal_senders
Each node holds a logical replication slot for every subscription that streams from it, plus a transient slot for each in-progress table sync. Sync slots are created as persistent slots (not temporary), so each one occupies a slot until the sync finishes and the slot is dropped. Every one of those slots has a walsender behind it while it is streaming, so set both parameters to the same value and compute it once, from the slot count.
Default values
The default is 10 for max_replication_slots and max_wal_senders. Consider
this a minimum value for each. If any formulas provide a value below 10,
simply retain the default value.
Where N is the number of Spock nodes and R the number of replicated
databases in the instance:
max_replication_slots = 2 * R * (N - 1) # apply slot + sync slot per subscription
+ physical_and_failover_slots
+ 2 # headroom for pg_basebackup and ad-hoc tooling
max_wal_senders = max_replication_slots
R is 1 in most deployments. A 5-node mesh replicating a single database, with
one physical standby per node, needs 2 * 1 * 4 + 1 + 2 = 11 for both
parameters; the same mesh replicating three databases needs
2 * 3 * 4 + 1 + 2 = 27.
Why the two values are equal
PostgreSQL's logical replication documentation recommends setting
max_wal_senders to at least max_replication_slots plus the number of
physical replicas connected at the same time. That extra term covers
physical standbys that stream without a slot; the formula above already
gives each physical standby a slot in physical_and_failover_slots, so
adding them a second time would double-count. Sizing every node from the
same formula, standbys included, also satisfies the separate rule that a
standby's max_wal_senders be at least as high as its primary's.
Not every replication connection is a walsender. Table sync workers open a
second, non-replication connection to run COPY, which draws from
max_connections. The slot sync worker on a standby likewise reaches the
primary over an ordinary connection. Both Spock's worker and PostgreSQL's
native one cost the primary a max_connections slot, not a walsender.
Replication Origins
Postgres uses origins to track local state for remote replication slots.
Internally, Postgres tracks replication origins separately from slots and
pins their count to max_replication_slots in Postgres versions 15-17. Our
formula should make it impossible for there to be more origins than slots in
those versions of Postgres.
However, Postgres 18 introduces max_active_replication_origins with a
default value of 10, which may be too low for an active Spock cluster. For
the sake of simplicity, we encourage using this simple formula for that
parameter:
max_active_replication_origins = max_replication_slots
This should cause Postgres 18+ to operate with a sufficiently large origin pool.
Worked Examples
Because these settings require so many inputs, this documentation provides some examples.
3-Node Mesh
Three nodes, each with a single replicated database app, plus the default
postgres and template1 databases. Each node subscribes to the other two,
and each node has one physical standby.
Per node: D = 3, R = 1, S = R * (N - 1) = 2.
# Background workers:
# 1 supervisor + 1 slot sync + 3 managers + 2 apply + 2 table sync = 9
# + 8 max_parallel_workers + 2 headroom = 19; 20 to match the table above
max_worker_processes = 20
# Slots and walsenders, set equal:
# (2 x 1 database x 2 subscribers) + 1 physical standby + 2 headroom = 7;
# This is lower than the default of 10, so do not reduce.
max_replication_slots = 10
max_wal_senders = 10
5-Node Mesh
Five nodes, each with replicated databases app and ledger, plus the default
postgres and template1 databases. Each node subscribes to the other four,
and each node has two physical standbys, common in clusters leveraging safe
synchronous replication.
Per node: D = 4, R = 2, S = R * (N - 1) = 8.
# Background workers:
# 1 supervisor + 1 slot sync + 4 managers + 8 apply + 8 table sync = 22
# + 8 max_parallel_workers + 2 headroom = 32
max_worker_processes = 32
# Slots and walsenders, set equal:
# (2 x 2 databases x 4 subscribers) + 2 standbys + 2 headroom = 20
max_replication_slots = 20
max_wal_senders = 20
Verifying Your Configuration
List the Spock workers currently running on a node:
SELECT pid, backend_type, datname
FROM pg_stat_activity
WHERE backend_type LIKE 'spock%'
ORDER BY backend_type;
Each Spock worker reports its role and the OIDs it serves in backend_type -
for example spock supervisor, spock manager 16384, and
spock apply 16384:1.
Compare the number of background workers in use against the limit:
SELECT current_setting('max_worker_processes')::int AS worker_limit,
count(*) AS in_use
FROM pg_stat_activity
WHERE backend_type NOT IN (
'client backend', 'walsender', 'autovacuum launcher',
'autovacuum worker', 'checkpointer', 'background writer',
'walwriter', 'walreceiver', 'walsummarizer', 'archiver',
'startup', 'io worker', 'standalone backend');
The exclusion list names the client backends and auxiliary processes that do
not draw on max_worker_processes; everything left over does. The list of
auxiliary process types changes between major versions, so check it against
pg_stat_activity
for your version.
Diagnosing Exhaustion
worker registration failed, you might want to increase max_worker_processes setting
Postgres refused to register a background worker because
max_worker_processes is fully allocated. Recompute with the formula above,
remembering that parallel query workers share the pool, and restart.
could not register spock worker: all background worker slots are already used
Spock's own worker-tracking array — sized to max_worker_processes at
startup — has no free entry. A worker that exits cleanly releases its entry
immediately, but a worker that crashes leaves its entry reserved for the
same database until that database's manager (or the supervisor) reclaims it.
Repeated worker crashes across many different databases can therefore exhaust
the array while Postgres still reports free worker slots. Check the server log
for the preceding crash and address that; raising max_worker_processes
enlarges the array but does not fix the underlying failures.
Subscription stuck in initializing
Usually max_replication_slots or max_wal_senders on the provider. A
subscription that is initializing needs a slot and walsender for its sync
worker in addition to those already held by its apply stream, which is why a
cluster that replicates fine in steady state can fail to add or resync a
table. See Troubleshooting.
Increasing replication lag with idle apply workers
If spock.lag_tracker shows growing lag but apply workers are not running,
check the server log for worker registration failures — the manager retries
registration on a delay, so an exhausted worker pool presents as intermittent
apply rather than a hard stop.