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;