1use crate::task::TaskState;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct StatusUpdate {
25 pub workflow_id: String,
27 pub task_id: String,
29 pub state: TaskState,
31 pub error: Option<String>,
33 pub timestamp_secs: u64,
35 pub seq: u64,
37}
38
39impl StatusUpdate {
40 #[must_use]
42 pub fn now(
43 workflow_id: impl Into<String>,
44 task_id: impl Into<String>,
45 state: TaskState,
46 error: Option<String>,
47 seq: u64,
48 ) -> Self {
49 let ts = SystemTime::now()
50 .duration_since(UNIX_EPOCH)
51 .unwrap_or(Duration::ZERO)
52 .as_secs();
53 Self {
54 workflow_id: workflow_id.into(),
55 task_id: task_id.into(),
56 state,
57 error,
58 timestamp_secs: ts,
59 seq,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Default)]
70pub struct FlushResult {
71 pub updates_flushed: usize,
73 pub workflows_touched: usize,
75 pub estimated_bytes: usize,
77}
78
79#[derive(Debug)]
90pub struct BatchStatusWriter {
91 pending: HashMap<(String, String), StatusUpdate>,
93 seq: u64,
95 max_batch_size: usize,
97 total_accepted: u64,
99 total_flushes: u64,
101}
102
103impl BatchStatusWriter {
104 #[must_use]
106 pub fn new(max_batch_size: usize) -> Self {
107 Self {
108 pending: HashMap::new(),
109 seq: 0,
110 max_batch_size,
111 total_accepted: 0,
112 total_flushes: 0,
113 }
114 }
115
116 pub fn record(
121 &mut self,
122 workflow_id: impl Into<String>,
123 task_id: impl Into<String>,
124 state: TaskState,
125 error: Option<String>,
126 ) {
127 let wf = workflow_id.into();
128 let tid = task_id.into();
129 let seq = self.seq;
130 self.seq += 1;
131 self.total_accepted += 1;
132
133 let update = StatusUpdate::now(wf.clone(), tid.clone(), state, error, seq);
135 self.pending.insert((wf, tid), update);
136 }
137
138 #[must_use]
140 pub fn needs_flush(&self) -> bool {
141 self.pending.len() >= self.max_batch_size
142 }
143
144 #[must_use]
146 pub fn pending_count(&self) -> usize {
147 self.pending.len()
148 }
149
150 pub fn flush<E, F>(&mut self, sink: F) -> Result<FlushResult, E>
160 where
161 F: FnOnce(Vec<StatusUpdate>) -> Result<(), E>,
162 {
163 if self.pending.is_empty() {
164 return Ok(FlushResult::default());
165 }
166
167 let mut batch: Vec<StatusUpdate> = self.pending.drain().map(|(_, v)| v).collect();
169 batch.sort_by_key(|u| u.seq);
170
171 let updates_flushed = batch.len();
172 let workflows_touched = batch
173 .iter()
174 .map(|u| u.workflow_id.as_str())
175 .collect::<std::collections::HashSet<_>>()
176 .len();
177 let estimated_bytes = batch
178 .iter()
179 .map(|u| u.workflow_id.len() + u.task_id.len() + 32)
180 .sum();
181
182 sink(batch)?;
183 self.total_flushes += 1;
184
185 Ok(FlushResult {
186 updates_flushed,
187 workflows_touched,
188 estimated_bytes,
189 })
190 }
191
192 pub fn flush_to_vec(&mut self) -> Vec<StatusUpdate> {
194 let mut result = Vec::new();
195 let _ = self.flush::<std::convert::Infallible, _>(|batch| {
197 result = batch;
198 Ok(())
199 });
200 result
201 }
202
203 #[must_use]
205 pub fn total_accepted(&self) -> u64 {
206 self.total_accepted
207 }
208
209 #[must_use]
211 pub fn total_flushes(&self) -> u64 {
212 self.total_flushes
213 }
214
215 pub fn set_max_batch_size(&mut self, max: usize) {
217 self.max_batch_size = max;
218 }
219
220 #[must_use]
222 pub fn max_batch_size(&self) -> usize {
223 self.max_batch_size
224 }
225}
226
227#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn test_writer_accepts_updates() {
237 let mut w = BatchStatusWriter::new(100);
238 w.record("wf-1", "task-a", TaskState::Running, None);
239 w.record("wf-1", "task-b", TaskState::Running, None);
240 assert_eq!(w.pending_count(), 2);
241 assert_eq!(w.total_accepted(), 2);
242 }
243
244 #[test]
245 fn test_writer_deduplicates_same_task() {
246 let mut w = BatchStatusWriter::new(100);
247 w.record("wf-1", "task-a", TaskState::Pending, None);
248 w.record("wf-1", "task-a", TaskState::Running, None);
249 w.record("wf-1", "task-a", TaskState::Completed, None);
250 assert_eq!(w.pending_count(), 1);
252 assert_eq!(w.total_accepted(), 3);
253
254 let batch = w.flush_to_vec();
255 assert_eq!(batch.len(), 1);
256 assert_eq!(batch[0].state, TaskState::Completed);
257 }
258
259 #[test]
260 fn test_writer_needs_flush_at_capacity() {
261 let mut w = BatchStatusWriter::new(3);
262 assert!(!w.needs_flush());
263 w.record("wf-1", "a", TaskState::Running, None);
264 w.record("wf-1", "b", TaskState::Running, None);
265 assert!(!w.needs_flush());
266 w.record("wf-1", "c", TaskState::Running, None);
267 assert!(w.needs_flush());
268 }
269
270 #[test]
271 fn test_flush_clears_pending() {
272 let mut w = BatchStatusWriter::new(100);
273 w.record("wf-1", "a", TaskState::Completed, None);
274 w.record("wf-1", "b", TaskState::Failed, Some("err".to_string()));
275
276 let batch = w.flush_to_vec();
277 assert_eq!(batch.len(), 2);
278 assert_eq!(w.pending_count(), 0);
279 assert_eq!(w.total_flushes(), 1);
280 }
281
282 #[test]
283 fn test_flush_empty_is_noop() {
284 let mut w = BatchStatusWriter::new(100);
285 let batch = w.flush_to_vec();
286 assert!(batch.is_empty());
287 assert_eq!(w.total_flushes(), 0);
288 }
289
290 #[test]
291 fn test_flush_sorts_by_sequence() {
292 let mut w = BatchStatusWriter::new(100);
293 w.record("wf-1", "z-task", TaskState::Completed, None);
295 w.record("wf-1", "a-task", TaskState::Running, None);
296 w.record("wf-1", "m-task", TaskState::Pending, None);
297
298 let batch = w.flush_to_vec();
299 assert_eq!(batch[0].task_id, "z-task");
301 assert_eq!(batch[1].task_id, "a-task");
302 assert_eq!(batch[2].task_id, "m-task");
303 }
304
305 #[test]
306 fn test_flush_workflows_touched() {
307 let mut w = BatchStatusWriter::new(100);
308 w.record("wf-1", "task-a", TaskState::Completed, None);
309 w.record("wf-2", "task-b", TaskState::Completed, None);
310 w.record("wf-2", "task-c", TaskState::Failed, None);
311
312 let mut result_ref = FlushResult::default();
313 let _ = w.flush::<std::convert::Infallible, _>(|batch| {
314 result_ref = FlushResult {
315 updates_flushed: batch.len(),
316 workflows_touched: batch
317 .iter()
318 .map(|u| u.workflow_id.as_str())
319 .collect::<std::collections::HashSet<_>>()
320 .len(),
321 estimated_bytes: 0,
322 };
323 Ok(())
324 });
325 assert_eq!(result_ref.updates_flushed, 3);
326 assert_eq!(result_ref.workflows_touched, 2);
327 }
328
329 #[test]
330 fn test_multiple_flush_cycles() {
331 let mut w = BatchStatusWriter::new(100);
332 w.record("wf-1", "t1", TaskState::Completed, None);
333 w.flush_to_vec();
334 w.record("wf-1", "t2", TaskState::Completed, None);
335 w.flush_to_vec();
336
337 assert_eq!(w.total_flushes(), 2);
338 assert_eq!(w.total_accepted(), 2);
339 }
340
341 #[test]
342 fn test_status_update_fields() {
343 let u = StatusUpdate::now("wf-x", "task-y", TaskState::Running, None, 42);
344 assert_eq!(u.workflow_id, "wf-x");
345 assert_eq!(u.task_id, "task-y");
346 assert_eq!(u.state, TaskState::Running);
347 assert_eq!(u.seq, 42);
348 assert!(u.timestamp_secs > 0);
349 }
350
351 #[test]
352 fn test_set_max_batch_size() {
353 let mut w = BatchStatusWriter::new(10);
354 assert_eq!(w.max_batch_size(), 10);
355 w.set_max_batch_size(50);
356 assert_eq!(w.max_batch_size(), 50);
357 }
358
359 #[test]
360 fn test_error_propagation() {
361 let mut w = BatchStatusWriter::new(100);
362 w.record("wf-1", "t1", TaskState::Failed, Some("boom".to_string()));
363
364 let result = w.flush::<String, _>(|_| Err("db unavailable".to_string()));
365 assert!(result.is_err());
366 assert_eq!(w.pending_count(), 0);
368 }
369}