Build a log shipping pipeline with PUSH and PULL
For Rust developers who know Tokio and want to see how PUSH/PULL handles fan-in, pipeline stages and slow consumers
You will build a small log shipper over TCP on localhost. Three apps push their log lines into one collector, which stamps each line and pushes it on to a writer stage. Then a stalled collector shows what the high-water marks and a send timeout do when the next stage can't keep up.
Before you start
You need Rust with Cargo. rzmq comes from crates.io:
rzmq = "0"Set up the project#
Start a new binary crate.
Add rzmq and Tokio. rzmq runs on the Tokio runtime, so you need both. Your [dependencies] section should look like this:
[]
= "0"
= { = "1", = ["full"] }The default features cover everything here. Every socket talks TCP on 127.0.0.1, so each piece could run as its own process. They all live in one process as Tokio tasks so you can run the whole thing at once. The programs use Tokio's single-threaded current_thread runtime, which keeps the output in the same order from run to run.
Collect lines from several apps#
The task ventilator in the earlier tutorial used PUSH/PULL to fan work out: one PUSH that binds, several PULL workers that connect. The same pair also works the other way round. Here one PULL socket is the stable end and binds. Each app connects its own PUSH socket to it. That is fan-in: many producers feed one consumer and none of the producers needs to know about the others.
A PULL socket only receives and a PUSH socket only sends, so there is no reply to wait for. An app hands a line to its socket and carries on.
Replace src/main.rs with this:
use ;
const COLLECTOR: &str = "tcp://127.0.0.1:5640";
const APPS: = ;
const LINES_PER_APP: usize = 3;
async
Each app gets its own PUSH socket and runs in its own Tokio task. The collector knows that three apps send three lines each, so it reads nine messages and stops. main then waits for the app tasks and calls ctx.term(), which closes every socket made from the context.
Run it:
The lines from one app always arrive in the order that app sent them. How lines from different apps mix depends on when each app sends. On the single-threaded runtime each app task sends its three lines in one go, so they arrive grouped by app.
Add a writer stage#
A real collector does something with each line and hands it on. Pipelines are what PUSH/PULL is for: each stage reads from a PULL socket, does its work and writes to a PUSH socket that the next stage reads from. Here the collector becomes an aggregator that gives every line a sequence number and pushes it to a writer.
The collector can no longer count on a fixed number of lines, so the apps say when they are done. Each app sends an END message after its last line. The aggregator counts the END messages and, once all three apps are finished, sends one END of its own down to the writer. The writer prints lines until it sees that END. A PUSH socket keeps each sender's messages in order, so an app's END always arrives after its last line.
The apps now start before the aggregator binds. That is the missing-peer case: a PUSH socket with nothing connected to it waits in send until a peer is there. Meanwhile the connect keeps retrying in the background until the aggregator's bind shows up. Nothing is lost and no app needs to know whether the collector is up yet.
Replace src/main.rs with the full program:
use ;
const COLLECTOR: &str = "tcp://127.0.0.1:5640";
const WRITER: &str = "tcp://127.0.0.1:5641";
const APPS: = ;
const LINES_PER_APP: usize = 3;
const END: & = b"END";
async
async
async
async
The aggregator binds both of its sockets. It sits in the middle of the pipeline and is the piece least likely to move, so the apps and the writer connect to it. writer runs on the main task and returns the number of lines it wrote.
Run it:
The apps have to reconnect once the aggregator binds. The order they get through that can change between runs, so the apps may appear in a different order. The sequence numbers always run from 0001 to 0009, each app's three lines stay in order and the writer always reports nine lines.
Stall the collector#
So far every stage keeps up. When one doesn't, messages queue up. Two socket options put a limit on that queue. SNDHWM is the high-water mark for the sending side: how many messages a PUSH socket holds for a peer that hasn't taken them yet. RCVHWM is the same for the receiving side: how many a PULL socket holds that your code hasn't read with recv. When the queue is full, send on the PUSH socket waits for space. That is the backpressure in this pattern. A slow consumer slows its producers down instead of making them buffer without end. The socket doesn't drop any message.
Waiting is right for the aggregator, which has nothing better to do. An app shouldn't stop serving requests because its log collector is stuck. SNDTIMEO sets how long send may wait, in milliseconds. When the time runs out, send returns ZmqError::Timeout and the message was not sent. The app can then decide what to do. Here it drops the rest of its burst.
This step uses one app talking straight to a collector, so there is only one queue to watch. The collector is stalled: it reads nothing until the app tells it how many lines got through. The app tries to ship a burst of 100,000 lines.
Replace src/main.rs:
use ;
use ;
use oneshot;
const COLLECTOR: &str = "tcp://127.0.0.1:5642";
const HWM: i32 = 100;
const BURST: usize = 100_000;
async
async
Both options take an i32 and are set before bind and connect. The high-water marks go on both ends, SNDHWM on the PUSH and RCVHWM on the PULL. The oneshot channel stands in for whatever unsticks a real collector. It also tells the collector how many lines to read.
Run it:
Your numbers will likely differ. Messages also sit in the operating system's TCP buffers between the two sockets, so the app gets further than the two high-water marks alone would allow. How much further depends on the machine and the size of each line. What stays fixed is that shipped plus dropped is always 100,000 and the collector gets every line that was shipped. Raise HWM and more lines get through before the timeout; lower it and fewer do.
Without SNDTIMEO the app's first send into a full queue would wait until the collector started reading again. That is exactly what you want for a pipeline stage like the aggregator, where a slow writer should slow down everything upstream of it.
Shut down cleanly#
In the pipeline every task knows when it is finished, because the END messages travel through every stage. Shutdown is waiting for the tasks in order and then terminating the context:
let writer_inbox = ctx.socket?;
writer_inbox.connect.await?;
let written = writer.await?;
println!;
aggregating.await.expect?;
for app in apps
ctx.term.await?;The writer returns after the aggregator's END, which the aggregator only sends after the END from every app. By the time main calls Context::term(), every message has been read and no task still holds a socket it is using. term() closes every socket made from the context and waits for their background tasks to finish. Messages still queued at that point are handled according to each socket's LINGER option. Any error along the way is passed up with ? instead of being ignored.
The END marker is one message per producer. The stage in the middle has to count them before passing its own on, since a PULL socket has no idea how many producers are connected to it.
Where to go next#
Every stage in this tutorial is a PULL that reads and a PUSH that writes, so you can add stages or run several copies of one. Several writers connected to the aggregator's PUSH would share the lines round-robin, which is the fan-out shape from the ventilator tutorial. Other options work the same way as the ones here. RCVTIMEO puts a time limit on recv and returns ZmqError::Timeout when it runs out. monitor_default() reports ConnectionCongested and ConnectionUncongested events when a peer's send queue hits its high-water mark and drains again.
The rzmq usage guide on GitHub covers socket options, monitoring, security and the io_uring backend.
The full API reference is on docs.rs.
