Skip to main content

spate_test/
coordination.rs

1//! Scripted coordination for testing coordinated sources.
2//!
3//! [`scripted_coordinator`] pairs a [`SplitCoordinator`] implementation
4//! with a [`CoordinatorScript`] handle (the `tower-test` philosophy used
5//! throughout this crate): the test scripts ownership events and commit
6//! outcomes, the source under test runs its real driver choreography, and
7//! the script observes every commit, failure report, and release — no
8//! store, no clock, fully deterministic.
9
10use spate_core::coordination::ControlWaker;
11use spate_core::coordination::{
12    CoordinationError, CoordinationErrorKind, CoordinationEvent, LeaseEpoch, SplitCoordinator,
13    SplitId, SplitPlanner, SplitProgress, SplitSpec,
14};
15use std::collections::{HashMap, VecDeque};
16use std::sync::{Arc, Mutex};
17
18#[derive(Default)]
19struct State {
20    events: Vec<CoordinationEvent>,
21    commit_outcomes: HashMap<SplitId, VecDeque<CoordinationErrorKind>>,
22    commits: Vec<(SplitId, SplitProgress)>,
23    failed: Vec<(SplitId, String)>,
24    released: Vec<SplitId>,
25    planner: Option<Box<dyn SplitPlanner>>,
26    started: bool,
27    waker: Option<ControlWaker>,
28}
29
30/// A [`SplitCoordinator`] whose events and outcomes are scripted by the
31/// paired [`CoordinatorScript`]. Build both with [`scripted_coordinator`].
32#[derive(Debug)]
33pub struct ScriptedCoordinator {
34    state: Arc<Mutex<State>>,
35}
36
37/// Scripting and observation handle for a [`ScriptedCoordinator`].
38///
39/// Events queued between two `poll` calls are delivered as **one batch**,
40/// matching the trait contract ("all pending events at once") — queue a
41/// [`lose`](CoordinatorScript::lose) and a [`gain`](CoordinatorScript::gain)
42/// back to back to exercise same-batch interleavings.
43#[derive(Clone, Debug)]
44pub struct CoordinatorScript {
45    state: Arc<Mutex<State>>,
46}
47
48impl std::fmt::Debug for State {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("State")
51            .field("pending_events", &self.events.len())
52            .field("commits", &self.commits.len())
53            .field("started", &self.started)
54            .finish_non_exhaustive()
55    }
56}
57
58/// A scripted coordinator and its handle.
59#[must_use]
60pub fn scripted_coordinator() -> (ScriptedCoordinator, CoordinatorScript) {
61    let state = Arc::new(Mutex::new(State::default()));
62    (
63        ScriptedCoordinator {
64            state: Arc::clone(&state),
65        },
66        CoordinatorScript { state },
67    )
68}
69
70impl CoordinatorScript {
71    /// Queue a [`CoordinationEvent::Gained`] for the next poll.
72    pub fn gain(&self, split: SplitSpec, epoch: u64, progress: Option<SplitProgress>) {
73        self.lock().events.push(CoordinationEvent::Gained {
74            split,
75            epoch: LeaseEpoch(epoch),
76            progress,
77        });
78        self.wake();
79    }
80
81    /// Queue a [`CoordinationEvent::Lost`] for the next poll.
82    pub fn lose(&self, split: &SplitId) {
83        self.lock().events.push(CoordinationEvent::Lost {
84            split: split.clone(),
85        });
86        self.wake();
87    }
88
89    /// Queue a [`CoordinationEvent::Quarantined`] for the next poll.
90    pub fn quarantine(&self, split: &SplitId, attempts: u32) {
91        self.lock().events.push(CoordinationEvent::Quarantined {
92            split: split.clone(),
93            attempts,
94        });
95        self.wake();
96    }
97
98    /// Queue [`CoordinationEvent::AllComplete`] for the next poll.
99    pub fn all_complete(&self) {
100        self.lock().events.push(CoordinationEvent::AllComplete);
101        self.wake();
102    }
103
104    /// Queue [`CoordinationEvent::Stalled`] for the next poll.
105    pub fn stalled(&self, completed: u64, quarantined: u64) {
106        self.lock().events.push(CoordinationEvent::Stalled {
107            completed,
108            quarantined,
109        });
110        self.wake();
111    }
112
113    /// Script the outcome of the next `commit` for `split` (repeat to
114    /// script a sequence). Unscripted commits succeed and are recorded.
115    pub fn fail_next_commit(&self, split: &SplitId, kind: CoordinationErrorKind) {
116        self.lock()
117            .commit_outcomes
118            .entry(split.clone())
119            .or_default()
120            .push_back(kind);
121    }
122
123    /// Every successful commit so far, in order.
124    #[must_use]
125    pub fn commits(&self) -> Vec<(SplitId, SplitProgress)> {
126        self.lock().commits.clone()
127    }
128
129    /// The last successful commit for `split`, if any.
130    #[must_use]
131    pub fn last_commit(&self, split: &SplitId) -> Option<SplitProgress> {
132        self.lock()
133            .commits
134            .iter()
135            .rev()
136            .find(|(s, _)| s == split)
137            .map(|(_, p)| p.clone())
138    }
139
140    /// Every `fail` report so far, in order.
141    #[must_use]
142    pub fn failed(&self) -> Vec<(SplitId, String)> {
143        self.lock().failed.clone()
144    }
145
146    /// Every released split so far, in release order.
147    #[must_use]
148    pub fn released(&self) -> Vec<SplitId> {
149        self.lock().released.clone()
150    }
151
152    /// Whether `start` ran.
153    #[must_use]
154    pub fn started(&self) -> bool {
155        self.lock().started
156    }
157
158    /// Take the planner captured at `start`: run it directly to inspect
159    /// its plan, then feed the resulting splits back through
160    /// [`gain`](CoordinatorScript::gain) — this is how a source tests its
161    /// planner and its driver choreography together.
162    #[must_use]
163    pub fn take_planner(&self) -> Option<Box<dyn SplitPlanner>> {
164        self.lock().planner.take()
165    }
166
167    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
168        self.state.lock().expect("coordinator script poisoned")
169    }
170
171    /// Wake the driver's control-plane park so a scripted event is picked
172    /// up immediately instead of waiting out the caller's poll timeout.
173    fn wake(&self) {
174        if let Some(w) = &self.lock().waker {
175            w.wake();
176        }
177    }
178}
179
180impl SplitCoordinator for ScriptedCoordinator {
181    fn start(&mut self, planner: Box<dyn SplitPlanner>) -> Result<(), CoordinationError> {
182        let mut state = self.state.lock().expect("coordinator script poisoned");
183        state.planner = Some(planner);
184        state.started = true;
185        Ok(())
186    }
187
188    fn set_waker(&mut self, waker: ControlWaker) {
189        self.state
190            .lock()
191            .expect("coordinator script poisoned")
192            .waker = Some(waker);
193    }
194
195    fn poll(&mut self) -> Result<Vec<CoordinationEvent>, CoordinationError> {
196        // Deterministic and non-blocking: queued events are one batch;
197        // an empty queue returns immediately rather than waiting out the
198        // timeout, so test loops never stall.
199        Ok(std::mem::take(
200            &mut self
201                .state
202                .lock()
203                .expect("coordinator script poisoned")
204                .events,
205        ))
206    }
207
208    fn commit(
209        &mut self,
210        split: &SplitId,
211        progress: &SplitProgress,
212    ) -> Result<(), CoordinationError> {
213        let mut state = self.state.lock().expect("coordinator script poisoned");
214        if let Some(kinds) = state.commit_outcomes.get_mut(split)
215            && let Some(kind) = kinds.pop_front()
216        {
217            return Err(CoordinationError::new(
218                kind,
219                format!("scripted {kind:?} for split {split}"),
220            ));
221        }
222        state.commits.push((split.clone(), progress.clone()));
223        Ok(())
224    }
225
226    fn fail(&mut self, split: &SplitId, reason: &str) -> Result<(), CoordinationError> {
227        self.state
228            .lock()
229            .expect("coordinator script poisoned")
230            .failed
231            .push((split.clone(), reason.to_string()));
232        Ok(())
233    }
234
235    fn release(&mut self, splits: &[SplitId]) -> Result<(), CoordinationError> {
236        self.state
237            .lock()
238            .expect("coordinator script poisoned")
239            .released
240            .extend(splits.iter().cloned());
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use spate_core::coordination::{PlanContext, PlanFinality, SplitPlan};
249
250    struct OneSplit;
251
252    impl SplitPlanner for OneSplit {
253        fn fingerprint(&self) -> String {
254            "test:v1".into()
255        }
256
257        fn plan(&mut self, _ctx: PlanContext<'_>) -> Result<SplitPlan, CoordinationError> {
258            Ok(SplitPlan::new(
259                vec![spate_core::coordination::PlannedSplit::new(SplitSpec::new(
260                    SplitId::new("only").unwrap(),
261                    b"all of it".to_vec(),
262                ))],
263                PlanFinality::Final,
264            ))
265        }
266    }
267
268    #[test]
269    fn scripts_events_and_observes_interactions() {
270        let (mut coordinator, script) = scripted_coordinator();
271        coordinator.start(Box::new(OneSplit)).unwrap();
272        assert!(script.started());
273
274        // The captured planner is runnable by the test.
275        let mut planner = script.take_planner().expect("planner captured");
276        let plan = planner.plan(PlanContext::new(None, 1)).unwrap();
277        assert_eq!(plan.splits.len(), 1);
278        let split = plan.splits[0].spec.clone();
279        let id = split.id.clone();
280
281        // lose+gain queued together arrive as one batch, in order.
282        script.gain(split, 1, None);
283        script.lose(&id);
284        let batch = coordinator.poll().unwrap();
285        assert_eq!(batch.len(), 2);
286        assert!(matches!(batch[0], CoordinationEvent::Gained { .. }));
287        assert!(matches!(batch[1], CoordinationEvent::Lost { .. }));
288        assert!(coordinator.poll().unwrap().is_empty());
289
290        // Scripted commit outcomes drain in order, then commits succeed.
291        script.fail_next_commit(&id, CoordinationErrorKind::Retryable);
292        let progress = SplitProgress::new(5, vec![]);
293        let err = coordinator.commit(&id, &progress).unwrap_err();
294        assert_eq!(err.kind, CoordinationErrorKind::Retryable);
295        coordinator.commit(&id, &progress).unwrap();
296        assert_eq!(script.commits().len(), 1);
297        assert_eq!(script.last_commit(&id).unwrap().watermark, 5);
298
299        coordinator.fail(&id, "poison").unwrap();
300        assert_eq!(script.failed()[0].1, "poison");
301        coordinator.release(std::slice::from_ref(&id)).unwrap();
302        assert_eq!(script.released(), vec![id]);
303    }
304}