DB Internals AtlasPostgreSQL 18.6 Home About

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_typeps title
logical replication launcherpostgres: 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 18

Lifecycle

  1. 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 SUBSCRIPTION wakes it; it starts an apply worker for every enabled subscription that has none.
  2. The apply worker connects to the publisher (a walsender there) and runs START_REPLICATION SLOT … LOGICAL.
  3. For tables in state i it starts up to max_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), runs COPY, then replays changes until it reaches the apply worker's position and hands the table over (s → r).
  4. Large transactions are streamed by the publisher before they commit once they exceed logical_decoding_work_mem. With streaming = parallel (the default since PostgreSQL 18; before it was off) the leader passes them to a parallel apply worker through a shared memory queue, up to max_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:

ERROR: conflict detected on relation "public.orders": conflict=insert_existsDETAIL: Key already exists in unique index "orders_pkey", modified in transaction 740.Key (id)=(42); existing local row (42, 'local'); remote row (42, 'remote').LOG: background worker "logical replication apply worker" (PID 1476) exited with exit code 1

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. Raise max_worker_processes with it: these are background workers, as are parallel query workers.
  • Each subscription needs a replication origin: max_active_replication_origins (new in 18, previously max_replication_slots was 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

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

  1. ApplyLauncherRegister() registers the launcher as a static background worker (BgWorkerStart_RecoveryFinished). Its loop reads get_subscription_list(), checks logicalrep_worker_find(), and starts missing apply workers, rate-limited by wal_retrieve_retry_interval (wait event LogicalLauncherMain).
  2. logicalrep_worker_launch() (used by the launcher and by apply workers) reserves a slot, enforces max_sync_workers_per_subscription and max_parallel_apply_workers_per_subscription, and calls RegisterDynamicBackgroundWorker() with bgw_function_name ApplyWorkerMain, TablesyncWorkerMain or ParallelApplyWorkerMain. Failures give out of logical replication worker slots or out of background worker slots warnings.
  3. The apply worker runs LogicalRepApplyLoop(): walrcv_receive() → apply_dispatch() → apply_handle_insert()/update/delete/commit…, and process_syncing_tables() after commits to start or finish table syncs (wait event LogicalApplyMain).
  4. Tablesync: LogicalRepSyncTableStart() creates the slot, copy_table() runs COPY through the walreceiver connection, then the same apply loop catches up until process_syncing_tables_for_sync() hands over.
  5. Streamed transactions: apply_handle_stream_start() picks a worker with pa_allocate_worker() and forwards messages with pa_send_data() over a shm_mq in a DSM segment; the worker consumes them in LogicalParallelApplyLoop(). Without a worker, stream_open_and_write_change() spools to a file applied by apply_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 = debug1 on the subscriber to see worker starts and state changes; check pg_replication_origin_status for 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