Skip to main content

oximedia_workflow/
batch_status.rs

1//! Batch task status update manager.
2//!
3//! Collects task status changes in memory and flushes them to a persistence
4//! backend in batches, reducing the number of database write operations
5//! compared to individual per-task updates. This is especially important for
6//! high-throughput workflows with hundreds of short-lived tasks.
7//!
8//! The [`BatchStatusWriter`] accumulates [`StatusUpdate`] records and
9//! exposes a [`BatchStatusWriter::flush`] method that emits all pending
10//! updates at once. A configurable `max_batch_size` prevents unbounded
11//! memory growth during long bursts.
12
13use crate::task::TaskState;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18// ---------------------------------------------------------------------------
19// StatusUpdate
20// ---------------------------------------------------------------------------
21
22/// A single pending task-status change to be persisted.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct StatusUpdate {
25    /// Workflow this task belongs to.
26    pub workflow_id: String,
27    /// Task identifier.
28    pub task_id: String,
29    /// New task state.
30    pub state: TaskState,
31    /// Optional error message (for failed tasks).
32    pub error: Option<String>,
33    /// Unix timestamp (seconds) when the change occurred.
34    pub timestamp_secs: u64,
35    /// Sequence number for ordering within a flush batch.
36    pub seq: u64,
37}
38
39impl StatusUpdate {
40    /// Create a new status update at the current wall-clock time.
41    #[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// ---------------------------------------------------------------------------
65// FlushResult
66// ---------------------------------------------------------------------------
67
68/// Statistics returned by [`BatchStatusWriter::flush`].
69#[derive(Debug, Clone, Default)]
70pub struct FlushResult {
71    /// Number of status updates emitted.
72    pub updates_flushed: usize,
73    /// Number of distinct workflows affected.
74    pub workflows_touched: usize,
75    /// Approximate byte size of the flushed batch (for logging).
76    pub estimated_bytes: usize,
77}
78
79// ---------------------------------------------------------------------------
80// BatchStatusWriter
81// ---------------------------------------------------------------------------
82
83/// Accumulates task status changes and flushes them in bulk.
84///
85/// Updates are keyed by `(workflow_id, task_id)`. If a task transitions
86/// multiple times before a flush (e.g., `Pending → Running → Completed`),
87/// only the most-recent state is retained — this de-duplicates writes for
88/// tasks that complete faster than the flush interval.
89#[derive(Debug)]
90pub struct BatchStatusWriter {
91    /// Pending updates: `(workflow_id, task_id) → StatusUpdate`.
92    pending: HashMap<(String, String), StatusUpdate>,
93    /// Monotonic sequence counter.
94    seq: u64,
95    /// Maximum number of updates to hold before forcing a flush.
96    max_batch_size: usize,
97    /// Total number of updates ever accepted (including de-duplicated ones).
98    total_accepted: u64,
99    /// Total number of flush operations performed.
100    total_flushes: u64,
101}
102
103impl BatchStatusWriter {
104    /// Create a new writer with the given `max_batch_size`.
105    #[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    /// Record a task status change.
117    ///
118    /// If the batch is full after accepting this update, the caller should
119    /// call [`Self::flush`] before continuing (check [`Self::needs_flush`]).
120    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        // Overwrite any existing pending update — keep only latest state.
134        let update = StatusUpdate::now(wf.clone(), tid.clone(), state, error, seq);
135        self.pending.insert((wf, tid), update);
136    }
137
138    /// Return `true` if the batch should be flushed (batch is full or overflowing).
139    #[must_use]
140    pub fn needs_flush(&self) -> bool {
141        self.pending.len() >= self.max_batch_size
142    }
143
144    /// Return the number of pending updates.
145    #[must_use]
146    pub fn pending_count(&self) -> usize {
147        self.pending.len()
148    }
149
150    /// Flush all pending updates.
151    ///
152    /// The provided `sink` callback receives the sorted batch of updates.
153    /// After calling `sink` the internal buffer is cleared regardless of
154    /// whether `sink` succeeds.
155    ///
156    /// # Errors
157    ///
158    /// Returns any error propagated from `sink`.
159    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        // Collect, sort by sequence number for deterministic ordering.
168        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    /// Flush into a `Vec<StatusUpdate>` and return it (useful for testing).
193    pub fn flush_to_vec(&mut self) -> Vec<StatusUpdate> {
194        let mut result = Vec::new();
195        // Use infallible sink
196        let _ = self.flush::<std::convert::Infallible, _>(|batch| {
197            result = batch;
198            Ok(())
199        });
200        result
201    }
202
203    /// Total number of updates ever accepted.
204    #[must_use]
205    pub fn total_accepted(&self) -> u64 {
206        self.total_accepted
207    }
208
209    /// Total number of flush operations.
210    #[must_use]
211    pub fn total_flushes(&self) -> u64 {
212        self.total_flushes
213    }
214
215    /// Change the maximum batch size.
216    pub fn set_max_batch_size(&mut self, max: usize) {
217        self.max_batch_size = max;
218    }
219
220    /// Maximum batch size.
221    #[must_use]
222    pub fn max_batch_size(&self) -> usize {
223        self.max_batch_size
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Tests
229// ---------------------------------------------------------------------------
230
231#[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        // Three transitions, but only 1 unique key
251        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        // Records with different task IDs (unique keys, so all retained)
294        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        // Should be sorted by sequence (insertion order): z=0, a=1, m=2
300        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        // After a failed flush, pending should be drained (we drained before calling sink)
367        assert_eq!(w.pending_count(), 0);
368    }
369}