Skip to main content

sayiir_postgres/
snapshot_store.rs

1//! [`SnapshotStore`] implementation for Postgres.
2
3use 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        // Bind the `&[String]` slice directly — sqlx encodes it as `text[]`,
40        // so the intermediate `Vec<&str>` collect (one alloc per save) is
41        // unnecessary.
42        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        // Ship the just-completed output to the sidecar ONLY when flagged
51        // unflushed (set by `mark_task_completed`, cleared post-write). On
52        // position-only saves (pause/cancel/delay/signal parking) the flag is
53        // false, so both binds are NULL and the `task_output` CTE no-ops via
54        // `WHERE $18 IS NOT NULL` — no re-shipping. Safe: an unflagged output
55        // is already in `workflow_tasks`.
56        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        // One round-trip via a CTE chain: bump the snapshot version,
72        // append the history row at that version, refresh the
73        // workflow_tasks lifecycle, and queue the wakeup NOTIFY — all
74        // in one statement. Sibling DML CTEs in PG always run; the
75        // gates (`WHERE $task_id IS NOT NULL`, `WHERE $terminal`,
76        // `WHERE $notify IS NOT NULL`) make zero-row branches cheap
77        // no-ops without conditional Rust string building.
78        //
79        // The trailing `SELECT history_version FROM snap` is needed for
80        // the CTE chain to be a valid statement, but the value is not
81        // read on the caller side. `.execute()` skips PgRow allocation
82        // on the Rust side while still driving the full CTE on PG.
83        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) // $1
171            .bind(status) // $2
172            .bind(snapshot.definition_hash.as_bytes().as_slice()) // $3
173            .bind(task_id) // $4
174            .bind(task_count) // $5
175            .bind(&error) // $6
176            .bind(terminal) // $7
177            .bind(pos_kind) // $8
178            .bind(wake_at) // $9
179            .bind(snapshot.trace_parent.as_deref()) // $10
180            .bind(task_priority) // $11
181            .bind(task_tags) // $12
182            .bind(data_hash.as_slice()) // $13
183            .bind(&data) // $14
184            .bind(terminal_status) // $15
185            .bind(TASK_READY_CHANNEL) // $16
186            .bind(notify_payload.as_deref()) // $17
187            .bind(last_completed_slice) // $18
188            .bind(last_completed_output_slice) // $19
189            .execute(&self.pool)
190            .await
191            .map_err(PgError)?;
192        // The last completed output (if any) is now durable in
193        // `workflow_tasks`; clear the marker so a later save of this same
194        // in-memory snapshot binds NULL instead of re-shipping the bytes.
195        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        // FOR UPDATE on s serialises read-modify-write across this path
215        // and signal_store's mutators — TaskClaim only protects against
216        // peer workers, not against concurrent check_and_cancel.
217        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        // UPDATE snapshots + INSERT history + UPSERT workflow_tasks in
233        // one CTE — the lock acquired above by
234        // lock_snapshot_for_mutation is still held in this same tx, so
235        // all three writes commit together.
236        //
237        // NOTIFY is deliberately NOT emitted here: `current_task_id`
238        // has just been marked completed in `completed_tasks` but the
239        // snapshot row's `current_task_id` still points at it (the
240        // position advance happens in the immediately-following
241        // `save_snapshot` from `commit_task_result`). A NOTIFY here
242        // would carry a stale hint — recipients would call
243        // `find_hinted_task`, hydrate workflow_tasks, see the task
244        // already completed in `completed_tasks`, and bail. On the
245        // linear bench this stale half doubled NOTIFY pressure and
246        // blew through the 16 384-slot mpmc channel (~9 600 drops on
247        // CI), forcing workers onto the slower polling fallback.
248        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) // $1
281        .bind(current) // $2
282        .bind(task_count) // $3
283        .bind(next_history_version) // $4
284        .bind(data_hash.as_slice()) // $5
285        .bind(instance_id) // $6
286        .bind(&data) // $7
287        .bind(task_id.as_bytes().as_slice()) // $8
288        .bind(output_bytes.as_ref()) // $9
289        .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}