Skip to main content

sayiir_persistence/
backend.rs

1//! Persistent backend traits for storing and retrieving workflow snapshots.
2//!
3//! The trait hierarchy is decomposed into focused sub-traits:
4//!
5//! - [`SnapshotStore`]: Core CRUD for workflow snapshots (5 methods).
6//! - [`SignalStore`]: Cancel + pause signal primitives with default composite
7//!   implementations (3 required + 3 default methods).
8//! - [`TaskClaimStore`]: Distributed task claiming (4 methods, opt-in).
9//! - [`PersistentBackend`]: Supertrait = `SnapshotStore + SignalStore`, blanket-implemented.
10//!
11//! A minimal backend only needs to implement `SnapshotStore` + 3 `SignalStore` primitives
12//! (8 methods total) to satisfy `PersistentBackend`.
13
14use chrono::Duration;
15use sayiir_core::snapshot::{
16    PauseRequest, SignalKind, SignalRequest, WorkflowSnapshot, WorkflowSnapshotState,
17};
18use sayiir_core::task_claim::{AvailableTask, TaskClaim};
19
20/// Wire-side representation of a 32-byte SHA-256 hash.
21///
22/// The semantic newtypes [`sayiir_core::TaskId`] / [`sayiir_core::DefinitionHash`]
23/// don't cross this boundary as-is (would couple `sayiir-core` to nanoserde).
24/// Producers convert with `*hash.as_bytes()`; consumers wrap back with
25/// `TaskId::from_bytes(field)` / `DefinitionHash::from_bytes(field)`. The
26/// type-system guarantees live on either side of this alias — over the wire
27/// the payload is just 32 raw bytes.
28pub type HashBytes = [u8; 32];
29
30/// Routing-and-eligibility hint a producer attaches to a "task ready"
31/// wakeup. Workers consume it to:
32///
33/// * **Filter** — drop the wake without polling if the worker's tags don't
34///   match, or if the worker doesn't have the workflow registered. Cuts
35///   PG load proportional to NOTIFY volume × fleet-tag-fragmentation.
36/// * **Direct-claim** — call [`TaskClaimStore::find_hinted_task`] to skip
37///   the full `find_available_tasks` scan on the happy path; the producer
38///   already named the task that just became ready.
39#[derive(Debug, Clone, PartialEq, Eq, nanoserde::SerBin, nanoserde::DeBin)]
40pub struct TaskWakeupHint {
41    /// Workflow instance the task belongs to (user-supplied, human-readable
42    /// — *not* a hash; this is the same string the user passes to
43    /// `runner.run("…")` and that appears in Postgres `instance_id` columns
44    /// and log spans).
45    pub instance_id: String,
46    /// SHA-256 of the task node id — wraps to [`sayiir_core::TaskId`].
47    pub task_id: HashBytes,
48    /// SHA-256 of the workflow definition — wraps to [`sayiir_core::DefinitionHash`].
49    /// Workers without this definition registered drop the wake without
50    /// touching the DB.
51    pub definition_hash: HashBytes,
52    /// Task tags. A worker can handle the task only if its tag set is a
53    /// superset of `tags` (untagged tasks are claimable by anyone).
54    pub tags: Vec<String>,
55}
56
57/// Wire-format version byte prepended to the nanoserde blob. Bump on
58/// any breaking change to [`TaskWakeupHint`]'s field layout so decoders
59/// reject payloads they don't understand instead of silently
60/// misparsing them.
61const HINT_WIRE_VERSION: u8 = 2;
62
63impl TaskWakeupHint {
64    /// Encode to a base64-wrapped binary blob, suitable for any text-only
65    /// transport (e.g. PG's `pg_notify` payload).
66    ///
67    /// Wire layout: `[version u8][nanoserde SerBin bytes]`. nanoserde
68    /// uses length-prefixed (u64 LE) encoding for strings and `Vec`,
69    /// little-endian for primitives. Typical hint encodes to ~70–100
70    /// bytes raw and ~95–135 bytes base64 — comfortably under PG's
71    /// 8 kB `NOTIFY` payload cap.
72    #[must_use]
73    pub fn encode(&self) -> String {
74        use base64::Engine;
75        use nanoserde::SerBin;
76
77        let mut buf = Vec::with_capacity(96);
78        buf.push(HINT_WIRE_VERSION);
79        self.ser_bin(&mut buf);
80        base64::engine::general_purpose::STANDARD_NO_PAD.encode(&buf)
81    }
82
83    /// Decode from the wire format produced by [`Self::encode`].
84    ///
85    /// Returns `Err` with a human-readable reason for any corrupt,
86    /// truncated, or version-mismatched payload. The caller should log
87    /// and treat the failure as a missed wakeup — the fallback poll
88    /// will catch up.
89    pub fn decode(payload: &str) -> Result<Self, String> {
90        use base64::Engine;
91        use nanoserde::DeBin;
92
93        let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
94            .decode(payload)
95            .map_err(|e| format!("base64: {e}"))?;
96        let Some((&version, body)) = bytes.split_first() else {
97            return Err("empty payload".into());
98        };
99        if version != HINT_WIRE_VERSION {
100            return Err(format!("unsupported wire version: {version}"));
101        }
102        Self::deserialize_bin(body).map_err(|e| format!("nanoserde: {e}"))
103    }
104}
105
106#[cfg(test)]
107mod hint_tests {
108    use super::TaskWakeupHint;
109
110    fn sample() -> TaskWakeupHint {
111        TaskWakeupHint {
112            instance_id: "wf-abc-123".to_string(),
113            task_id: [0x42; 32],
114            definition_hash: [0xab; 32],
115            tags: vec!["gpu".to_string(), "cuda-12".to_string()],
116        }
117    }
118
119    #[test]
120    fn roundtrip() {
121        let hint = sample();
122        let encoded = hint.encode();
123        let decoded = TaskWakeupHint::decode(&encoded).expect("roundtrip");
124        assert_eq!(hint, decoded);
125    }
126
127    #[test]
128    fn roundtrip_empty_tags() {
129        let mut hint = sample();
130        hint.tags.clear();
131        let decoded = TaskWakeupHint::decode(&hint.encode()).unwrap();
132        assert_eq!(hint, decoded);
133    }
134
135    #[test]
136    fn rejects_garbage_payload() {
137        assert!(TaskWakeupHint::decode("not base64!@#").is_err());
138        assert!(TaskWakeupHint::decode("").is_err());
139    }
140
141    #[test]
142    fn rejects_unknown_version() {
143        use base64::Engine;
144        let buf = vec![99u8, 0, 0, 0, 0, 0, 0, 0, 0];
145        let payload = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&buf);
146        let err = TaskWakeupHint::decode(&payload).unwrap_err();
147        assert!(err.contains("unsupported wire version"));
148    }
149}
150
151/// Error type for backend operations.
152#[derive(Debug, thiserror::Error)]
153pub enum BackendError {
154    /// Snapshot not found.
155    #[error("Snapshot not found: {0}")]
156    NotFound(String),
157    /// Serialization/deserialization error.
158    #[error("Serialization error: {0}")]
159    Serialization(String),
160    /// Backend-specific error.
161    #[error("Backend error: {0}")]
162    Backend(String),
163    /// Cannot cancel workflow in current state.
164    #[error("Cannot cancel workflow in state: {0}")]
165    CannotCancel(String),
166    /// Cannot pause workflow in current state.
167    #[error("Cannot pause workflow in state: {0}")]
168    CannotPause(String),
169}
170
171// ---------------------------------------------------------------------------
172// SnapshotStore — core CRUD, every backend implements this
173// ---------------------------------------------------------------------------
174
175/// Core snapshot CRUD operations.
176///
177/// Every persistent backend must implement these 5 methods.
178pub trait SnapshotStore: Send + Sync {
179    /// Save a workflow snapshot.
180    ///
181    /// If a snapshot with the same instance_id already exists, it should be overwritten.
182    ///
183    /// Takes `&mut` so backends can encode the blob without cloning the
184    /// snapshot (strip task outputs in place, encode, restore) and clear
185    /// any in-memory "output unflushed" marker once the write lands. The
186    /// snapshot is logically unchanged on return.
187    fn save_snapshot(
188        &self,
189        snapshot: &mut WorkflowSnapshot,
190    ) -> impl Future<Output = Result<(), BackendError>> + Send;
191
192    /// Save a single task result atomically.
193    ///
194    /// This is more granular than `save_snapshot` and allows concurrent task
195    /// completions (e.g., in fork branches) without overwriting each other.
196    fn save_task_result(
197        &self,
198        instance_id: &str,
199        task_id: &sayiir_core::TaskId,
200        output: bytes::Bytes,
201    ) -> impl Future<Output = Result<(), BackendError>> + Send;
202
203    /// Load a workflow snapshot by instance ID.
204    fn load_snapshot(
205        &self,
206        instance_id: &str,
207    ) -> impl Future<Output = Result<WorkflowSnapshot, BackendError>> + Send;
208
209    /// Delete a workflow snapshot.
210    fn delete_snapshot(
211        &self,
212        instance_id: &str,
213    ) -> impl Future<Output = Result<(), BackendError>> + Send;
214
215    /// List all snapshot instance IDs.
216    fn list_snapshots(&self) -> impl Future<Output = Result<Vec<String>, BackendError>> + Send;
217}
218
219// ---------------------------------------------------------------------------
220// SignalStore — cancel + pause via SignalKind
221// ---------------------------------------------------------------------------
222
223/// Signal storage for cancel and pause workflows.
224///
225/// Backends implement the 3 primitives (`store_signal`, `get_signal`,
226/// `clear_signal`). The 3 composite methods (`check_and_cancel`,
227/// `check_and_pause`, `unpause`) have default implementations built from
228/// the primitives + `SnapshotStore`. Backends may override them for atomicity.
229pub trait SignalStore: SnapshotStore {
230    // --- 3 primitives (backend must implement) ---
231
232    /// Store a signal (cancel or pause) for a workflow instance.
233    fn store_signal(
234        &self,
235        instance_id: &str,
236        kind: SignalKind,
237        request: SignalRequest,
238    ) -> impl Future<Output = Result<(), BackendError>> + Send;
239
240    /// Get the pending signal of the given kind, if any.
241    fn get_signal(
242        &self,
243        instance_id: &str,
244        kind: SignalKind,
245    ) -> impl Future<Output = Result<Option<SignalRequest>, BackendError>> + Send;
246
247    /// Clear the signal of the given kind.
248    fn clear_signal(
249        &self,
250        instance_id: &str,
251        kind: SignalKind,
252    ) -> impl Future<Output = Result<(), BackendError>> + Send;
253
254    /// Send an external event to a workflow instance.
255    ///
256    /// Events are buffered per `(instance_id, signal_name)` in FIFO order.
257    /// Sending to a nonexistent or terminal instance silently stores the event.
258    fn send_event(
259        &self,
260        instance_id: &str,
261        signal_name: &str,
262        payload: bytes::Bytes,
263    ) -> impl Future<Output = Result<(), BackendError>> + Send;
264
265    /// Consume the oldest buffered event for the given signal name, if any.
266    ///
267    /// Returns `Some(payload)` if an event was consumed, `None` otherwise.
268    fn consume_event(
269        &self,
270        instance_id: &str,
271        signal_name: &str,
272    ) -> impl Future<Output = Result<Option<bytes::Bytes>, BackendError>> + Send;
273
274    // --- 3 composites with default impls (overridable for atomicity) ---
275
276    /// Atomically check for cancellation and transition to cancelled state.
277    ///
278    /// Returns `true` if the workflow was cancelled, `false` if no cancellation
279    /// was pending.
280    fn check_and_cancel(
281        &self,
282        instance_id: &str,
283        interrupted_at_task: Option<sayiir_core::TaskId>,
284    ) -> impl Future<Output = Result<bool, BackendError>> + Send {
285        async move {
286            let Some(request) = self.get_signal(instance_id, SignalKind::Cancel).await? else {
287                return Ok(false);
288            };
289            let mut snapshot = self.load_snapshot(instance_id).await?;
290            if !snapshot.state.is_in_progress() {
291                return Ok(false);
292            }
293            snapshot.mark_cancelled(request.reason, request.requested_by, interrupted_at_task);
294            self.save_snapshot(&mut snapshot).await?;
295            self.clear_signal(instance_id, SignalKind::Cancel).await?;
296            Ok(true)
297        }
298    }
299
300    /// Atomically check for a pause request and transition to paused state.
301    ///
302    /// Returns `true` if the workflow was paused, `false` if no pause was pending.
303    fn check_and_pause(
304        &self,
305        instance_id: &str,
306    ) -> impl Future<Output = Result<bool, BackendError>> + Send {
307        async move {
308            let Some(request) = self.get_signal(instance_id, SignalKind::Pause).await? else {
309                return Ok(false);
310            };
311            let mut snapshot = self.load_snapshot(instance_id).await?;
312            if !snapshot.state.is_in_progress() {
313                return Ok(false);
314            }
315            let pause_request: PauseRequest = request.into();
316            snapshot.mark_paused(&pause_request);
317            self.save_snapshot(&mut snapshot).await?;
318            self.clear_signal(instance_id, SignalKind::Pause).await?;
319            Ok(true)
320        }
321    }
322
323    /// Transition a paused workflow back to in-progress and return the updated snapshot.
324    fn unpause(
325        &self,
326        instance_id: &str,
327    ) -> impl Future<Output = Result<WorkflowSnapshot, BackendError>> + Send {
328        async move {
329            let mut snapshot = self.load_snapshot(instance_id).await?;
330            if !snapshot.state.is_paused() {
331                let state_name = match &snapshot.state {
332                    WorkflowSnapshotState::InProgress { .. } => "InProgress",
333                    WorkflowSnapshotState::Completed { .. } => "Completed",
334                    WorkflowSnapshotState::Failed { .. } => "Failed",
335                    WorkflowSnapshotState::Cancelled { .. } => "Cancelled",
336                    WorkflowSnapshotState::Paused { .. } => "Paused",
337                };
338                return Err(BackendError::CannotPause(format!(
339                    "Workflow is not paused (current state: {state_name:?})"
340                )));
341            }
342            snapshot.mark_unpaused();
343            self.save_snapshot(&mut snapshot).await?;
344            Ok(snapshot)
345        }
346    }
347}
348
349// ---------------------------------------------------------------------------
350// TaskClaimStore — only for distributed workers
351// ---------------------------------------------------------------------------
352
353/// Task claiming for distributed multi-worker execution.
354///
355/// Only needed when using [`PooledWorker`](crate). Single-process backends
356/// (used with `CheckpointingRunner`) do not need to implement this.
357pub trait TaskClaimStore: Send + Sync {
358    /// Claim a task for execution by a worker node.
359    ///
360    /// Returns `Ok(Some(claim))` if successful, `Ok(None)` if already claimed.
361    fn claim_task(
362        &self,
363        instance_id: &str,
364        task_id: &sayiir_core::TaskId,
365        worker_id: &str,
366        ttl: Option<Duration>,
367    ) -> impl Future<Output = Result<Option<TaskClaim>, BackendError>> + Send;
368
369    /// Release a task claim.
370    fn release_task_claim(
371        &self,
372        instance_id: &str,
373        task_id: &sayiir_core::TaskId,
374        worker_id: &str,
375    ) -> impl Future<Output = Result<(), BackendError>> + Send;
376
377    /// Extend a task claim's expiration time.
378    fn extend_task_claim(
379        &self,
380        instance_id: &str,
381        task_id: &sayiir_core::TaskId,
382        worker_id: &str,
383        additional_duration: Duration,
384    ) -> impl Future<Output = Result<(), BackendError>> + Send;
385
386    /// Find available tasks across all workflow instances.
387    ///
388    /// `aging_interval` controls starvation prevention: lower-priority tasks
389    /// that have been waiting longer than this interval effectively gain one
390    /// priority level per interval elapsed. Pass `Duration::MAX` to disable aging.
391    ///
392    /// # Constraints
393    ///
394    /// `aging_interval` **must be positive** (non-zero). Implementations may
395    /// divide by this value; passing zero or a negative duration can cause
396    /// division-by-zero or nonsensical ordering. Implementations should
397    /// defensively clamp to a minimum of 1 second, but callers must not rely
398    /// on this.
399    fn find_available_tasks(
400        &self,
401        worker_id: &str,
402        limit: usize,
403        aging_interval: Duration,
404        worker_tags: &[String],
405    ) -> impl Future<Output = Result<Vec<AvailableTask>, BackendError>> + Send;
406
407    /// Block until a wakeup arrives or `timeout` elapses. `Some(hint)`
408    /// when a producer attached one; `None` for hint-less wakeups,
409    /// timeout, or backends without a notification channel.
410    ///
411    /// Default returns a future that never resolves (`std::future::pending`)
412    /// — the worker's `interval.tick()` arm provides the cadence, so
413    /// non-overriding backends keep their fixed-interval poll. Keeps
414    /// `sayiir-persistence` runtime-agnostic.
415    fn wait_for_wakeup(
416        &self,
417        _timeout: std::time::Duration,
418    ) -> impl Future<Output = Result<Option<TaskWakeupHint>, BackendError>> + Send {
419        async move {
420            std::future::pending::<Option<TaskWakeupHint>>().await;
421            Ok(None)
422        }
423    }
424
425    /// Resolve a wakeup hint into a claimable [`AvailableTask`], or
426    /// `None` if the snapshot moved on / is claim-blocked / signal-blocked.
427    /// Does NOT claim — the caller runs the normal claim+execute pipeline.
428    /// Default returns `None`; backends opt in for the targeted lookup.
429    fn find_hinted_task(
430        &self,
431        _hint: &TaskWakeupHint,
432    ) -> impl Future<Output = Result<Option<AvailableTask>, BackendError>> + Send {
433        async move { Ok(None) }
434    }
435}
436
437// ---------------------------------------------------------------------------
438// TaskResultStore — opt-in, like TaskClaimStore
439// ---------------------------------------------------------------------------
440
441/// Read-only access to individual task results from a workflow instance.
442///
443/// This trait allows retrieving completed task outputs (intermediate step
444/// results) without loading the full snapshot. For in-progress, cancelled, or
445/// paused workflows the results come straight from the current snapshot. For
446/// completed or failed workflows the results are recovered from history (e.g.
447/// the Postgres snapshot history table) or from an in-memory cache.
448///
449/// Implementations must also implement [`SnapshotStore`] because the current
450/// snapshot is the primary source of truth for non-terminal states.
451pub trait TaskResultStore: SnapshotStore {
452    /// Load a single task result by task ID.
453    ///
454    /// Returns `Ok(Some(bytes))` if the task completed, `Ok(None)` if the task
455    /// was never executed or is not found, and `Err` on I/O failure.
456    fn load_task_result(
457        &self,
458        instance_id: &str,
459        task_id: &sayiir_core::TaskId,
460    ) -> impl Future<Output = Result<Option<bytes::Bytes>, BackendError>> + Send;
461}
462
463// ---------------------------------------------------------------------------
464// PersistentBackend — supertrait + blanket impl
465// ---------------------------------------------------------------------------
466
467/// Supertrait combining [`SnapshotStore`] and [`SignalStore`].
468///
469/// This is the bound used by `CheckpointingRunner` and most of the runtime.
470/// It is blanket-implemented for any type that implements both sub-traits,
471/// so backends never need to implement it directly.
472pub trait PersistentBackend: SnapshotStore + SignalStore {}
473
474impl<T: SnapshotStore + SignalStore> PersistentBackend for T {}
475
476// Re-export Future so the trait method return types resolve.
477use std::future::Future;