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