Streaming and pull semantics
How data moves between components — and the one invariant that, when broken, deadlocks a graph in complete silence.
TL;DR
- The graph is pull-driven. Each tick, a worker declares how far to advance on each of its inputs; the runtime fires it when that pull is satisfiable.
- A producer's output port is one append-only stream, and every consumer wired to it holds its own cursor. The stream can only reclaim messages behind the slowest cursor.
- Every wired input leg must keep advancing, forever. A worker that is done emitting must still keep consuming and discarding.
- A pinned cursor blocks the producer and starves every sibling branch, not just the worker that pinned.
Mental model
A component container does not receive pushed data. On every tick it hands the runtime a request describing, per input, how many messages to take and how far to advance its position in that input's stream. The runtime fires the worker once that request can be satisfied.
Each output port owns one append-only stream. Wiring three consumers to one port does not make three copies — it makes one stream with three independent cursors:
producer out_0 ──► [ m0 m1 m2 m3 m4 m5 … ] one stream
▲ ▲ ▲
│ │ └── consumer C (start index 5)
│ └──────── consumer B (start index 3)
└───────────────── consumer A (start index 1)
retire boundary = slowest cursor = A. Nothing at or past m1 can be reclaimed.
If consumer A stops advancing, the stream never reclaims anything. It grows to capacity, the producer blocks trying to push, and B and C starve — even though neither has anything to do with A. A local decision by one worker becomes a global deadlock.
The invariant
A leg that stops advancing blocks its producer. That is correct back-pressure when the producer feeds only you, and a deadlock when it feeds anyone else.
A component that has done its job and has no further use for its input faces two different situations. If it is the only consumer of that producer, stopping is right: the producer idles instead of manufacturing values nothing will read. If the producer also feeds something else, stopping is a bug — the frozen cursor holds the shared stream open, the producer blocks, and unrelated branches stop receiving.
A worker sees its own inputs, never the topology around them, so it cannot tell these apart. The decision belongs in config, chosen per placement by whoever wired the graph.
Closing a stream is a producer-side operation, and nothing lets a consumer detach from an edge it is wired to. Draining is the only way to stay wired without holding the stream.
Zero-advance is also legal as a phase — a multi-rate re-clocker holds a slow leg between scheduled refreshes — because the cursor still moves on schedule.
Recognising a pinned cursor
- The graph produces a burst of messages and then stops forever, after a count you never configured.
- Every container reports
running. No crash, no restart, no error line. - Static validation passes — nothing about a pinned cursor is visible before the graph runs.
- The branch that goes quiet is often not the one containing the bug.
Next steps
- /component-api/cpp/class-description
- /concepts/components
For the exact StreamPullShift field contract and the pull-option matching
rules, see the page in the CLI:
ppl --agent=general docs get concepts/streaming