Skip to main content

spate_core/sink/
queue.rs

1//! Bounded per-shard chunk queues: the pipeline→sink handoff.
2//!
3//! Senders live on pipeline threads and only ever `try_send`, so a poll loop
4//! never blocks; receivers live in shard worker tasks on the I/O runtime.
5
6use super::EncodedChunk;
7use crate::metrics::{ComponentLabels, MetricsError, QueueMetrics};
8use std::sync::Arc;
9use tokio::sync::mpsc;
10
11/// A rejected chunk, handed back so the terminal stage can park it and
12/// report `Blocked` upstream.
13#[derive(Debug)]
14pub struct ChunkSendError(pub EncodedChunk);
15
16/// Sending side of every shard queue, shared by pipeline threads.
17#[derive(Clone, Debug)]
18pub struct ShardQueues {
19    senders: Vec<mpsc::Sender<EncodedChunk>>,
20    capacity: usize,
21    /// Per-shard `spate_queue_*` handles, shared across every producer clone of
22    /// this queue. `None` until `attach_metrics` runs (and in bare transport
23    /// tests), so the no-metrics path stays untouched.
24    metrics: Option<Arc<Vec<QueueMetrics>>>,
25}
26
27impl ShardQueues {
28    /// Number of shards.
29    #[must_use]
30    pub fn num_shards(&self) -> usize {
31        self.senders.len()
32    }
33
34    /// Configured per-shard capacity (in chunks).
35    #[must_use]
36    pub fn capacity(&self) -> usize {
37        self.capacity
38    }
39
40    /// Non-blocking send to `shard`. On `Err` the chunk comes back and the
41    /// caller applies backpressure. A closed queue (sink shut down) also
42    /// returns the chunk; the driver observes shutdown separately.
43    pub fn try_send(&self, shard: usize, chunk: EncodedChunk) -> Result<(), ChunkSendError> {
44        let (result, live) = match self.senders[shard].try_send(chunk) {
45            Ok(()) => (Ok(()), true),
46            Err(mpsc::error::TrySendError::Full(c)) => {
47                // A full queue is a backpressure signal; count it. The queue is
48                // still live, so its depth is meaningful (and reads as full).
49                if let Some(m) = &self.metrics {
50                    m[shard].full_events(1);
51                }
52                (Err(ChunkSendError(c)), true)
53            }
54            // A closed queue is shutdown rather than backpressure. Don't count
55            // it, and don't sample a depth from a torn-down channel.
56            Err(mpsc::error::TrySendError::Closed(c)) => (Err(ChunkSendError(c)), false),
57        };
58        // Sample depth from the live channel, mirroring the `all_below` fill
59        // calculation. `capacity()` is the free slots remaining.
60        if let Some(m) = self.metrics.as_ref().filter(|_| live) {
61            m[shard].set_depth(self.capacity - self.senders[shard].capacity());
62        }
63        result
64    }
65
66    /// Pre-register one `QueueMetrics` per shard, resolved through `labels` so
67    /// the `spate_queue_*` series inherit the standard component labels. Call
68    /// once, before this handle is cloned into pipeline terminals, so every
69    /// producer clone shares the same handles.
70    ///
71    /// Fails when another live handle set already owns one of these queue
72    /// edges, meaning two pipelines with the same names in one process. The
73    /// depth gauge cannot be shared, so the caller refuses to build rather
74    /// than letting two writers alternate readings.
75    pub(crate) fn attach_metrics(&mut self, labels: &ComponentLabels) -> Result<(), MetricsError> {
76        let metrics = (0..self.senders.len())
77            .map(|i| {
78                QueueMetrics::try_new(labels, &format!("chain->sink/shard-{i}"), self.capacity)
79            })
80            .collect::<Result<_, _>>()?;
81        self.metrics = Some(Arc::new(metrics));
82        Ok(())
83    }
84
85    /// Whether every shard queue is below `ratio` of its capacity, the resume
86    /// condition the backpressure controller asks about.
87    #[must_use]
88    pub fn all_below(&self, ratio: f64) -> bool {
89        let threshold = (self.capacity as f64 * ratio) as usize;
90        self.senders
91            .iter()
92            .all(|s| self.capacity - s.capacity() <= threshold)
93    }
94}
95
96/// Build the queues: one bounded channel per shard. Returns the shared
97/// sender handle and the per-shard receivers for the workers.
98#[must_use]
99pub fn shard_queues(
100    num_shards: usize,
101    capacity: usize,
102) -> (ShardQueues, Vec<mpsc::Receiver<EncodedChunk>>) {
103    assert!(num_shards > 0, "a sink needs at least one shard");
104    assert!(capacity > 0, "shard queues need non-zero capacity");
105    let (senders, receivers): (Vec<_>, Vec<_>) =
106        (0..num_shards).map(|_| mpsc::channel(capacity)).unzip();
107    (
108        ShardQueues {
109            senders,
110            capacity,
111            metrics: None,
112        },
113        receivers,
114    )
115}
116
117#[cfg(all(test, not(loom)))]
118mod tests {
119    use super::*;
120    use bytes::Bytes;
121
122    fn chunk() -> EncodedChunk {
123        EncodedChunk {
124            oldest_ingest: std::time::Instant::now(),
125            oldest_event_ms: 0,
126            frame: Bytes::from_static(b"x"),
127            rows: 1,
128            acks: crate::checkpoint::AckSet::new(),
129        }
130    }
131
132    #[test]
133    fn try_send_never_blocks_and_returns_the_chunk_when_full() {
134        let (q, mut rx) = shard_queues(1, 2);
135        assert!(q.try_send(0, chunk()).is_ok());
136        assert!(q.try_send(0, chunk()).is_ok());
137        let ChunkSendError(returned) = q.try_send(0, chunk()).unwrap_err();
138        assert_eq!(returned.rows, 1);
139        assert!(rx[0].try_recv().is_ok());
140        assert!(q.try_send(0, chunk()).is_ok(), "capacity freed");
141        let _ = rx;
142    }
143
144    #[test]
145    fn dropping_a_receiver_with_queued_chunks_fails_their_acks() {
146        use crate::checkpoint::{AckRef, AckStatus};
147        let (q, rx) = shard_queues(1, 4);
148        let (ack, ack_rx) = AckRef::test_pair();
149        let mut c = chunk();
150        c.acks = vec![ack.clone()].into();
151        drop(ack);
152        q.try_send(0, c).expect("queued");
153        drop(rx); // sink torn down with the chunk still queued
154        assert_eq!(
155            ack_rx.try_recv().expect("resolved").status,
156            AckStatus::Failed,
157            "chunks lost in a dropped queue must fail their batches"
158        );
159    }
160
161    #[test]
162    fn closed_queue_hands_the_chunk_back() {
163        let (q, rx) = shard_queues(1, 1);
164        drop(rx);
165        assert!(q.try_send(0, chunk()).is_err());
166    }
167
168    #[test]
169    fn all_below_reflects_fill_ratio() {
170        let (q, _rx) = shard_queues(2, 4);
171        assert!(q.all_below(0.5));
172        q.try_send(0, chunk()).unwrap();
173        q.try_send(0, chunk()).unwrap();
174        q.try_send(0, chunk()).unwrap();
175        assert!(!q.all_below(0.5), "shard 0 is 75% full");
176    }
177
178    #[test]
179    fn attached_metrics_emit_the_documented_queue_family() {
180        use metrics_exporter_prometheus::PrometheusBuilder;
181
182        let recorder = PrometheusBuilder::new().build_recorder();
183        let handle = recorder.handle();
184        metrics::with_local_recorder(&recorder, || {
185            let (mut q, rx) = shard_queues(1, 2);
186            q.attach_metrics(&ComponentLabels::new(
187                "orders",
188                "queue-family-test",
189                "clickhouse",
190            ))
191            .expect("free series");
192            q.try_send(0, chunk()).expect("first send fits"); // depth -> 1
193            q.try_send(0, chunk()).expect("second send fills"); // depth -> 2
194            q.try_send(0, chunk()).expect_err("full"); // Full: full_events -> 1, depth -> 2
195            drop(rx); // tear the sink down
196            q.try_send(0, chunk()).expect_err("closed"); // Closed: must NOT count, no resample
197        });
198        let rendered = handle.render();
199
200        let series = r#"{pipeline="orders",component="queue-family-test",component_type="clickhouse",queue="chain->sink/shard-0"}"#;
201        for needle in [
202            format!("spate_queue_capacity{series} 2"),
203            // A single Full rejection; the following Closed rejection is excluded.
204            format!("spate_queue_full_events_total{series} 1"),
205            // Depth was last sampled on the Full send, reading the full queue.
206            format!("spate_queue_depth{series} 2"),
207        ] {
208            assert!(
209                rendered.contains(&needle),
210                "rendered output missing `{needle}`:\n{rendered}"
211            );
212        }
213    }
214}