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 live_reference_summary(
1023        &self,
1024    ) -> Result<Vec<ProcessLiveReferenceSummary>, lash_core::PluginError> {
1025        let records = self.list_non_terminal().await?;
1026        Ok(ProcessLiveReferenceSummary::from_records(records.iter()))
1027    }
1028
1029    async fn claim_process_lease(
1030        &self,
1031        process_id: &str,
1032        owner: &LeaseOwnerIdentity,
1033        lease_ttl_ms: u64,
1034    ) -> Result<ProcessLeaseClaimOutcome, lash_core::PluginError> {
1035        let process_id = process_id.to_string();
1036        let owner = owner.clone();
1037        let now = self.clock.timestamp_ms();
1038        self.conn
1039            .write_flow(move |tx| {
1040                Ok(tx_outcome((|| {
1041                    if Self::load_process_conn(tx, &process_id)?.is_none() {
1042                        return Err(lash_core::PluginError::Session(format!(
1043                            "unknown process `{process_id}`"
1044                        )));
1045                    }
1046                    let current = Self::load_process_lease_conn(tx, &process_id)?;
1047                    if let Some(current) = current.as_ref()
1048                        && current.expires_at_epoch_ms > now
1049                    {
1050                        if current.owner.same_incarnation(&owner) {
1051                            // Same incarnation re-enters its own live lease:
1052                            // extend the expiry, keep token and fencing token.
1053                            let expires_at = now.saturating_add(lease_ttl_ms);
1054                            tx.execute(
1055                                "UPDATE process_leases
1056                                 SET lease_expires_at_ms = ?2
1057                                 WHERE process_id = ?1",
1058                                params![process_id, expires_at as i64],
1059                            )
1060                            .map_err(process_sqlite_error)?;
1061                            return Ok(ProcessLeaseClaimOutcome::Acquired(ProcessLease {
1062                                expires_at_epoch_ms: expires_at,
1063                                ..current.clone()
1064                            }));
1065                        }
1066                        return Ok(ProcessLeaseClaimOutcome::Busy {
1067                            holder: current.clone(),
1068                        });
1069                    }
1070                    // Read the raw fencing token directly: a completed/abandoned
1071                    // lease nulls the owner/token columns but retains the
1072                    // monotonically-increasing `lease_fencing_token`, so a
1073                    // re-claim never reuses a stale writer's token.
1074                    let fencing_token: u64 = tx
1075                        .query_row(
1076                            "SELECT lease_fencing_token FROM process_leases WHERE process_id = ?1",
1077                            params![process_id],
1078                            |row| row.get::<_, i64>(0),
1079                        )
1080                        .optional()
1081                        .map_err(process_sqlite_error)?
1082                        .unwrap_or(0) as u64
1083                        + 1;
1084                    Ok(ProcessLeaseClaimOutcome::Acquired(
1085                        Self::acquire_process_lease_conn(
1086                            tx,
1087                            &process_id,
1088                            &owner,
1089                            fencing_token,
1090                            now,
1091                            lease_ttl_ms,
1092                        )?,
1093                    ))
1094                })()))
1095            })
1096            .await
1097            .map_err(process_sqlite_error)?
1098    }
1099
1100    async fn reclaim_process_lease(
1101        &self,
1102        process_id: &str,
1103        owner: &LeaseOwnerIdentity,
1104        observed_holder: &ProcessLease,
1105        lease_ttl_ms: u64,
1106    ) -> Result<ProcessLeaseClaimOutcome, lash_core::PluginError> {
1107        let process_id = process_id.to_string();
1108        let owner = owner.clone();
1109        let observed_holder = observed_holder.clone();
1110        let now = self.clock.timestamp_ms();
1111        self.conn
1112            .write_flow(move |tx| {
1113                Ok(tx_outcome((|| {
1114                    if Self::load_process_conn(tx, &process_id)?.is_none() {
1115                        return Err(lash_core::PluginError::Session(format!(
1116                            "unknown process `{process_id}`"
1117                        )));
1118                    }
1119                    let current = Self::load_process_lease_conn(tx, &process_id)?;
1120                    let Some(current) = current else {
1121                        // Free (or released) lease: acquire on the retained
1122                        // fencing token like a plain claim would.
1123                        let fencing_token: u64 = tx
1124                            .query_row(
1125                                "SELECT lease_fencing_token FROM process_leases WHERE process_id = ?1",
1126                                params![process_id],
1127                                |row| row.get::<_, i64>(0),
1128                            )
1129                            .optional()
1130                            .map_err(process_sqlite_error)?
1131                            .unwrap_or(0) as u64
1132                            + 1;
1133                        return Ok(ProcessLeaseClaimOutcome::Acquired(
1134                            Self::acquire_process_lease_conn(
1135                                tx,
1136                                &process_id,
1137                                &owner,
1138                                fencing_token,
1139                                now,
1140                                lease_ttl_ms,
1141                            )?,
1142                        ));
1143                    };
1144                    if current.expires_at_epoch_ms <= now {
1145                        return Ok(ProcessLeaseClaimOutcome::Acquired(
1146                            Self::acquire_process_lease_conn(
1147                                tx,
1148                                &process_id,
1149                                &owner,
1150                                current.fencing_token.saturating_add(1),
1151                                now,
1152                                lease_ttl_ms,
1153                            )?,
1154                        ));
1155                    }
1156                    // Fenced CAS on the observed holder: identity, token, and
1157                    // fencing token must all still match, and the holder must
1158                    // be definitely dead for this claimant.
1159                    if observed_holder.process_id == process_id
1160                        && current.owner.same_incarnation(&observed_holder.owner)
1161                        && current.lease_token == observed_holder.lease_token
1162                        && current.fencing_token == observed_holder.fencing_token
1163                        && current.owner.is_definitely_dead_for_claimant(&owner)
1164                    {
1165                        let fencing_token = current.fencing_token.saturating_add(1);
1166                        let lease = ProcessLease {
1167                            schema_version: PROCESS_LEASE_SCHEMA_VERSION,
1168                            process_id: process_id.clone(),
1169                            owner: owner.clone(),
1170                            lease_token: format!(
1171                                "{:x}",
1172                                Sha256::digest(
1173                                    format!(
1174                                        "{process_id}:{}:{}:{now}:{fencing_token}",
1175                                        owner.owner_id, owner.incarnation_id
1176                                    )
1177                                    .as_bytes()
1178                                )
1179                            ),
1180                            fencing_token,
1181                            claimed_at_epoch_ms: now,
1182                            expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
1183                        };
1184                        let changed = tx
1185                            .execute(
1186                                "UPDATE process_leases
1187                                 SET lease_owner_id = ?1,
1188                                     lease_owner_incarnation_id = ?2,
1189                                     lease_owner_liveness_json = ?3,
1190                                     lease_token = ?4,
1191                                     lease_fencing_token = ?5,
1192                                     lease_claimed_at_ms = ?6,
1193                                     lease_expires_at_ms = ?7
1194                                 WHERE process_id = ?8
1195                                   AND lease_owner_id = ?9
1196                                   AND lease_owner_incarnation_id = ?10
1197                                   AND lease_token = ?11
1198                                   AND lease_fencing_token = ?12",
1199                                params![
1200                                    lease.owner.owner_id,
1201                                    lease.owner.incarnation_id,
1202                                    encode_process_lease_liveness(&lease.owner.liveness)?,
1203                                    lease.lease_token,
1204                                    lease.fencing_token as i64,
1205                                    lease.claimed_at_epoch_ms as i64,
1206                                    lease.expires_at_epoch_ms as i64,
1207                                    process_id,
1208                                    observed_holder.owner.owner_id,
1209                                    observed_holder.owner.incarnation_id,
1210                                    observed_holder.lease_token,
1211                                    observed_holder.fencing_token as i64,
1212                                ],
1213                            )
1214                            .map_err(process_sqlite_error)?;
1215                        if changed == 1 {
1216                            return Ok(ProcessLeaseClaimOutcome::Acquired(lease));
1217                        }
1218                        // Lost the CAS race: re-read and report the winner.
1219                        if let Some(current) = Self::load_process_lease_conn(tx, &process_id)?
1220                            && current.expires_at_epoch_ms > now
1221                        {
1222                            return Ok(ProcessLeaseClaimOutcome::Busy { holder: current });
1223                        }
1224                        return Err(process_lease_expired(&process_id));
1225                    }
1226                    Ok(ProcessLeaseClaimOutcome::Busy { holder: current })
1227                })()))
1228            })
1229            .await
1230            .map_err(process_sqlite_error)?
1231    }
1232
1233    async fn renew_process_lease(
1234        &self,
1235        lease: &ProcessLease,
1236        lease_ttl_ms: u64,
1237    ) -> Result<ProcessLease, lash_core::PluginError> {
1238        let lease = lease.clone();
1239        let now = self.clock.timestamp_ms();
1240        self.conn
1241            .write_flow(move |tx| {
1242                Ok(tx_outcome((|| {
1243                    let current = Self::load_process_lease_conn(tx, &lease.process_id)?;
1244                    if !guard_lease(current.as_ref(), &lease.lease_token, now)
1245                        || !current.as_ref().is_some_and(|current| {
1246                            current.owner.same_incarnation(&lease.owner)
1247                                && current.fencing_token == lease.fencing_token
1248                        })
1249                    {
1250                        return Err(process_lease_expired(&lease.process_id));
1251                    }
1252                    let renewed = ProcessLease {
1253                        expires_at_epoch_ms: now.saturating_add(lease_ttl_ms),
1254                        ..lease.clone()
1255                    };
1256                    tx.execute(
1257                        "UPDATE process_leases
1258                         SET lease_expires_at_ms = ?2
1259                         WHERE process_id = ?1 AND lease_token = ?3",
1260                        params![
1261                            renewed.process_id.as_str(),
1262                            renewed.expires_at_epoch_ms as i64,
1263                            renewed.lease_token.as_str(),
1264                        ],
1265                    )
1266                    .map_err(process_sqlite_error)?;
1267                    Ok(renewed)
1268                })()))
1269            })
1270            .await
1271            .map_err(process_sqlite_error)?
1272    }
1273
1274    async fn get_process_lease(
1275        &self,
1276        process_id: &str,
1277    ) -> Result<Option<ProcessLease>, lash_core::PluginError> {
1278        let process_id = process_id.to_string();
1279        self.conn
1280            .call(move |conn| Ok(Self::load_process_lease_conn(conn, &process_id)))
1281            .await
1282            .map_err(process_sqlite_error)?
1283    }
1284
1285    async fn complete_process_lease(
1286        &self,
1287        completion: &ProcessLeaseCompletion,
1288    ) -> Result<(), lash_core::PluginError> {
1289        let process_id = completion.process_id.clone();
1290        let lease_token = completion.lease_token.clone();
1291        self.conn
1292            .call(move |conn| {
1293                conn.execute(
1294                    "UPDATE process_leases
1295                     SET lease_owner_id = NULL,
1296                         lease_token = NULL,
1297                         lease_claimed_at_ms = 0,
1298                         lease_expires_at_ms = 0
1299                     WHERE process_id = ?1 AND lease_token = ?2",
1300                    params![process_id, lease_token],
1301                )
1302            })
1303            .await
1304            .map_err(process_sqlite_error)?;
1305        Ok(())
1306    }
1307
1308    async fn prune_terminal_processes(
1309        &self,
1310        cutoff_epoch_ms: u64,
1311        filter: Option<ProcessListFilter>,
1312        up_to_change_seq: Option<ProcessChangeCursor>,
1313    ) -> Result<ProcessPruneReport, lash_core::PluginError> {
1314        let cutoff = cutoff_epoch_ms as i64;
1315        let max_change_seq = up_to_change_seq.map(ProcessChangeCursor::store_sequence);
1316        self.conn
1317            .write_flow(move |tx| {
1318                Ok(tx_outcome(
1319                    crate::process_registry_change::prune_terminal_processes_conn(
1320                        tx,
1321                        cutoff,
1322                        filter,
1323                        max_change_seq,
1324                    ),
1325                ))
1326            })
1327            .await
1328            .map_err(process_sqlite_error)?
1329    }
1330}
1331
1332/// Loud, stable error for a superseded or expired process lease.
1333pub(super) fn process_lease_expired(process_id: &str) -> lash_core::PluginError {
1334    lash_core::PluginError::Session(format!(
1335        "process lease for `{process_id}` is missing or expired"
1336    ))
1337}
1338
1339fn process_lease_owner_from_columns(
1340    owner_id: String,
1341    incarnation_id: Option<String>,
1342    liveness_json: Option<String>,
1343) -> LeaseOwnerIdentity {
1344    LeaseOwnerIdentity {
1345        incarnation_id: incarnation_id.unwrap_or_else(|| owner_id.clone()),
1346        owner_id,
1347        liveness: liveness_json
1348            .as_deref()
1349            .and_then(|json| serde_json::from_str(json).ok())
1350            .unwrap_or(LeaseOwnerLiveness::Opaque),
1351    }
1352}
1353
1354fn encode_process_lease_liveness(
1355    liveness: &LeaseOwnerLiveness,
1356) -> Result<String, lash_core::PluginError> {
1357    serde_json::to_string(liveness).map_err(|err| {
1358        lash_core::PluginError::Session(format!("failed to encode process lease liveness: {err}"))
1359    })
1360}
1361
1362fn process_external_ref_conflict(
1363    process_id: &str,
1364    existing: &ProcessExternalRef,
1365    new: &ProcessExternalRef,
1366) -> lash_core::PluginError {
1367    lash_core::PluginError::Session(format!(
1368        "process `{process_id}` external ref conflict: existing {existing:?}, new {new:?}"
1369    ))
1370}