Skip to main content

spate_test/
sink.rs

1//! Sink-side mocks: a scriptable [`ShardWriter`] that captures every write,
2//! and a length-prefixed [`RowEncoder`] whose frames tests can decode.
3
4use bytes::{BufMut, Bytes, BytesMut};
5use spate_core::deser::Owned;
6use spate_core::error::{ErrorClass, SinkError};
7use spate_core::metrics::Meter;
8use spate_core::record::Record;
9use spate_core::sink::{
10    RowEncoder, SealedBatch, ShardWriter, SinkBundle, SinkParts, SinkPoolConfig, endpoint_probe,
11};
12use std::collections::{HashMap, VecDeque};
13use std::sync::{Arc, Mutex, MutexGuard};
14use std::time::Duration;
15
16/// A replica endpoint tag — [`CaptureWriter`]'s
17/// [`Endpoint`](ShardWriter::Endpoint).
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub struct ReplicaTag {
20    /// Shard index.
21    pub shard: usize,
22    /// Replica index within the shard.
23    pub replica: usize,
24}
25
26/// How a scripted write resolves.
27#[derive(Clone, Debug, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum ScriptedResult {
30    /// The write succeeds (the durable-ack point).
31    Ok,
32    /// The write fails with [`ErrorClass::Retryable`].
33    Retryable(String),
34    /// The write fails with [`ErrorClass::Fatal`].
35    Fatal(String),
36}
37
38/// One scripted write outcome: an optional delay, then a result.
39#[derive(Clone, Debug, PartialEq, Eq)]
40#[non_exhaustive]
41pub struct WriteOutcome {
42    /// Sleep this long (tokio time — controllable with `test-util`) before
43    /// resolving.
44    pub delay: Option<Duration>,
45    /// How the write resolves.
46    pub result: ScriptedResult,
47}
48
49impl WriteOutcome {
50    /// A successful write.
51    #[must_use]
52    pub fn ok() -> Self {
53        WriteOutcome {
54            delay: None,
55            result: ScriptedResult::Ok,
56        }
57    }
58
59    /// A retryable failure.
60    #[must_use]
61    pub fn retryable(reason: impl Into<String>) -> Self {
62        WriteOutcome {
63            delay: None,
64            result: ScriptedResult::Retryable(reason.into()),
65        }
66    }
67
68    /// A fatal failure.
69    #[must_use]
70    pub fn fatal(reason: impl Into<String>) -> Self {
71        WriteOutcome {
72            delay: None,
73            result: ScriptedResult::Fatal(reason.into()),
74        }
75    }
76
77    /// Resolve only after `delay` of tokio time.
78    #[must_use]
79    pub fn after(mut self, delay: Duration) -> Self {
80        self.delay = Some(delay);
81        self
82    }
83}
84
85/// One recorded [`ShardWriter::write_batch`] attempt.
86#[derive(Clone, Debug)]
87pub struct CapturedWrite {
88    /// Target shard.
89    pub shard: usize,
90    /// Target replica.
91    pub replica: usize,
92    /// `SealedBatch::rows`.
93    pub rows: u64,
94    /// `SealedBatch::bytes`.
95    pub bytes: u64,
96    /// `SealedBatch::dedup_token`.
97    pub dedup_token: String,
98    /// All frames concatenated (decode with [`decode_rows`](crate::decode_rows)
99    /// when produced by [`TestEncoder`]).
100    pub payload: Vec<u8>,
101    /// How the scripted attempt resolved.
102    pub result: ScriptedResult,
103}
104
105#[derive(Debug, Default)]
106struct SinkState {
107    global: VecDeque<WriteOutcome>,
108    per_endpoint: HashMap<(usize, usize), VecDeque<WriteOutcome>>,
109    probe_failures: HashMap<(usize, usize), String>,
110    writes: Vec<CapturedWrite>,
111}
112
113/// Build a paired scriptable writer and its handle.
114#[must_use]
115pub fn capture_writer() -> (CaptureWriter, SinkScript) {
116    let state = Arc::new(Mutex::new(SinkState::default()));
117    (
118        CaptureWriter {
119            state: Arc::clone(&state),
120        },
121        SinkScript { state },
122    )
123}
124
125/// Scripts outcomes for a [`CaptureWriter`] and reads back its captures.
126/// Cloneable; all clones address the same writer.
127#[derive(Clone, Debug)]
128pub struct SinkScript {
129    state: Arc<Mutex<SinkState>>,
130}
131
132impl SinkScript {
133    fn lock(&self) -> MutexGuard<'_, SinkState> {
134        self.state.lock().expect("spate-test sink state poisoned")
135    }
136
137    /// Queue an outcome consumed by the next otherwise-unscripted write to
138    /// **any** endpoint (FIFO). Endpoint-specific scripts take precedence.
139    pub fn enqueue_global(&self, outcome: WriteOutcome) {
140        self.lock().global.push_back(outcome);
141    }
142
143    /// Queue an outcome for the next write to `(shard, replica)` (FIFO).
144    pub fn enqueue_for(&self, shard: usize, replica: usize, outcome: WriteOutcome) {
145        self.lock()
146            .per_endpoint
147            .entry((shard, replica))
148            .or_default()
149            .push_back(outcome);
150    }
151
152    /// Make [`ShardWriter::probe`] of `(shard, replica)` fail with a
153    /// retryable error until [`heal_probe`](Self::heal_probe).
154    pub fn fail_probe(&self, shard: usize, replica: usize, reason: impl Into<String>) {
155        self.lock()
156            .probe_failures
157            .insert((shard, replica), reason.into());
158    }
159
160    /// Let `(shard, replica)`'s probe succeed again.
161    pub fn heal_probe(&self, shard: usize, replica: usize) {
162        self.lock().probe_failures.remove(&(shard, replica));
163    }
164
165    /// Every write attempt so far, in order.
166    #[must_use]
167    pub fn writes(&self) -> Vec<CapturedWrite> {
168        self.lock().writes.clone()
169    }
170}
171
172/// A [`ShardWriter`] that records every attempt and resolves it according
173/// to the paired [`SinkScript`] (default: success). Unscripted writes
174/// succeed, so happy-path tests need no scripting at all.
175#[derive(Clone, Debug)]
176pub struct CaptureWriter {
177    state: Arc<Mutex<SinkState>>,
178}
179
180impl ShardWriter for CaptureWriter {
181    type Endpoint = ReplicaTag;
182
183    fn attach_metrics(&mut self, meter: Option<Meter>) {
184        // Exercise the connector-metrics seam: a family under the sink's
185        // `spate_<component_type>_sink_*` namespace (e.g. `spate_capture_sink_*`).
186        if let Some(meter) = meter {
187            meter.counter("attaches_total", &[]).increment(1);
188        }
189    }
190
191    fn write_batch(
192        &self,
193        endpoint: &ReplicaTag,
194        batch: &SealedBatch,
195    ) -> impl Future<Output = Result<(), SinkError>> + Send {
196        let state = Arc::clone(&self.state);
197        let endpoint = *endpoint;
198        let rows = batch.rows;
199        let bytes = batch.bytes;
200        let dedup_token = batch.dedup_token.clone();
201        let payload: Vec<u8> = batch
202            .frames
203            .iter()
204            .flat_map(|f: &Bytes| f.iter().copied())
205            .collect();
206        async move {
207            let outcome = {
208                let mut st = state.lock().expect("spate-test sink state poisoned");
209                let outcome = st
210                    .per_endpoint
211                    .get_mut(&(endpoint.shard, endpoint.replica))
212                    .and_then(VecDeque::pop_front)
213                    .or_else(|| st.global.pop_front())
214                    .unwrap_or_else(WriteOutcome::ok);
215                st.writes.push(CapturedWrite {
216                    shard: endpoint.shard,
217                    replica: endpoint.replica,
218                    rows,
219                    bytes,
220                    dedup_token,
221                    payload,
222                    result: outcome.result.clone(),
223                });
224                outcome
225            };
226            if let Some(delay) = outcome.delay {
227                tokio::time::sleep(delay).await;
228            }
229            match outcome.result {
230                ScriptedResult::Ok => Ok(()),
231                ScriptedResult::Retryable(reason) => Err(SinkError::Client {
232                    class: ErrorClass::Retryable,
233                    reason,
234                }),
235                ScriptedResult::Fatal(reason) => Err(SinkError::Client {
236                    class: ErrorClass::Fatal,
237                    reason,
238                }),
239            }
240        }
241    }
242
243    fn probe(&self, endpoint: &ReplicaTag) -> impl Future<Output = Result<(), SinkError>> + Send {
244        let state = Arc::clone(&self.state);
245        let endpoint = *endpoint;
246        async move {
247            let failure = state
248                .lock()
249                .expect("spate-test sink state poisoned")
250                .probe_failures
251                .get(&(endpoint.shard, endpoint.replica))
252                .cloned();
253            match failure {
254                None => Ok(()),
255                Some(reason) => Err(SinkError::Client {
256                    class: ErrorClass::Retryable,
257                    reason,
258                }),
259            }
260        }
261    }
262}
263
264/// Build a ready-to-run capturing sink: a `shards × replicas` in-memory
265/// topology over a [`CaptureWriter`], with its scripting handle.
266///
267/// The returned [`CaptureSink`] implements
268/// [`SinkBundle`](spate_core::sink::SinkBundle), so it drops straight into
269/// `Pipeline::sink` — the first-class mock path for testing whole
270/// assemblies. Unscripted writes succeed; drive failures and probe health
271/// through the [`SinkScript`].
272#[must_use]
273pub fn capture_sink(shards: usize, replicas: usize) -> (CaptureSink, SinkScript) {
274    assert!(shards > 0, "capture_sink needs at least one shard");
275    assert!(replicas > 0, "capture_sink needs at least one replica");
276    let (writer, script) = capture_writer();
277    (
278        CaptureSink {
279            writer,
280            shards,
281            replicas,
282            pool: SinkPoolConfig::default(),
283        },
284        script,
285    )
286}
287
288/// A capturing sink bundle for whole-pipeline tests — see [`capture_sink`].
289#[derive(Clone, Debug)]
290pub struct CaptureSink {
291    writer: CaptureWriter,
292    shards: usize,
293    replicas: usize,
294    pool: SinkPoolConfig,
295}
296
297impl CaptureSink {
298    /// Override the pool tuning (tests usually shrink `linger`).
299    #[must_use]
300    pub fn with_pool_config(mut self, pool: SinkPoolConfig) -> Self {
301        self.pool = pool;
302        self
303    }
304}
305
306impl SinkBundle for CaptureSink {
307    type Writer = CaptureWriter;
308
309    fn into_parts(self) -> SinkParts<CaptureWriter> {
310        let endpoints: Vec<Vec<ReplicaTag>> = (0..self.shards)
311            .map(|shard| {
312                (0..self.replicas)
313                    .map(|replica| ReplicaTag { shard, replica })
314                    .collect()
315            })
316            .collect();
317        // A mock scripts probe health directly (via `SinkScript`), so this
318        // reuses the capture writer rather than an independent client set —
319        // the separate-probe-clients rule matters for real connectors with a
320        // live pool, not an in-memory fake.
321        let probe = endpoint_probe(self.writer.clone(), Arc::new(endpoints.clone()));
322        SinkParts::new(self.writer, endpoints, self.pool)
323            .with_component_type("capture")
324            .with_probe(probe)
325    }
326}
327
328/// [`RowEncoder`] writing each row as `u32` little-endian length + bytes.
329/// Decode captured frames with [`decode_rows`](crate::decode_rows).
330#[derive(Clone, Copy, Debug, Default)]
331pub struct TestEncoder;
332
333impl RowEncoder<Owned<Vec<u8>>> for TestEncoder {
334    fn encode<'buf>(&mut self, rec: &Record<Vec<u8>>, buf: &mut BytesMut) -> Result<(), SinkError> {
335        let len = u32::try_from(rec.payload.len()).map_err(|_| SinkError::Client {
336            class: ErrorClass::RecordLevel,
337            reason: "row longer than u32::MAX".to_owned(),
338        })?;
339        buf.put_u32_le(len);
340        buf.extend_from_slice(&rec.payload);
341        Ok(())
342    }
343}
344
345/// Decode a frame produced by [`TestEncoder`] back into rows.
346///
347/// # Panics
348///
349/// Panics on truncated input — the frame did not come from [`TestEncoder`].
350#[must_use]
351pub fn decode_rows(mut bytes: &[u8]) -> Vec<Vec<u8>> {
352    let mut rows = Vec::new();
353    while !bytes.is_empty() {
354        assert!(bytes.len() >= 4, "decode_rows: truncated length prefix");
355        let len = u32::from_le_bytes(bytes[..4].try_into().expect("4 bytes")) as usize;
356        bytes = &bytes[4..];
357        assert!(bytes.len() >= len, "decode_rows: truncated row body");
358        rows.push(bytes[..len].to_vec());
359        bytes = &bytes[len..];
360    }
361    rows
362}