Background processes
Logical replication workers
On a subscriber, a launcher process starts one apply worker per subscription. The apply worker copies new tables with short-lived table synchronization workers and can hand big streamed transactions to parallel apply workers.
In plain words
With logical replication a subscriber database receives row changes (INSERT, UPDATE, DELETE) from a publisher. On the subscriber, a small team of background processes does the work.
- The launcher (running on every server that is not a standby) notices subscriptions and starts workers for them.
- One apply worker per subscription receives the stream of changes and applies them like a normal session would.
- For each newly added table a table synchronization worker copies the existing rows, catches up, then exits.
- Parallel apply workers (PostgreSQL 16+) apply very large transactions while they are still being streamed.
The workers (not the launcher) are taken from a limited pool (max_logical_replication_workers, default 4) which itself comes from max_worker_processes. Step through to see who starts what.
For DBAs and operators
Processes and how to see them
| backend_type | ps title |
|---|---|
logical replication launcher | postgres: logical replication launcher |
logical replication apply worker | … apply worker for subscription 16419 |
logical replication tablesync worker | … tablesync worker for subscription 16419 sync 16405 |
logical replication parallel worker | … parallel apply worker for subscription 16419 |
SELECT subname, worker_type, pid, leader_pid, relid::regclass,
received_lsn, latest_end_lsn, last_msg_receipt_time
FROM pg_stat_subscription; -- worker_type: apply | table synchronization | parallel apply
SELECT srrelid::regclass, srsubstate FROM pg_subscription_rel;
-- i = init, d = copying data, f = copy finished, s = synchronized, r = ready
SELECT subname, apply_error_count, sync_error_count, confl_insert_exists
FROM pg_stat_subscription_stats; -- confl_* columns are new in 18Lifecycle
- The launcher is a background worker started at server start (it exists on every server that is not in recovery, even without subscriptions, as long as
max_logical_replication_workers> 0; a standby starts it only after promotion).CREATE/ALTER SUBSCRIPTIONwakes it; it starts an apply worker for every enabled subscription that has none. - The apply worker connects to the publisher (a walsender there) and runs
START_REPLICATION SLOT … LOGICAL. - For tables in state
iit starts up tomax_sync_workers_per_subscription(default 2) tablesync workers. Each opens its own walsender, creates its own replication slot (pg_<subid>_sync_<relid>_<sysid>, dropped when the sync is done), runsCOPY, then replays changes until it reaches the apply worker's position and hands the table over (s→r). - Large transactions are streamed by the publisher before they commit once they exceed
logical_decoding_work_mem. Withstreaming = parallel(the default since PostgreSQL 18; before it wasoff) the leader passes them to a parallel apply worker through a shared memory queue, up tomax_parallel_apply_workers_per_subscription(default 2). Without a free worker it writes the stream to a temporary file and applies it at commit.
Errors and restarts
A conflict (unique violation, missing permissions, missing table) makes the apply worker exit; the launcher restarts it after wal_retrieve_retry_interval (5s), it hits the same change again, and so on:
The CONTEXT line shows the origin, the remote transaction and its "finished at" LSN. Options: fix the data on the subscriber, ALTER SUBSCRIPTION … SKIP (lsn = '…'), or create the subscription with disable_on_error = true so it stops instead of looping. Meanwhile the publisher's slot retains WAL.
Sizing
- Apply + tablesync + parallel apply workers of all subscriptions share
max_logical_replication_workers(restart to change). If it is too small, table syncs simply wait. Raisemax_worker_processeswith it: these are background workers, as are parallel query workers. - Each subscription needs a replication origin:
max_active_replication_origins(new in 18, previouslymax_replication_slotswas used on the subscriber). - The publisher needs one walsender (and slot) per apply worker plus one per running tablesync.
- Killing an apply worker with
pg_terminate_backend()just makes the launcher start a new one; a SIGKILL causes a crash restart of the subscriber.
For developers: inside the source
Where it lives
launcher.c: launcher (ApplyLauncherMain()) and worker-slot management.worker.c: apply worker (ApplyWorkerMain(), the protocol handlersapply_handle_*()).tablesync.c: table synchronization (TablesyncWorkerMain()).applyparallelworker.c: parallel apply (ParallelApplyWorkerMain()).conflict.c(ReportApplyConflict()),origin.c(replication origins),proto.c(the pgoutput message format),worker_internal.h.
Key data structures
LogicalRepCtxStruct in shared memory has a workers[] array of max_logical_replication_workers LogicalRepWorker entries, guarded by LogicalRepWorkerLock. Each entry records type (WORKERTYPE_APPLY, WORKERTYPE_TABLESYNC, WORKERTYPE_PARALLEL_APPLY), subid, relid, relstate/relstate_lsn (under the relmutex spinlock), leader_pid, and the LSN/timestamp fields shown by pg_stat_subscription (built by pg_stat_get_subscription()).
Call flow
ApplyLauncherRegister()registers the launcher as a static background worker (BgWorkerStart_RecoveryFinished). Its loop readsget_subscription_list(), checkslogicalrep_worker_find(), and starts missing apply workers, rate-limited bywal_retrieve_retry_interval(wait eventLogicalLauncherMain).logicalrep_worker_launch()(used by the launcher and by apply workers) reserves a slot, enforcesmax_sync_workers_per_subscriptionandmax_parallel_apply_workers_per_subscription, and callsRegisterDynamicBackgroundWorker()withbgw_function_nameApplyWorkerMain,TablesyncWorkerMainorParallelApplyWorkerMain. Failures giveout of logical replication worker slotsorout of background worker slotswarnings.- The apply worker runs
LogicalRepApplyLoop():walrcv_receive()→apply_dispatch()→apply_handle_insert()/update/delete/commit…, andprocess_syncing_tables()after commits to start or finish table syncs (wait eventLogicalApplyMain). - Tablesync:
LogicalRepSyncTableStart()creates the slot,copy_table()runsCOPYthrough the walreceiver connection, then the same apply loop catches up untilprocess_syncing_tables_for_sync()hands over. - Streamed transactions:
apply_handle_stream_start()picks a worker withpa_allocate_worker()and forwards messages withpa_send_data()over ashm_mqin a DSM segment; the worker consumes them inLogicalParallelApplyLoop(). Without a worker,stream_open_and_write_change()spools to a file applied byapply_spooled_messages().
Experimenting
- TAP suites in
src/test/subscription/t:004_sync.pl,015_stream.pl,026_stats.pl,029_on_error.pl,035_conflicts.pl. - Set
log_min_messages = debug1on the subscriber to see worker starts and state changes; checkpg_replication_origin_statusfor each subscription's progress.
Parameters that shape this
max_logical_replication_workers max_sync_workers_per_subscription max_parallel_apply_workers_per_subscription max_worker_processes wal_retrieve_retry_interval max_active_replication_origins logical_decoding_work_mem wal_receiver_timeout
Related topics
Logical replication WAL sender Replication slots Parallel and background workers Postmaster Cumulative statistics Process model