1use sayiir_core::codec;
4use sayiir_core::snapshot::{SnapshotStatus, WorkflowSnapshot};
5use sayiir_persistence::{BackendError, SnapshotStore};
6use sqlx::Row;
7
8use crate::backend::PostgresBackend;
9use crate::error::PgError;
10use crate::wakeup::{TASK_READY_CHANNEL, build_task_ready_payload};
11
12impl<C> SnapshotStore for PostgresBackend<C>
13where
14 C: codec::SnapshotCodec,
15{
16 #[tracing::instrument(
17 name = "db.save_snapshot",
18 skip(self, snapshot),
19 fields(
20 db.system = "postgresql",
21 instance_id = %snapshot.instance_id,
22 status = %snapshot.state.as_ref(),
23 ),
24 err(level = tracing::Level::ERROR),
25 )]
26 #[allow(clippy::too_many_lines)]
27 async fn save_snapshot(&self, snapshot: &mut WorkflowSnapshot) -> Result<(), BackendError> {
28 tracing::debug!("saving snapshot");
29 let (data, data_hash) = self.encode_blob_preserving(snapshot)?;
30 let status = snapshot.state.as_ref();
31 let task_id_bytes: Option<[u8; 32]> = snapshot.current_task_id().map(|t| *t.as_bytes());
32 let task_id: Option<&[u8]> = task_id_bytes.as_ref().map(<[u8; 32]>::as_slice);
33 let task_count = snapshot.completed_task_count();
34 let error = snapshot.error_message().map(ToString::to_string);
35 let terminal = snapshot.state.is_terminal();
36 let pos_kind = snapshot.position_kind();
37 let wake_at = snapshot.delay_wake_at();
38 let task_priority = i16::from(snapshot.current_task_priority());
39 let task_tags = snapshot.current_task_tags();
43 let terminal_status = match SnapshotStatus::from(&snapshot.state) {
44 SnapshotStatus::Failed => "failed",
45 SnapshotStatus::Cancelled => "cancelled",
46 _ => "completed",
47 };
48 let notify_payload = build_task_ready_payload(snapshot);
49
50 let output_unflushed = snapshot.output_unflushed;
57 let last_completed_task_id: Option<[u8; 32]> = output_unflushed
58 .then(|| snapshot.last_completed_task_id().map(|t| *t.as_bytes()))
59 .flatten();
60 let last_completed_slice: Option<&[u8]> =
61 last_completed_task_id.as_ref().map(<[u8; 32]>::as_slice);
62 let last_completed_output: Option<bytes::Bytes> = output_unflushed
63 .then(|| {
64 snapshot
65 .last_completed_task_id()
66 .and_then(|id| snapshot.get_task_result(&id).map(|r| r.output.clone()))
67 })
68 .flatten();
69 let last_completed_output_slice: Option<&[u8]> = last_completed_output.as_deref();
70
71 let query = "
84 WITH snap AS (
85 INSERT INTO sayiir_workflow_snapshots
86 (instance_id, status, definition_hash, current_task_id,
87 completed_task_count, error, position_kind, delay_wake_at,
88 trace_parent, task_priority, task_tags, data_hash,
89 completed_at, updated_at)
90 VALUES ($1, $2, $3, $4, $5, $6, $8, $9, $10, $11, $12, $13,
91 CASE WHEN $7 THEN now() ELSE NULL END, now())
92 ON CONFLICT (instance_id) DO UPDATE SET
93 status = $2,
94 definition_hash = $3,
95 current_task_id = $4,
96 completed_task_count = $5,
97 error = $6,
98 position_kind = $8,
99 delay_wake_at = $9,
100 trace_parent = $10,
101 task_priority = $11,
102 task_tags = $12,
103 data_hash = $13,
104 history_version = sayiir_workflow_snapshots.history_version + 1,
105 completed_at = CASE WHEN $7 THEN now() ELSE sayiir_workflow_snapshots.completed_at END,
106 updated_at = now()
107 WHERE sayiir_workflow_snapshots.status NOT IN ('Completed', 'Failed', 'Cancelled')
108 RETURNING history_version
109 ),
110 hist AS (
111 INSERT INTO sayiir_workflow_snapshot_history
112 (instance_id, version, status, current_task_id, data, data_hash)
113 SELECT $1, snap.history_version, $2, $4, $14, $13
114 FROM snap
115 RETURNING 1
116 ),
117 task_active AS (
118 INSERT INTO sayiir_workflow_tasks (instance_id, task_id, status, started_at)
119 SELECT $1, $4, 'active', now()
120 WHERE $4 IS NOT NULL AND NOT $7
121 -- Skip when the just-completed task IS the current task
122 -- (final task in a chain has next=None, so position stays
123 -- on it and current_task_id == last_completed_task_id).
124 -- The task_output CTE below already UPSERTs that row;
125 -- this CTE doing it too trips PG`s rule that ON CONFLICT
126 -- DO UPDATE cannot affect a row twice in one statement.
127 AND $4 IS DISTINCT FROM $18
128 ON CONFLICT (instance_id, task_id) DO UPDATE SET
129 status = CASE
130 WHEN sayiir_workflow_tasks.status = 'completed' THEN sayiir_workflow_tasks.status
131 ELSE 'active'
132 END,
133 started_at = COALESCE(sayiir_workflow_tasks.started_at, now())
134 RETURNING 1
135 ),
136 task_terminal AS (
137 UPDATE sayiir_workflow_tasks
138 SET status = $15, completed_at = now(), error = $6
139 WHERE instance_id = $1 AND status = 'active' AND $7
140 RETURNING 1
141 ),
142 task_output AS (
143 INSERT INTO sayiir_workflow_tasks
144 (instance_id, task_id, status, completed_at, output)
145 SELECT $1, $18, 'completed', now(), $19
146 WHERE $18 IS NOT NULL
147 ON CONFLICT (instance_id, task_id) DO UPDATE SET
148 status = 'completed',
149 completed_at = now(),
150 error = NULL,
151 output = EXCLUDED.output
152 WHERE sayiir_workflow_tasks.output IS NULL
153 OR sayiir_workflow_tasks.output IS DISTINCT FROM EXCLUDED.output
154 RETURNING 1
155 )
156 -- pg_notify lives in the outer SELECT (not a sibling CTE)
157 -- because PG's optimizer prunes unreferenced non-DML CTEs.
158 -- `WITH notify AS (SELECT pg_notify(...) WHERE ...)` without
159 -- a reference from the outer query is silently dropped and
160 -- the wake never fires; the `wait_for_wakeup_*` integration
161 -- tests catch that regression. CASE keeps the conditional
162 -- behaviour — `pg_notify` is only evaluated when the
163 -- payload is non-NULL because CASE short-circuits its arms.
164 SELECT history_version,
165 CASE WHEN $17 IS NOT NULL THEN pg_notify($16, $17) END
166 FROM snap
167 ";
168
169 sqlx::query(query)
170 .bind(&*snapshot.instance_id) .bind(status) .bind(snapshot.definition_hash.as_bytes().as_slice()) .bind(task_id) .bind(task_count) .bind(&error) .bind(terminal) .bind(pos_kind) .bind(wake_at) .bind(snapshot.trace_parent.as_deref()) .bind(task_priority) .bind(task_tags) .bind(data_hash.as_slice()) .bind(&data) .bind(terminal_status) .bind(TASK_READY_CHANNEL) .bind(notify_payload.as_deref()) .bind(last_completed_slice) .bind(last_completed_output_slice) .execute(&self.pool)
190 .await
191 .map_err(PgError)?;
192 snapshot.output_unflushed = false;
196 Ok(())
197 }
198
199 #[tracing::instrument(
200 name = "db.save_task_result",
201 skip(self, output),
202 fields(db.system = "postgresql"),
203 err(level = tracing::Level::ERROR),
204 )]
205 async fn save_task_result(
206 &self,
207 instance_id: &str,
208 task_id: &sayiir_core::TaskId,
209 output: bytes::Bytes,
210 ) -> Result<(), BackendError> {
211 tracing::debug!("saving task result");
212 let mut tx = self.pool.begin().await.map_err(PgError)?;
213
214 let (mut snapshot, prev_history_version) = self
218 .lock_snapshot_for_mutation(&mut tx, instance_id)
219 .await?
220 .ok_or_else(|| BackendError::NotFound(instance_id.to_string()))?;
221
222 let output_bytes = output.clone();
223 snapshot.mark_task_completed(*task_id, output);
224
225 let (data, data_hash) = self.encode_blob(&mut snapshot)?;
226 let status = snapshot.state.as_ref();
227 let current_bytes: Option<[u8; 32]> = snapshot.current_task_id().map(|t| *t.as_bytes());
228 let current: Option<&[u8]> = current_bytes.as_ref().map(<[u8; 32]>::as_slice);
229 let task_count = snapshot.completed_task_count();
230 let next_history_version = prev_history_version + 1;
231
232 sqlx::query(
249 "WITH upd AS (
250 UPDATE sayiir_workflow_snapshots
251 SET status = $1, current_task_id = $2,
252 completed_task_count = $3, history_version = $4,
253 data_hash = $5, updated_at = now()
254 WHERE instance_id = $6
255 RETURNING 1
256 ),
257 hist AS (
258 INSERT INTO sayiir_workflow_snapshot_history
259 (instance_id, version, status, current_task_id, data, data_hash)
260 VALUES ($6, $4, $1, $2, $7, $5)
261 RETURNING 1
262 ),
263 task AS (
264 INSERT INTO sayiir_workflow_tasks
265 (instance_id, task_id, status, completed_at, output)
266 VALUES ($6, $8, 'completed', now(), $9)
267 ON CONFLICT (instance_id, task_id) DO UPDATE SET
268 status = 'completed', completed_at = now(), error = NULL,
269 output = EXCLUDED.output
270 RETURNING 1
271 )
272 -- Anchor the outer SELECT to `upd` so the structural
273 -- defence against unreferenced-CTE pruning matches
274 -- save_snapshot. upd/hist/task are all DML so PG executes
275 -- them unconditionally regardless of references, but any
276 -- future refactor that converts one to a non-DML CTE
277 -- would silently drop the write without this anchor.
278 SELECT 1 FROM upd",
279 )
280 .bind(status) .bind(current) .bind(task_count) .bind(next_history_version) .bind(data_hash.as_slice()) .bind(instance_id) .bind(&data) .bind(task_id.as_bytes().as_slice()) .bind(output_bytes.as_ref()) .execute(&mut *tx)
290 .await
291 .map_err(PgError)?;
292
293 tx.commit().await.map_err(PgError)?;
294 Ok(())
295 }
296
297 #[tracing::instrument(
298 name = "db.load_snapshot",
299 skip(self),
300 fields(db.system = "postgresql"),
301 err(level = tracing::Level::ERROR),
302 )]
303 async fn load_snapshot(&self, instance_id: &str) -> Result<WorkflowSnapshot, BackendError> {
304 tracing::debug!("loading snapshot");
305 let row = sqlx::query(
306 "SELECT history_version, trace_parent
307 FROM sayiir_workflow_snapshots
308 WHERE instance_id = $1",
309 )
310 .bind(instance_id)
311 .fetch_optional(&self.pool)
312 .await
313 .map_err(PgError)?
314 .ok_or_else(|| BackendError::NotFound(instance_id.to_string()))?;
315
316 let history_version: i32 = row.get("history_version");
317 let data = self
318 .fetch_blob(&self.pool, instance_id, history_version)
319 .await?;
320 let mut snapshot = self.decode(&data)?;
321 snapshot.trace_parent = row.get("trace_parent");
322 let outputs = crate::history::fetch_task_outputs(&self.pool, instance_id).await?;
323 snapshot.hydrate_task_outputs(outputs);
324 Ok(snapshot)
325 }
326
327 #[tracing::instrument(
328 name = "db.delete_snapshot",
329 skip(self),
330 fields(db.system = "postgresql"),
331 err(level = tracing::Level::ERROR),
332 )]
333 async fn delete_snapshot(&self, instance_id: &str) -> Result<(), BackendError> {
334 tracing::debug!("deleting snapshot");
335
336 let mut tx = self.pool.begin().await.map_err(PgError)?;
337
338 for table in crate::WORKFLOW_CHILD_TABLES {
339 sqlx::query(&format!("DELETE FROM {table} WHERE instance_id = $1"))
340 .bind(instance_id)
341 .execute(&mut *tx)
342 .await
343 .map_err(PgError)?;
344 }
345
346 let result = sqlx::query("DELETE FROM sayiir_workflow_snapshots WHERE instance_id = $1")
347 .bind(instance_id)
348 .execute(&mut *tx)
349 .await
350 .map_err(PgError)?;
351
352 tx.commit().await.map_err(PgError)?;
353
354 if result.rows_affected() == 0 {
355 return Err(BackendError::NotFound(instance_id.to_string()));
356 }
357 Ok(())
358 }
359
360 #[tracing::instrument(
361 name = "db.list_snapshots",
362 skip(self),
363 fields(db.system = "postgresql"),
364 err(level = tracing::Level::ERROR),
365 )]
366 async fn list_snapshots(&self) -> Result<Vec<String>, BackendError> {
367 tracing::debug!("listing snapshots");
368 let rows = sqlx::query("SELECT instance_id FROM sayiir_workflow_snapshots")
369 .fetch_all(&self.pool)
370 .await
371 .map_err(PgError)?;
372
373 Ok(rows.iter().map(|r| r.get("instance_id")).collect())
374 }
375}