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