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