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