1use 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#[derive(Debug)]
33pub struct ScriptedCoordinator {
34 state: Arc<Mutex<State>>,
35}
36
37#[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#[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 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 pub fn lose(&self, split: &SplitId) {
83 self.lock().events.push(CoordinationEvent::Lost {
84 split: split.clone(),
85 });
86 self.wake();
87 }
88
89 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 pub fn all_complete(&self) {
100 self.lock().events.push(CoordinationEvent::AllComplete);
101 self.wake();
102 }
103
104 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 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 #[must_use]
125 pub fn commits(&self) -> Vec<(SplitId, SplitProgress)> {
126 self.lock().commits.clone()
127 }
128
129 #[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 #[must_use]
142 pub fn failed(&self) -> Vec<(SplitId, String)> {
143 self.lock().failed.clone()
144 }
145
146 #[must_use]
148 pub fn released(&self) -> Vec<SplitId> {
149 self.lock().released.clone()
150 }
151
152 #[must_use]
154 pub fn started(&self) -> bool {
155 self.lock().started
156 }
157
158 #[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 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 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 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 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 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}