Streaming Dataflow in Numaflow
This document explains how Numaflow's Rust data-plane moves data in a streaming fashion, ensuring at-least-once semantics and correct watermark propagation for reduce operations. You can read why we changed to streaming in our blog post.
Overview
Numaflow processes data as a continuous stream rather than discrete batches (a major change in 1.6). The key design principles are:
- Always maintain active work - Keep
batch_sizemessages in-flight at all times for maximum throughput and reduce tail latency. - Track the smallest offset - Never advance watermarks past unprocessed data, ensuring reduce correctness (oldest work yet to be completed)
- Backpressure via semaphores - Prevent memory exhaustion while maximizing parallelism (throttling read if processing or write is pending)
Core Components
ISB Reader
Reads messages from the Inter-Step Buffer (e.g., JetStream), enriches them with watermarks, and tracks them until fully processed, and written/forwarded to the next Vertex.
ISB Writer
Writes processed messages to downstream ISB streams, handles routing based on tags/conditions, and publishes watermarks.
Tracker
Maintains a sorted map (BTreeMap) of all in-flight messages per partition. Used to:
- Track message completion for ACK/NACK
- Compute the lowest watermark among all in-flight messages
- Handle serving callbacks
Watermark Computation
Ensures temporal correctness by tracking event-time progress and publishing watermarks to downstream vertices.
Maintaining Active Work with Semaphores
The reader maintains a bounded number of in-flight messages using a semaphore. This provides:
- Constant throughput - Always
batch_sizemessages being processed - Backpressure - Prevents unbounded memory growth
- Graceful shutdown - Waits for all the messages to be processed before exiting
How It Works
- Reader acquires permits - before fetching messages (
acquire_many(batch_size)) - Each message carries a permit - split from the batch permit
- Permit released on ACK - when downstream confirms receipt
- Backpressure automatic - if processing is slow, reader blocks on permit acquisition
// Semaphore controls max in-flight messages
let semaphore = Arc::new(Semaphore::new(max_ack_pending));
loop {
// Block until we can process more messages
let permits = semaphore.acquire_many_owned(batch_size).await?;
// Fetch and process batch
let batch = self.fetch_messages(batch_size).await;
for message in batch {
// Each message gets 1 permit, released on ACK
let permit = permits.split(1);
self.process_message(message, permit).await;
}
}
Message Lifecycle
A message flows through several stages from read to acknowledgment:
Key Points
- Watermark enriched at read time - Each message gets the watermark for its offset
- Tracker insertion before processing - Ensures we never lose track of a message
- WIP (Work-In-Progress) loop - Periodically marks message as still being processed
- ACK/NACK triggers cleanup - Removes from tracker and releases permit
Offset Tracking for Reduce Correctness
The Tracker maintains a BTreeMap of offsets per partition. This sorted structure is critical for watermark correctness:
Why BTreeMap?
- Sorted by offset - Oldest message is always
first_key_value() - O(log n) operations - Efficient insert/delete
- Minimum watermark - The first entry's watermark is the oldest in-flight
The Lowest Watermark Guarantee
/// Returns the lowest watermark among all tracked offsets.
pub async fn lowest_watermark(&self) -> DateTime<Utc> {
let state = self.state.read().await;
state.entries
.values()
.filter_map(|partition_entries| {
partition_entries
.first_key_value() // Oldest offset per partition
.and_then(|(_, entry)| entry.watermark)
})
.min() // Minimum across all partitions
}
Why This Matters for Reduce
Reduce operations group data by time windows. If we wrongly advance the watermark past unprocessed data, we will wrongly invoke close-of-book (COB) for windows, which will lead to incorrect results, like:
- Windows close prematurely - Data arrives after window is closed
- Late data is dropped - Correctness is violated
- Results are wrong - Aggregations miss data
By always publishing the lowest watermark among in-flight messages:
The watermark is a promise: "No more data with event-time < watermark will arrive."
Watermark Publishing
Watermarks are published to downstream vertices via OT (Offset-Timeline) stores in JetStream KV.
Computing the Watermark
The watermark to publish depends on the vertex type:
For Map/Sink Vertices
Use the tracker's lowest watermark - the minimum watermark among all in-flight messages.
For Reduce Vertices
Use the oldest open window's end time - 1ms. This ensures:
- Windows aren't closed until all their data is processed
- Late data within allowed lateness is handled correctly
Idle Watermark Handling
When no data is flowing, we still need to advance watermarks:
- Detect idle state - No messages read for a period
- Fetch head WMB - Get the latest watermark marker from upstream
- Publish idle watermark - Allow downstream to make progress
Streaming Flow Patterns
Map Forwarder
All components are connected via Tokio channels ReceiverStream,
enabling true streaming without batch boundaries.
Reduce Forwarder
Summary
The streaming architecture ensures that:
- Throughput is maximized - Always
batch_sizemessages in flight - Memory is bounded - Semaphore prevents unbounded growth
- Reduce is correct - Watermarks never advance past unprocessed data
- Recovery is possible - Tracker + WIP loop enables at-least-once or "almost" exactly-once
- WAL - WAL is used for recovery in case of pod restarts for Reduce vertices.