Skip to main content

spate_test/
coordination.rs

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