Skip to main content

lash_sqlite_store/
process_registry.rs

1//! SQLite-backed [`ProcessRegistry`] (`SqliteProcessRegistry`).
2//!
3//! First-party SQLite implementation of the public async process-registry
4//! surface. Every DB body is a *synchronous* rusqlite closure handed
5//! to [`SqliteConnection::call`] (reads) or [`SqliteConnection::write_flow`]
6//! (read-then-write).
7//!
8//! ## Why `write_flow`, not `write`
9//!
10//! The registry's transactional methods produce a [`lash_core::PluginError`],
11//! not a `rusqlite::Error`. `SqliteConnection::write` rolls back only when the
12//! closure returns `Err(rusqlite::Error)`, so a logical `PluginError` (e.g. a
13//! registration-hash conflict) would otherwise *commit* the partial work. Each
14//! such method therefore runs its synchronous body returning
15//! `Result<T, PluginError>` and maps it to a [`TxOutcome`]: `Ok` ⇒
16//! `Commit(Ok(value))`, `Err` ⇒ `Rollback(Err(error))`. That preserves the
17//! prior behaviour of rolling back on every error while still carrying the
18//! `PluginError` back to the caller. The outer `rusqlite::Error` channel only
19//! carries genuine SQLite/connection failures, mapped via `process_sqlite_error`.
20//!
21//! The `*_conn` helpers are synchronous and take a `&rusqlite::Connection` so
22//! they compose inside either closure — including from within a `&Transaction`,
23//! which derefs to `&Connection`.
24
25use super::*;
26
27fn process_status_label(record: &ProcessRecord) -> &'static str {
28    record.status.label()
29}
30
31impl SqliteProcessRegistry {
32    pub async fn open(path: &Path) -> tokio_rusqlite::Result<Self> {
33        let conn = SqliteConnection::open(path).await?;
34        ensure_process_schema(&conn).await?;
35        apply_pragmas(&conn, StoreBacking::File).await?;
36        Ok(Self { conn })
37    }
38
39    pub async fn memory() -> tokio_rusqlite::Result<Self> {
40        let conn = SqliteConnection::open_in_memory().await?;
41        ensure_process_schema(&conn).await?;
42        apply_pragmas(&conn, StoreBacking::Memory).await?;
43        Ok(Self { conn })
44    }
45
46    fn load_process_conn(
47        conn: &Connection,
48        process_id: &str,
49    ) -> Result<Option<ProcessRecord>, lash_core::PluginError> {
50        let json: Option<String> = conn
51            .query_row(
52                "SELECT record_json FROM processes WHERE process_id = ?1",
53                params![process_id],
54                |row| row.get(0),
55            )
56            .optional()
57            .map_err(process_sqlite_error)?;
58        json.map(|json| serde_json::from_str(&json).map_err(process_decode_error))
59            .transpose()
60    }
61
62    fn save_process_conn(
63        conn: &Connection,
64        record: &ProcessRecord,
65    ) -> Result<(), lash_core::PluginError> {
66        conn.execute(
67            "UPDATE processes
68             SET updated_at_ms = ?2, status = ?3, record_json = ?4
69             WHERE process_id = ?1",
70            params![
71                record.id.as_str(),
72                record.updated_at_ms as i64,
73                process_status_label(record),
74                process_encode_json(record)?
75            ],
76        )
77        .map_err(process_sqlite_error)?;
78        Ok(())
79    }
80
81    fn load_event_by_key_conn(
82        conn: &Connection,
83        process_id: &str,
84        replay_key: &str,
85    ) -> Result<Option<(String, ProcessEvent)>, lash_core::PluginError> {
86        let row: Option<(String, String)> = conn
87            .query_row(
88                "SELECT payload_hash, event_json
89                 FROM process_events
90                 WHERE process_id = ?1 AND idempotency_key = ?2",
91                params![process_id, replay_key],
92                |row| Ok((row.get(0)?, row.get(1)?)),
93            )
94            .optional()
95            .map_err(process_sqlite_error)?;
96        row.map(|(hash, json)| {
97            serde_json::from_str(&json)
98                .map(|event| (hash, event))
99                .map_err(process_decode_error)
100        })
101        .transpose()
102    }
103
104    fn load_process_lease_conn(
105        conn: &Connection,
106        process_id: &str,
107    ) -> Result<Option<ProcessLease>, lash_core::PluginError> {
108        conn.query_row(
109            "SELECT lease_owner_id, lease_token, lease_fencing_token,
110                    lease_claimed_at_ms, lease_expires_at_ms,
111                    lease_owner_incarnation_id, lease_owner_liveness_json
112             FROM process_leases
113             WHERE process_id = ?1",
114            params![process_id],
115            |row| {
116                let owner_id: Option<String> = row.get(0)?;
117                let lease_token: Option<String> = row.get(1)?;
118                let incarnation_id: Option<String> = row.get(5)?;
119                let liveness_json: Option<String> = row.get(6)?;
120                let (Some(owner_id), Some(lease_token)) = (owner_id, lease_token) else {
121                    return Ok(None);
122                };
123                Ok(Some(ProcessLease {
124                    schema_version: PROCESS_LEASE_SCHEMA_VERSION,
125                    process_id: process_id.to_string(),
126                    owner: process_lease_owner_from_columns(
127                        owner_id,
128                        incarnation_id,
129                        liveness_json,
130                    ),
131                    lease_token,
132                    fencing_token: row.get::<_, i64>(2)? as u64,
133                    claimed_at_epoch_ms: row.get::<_, i64>(3)? as u64,
134                    expires_at_epoch_ms: row.get::<_, i64>(4)? as u64,
135                }))
136            },
137        )
138        .optional()
139        .map(|lease| lease.flatten())
140        .map_err(process_sqlite_error)
141    }
142
143    /// Insert-or-replace the persisted lease row for `process_id` with a fresh
144    /// lease owned by `owner` at `fencing_token`.
145    fn acquire_process_lease_conn(
146        conn: &Connection,
147        process_id: &str,
148        owner: &LeaseOwnerIdentity,
149        fencing_token: u64,
150        now: u64,
151        lease_ttl_ms: u64,
152    ) -> Result<ProcessLease, lash_core::PluginError> {
153        let lease = ProcessLease {
154            schema_version: PROCESS_LEASE_SCHEMA_VERSION,
155            process_id: process_id.to_string(),
156            owner: owner.clone(),
157            lease_token: format!(
158                "{:x}",
159                Sha256::digest(
160                    format!(
161                        "{process_id}:{}:{}:{now}:{fencing_token}",
162                        owner.owner_id, owner.incarnation_id
163                    )
164                    .as_bytes()
165                )
166            ),
167            fencing_token,
168            claimed_at_epoch_ms: now,
169            expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
170        };
171        conn.execute(
172            "INSERT INTO process_leases (
173                process_id, lease_owner_id, lease_owner_incarnation_id,
174                lease_owner_liveness_json, lease_token, lease_fencing_token,
175                lease_claimed_at_ms, lease_expires_at_ms
176             )
177             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
178             ON CONFLICT(process_id) DO UPDATE SET
179                lease_owner_id = excluded.lease_owner_id,
180                lease_owner_incarnation_id = excluded.lease_owner_incarnation_id,
181                lease_owner_liveness_json = excluded.lease_owner_liveness_json,
182                lease_token = excluded.lease_token,
183                lease_fencing_token = excluded.lease_fencing_token,
184                lease_claimed_at_ms = excluded.lease_claimed_at_ms,
185                lease_expires_at_ms = excluded.lease_expires_at_ms",
186            params![
187                lease.process_id.as_str(),
188                lease.owner.owner_id.as_str(),
189                lease.owner.incarnation_id.as_str(),
190                encode_process_lease_liveness(&lease.owner.liveness)?,
191                lease.lease_token.as_str(),
192                lease.fencing_token as i64,
193                lease.claimed_at_epoch_ms as i64,
194                lease.expires_at_epoch_ms as i64,
195            ],
196        )
197        .map_err(process_sqlite_error)?;
198        Ok(lease)
199    }
200
201    fn list_grants_for_scope_conn(
202        conn: &Connection,
203        session_scope: &SessionScope,
204        live_only: bool,
205    ) -> Result<Vec<ProcessHandleGrantEntry>, lash_core::PluginError> {
206        let session_scope_id = session_scope.id();
207        let status_clause = if live_only {
208            "AND p.status = 'running'"
209        } else {
210            ""
211        };
212        let mut stmt = conn
213            .prepare(&format!(
214                "SELECT g.process_id, g.descriptor_json, p.record_json
215                 FROM process_handle_grants g
216                 JOIN processes p ON p.process_id = g.process_id
217                 WHERE g.scope_id = ?1 {status_clause}
218                 ORDER BY g.process_id ASC"
219            ))
220            .map_err(process_sqlite_error)?;
221        let rows = stmt
222            .query_map(params![session_scope_id.as_str()], |row| {
223                Ok((
224                    row.get::<_, String>(0)?,
225                    row.get::<_, String>(1)?,
226                    row.get::<_, String>(2)?,
227                ))
228            })
229            .map_err(process_sqlite_error)?;
230        let mut entries = Vec::new();
231        for row in rows {
232            let (process_id, descriptor_json, record_json) = row.map_err(process_sqlite_error)?;
233            let descriptor: ProcessHandleDescriptor =
234                serde_json::from_str(&descriptor_json).map_err(process_decode_error)?;
235            let record: ProcessRecord =
236                serde_json::from_str(&record_json).map_err(process_decode_error)?;
237            entries.push((
238                ProcessHandleGrant {
239                    session_id: session_scope.session_id.clone(),
240                    process_id,
241                    descriptor,
242                },
243                record,
244            ));
245        }
246        Ok(entries)
247    }
248}
249
250/// Map a `Result<T, PluginError>` produced by a synchronous transaction body to
251/// a [`TxOutcome`]: commit on success, roll back on logical error. Both arms
252/// carry the inner `Result` back so the caller recovers the value or the
253/// `PluginError` after the transaction resolves.
254fn tx_outcome<T>(
255    result: Result<T, lash_core::PluginError>,
256) -> TxOutcome<Result<T, lash_core::PluginError>> {
257    match result {
258        Ok(value) => TxOutcome::Commit(Ok(value)),
259        Err(err) => TxOutcome::Rollback(Err(err)),
260    }
261}
262
263#[async_trait::async_trait]
264impl ProcessRegistry for SqliteProcessRegistry {
265    fn durability_tier(&self) -> DurabilityTier {
266        DurabilityTier::Durable
267    }
268
269    async fn register_process(
270        &self,
271        registration: ProcessRegistration,
272    ) -> Result<ProcessRecord, lash_core::PluginError> {
273        let (registration, registration_hash) = prepare_process_registration(registration)?;
274        let record = self
275            .conn
276            .write_flow(move |tx| {
277                Ok(tx_outcome((|| {
278                    if let Some(existing) = Self::load_process_conn(tx, &registration.id)? {
279                        if existing.registration_hash == registration_hash {
280                            return Ok(existing);
281                        }
282                        return Err(lash_core::PluginError::Session(format!(
283                            "process `{}` registration hash conflict: existing {}, new {}",
284                            registration.id, existing.registration_hash, registration_hash
285                        )));
286                    }
287                    let now = current_epoch_ms();
288                    let record = ProcessRecord::from_prepared_registration(
289                        registration,
290                        registration_hash,
291                        now,
292                    );
293                    let originator_scope_id = record.originator_scope_id();
294                    tx.execute(
295                        "INSERT INTO processes (
296                            process_id, registration_hash, owner_scope_id,
297                            created_at_ms, updated_at_ms, status, record_json
298                         )
299                         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
300                        params![
301                            record.id.as_str(),
302                            record.registration_hash.as_str(),
303                            originator_scope_id.as_str(),
304                            record.created_at_ms as i64,
305                            record.updated_at_ms as i64,
306                            process_status_label(&record),
307                            process_encode_json(&record)?,
308                        ],
309                    )
310                    .map_err(process_sqlite_error)?;
311                    Ok(record)
312                })()))
313            })
314            .await
315            .map_err(process_sqlite_error)??;
316        Ok(record)
317    }
318
319    async fn set_external_ref(
320        &self,
321        process_id: &str,
322        external_ref: ProcessExternalRef,
323    ) -> Result<ProcessRecord, lash_core::PluginError> {
324        let process_id = process_id.to_string();
325        let (record, _changed) = self
326            .conn
327            .write_flow(move |tx| {
328                Ok(tx_outcome((|| {
329                    let mut record =
330                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
331                            lash_core::PluginError::Session(format!(
332                                "unknown process `{process_id}`"
333                            ))
334                        })?;
335                    if let Some(existing) = &record.external_ref {
336                        if existing == &external_ref {
337                            return Ok((record, false));
338                        }
339                        return Err(process_external_ref_conflict(
340                            &process_id,
341                            existing,
342                            &external_ref,
343                        ));
344                    }
345                    record.external_ref = Some(external_ref);
346                    record.updated_at_ms = current_epoch_ms();
347                    Self::save_process_conn(tx, &record)?;
348                    Ok((record, true))
349                })()))
350            })
351            .await
352            .map_err(process_sqlite_error)??;
353        Ok(record)
354    }
355
356    async fn grant_handle(
357        &self,
358        session_scope: &SessionScope,
359        process_id: &str,
360        descriptor: ProcessHandleDescriptor,
361    ) -> Result<ProcessHandleGrant, lash_core::PluginError> {
362        let session_scope = session_scope.clone();
363        let process_id = process_id.to_string();
364        self.conn
365            .write_flow(move |tx| {
366                Ok(tx_outcome((|| {
367                    let session_scope_id = session_scope.id();
368                    if Self::load_process_conn(tx, &process_id)?.is_none() {
369                        return Err(lash_core::PluginError::Session(format!(
370                            "unknown process `{process_id}`"
371                        )));
372                    }
373                    tx.execute(
374                        "INSERT INTO process_handle_grants (session_id, scope_id, process_id, descriptor_json)
375                         VALUES (?1, ?2, ?3, ?4)
376                         ON CONFLICT(scope_id, process_id) DO UPDATE SET
377                            session_id = excluded.session_id,
378                            descriptor_json = excluded.descriptor_json",
379                        params![
380                            session_scope.session_id.as_str(),
381                            session_scope_id.as_str(),
382                            process_id.as_str(),
383                            process_encode_json(&descriptor)?
384                        ],
385                    )
386                    .map_err(process_sqlite_error)?;
387                    Ok(ProcessHandleGrant {
388                        session_id: session_scope.session_id.clone(),
389                        process_id: process_id.clone(),
390                        descriptor,
391                    })
392                })()))
393            })
394            .await
395            .map_err(process_sqlite_error)?
396    }
397
398    async fn revoke_handle(
399        &self,
400        session_scope: &SessionScope,
401        process_id: &str,
402    ) -> Result<(), lash_core::PluginError> {
403        let session_scope_id = session_scope.id().as_str().to_string();
404        let process_id = process_id.to_string();
405        self.conn
406            .call(move |conn| {
407                conn.execute(
408                    "DELETE FROM process_handle_grants WHERE scope_id = ?1 AND process_id = ?2",
409                    params![session_scope_id, process_id],
410                )
411            })
412            .await
413            .map_err(process_sqlite_error)?;
414        Ok(())
415    }
416
417    async fn transfer_handle_grants(
418        &self,
419        from_scope: &SessionScope,
420        to_scope: &SessionScope,
421        process_ids: &[String],
422    ) -> Result<(), lash_core::PluginError> {
423        let from_scope = from_scope.clone();
424        let to_scope = to_scope.clone();
425        let process_ids = process_ids.to_vec();
426        self.conn
427            .write_flow(move |tx| {
428                Ok(tx_outcome((|| {
429                    let from_scope_id = from_scope.id();
430                    let to_scope_id = to_scope.id();
431                    for process_id in &process_ids {
432                        let descriptor_json: Option<String> = tx
433                            .query_row(
434                                "SELECT descriptor_json
435                                 FROM process_handle_grants
436                                 WHERE scope_id = ?1 AND process_id = ?2",
437                                params![from_scope_id.as_str(), process_id.as_str()],
438                                |row| row.get(0),
439                            )
440                            .optional()
441                            .map_err(process_sqlite_error)?;
442                        let Some(descriptor_json) = descriptor_json else {
443                            return Err(lash_core::PluginError::Session(format!(
444                                "process handle `{process_id}` is not granted to session `{}`",
445                                from_scope.session_id
446                            )));
447                        };
448                        tx.execute(
449                            "DELETE FROM process_handle_grants
450                             WHERE scope_id = ?1 AND process_id = ?2",
451                            params![from_scope_id.as_str(), process_id.as_str()],
452                        )
453                        .map_err(process_sqlite_error)?;
454                        tx.execute(
455                            "INSERT INTO process_handle_grants (session_id, scope_id, process_id, descriptor_json)
456                             VALUES (?1, ?2, ?3, ?4)
457                             ON CONFLICT(scope_id, process_id) DO UPDATE SET
458                                session_id = excluded.session_id,
459                                descriptor_json = excluded.descriptor_json",
460                            params![
461                                to_scope.session_id.as_str(),
462                                to_scope_id.as_str(),
463                                process_id.as_str(),
464                                descriptor_json
465                            ],
466                        )
467                        .map_err(process_sqlite_error)?;
468                    }
469                    Ok(())
470                })()))
471            })
472            .await
473            .map_err(process_sqlite_error)?
474    }
475
476    async fn list_handle_grants(
477        &self,
478        session_scope: &SessionScope,
479    ) -> Result<Vec<ProcessHandleGrantEntry>, lash_core::PluginError> {
480        let session_scope = session_scope.clone();
481        self.conn
482            .call(move |conn| {
483                Ok(Self::list_grants_for_scope_conn(
484                    conn,
485                    &session_scope,
486                    false,
487                ))
488            })
489            .await
490            .map_err(process_sqlite_error)?
491    }
492
493    async fn list_live_handle_grants(
494        &self,
495        session_scope: &SessionScope,
496    ) -> Result<Vec<ProcessHandleGrantEntry>, lash_core::PluginError> {
497        let session_scope = session_scope.clone();
498        self.conn
499            .call(move |conn| Ok(Self::list_grants_for_scope_conn(conn, &session_scope, true)))
500            .await
501            .map_err(process_sqlite_error)?
502    }
503
504    async fn has_handle_grant(
505        &self,
506        session_scope: &SessionScope,
507        process_id: &str,
508    ) -> Result<bool, lash_core::PluginError> {
509        let session_scope_id = session_scope.id().as_str().to_string();
510        let process_id = process_id.to_string();
511        self.conn
512            .call(move |conn| {
513                let exists = conn
514                    .query_row(
515                        "SELECT 1
516                         FROM process_handle_grants g
517                         JOIN processes p ON p.process_id = g.process_id
518                         WHERE g.scope_id = ?1 AND g.process_id = ?2
519                         LIMIT 1",
520                        params![session_scope_id, process_id],
521                        |_| Ok(()),
522                    )
523                    .optional()?
524                    .is_some();
525                Ok(exists)
526            })
527            .await
528            .map_err(process_sqlite_error)
529    }
530
531    async fn handle_grants_for_process(
532        &self,
533        process_id: &str,
534    ) -> Result<Vec<ProcessHandleGrant>, lash_core::PluginError> {
535        let process_id = process_id.to_string();
536        self.conn
537            .call(move |conn| {
538                Ok((|| {
539                    if Self::load_process_conn(conn, &process_id)?.is_none() {
540                        return Err(lash_core::PluginError::Session(format!(
541                            "unknown process `{process_id}`"
542                        )));
543                    }
544                    let mut stmt = conn
545                        .prepare(
546                            "SELECT session_id, descriptor_json
547                             FROM process_handle_grants
548                             WHERE process_id = ?1
549                             ORDER BY session_id ASC, scope_id ASC",
550                        )
551                        .map_err(process_sqlite_error)?;
552                    let rows = stmt
553                        .query_map(params![process_id], |row| {
554                            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
555                        })
556                        .map_err(process_sqlite_error)?;
557                    let mut grants = Vec::new();
558                    for row in rows {
559                        let (session_id, descriptor_json) = row.map_err(process_sqlite_error)?;
560                        let descriptor: ProcessHandleDescriptor =
561                            serde_json::from_str(&descriptor_json).map_err(process_decode_error)?;
562                        grants.push(ProcessHandleGrant {
563                            session_id,
564                            process_id: process_id.clone(),
565                            descriptor,
566                        });
567                    }
568                    Ok(grants)
569                })())
570            })
571            .await
572            .map_err(process_sqlite_error)?
573    }
574
575    async fn delete_session_process_state(
576        &self,
577        session_id: &str,
578    ) -> Result<lash_core::ProcessSessionDeleteReport, lash_core::PluginError> {
579        let session_id_owned = session_id.to_string();
580        let (
581            revoked_handle_count,
582            deleted_wake_count,
583            mut orphaned_process_ids,
584            mut preserved_process_ids,
585        ) = self
586            .conn
587            .write_flow(move |tx| {
588                Ok(tx_outcome((|| {
589                    let session_id = session_id_owned;
590                    let removed = {
591                        let mut stmt = tx
592                            .prepare(
593                                "SELECT g.process_id, p.record_json
594                                 FROM process_handle_grants g
595                                 JOIN processes p ON p.process_id = g.process_id
596                                 WHERE g.session_id = ?1
597                                 ORDER BY g.process_id ASC",
598                            )
599                            .map_err(process_sqlite_error)?;
600                        let rows = stmt
601                            .query_map(params![session_id], |row| {
602                                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
603                            })
604                            .map_err(process_sqlite_error)?;
605                        let mut removed = Vec::new();
606                        for row in rows {
607                            let (process_id, record_json) = row.map_err(process_sqlite_error)?;
608                            let record: ProcessRecord =
609                                serde_json::from_str(&record_json).map_err(process_decode_error)?;
610                            removed.push((process_id, record));
611                        }
612                        removed
613                    };
614
615                    // Wake acknowledgements are process-scoped consumed-event markers.
616                    // Session deletion removes materialized session-addressed deliveries
617                    // through the session store; clearing these rows would re-expose
618                    // already-consumed wakes to surviving grants or future host readers.
619                    let deleted_wake_count = 0;
620                    let revoked_handle_count = tx
621                        .execute(
622                            "DELETE FROM process_handle_grants WHERE session_id = ?1",
623                            params![session_id],
624                        )
625                        .map_err(process_sqlite_error)?;
626                    let mut orphaned_process_ids = Vec::new();
627                    let mut preserved_process_ids = Vec::new();
628                    for (process_id, record) in removed {
629                        if record.is_terminal() {
630                            continue;
631                        }
632                        let remaining_grants: i64 = tx
633                            .query_row(
634                                "SELECT COUNT(*) FROM process_handle_grants WHERE process_id = ?1",
635                                params![process_id],
636                                |row| row.get(0),
637                            )
638                            .map_err(process_sqlite_error)?;
639                        if remaining_grants == 0 {
640                            orphaned_process_ids.push(process_id);
641                        } else {
642                            preserved_process_ids.push(process_id);
643                        }
644                    }
645                    let wake_targeted = {
646                        let mut stmt = tx
647                            .prepare("SELECT process_id, record_json FROM processes ORDER BY process_id ASC")
648                            .map_err(process_sqlite_error)?;
649                        let rows = stmt
650                            .query_map([], |row| {
651                                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
652                            })
653                            .map_err(process_sqlite_error)?;
654                        let mut records = Vec::new();
655                        for row in rows {
656                            let (process_id, record_json) = row.map_err(process_sqlite_error)?;
657                            let record: ProcessRecord =
658                                serde_json::from_str(&record_json).map_err(process_decode_error)?;
659                            records.push((process_id, record));
660                        }
661                        records
662                    };
663                    for (_process_id, mut record) in wake_targeted {
664                        if record.clear_wake_target_for_session(&session_id) {
665                            Self::save_process_conn(tx, &record)?;
666                        }
667                    }
668                    Ok((
669                        revoked_handle_count,
670                        deleted_wake_count,
671                        orphaned_process_ids,
672                        preserved_process_ids,
673                    ))
674                })()))
675            })
676            .await
677            .map_err(process_sqlite_error)??;
678        orphaned_process_ids.sort();
679        orphaned_process_ids.dedup();
680        preserved_process_ids.sort();
681        preserved_process_ids.dedup();
682        Ok(lash_core::ProcessSessionDeleteReport {
683            session_id: session_id.to_string(),
684            revoked_handle_count,
685            deleted_wake_count,
686            orphaned_process_ids,
687            preserved_process_ids,
688        })
689    }
690
691    async fn append_event(
692        &self,
693        process_id: &str,
694        request: ProcessEventAppendRequest,
695    ) -> Result<ProcessEventAppendResult, lash_core::PluginError> {
696        let process_id = process_id.to_string();
697        let (result, _appended) = self
698            .conn
699            .write_flow(move |tx| {
700                Ok(tx_outcome((|| {
701                    let mut record =
702                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
703                            lash_core::PluginError::Session(format!(
704                                "unknown process `{process_id}`"
705                            ))
706                        })?;
707                    let replay_lookup = if let Some(replay_key) =
708                        request.replay.as_ref().map(|replay| replay.key.as_str())
709                    {
710                        Self::load_event_by_key_conn(tx, &process_id, replay_key)?
711                    } else {
712                        None
713                    };
714                    let sequence = tx
715                        .query_row(
716                            "SELECT COALESCE(MAX(sequence), 0) + 1 FROM process_events WHERE process_id = ?1",
717                            params![process_id],
718                            |row| row.get::<_, i64>(0),
719                        )
720                        .map_err(process_sqlite_error)? as u64;
721                    let occurred_at_ms = current_epoch_ms();
722                    let prepared = prepare_process_event_append(
723                        &record,
724                        request,
725                        sequence,
726                        replay_lookup,
727                        occurred_at_ms,
728                    )?;
729                    match prepared {
730                        lash_core::ProcessEventAppendPlan::Replay {
731                            event,
732                            repair_status,
733                            wake_delivery,
734                            occurred_at_ms,
735                        } => {
736                            let repaired = if let Some(status) = repair_status {
737                                lash_core::apply_process_status_projection(
738                                    &mut record,
739                                    status,
740                                    occurred_at_ms,
741                                );
742                                Self::save_process_conn(tx, &record)?;
743                                true
744                            } else {
745                                false
746                            };
747                            Ok((
748                                ProcessEventAppendResult {
749                                    event,
750                                    wake_delivery,
751                                },
752                                repaired,
753                            ))
754                        }
755                        lash_core::ProcessEventAppendPlan::Insert {
756                            event,
757                            payload_hash,
758                            status_update,
759                            wake_delivery,
760                            occurred_at_ms,
761                        } => {
762                            tx.execute(
763                                "INSERT INTO process_events (
764                                    process_id, sequence, event_type, payload_hash, idempotency_key,
765                                    occurred_at_ms, event_json
766                                 )
767                                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
768                                params![
769                                    process_id,
770                                    sequence as i64,
771                                    event.event_type.as_str(),
772                                    payload_hash.as_str(),
773                                    event.invocation.replay_key(),
774                                    occurred_at_ms as i64,
775                                    process_encode_json(&event)?,
776                                ],
777                            )
778                            .map_err(process_sqlite_error)?;
779                            if let Some(status) = status_update {
780                                lash_core::apply_process_status_projection(
781                                    &mut record,
782                                    status,
783                                    occurred_at_ms,
784                                );
785                            } else {
786                                record.updated_at_ms = occurred_at_ms;
787                            }
788                            Self::save_process_conn(tx, &record)?;
789                            Ok((
790                                ProcessEventAppendResult {
791                                    event,
792                                    wake_delivery,
793                                },
794                                true,
795                            ))
796                        }
797                    }
798                })()))
799            })
800            .await
801            .map_err(process_sqlite_error)??;
802        Ok(result)
803    }
804
805    async fn events_after(
806        &self,
807        process_id: &str,
808        after_sequence: u64,
809    ) -> Result<Vec<ProcessEvent>, lash_core::PluginError> {
810        let process_id = process_id.to_string();
811        self.conn
812            .call(move |conn| {
813                Ok((|| {
814                    if Self::load_process_conn(conn, &process_id)?.is_none() {
815                        return Err(lash_core::PluginError::Session(format!(
816                            "unknown process `{process_id}`"
817                        )));
818                    }
819                    let mut stmt = conn
820                        .prepare(
821                            "SELECT event_json FROM process_events
822                             WHERE process_id = ?1 AND sequence > ?2
823                             ORDER BY sequence ASC",
824                        )
825                        .map_err(process_sqlite_error)?;
826                    let rows = stmt
827                        .query_map(params![process_id, after_sequence as i64], |row| {
828                            row.get::<_, String>(0)
829                        })
830                        .map_err(process_sqlite_error)?;
831                    let mut events = Vec::new();
832                    for row in rows {
833                        events.push(
834                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
835                                .map_err(process_decode_error)?,
836                        );
837                    }
838                    Ok(events)
839                })())
840            })
841            .await
842            .map_err(process_sqlite_error)?
843    }
844
845    async fn count_events_through(
846        &self,
847        process_id: &str,
848        event_type: &str,
849        up_to_sequence: u64,
850    ) -> Result<u64, lash_core::PluginError> {
851        let process_id = process_id.to_string();
852        let event_type = event_type.to_string();
853        self.conn
854            .call(move |conn| {
855                Ok((|| {
856                    if Self::load_process_conn(conn, &process_id)?.is_none() {
857                        return Err(lash_core::PluginError::Session(format!(
858                            "unknown process `{process_id}`"
859                        )));
860                    }
861                    conn.query_row(
862                        "SELECT COUNT(*) FROM process_events
863                         WHERE process_id = ?1 AND event_type = ?2 AND sequence <= ?3",
864                        params![process_id, event_type, up_to_sequence as i64],
865                        |row| row.get::<_, i64>(0),
866                    )
867                    .map(|count| count as u64)
868                    .map_err(process_sqlite_error)
869                })())
870            })
871            .await
872            .map_err(process_sqlite_error)?
873    }
874
875    async fn recent_events(
876        &self,
877        process_id: &str,
878        limit: usize,
879    ) -> Result<Vec<ProcessEvent>, lash_core::PluginError> {
880        let process_id = process_id.to_string();
881        self.conn
882            .call(move |conn| {
883                Ok((|| {
884                    if Self::load_process_conn(conn, &process_id)?.is_none() {
885                        return Err(lash_core::PluginError::Session(format!(
886                            "unknown process `{process_id}`"
887                        )));
888                    }
889                    let mut stmt = conn
890                        .prepare(
891                            "SELECT event_json FROM process_events
892                             WHERE process_id = ?1
893                             ORDER BY sequence DESC
894                             LIMIT ?2",
895                        )
896                        .map_err(process_sqlite_error)?;
897                    let rows = stmt
898                        .query_map(params![process_id, limit as i64], |row| {
899                            row.get::<_, String>(0)
900                        })
901                        .map_err(process_sqlite_error)?;
902                    let mut events: Vec<ProcessEvent> = Vec::new();
903                    for row in rows {
904                        events.push(
905                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
906                                .map_err(process_decode_error)?,
907                        );
908                    }
909                    events.reverse();
910                    Ok(events)
911                })())
912            })
913            .await
914            .map_err(process_sqlite_error)?
915    }
916
917    async fn wake_events_after(
918        &self,
919        process_id: &str,
920        after_sequence: u64,
921    ) -> Result<Vec<ProcessEvent>, lash_core::PluginError> {
922        let acked: std::collections::HashSet<u64> = {
923            let process_id = process_id.to_string();
924            self.conn
925                .call(move |conn| {
926                    Ok(
927                        (|| -> Result<std::collections::HashSet<u64>, lash_core::PluginError> {
928                            let mut stmt = conn
929                                .prepare(
930                                    "SELECT sequence FROM process_wake_acks WHERE process_id = ?1",
931                                )
932                                .map_err(process_sqlite_error)?;
933                            let rows = stmt
934                                .query_map(params![process_id], |row| row.get::<_, i64>(0))
935                                .map_err(process_sqlite_error)?;
936                            let mut set = std::collections::HashSet::new();
937                            for row in rows {
938                                set.insert(row.map_err(process_sqlite_error)? as u64);
939                            }
940                            Ok(set)
941                        })(),
942                    )
943                })
944                .await
945                .map_err(process_sqlite_error)??
946        };
947        Ok(self
948            .events_after(process_id, after_sequence)
949            .await?
950            .into_iter()
951            .filter(|event| event.semantics.wake.is_some() && !acked.contains(&event.sequence))
952            .collect())
953    }
954
955    async fn complete_process(
956        &self,
957        process_id: &str,
958        await_output: ProcessAwaitOutput,
959    ) -> Result<ProcessRecord, lash_core::PluginError> {
960        let event_type = match await_output.terminal_state() {
961            lash_core::ProcessTerminalState::Completed => "process.completed",
962            lash_core::ProcessTerminalState::Failed => "process.failed",
963            lash_core::ProcessTerminalState::Cancelled => "process.cancelled",
964        };
965        self.append_event(
966            process_id,
967            ProcessEventAppendRequest::new(
968                event_type,
969                serde_json::json!({ "await_output": await_output }),
970            )
971            .with_replay_key(format!("process:{process_id}:terminal:{event_type}")),
972        )
973        .await?;
974        self.get_process(process_id).await.ok_or_else(|| {
975            lash_core::PluginError::Session(format!(
976                "unknown process `{process_id}` after terminal event"
977            ))
978        })
979    }
980
981    async fn set_process_wait(
982        &self,
983        process_id: &str,
984        wait: lash_core::WaitState,
985    ) -> Result<ProcessRecord, lash_core::PluginError> {
986        let process_id = process_id.to_string();
987        self.conn
988            .write_flow(move |tx| {
989                Ok(tx_outcome((|| {
990                    let mut record =
991                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
992                            lash_core::PluginError::Session(format!(
993                                "unknown process `{process_id}`"
994                            ))
995                        })?;
996                    if record.is_terminal() {
997                        return Err(lash_core::PluginError::Session(format!(
998                            "terminal process `{process_id}` cannot enter a wait state"
999                        )));
1000                    }
1001                    record.wait = Some(wait);
1002                    record.updated_at_ms = current_epoch_ms();
1003                    Self::save_process_conn(tx, &record)?;
1004                    Ok(record)
1005                })()))
1006            })
1007            .await
1008            .map_err(process_sqlite_error)?
1009    }
1010
1011    async fn clear_process_wait(
1012        &self,
1013        process_id: &str,
1014    ) -> Result<ProcessRecord, lash_core::PluginError> {
1015        let process_id = process_id.to_string();
1016        self.conn
1017            .write_flow(move |tx| {
1018                Ok(tx_outcome((|| {
1019                    let mut record =
1020                        Self::load_process_conn(tx, &process_id)?.ok_or_else(|| {
1021                            lash_core::PluginError::Session(format!(
1022                                "unknown process `{process_id}`"
1023                            ))
1024                        })?;
1025                    record.wait = None;
1026                    record.updated_at_ms = current_epoch_ms();
1027                    Self::save_process_conn(tx, &record)?;
1028                    Ok(record)
1029                })()))
1030            })
1031            .await
1032            .map_err(process_sqlite_error)?
1033    }
1034
1035    async fn get_process(&self, process_id: &str) -> Option<ProcessRecord> {
1036        let process_id = process_id.to_string();
1037        self.conn
1038            .call(move |conn| Ok(Self::load_process_conn(conn, &process_id).ok().flatten()))
1039            .await
1040            .ok()
1041            .flatten()
1042    }
1043
1044    async fn list_processes(
1045        &self,
1046        filter: &lash_core::ProcessListFilter,
1047    ) -> Result<Vec<ProcessRecord>, lash_core::PluginError> {
1048        let filter = filter.clone();
1049        self.conn
1050            .call(move |conn| {
1051                Ok((|| {
1052                    let mut stmt = conn
1053                        .prepare(
1054                            "SELECT record_json FROM processes
1055                             ORDER BY process_id ASC",
1056                        )
1057                        .map_err(process_sqlite_error)?;
1058                    let rows = stmt
1059                        .query_map([], |row| row.get::<_, String>(0))
1060                        .map_err(process_sqlite_error)?;
1061                    let mut records = Vec::new();
1062                    for row in rows {
1063                        let record: ProcessRecord =
1064                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
1065                                .map_err(process_decode_error)?;
1066                        if filter.matches_record(&record) {
1067                            records.push(record);
1068                        }
1069                    }
1070                    Ok(records)
1071                })())
1072            })
1073            .await
1074            .map_err(process_sqlite_error)?
1075    }
1076
1077    async fn ack_wake(
1078        &self,
1079        process_id: &str,
1080        sequence: u64,
1081    ) -> Result<(), lash_core::PluginError> {
1082        let process_id = process_id.to_string();
1083        self.conn
1084            .call(move |conn| {
1085                Ok((|| {
1086                    if Self::load_process_conn(conn, &process_id)?.is_none() {
1087                        return Err(lash_core::PluginError::Session(format!(
1088                            "unknown process `{process_id}`"
1089                        )));
1090                    }
1091                    conn.execute(
1092                        "INSERT OR IGNORE INTO process_wake_acks (process_id, sequence) VALUES (?1, ?2)",
1093                        params![process_id, sequence as i64],
1094                    )
1095                    .map_err(process_sqlite_error)?;
1096                    Ok(())
1097                })())
1098            })
1099            .await
1100            .map_err(process_sqlite_error)?
1101    }
1102
1103    async fn list_non_terminal(&self) -> Result<Vec<ProcessRecord>, lash_core::PluginError> {
1104        self.conn
1105            .call(move |conn| {
1106                Ok((|| {
1107                    let mut stmt = conn
1108                        .prepare(
1109                            "SELECT record_json FROM processes
1110                             WHERE status = 'running'
1111                             ORDER BY process_id ASC",
1112                        )
1113                        .map_err(process_sqlite_error)?;
1114                    let rows = stmt
1115                        .query_map([], |row| row.get::<_, String>(0))
1116                        .map_err(process_sqlite_error)?;
1117                    let mut records = Vec::new();
1118                    for row in rows {
1119                        let record: ProcessRecord =
1120                            serde_json::from_str(&row.map_err(process_sqlite_error)?)
1121                                .map_err(process_decode_error)?;
1122                        records.push(record);
1123                    }
1124                    Ok(records)
1125                })())
1126            })
1127            .await
1128            .map_err(process_sqlite_error)?
1129    }
1130
1131    async fn claim_process_lease(
1132        &self,
1133        process_id: &str,
1134        owner: &LeaseOwnerIdentity,
1135        lease_ttl_ms: u64,
1136    ) -> Result<ProcessLeaseClaimOutcome, lash_core::PluginError> {
1137        let process_id = process_id.to_string();
1138        let owner = owner.clone();
1139        self.conn
1140            .write_flow(move |tx| {
1141                Ok(tx_outcome((|| {
1142                    if Self::load_process_conn(tx, &process_id)?.is_none() {
1143                        return Err(lash_core::PluginError::Session(format!(
1144                            "unknown process `{process_id}`"
1145                        )));
1146                    }
1147                    let now = current_epoch_ms();
1148                    let current = Self::load_process_lease_conn(tx, &process_id)?;
1149                    if let Some(current) = current.as_ref()
1150                        && current.expires_at_epoch_ms > now
1151                    {
1152                        if current.owner.same_incarnation(&owner) {
1153                            // Same incarnation re-enters its own live lease:
1154                            // extend the expiry, keep token and fencing token.
1155                            let expires_at = now.saturating_add(lease_ttl_ms);
1156                            tx.execute(
1157                                "UPDATE process_leases
1158                                 SET lease_expires_at_ms = ?2
1159                                 WHERE process_id = ?1",
1160                                params![process_id, expires_at as i64],
1161                            )
1162                            .map_err(process_sqlite_error)?;
1163                            return Ok(ProcessLeaseClaimOutcome::Acquired(ProcessLease {
1164                                expires_at_epoch_ms: expires_at,
1165                                ..current.clone()
1166                            }));
1167                        }
1168                        return Ok(ProcessLeaseClaimOutcome::Busy {
1169                            holder: current.clone(),
1170                        });
1171                    }
1172                    // Read the raw fencing token directly: a completed/abandoned
1173                    // lease nulls the owner/token columns but retains the
1174                    // monotonically-increasing `lease_fencing_token`, so a
1175                    // re-claim never reuses a stale writer's token.
1176                    let fencing_token: u64 = tx
1177                        .query_row(
1178                            "SELECT lease_fencing_token FROM process_leases WHERE process_id = ?1",
1179                            params![process_id],
1180                            |row| row.get::<_, i64>(0),
1181                        )
1182                        .optional()
1183                        .map_err(process_sqlite_error)?
1184                        .unwrap_or(0) as u64
1185                        + 1;
1186                    Ok(ProcessLeaseClaimOutcome::Acquired(
1187                        Self::acquire_process_lease_conn(
1188                            tx,
1189                            &process_id,
1190                            &owner,
1191                            fencing_token,
1192                            now,
1193                            lease_ttl_ms,
1194                        )?,
1195                    ))
1196                })()))
1197            })
1198            .await
1199            .map_err(process_sqlite_error)?
1200    }
1201
1202    async fn reclaim_process_lease(
1203        &self,
1204        process_id: &str,
1205        owner: &LeaseOwnerIdentity,
1206        observed_holder: &ProcessLease,
1207        lease_ttl_ms: u64,
1208    ) -> Result<ProcessLeaseClaimOutcome, lash_core::PluginError> {
1209        let process_id = process_id.to_string();
1210        let owner = owner.clone();
1211        let observed_holder = observed_holder.clone();
1212        self.conn
1213            .write_flow(move |tx| {
1214                Ok(tx_outcome((|| {
1215                    if Self::load_process_conn(tx, &process_id)?.is_none() {
1216                        return Err(lash_core::PluginError::Session(format!(
1217                            "unknown process `{process_id}`"
1218                        )));
1219                    }
1220                    let now = current_epoch_ms();
1221                    let current = Self::load_process_lease_conn(tx, &process_id)?;
1222                    let Some(current) = current else {
1223                        // Free (or released) lease: acquire on the retained
1224                        // fencing token like a plain claim would.
1225                        let fencing_token: u64 = tx
1226                            .query_row(
1227                                "SELECT lease_fencing_token FROM process_leases WHERE process_id = ?1",
1228                                params![process_id],
1229                                |row| row.get::<_, i64>(0),
1230                            )
1231                            .optional()
1232                            .map_err(process_sqlite_error)?
1233                            .unwrap_or(0) as u64
1234                            + 1;
1235                        return Ok(ProcessLeaseClaimOutcome::Acquired(
1236                            Self::acquire_process_lease_conn(
1237                                tx,
1238                                &process_id,
1239                                &owner,
1240                                fencing_token,
1241                                now,
1242                                lease_ttl_ms,
1243                            )?,
1244                        ));
1245                    };
1246                    if current.expires_at_epoch_ms <= now {
1247                        return Ok(ProcessLeaseClaimOutcome::Acquired(
1248                            Self::acquire_process_lease_conn(
1249                                tx,
1250                                &process_id,
1251                                &owner,
1252                                current.fencing_token.saturating_add(1),
1253                                now,
1254                                lease_ttl_ms,
1255                            )?,
1256                        ));
1257                    }
1258                    // Fenced CAS on the observed holder: identity, token, and
1259                    // fencing token must all still match, and the holder must
1260                    // be definitely dead for this claimant.
1261                    if observed_holder.process_id == process_id
1262                        && current.owner.same_incarnation(&observed_holder.owner)
1263                        && current.lease_token == observed_holder.lease_token
1264                        && current.fencing_token == observed_holder.fencing_token
1265                        && current.owner.is_definitely_dead_for_claimant(&owner)
1266                    {
1267                        let fencing_token = current.fencing_token.saturating_add(1);
1268                        let lease = ProcessLease {
1269                            schema_version: PROCESS_LEASE_SCHEMA_VERSION,
1270                            process_id: process_id.clone(),
1271                            owner: owner.clone(),
1272                            lease_token: format!(
1273                                "{:x}",
1274                                Sha256::digest(
1275                                    format!(
1276                                        "{process_id}:{}:{}:{now}:{fencing_token}",
1277                                        owner.owner_id, owner.incarnation_id
1278                                    )
1279                                    .as_bytes()
1280                                )
1281                            ),
1282                            fencing_token,
1283                            claimed_at_epoch_ms: now,
1284                            expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
1285                        };
1286                        let changed = tx
1287                            .execute(
1288                                "UPDATE process_leases
1289                                 SET lease_owner_id = ?1,
1290                                     lease_owner_incarnation_id = ?2,
1291                                     lease_owner_liveness_json = ?3,
1292                                     lease_token = ?4,
1293                                     lease_fencing_token = ?5,
1294                                     lease_claimed_at_ms = ?6,
1295                                     lease_expires_at_ms = ?7
1296                                 WHERE process_id = ?8
1297                                   AND lease_owner_id = ?9
1298                                   AND lease_owner_incarnation_id = ?10
1299                                   AND lease_token = ?11
1300                                   AND lease_fencing_token = ?12",
1301                                params![
1302                                    lease.owner.owner_id,
1303                                    lease.owner.incarnation_id,
1304                                    encode_process_lease_liveness(&lease.owner.liveness)?,
1305                                    lease.lease_token,
1306                                    lease.fencing_token as i64,
1307                                    lease.claimed_at_epoch_ms as i64,
1308                                    lease.expires_at_epoch_ms as i64,
1309                                    process_id,
1310                                    observed_holder.owner.owner_id,
1311                                    observed_holder.owner.incarnation_id,
1312                                    observed_holder.lease_token,
1313                                    observed_holder.fencing_token as i64,
1314                                ],
1315                            )
1316                            .map_err(process_sqlite_error)?;
1317                        if changed == 1 {
1318                            return Ok(ProcessLeaseClaimOutcome::Acquired(lease));
1319                        }
1320                        // Lost the CAS race: re-read and report the winner.
1321                        if let Some(current) = Self::load_process_lease_conn(tx, &process_id)?
1322                            && current.expires_at_epoch_ms > now
1323                        {
1324                            return Ok(ProcessLeaseClaimOutcome::Busy { holder: current });
1325                        }
1326                        return Err(process_lease_expired(&process_id));
1327                    }
1328                    Ok(ProcessLeaseClaimOutcome::Busy { holder: current })
1329                })()))
1330            })
1331            .await
1332            .map_err(process_sqlite_error)?
1333    }
1334
1335    async fn renew_process_lease(
1336        &self,
1337        lease: &ProcessLease,
1338        lease_ttl_ms: u64,
1339    ) -> Result<ProcessLease, lash_core::PluginError> {
1340        let lease = lease.clone();
1341        self.conn
1342            .write_flow(move |tx| {
1343                Ok(tx_outcome((|| {
1344                    let now = current_epoch_ms();
1345                    let current = Self::load_process_lease_conn(tx, &lease.process_id)?;
1346                    if !guard_lease(current.as_ref(), &lease.lease_token, now)
1347                        || !current.as_ref().is_some_and(|current| {
1348                            current.owner.same_incarnation(&lease.owner)
1349                                && current.fencing_token == lease.fencing_token
1350                        })
1351                    {
1352                        return Err(process_lease_expired(&lease.process_id));
1353                    }
1354                    let renewed = ProcessLease {
1355                        expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
1356                        ..lease.clone()
1357                    };
1358                    tx.execute(
1359                        "UPDATE process_leases
1360                         SET lease_expires_at_ms = ?2
1361                         WHERE process_id = ?1 AND lease_token = ?3",
1362                        params![
1363                            renewed.process_id.as_str(),
1364                            renewed.expires_at_epoch_ms as i64,
1365                            renewed.lease_token.as_str(),
1366                        ],
1367                    )
1368                    .map_err(process_sqlite_error)?;
1369                    Ok(renewed)
1370                })()))
1371            })
1372            .await
1373            .map_err(process_sqlite_error)?
1374    }
1375
1376    async fn complete_process_lease(
1377        &self,
1378        completion: &ProcessLeaseCompletion,
1379    ) -> Result<(), lash_core::PluginError> {
1380        let process_id = completion.process_id.clone();
1381        let lease_token = completion.lease_token.clone();
1382        self.conn
1383            .call(move |conn| {
1384                conn.execute(
1385                    "UPDATE process_leases
1386                     SET lease_owner_id = NULL,
1387                         lease_token = NULL,
1388                         lease_claimed_at_ms = 0,
1389                         lease_expires_at_ms = 0
1390                     WHERE process_id = ?1 AND lease_token = ?2",
1391                    params![process_id, lease_token],
1392                )
1393            })
1394            .await
1395            .map_err(process_sqlite_error)?;
1396        Ok(())
1397    }
1398
1399    async fn prune_terminal_processes(
1400        &self,
1401        cutoff_epoch_ms: u64,
1402    ) -> Result<ProcessPruneReport, lash_core::PluginError> {
1403        let cutoff = cutoff_epoch_ms as i64;
1404        self.conn
1405            .write_flow(move |tx| {
1406                Ok(tx_outcome((|| {
1407                    // The child deletes select from `processes` (still present
1408                    // until the final delete) so they resolve the same terminal,
1409                    // older-than-cutoff set. Delete children first, then the
1410                    // parent rows.
1411                    const SELECTOR: &str = "process_id IN (
1412                        SELECT process_id FROM processes
1413                        WHERE status != 'running' AND updated_at_ms < ?1
1414                    )";
1415                    let pruned_events = tx
1416                        .execute(
1417                            &format!("DELETE FROM process_events WHERE {SELECTOR}"),
1418                            params![cutoff],
1419                        )
1420                        .map_err(process_sqlite_error)?;
1421                    tx.execute(
1422                        &format!("DELETE FROM process_wake_acks WHERE {SELECTOR}"),
1423                        params![cutoff],
1424                    )
1425                    .map_err(process_sqlite_error)?;
1426                    tx.execute(
1427                        &format!("DELETE FROM process_handle_grants WHERE {SELECTOR}"),
1428                        params![cutoff],
1429                    )
1430                    .map_err(process_sqlite_error)?;
1431                    tx.execute(
1432                        &format!("DELETE FROM process_leases WHERE {SELECTOR}"),
1433                        params![cutoff],
1434                    )
1435                    .map_err(process_sqlite_error)?;
1436                    let pruned_processes = tx
1437                        .execute(
1438                            "DELETE FROM processes
1439                             WHERE status != 'running' AND updated_at_ms < ?1",
1440                            params![cutoff],
1441                        )
1442                        .map_err(process_sqlite_error)?;
1443                    Ok(ProcessPruneReport {
1444                        pruned_processes,
1445                        pruned_events,
1446                    })
1447                })()))
1448            })
1449            .await
1450            .map_err(process_sqlite_error)?
1451    }
1452}
1453
1454/// Loud, stable error for a superseded or expired process lease.
1455fn process_lease_expired(process_id: &str) -> lash_core::PluginError {
1456    lash_core::PluginError::Session(format!(
1457        "process lease for `{process_id}` is missing or expired"
1458    ))
1459}
1460
1461fn process_lease_owner_from_columns(
1462    owner_id: String,
1463    incarnation_id: Option<String>,
1464    liveness_json: Option<String>,
1465) -> LeaseOwnerIdentity {
1466    LeaseOwnerIdentity {
1467        incarnation_id: incarnation_id.unwrap_or_else(|| owner_id.clone()),
1468        owner_id,
1469        liveness: liveness_json
1470            .as_deref()
1471            .and_then(|json| serde_json::from_str(json).ok())
1472            .unwrap_or(LeaseOwnerLiveness::Opaque),
1473    }
1474}
1475
1476fn encode_process_lease_liveness(
1477    liveness: &LeaseOwnerLiveness,
1478) -> Result<String, lash_core::PluginError> {
1479    serde_json::to_string(liveness).map_err(|err| {
1480        lash_core::PluginError::Session(format!("failed to encode process lease liveness: {err}"))
1481    })
1482}
1483
1484fn process_external_ref_conflict(
1485    process_id: &str,
1486    existing: &ProcessExternalRef,
1487    new: &ProcessExternalRef,
1488) -> lash_core::PluginError {
1489    lash_core::PluginError::Session(format!(
1490        "process `{process_id}` external ref conflict: existing {existing:?}, new {new:?}"
1491    ))
1492}