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