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