70.1.2. pepsi-dispatch

drive messages through the stage pipeline

Manual section:

1

70.1.2.1.1. Name

pepsi-dispatch - claim pending messages and run their stage programs.

70.1.2.1.2. Synopsis

pepsi-dispatch [GLOBAL-OPTIONS] serve

70.1.2.1.3. Description

pepsi-dispatch is the long-lived dispatcher that advances messages through the stage pipeline (see pepsi.conf(5)). It claims pending rows from pepsi.ingress, sets each to running, looks up the message’s stage in its [stage-<stage>] section, and hands the message id to a worker process of that stage. Each stage runs as a pool of persistent workers started as PROGRAM worker (the program is found on the PATH unless PROGRAM is an absolute path); a worker connects to the database once and then reads message ids from its standard input and writes one status line per message to its standard output (0 on success — the value the process exit code carried in earlier releases), in input order. A status may be followed by a space and a JSON report of the stages that were fused into that pass, [["<stage>",<microseconds>], …], which is how per-stage statistics stay whole without the workers writing them (see Statistics below). Only an unreadable status is fatal — it desynchronises the positional protocol, so the dispatcher tears the worker down; an unreadable report is dropped with a warning, so a worker and a dispatcher from different builds lose statistics for the length of a restart window rather than messages. Run exactly one dispatcher per system; it is the only process that starts stage programs (though many workers then run concurrently against the shared database). New work is picked up from a LISTEN on the ingress channel, with a periodic safety-net heartbeat (POLL_INTERVAL).

Notifications on that channel are issued explicitly, and only by writers outside the dispatcher’s own loop: pepsi-ingress(1) (once per burst of accepted messages, not once per message), the ingress_inject and ingress_resume SQL functions, pepsi-keydisc(1) releasing parked mail, pepsi-failure-bouncer(1) and the pepsi-queue(1) repair commands. A stage advancing a message deliberately does not notify: the worker reports the finished message on its standard output, and the dispatcher answers that with the same full claim a notification would have triggered, so the notification would carry no information. Earlier releases used a table trigger that fired for every row becoming pending; PostgreSQL holds a database-wide lock from the moment a transaction queues a notification until it commits, so that made every stage hop serialise the whole database’s commits — see the Performance chapter of the manual.

Batched claiming. On any wake (a notification, a freed or dead worker, the poll heartbeat, a listener (re)connect) the dispatcher first drains every pending internal event so its per-stage capacities are current, then runs one UPDATE RETURNING that claims, for every stage with spare capacity at once, up to that stage’s own remaining capacity (pending``→``running, bounded per-stage by a LATERAL subquery whose ORDER BY ingress_id LIMIT cap stops scanning each stage’s backlog at its cap, driven by the partial index on (stage, ingress_id) WHERE status='pending'). The notification carries an empty payload — a notification is simply a wake, and a burst of them collapses into one batched claim (PostgreSQL also coalesces identical queued notifications).

Pipelined, elastic worker pools. Each stage’s pool grows on demand up to its PARALLELISM (default 4) and shrinks when idle: no worker runs until the stage sees traffic; when a stage has work and spare capacity the dispatcher fills an existing worker’s free slot or starts a new worker; work is packed onto the fewest workers so a surplus goes cold and is stopped after WORKER_IDLE_TIMEOUT (default 5 s). Under steady light load a stage therefore hovers around a single worker. A worker is handed up to QUEUE_LIMIT (default 4) message ids at once — written to its standard input in a single buffer — rather than one-at-a-time; it still processes them strictly serially, but the next id is already queued, so completing a message frees it without a coordinator round-trip first. A stage’s total in-flight capacity is thus QUEUE_LIMIT × PARALLELISM. Because the worker count (and so the database-connection count) is unchanged, raising QUEUE_LIMIT trades a little head-of-line latency for throughput without spending more connections. After MAX_MESSAGES (default 1000) a worker recycles its child — once its pipeline has drained — with a fresh process to bound memory growth.

Batched body-free stages. A worker for a stage that loads neither the header block nor the body (the metadata-only stages — srs, if, the whitelist stages, discard, aliases …) processes its pipelined ids in one batch rather than one at a time: it greedily takes every id already buffered on its standard input (up to QUEUE_LIMIT, stopping the instant a read would block, so a lone id is still handled immediately), loads them all in a single SELECT WHERE ingress_id = ANY(...), runs each stage body, then commits the advances/fails/requeues in one arrayed UPDATE FROM jsonb_to_recordset(...) per outcome shape (typically one). It still writes one status line per id, in order, so the worker protocol is unchanged. When the queue is full this cuts a body-free stage’s database round-trips from two per message to roughly two per QUEUE_LIMIT messages. Stages that load the headers/body keep the one-id-at-a-time path (their large columns do not array cheaply).

Advancing. A stage advances a message by setting its stage column to the next stage and the row back to pending in one update; the dispatcher never rewrites a row on success. Claiming (pending``→``running) is the dispatcher’s only write to a row’s status for forward progress, and it is the only writer that performs it. When a worker reports success (status 0) the dispatcher re-scans for work — which is precisely why an advance issues no notification of its own — so the next stage’s pool claims the now pending row on the following scheduling round (unlike earlier releases, an advanced message is no longer chained inside the same process). The message is done when the worker deletes the row, paused when it leaves it paused, or terminal when failed/timeout. A stage commits its outcome in a single round-trip — one UPDATE/DELETE, or one stored function that fans work out server-side (a recipient split, or a finish/pause that also spawns bounce, delay or success DSNs from arrays of clones) — never a multi-statement client transaction.

Failures and retries. A worker that reports a non-zero status leaves that message failed with the reason in state and stays alive for the next id. A worker whose child closes its output (a crash) fails its head-of-line message and is torn down; a child whose head-of-line message is not answered within MAX_RUNTIME (real time) is killed and that message set to timeout. In both of those cases the other messages already pipelined to that worker never ran, so they are reset to pending and re-dispatched (a single stuck or poison message therefore does not strand its in-flight siblings). The MAX_RUNTIME clock is per message and starts when a message reaches the head of the worker’s queue, so a message waiting behind a slow sibling is not charged for the wait. When a stage leaves a message paused it records, in timeout, when the message should be retried; the dispatcher sleeps until the earliest such time, flips the due rows back to pending, and re-scans for work. At start-up it resets any leftover running rows (orphaned by a previous dispatcher) back to pending. Because there is exactly one dispatcher and its coordinator claims serially — the sole writer that sets pending``→``running — the batched per-stage claim needs no FOR UPDATE SKIP LOCKED. Because at most one writer ever touches a given row, the shared database connection runs READ COMMITTED rather than SERIALIZABLE (queue operations are still routed through a retry helper as cheap insurance).

Database-overload backpressure. A worker that cannot reach the database because its connection limit is exhausted (PostgreSQL too_many_connections, or its pool times out acquiring a connection) reports a distinct status (EX_TEMPFAIL, 75) rather than the generic failure code — this is infrastructure pressure, not a defect in the message. The dispatcher then does not fail the message: it requeues it to pending for a later retry, and to shed connections it temporarily reduces the parallelism of the stage currently running the most worker processes (the largest contributor to the pressure, which need not be the stage that reported the error) — halving its cap, floor 1, for five minutes. Repeated reports ratchet that stage down further and refresh the window; existing workers drain via the idle reaper and the stage ramps back up once the window passes. The remedy for sustained pressure is to raise PostgreSQL’s max_connections or lower the stages’ PARALLELISM so that the sum of every component’s pool fits the server (see the [pepsi-postgres] connection-budget note in pepsi.conf(5)).

Per-address settings. Before a worker runs a stage’s logic it consults the pepsi.settings table (see pepsi-settings(1)) for the message’s correspondent — the envelope sender of a state.local_origin message, otherwise its recipients — and layers any per-address overrides onto the stage’s [stage-<name>] options. The overrides are fetched with the message row in the same query (a correlated subquery on the relevant addresses), so loading a message and its settings is a single round-trip. When the recipients of one inbound message resolve to different overrides for the stage about to run, the worker splits the message lazily: in one round-trip (the ingress_split function) it groups the recipients by their effective override, keeps the first group on the current row, and fans each remaining group out as a new pending row at the same stage (the trigger wakes the dispatcher for each). Each row then runs the stage under its own settings. A message whose recipients never diverge is never split.

Stage fusion. Many stages are very fast and need only the message metadata (the envelope, the extracted From:/Subject: and the state JSON), not the header block or body. When such a stage advances to a successor that needs no more data than is already loaded, routing the row back through the database and the dispatcher is pure overhead. Stage fusion removes it: instead of writing the row pending at the next stage and waiting for it to be claimed, the worker runs the successor’s body in the same process, reusing the row it already loaded. A whole chain of fused stages is one SELECT at the front, the stages’ own work, and a single terminal write, reported to the dispatcher as one completion.

A successor is fused only when all hold: stage fusion is enabled globally ([pepsi] ALLOW_FUSION, on by default); the successor’s section is marked FUSION = yes (the default for the fast metadata-only stages — pepsi-stage-if(1), pepsi-stage-discard(1), pepsi-stage-srs(1), pepsi-stage-check-whitelist(1), pepsi-stage-auto-whitelist(1), pepsi-stage-block-language(1), pepsi-stage-edit-settings(1)); the successor’s PROGRAM is folded into the same unified pepsi binary (so it can run in-process — fusion is therefore inert in a per-program multibin build); and the successor needs no message column the predecessor did not load. Any miss makes the advance a normal dispatched hop, so fusion never changes a message’s outcome — only whether the hand-off touches the database. A stage may additionally apply a data-dependent gate that refuses fusion for some messages: pepsi-stage-edit-settings(1) loads the full body, yet it does nothing to any message that is not a settings control message, so it fuses through every non-control message on metadata alone and declines fusion only for a genuine control message — which is then committed and dispatched normally so the worker loads its body. Per-address settings are still applied to each fused stage (their overrides were already fetched with the row), and a divergent recipient split still happens where needed. A misconfigured stage cycle is bounded by a fusion-depth cap, after which the advance is committed normally. Fused stages are still counted individually in pepsi.stage_stats, so per-stage statistics stay accurate — the worker reports them on the pass’s status line and the dispatcher folds them in (see Statistics); only the per-stage cost benchmark turns fusion off (with ALLOW_FUSION = no) so it can time each stage as its own dispatched worker.

Statistics. The cumulative counters in pepsi.stage_stats and pepsi.dispatch_stats are accumulated in memory by the dispatcher and written in a single transaction: every STATS_INTERVAL, whenever the pipeline goes idle (so a burst’s numbers are visible as soon as it ends rather than up to an interval later — rate-limited to at most one such flush a second), and on shutdown. An idle dispatcher performs no database work at all, because a flush with no accumulated deltas writes nothing.

The dispatcher is deliberately the only writer of these tables, which is why a fused hop is reported to it on the worker’s status line rather than written by the worker: stage_stats holds one row per stage, so a counter written per message would make every worker of a stage serialise on that one row. Carrying the figures on a line the worker already writes removes the write entirely. The trade is the usual one for statistics: deltas not yet flushed are lost if the dispatcher dies, which is why these counters are documented as best-effort. stages_executed counts worker passes, so a fused chain counts once however many stages it covers.

pepsi-dispatch does not itself process messages — it only runs the stage workers, which record their own outcome on the row.

70.1.2.1.4. Commands

serve

Run the dispatcher until interrupted. Requires that the schema has been installed with pepsi-setup(1).

70.1.2.1.5. Global Options

-c FILE, –config FILE

Read the configuration from FILE instead of searching the default locations. Set CONFIG_FILE in [pepsi-dispatch] to the same path so spawned stage programs inherit it (see pepsi.conf(5)).

-L LOGLEVEL, –log LOGLEVEL

Set the logging verbosity (default info).

-v, –verbose

Show log messages from all sources.

-h, –help; -V, –version

Print a usage summary / the version and exit.

70.1.2.1.6. Signals

SIGINT, SIGTERM

Initiate shutdown: stop claiming new work, stop every worker (killing its child), reset any in-flight or claimed-but-unassigned row back to pending, and exit.

70.1.2.1.7. Exit Status

0

Clean shutdown.

1

An error occurred (for example a malformed configuration file or a failed database connection). The reason is written to the log.

70.1.2.1.8. Examples

Run the dispatcher:

pepsi-dispatch -c /etc/pepsi/pepsi.conf serve

70.1.2.1.9. See Also

pepsi-config(1), pepsi.conf(5), pepsi-stage-srs(1), pepsi-stage-bounce(1), pepsi-stage-dkim-sign(1), pepsi-stage-relay-to-smarthost(1), pepsi-stage-relay-to-internet(1), pepsi-settings(1), pepsi-queue(1), pepsi-setup(1)

70.1.2.1.10. Bugs

Report bugs to the Pepsi issue tracker.