Skip to main content

lash_core/runtime/process/
registry.rs

1use crate::plugin::PluginError;
2
3use super::engine::PersistedSegmentHandover;
4use super::events::{
5    ProcessAwaitOutput, ProcessCompletionAuthority, ProcessEvent, ProcessEventAppendRequest,
6    ProcessEventAppendResult,
7};
8use super::model::{
9    AbandonRequest, ProcessChangeCursor, ProcessExternalRef, ProcessHandleDescriptor,
10    ProcessHandleGrant, ProcessHandleGrantEntry, ProcessLease, ProcessLeaseClaimOutcome,
11    ProcessLeaseCompletion, ProcessListFilter, ProcessRecord, ProcessRegistration,
12    ProcessSessionDeleteReport, ProcessStarted, SessionScope, WaitState,
13};
14use super::references::ProcessLiveReferenceSummary;
15
16/// Outcome of [`ProcessRegistry::prune_terminal_processes`]: how many terminal
17/// process rows and event rows were physically deleted.
18#[derive(Clone, Debug, Default, PartialEq, Eq)]
19pub struct ProcessPruneReport {
20    /// Terminal process rows deleted.
21    pub pruned_processes: usize,
22    /// Event rows deleted across those processes.
23    pub pruned_events: usize,
24}
25
26/// Durability-neutral process registry.
27///
28/// Process waits are coordination behavior and live on
29/// [`ProcessWorkDriver`](crate::ProcessWorkDriver) /
30/// [`ProcessAwaiter`](crate::ProcessAwaiter), not on persistence
31/// implementations. Registry methods are point reads and writes only. See
32/// `docs/adr/0016-process-waits-live-on-the-work-driver-seam.md`.
33#[async_trait::async_trait]
34pub trait ProcessRegistry: Send + Sync {
35    /// Durability tier this process registry provides; defaults to
36    /// [`DurabilityTier`](crate::DurabilityTier)`::Inline`.
37    fn durability_tier(&self) -> crate::DurabilityTier {
38        crate::DurabilityTier::Inline
39    }
40
41    async fn register_process(
42        &self,
43        registration: ProcessRegistration,
44    ) -> Result<ProcessRecord, PluginError>;
45
46    /// Persist the bounded engine continuation for exactly one segment.
47    /// Repeating an identical write is an idempotent no-op; conflicting data
48    /// for the same `(process_id, segment_ordinal)` is rejected.
49    async fn put_segment_handover(
50        &self,
51        process_id: &str,
52        handover: PersistedSegmentHandover,
53    ) -> Result<(), PluginError>;
54
55    /// Load the continuation for an exact process segment ordinal.
56    async fn get_segment_handover(
57        &self,
58        process_id: &str,
59        segment_ordinal: u64,
60    ) -> Result<Option<PersistedSegmentHandover>, PluginError>;
61
62    /// Load the highest persisted segment ordinal for recovery.
63    async fn latest_segment_handover(
64        &self,
65        process_id: &str,
66    ) -> Result<Option<PersistedSegmentHandover>, PluginError>;
67
68    /// Remove all cross-segment execution state once the process is terminal.
69    async fn delete_segment_handovers(&self, process_id: &str) -> Result<(), PluginError>;
70
71    /// Attach a durable backend reference to a registered process.
72    ///
73    /// Implementations must reject unknown process ids. The first assignment
74    /// stores the reference. Repeating the exact same assignment is an
75    /// idempotent no-op that returns the existing record unchanged. Assigning a
76    /// different reference after one has been stored is a registry model error.
77    async fn set_external_ref(
78        &self,
79        process_id: &str,
80        external_ref: ProcessExternalRef,
81    ) -> Result<ProcessRecord, PluginError>;
82
83    async fn grant_handle(
84        &self,
85        session_scope: &SessionScope,
86        process_id: &str,
87        descriptor: ProcessHandleDescriptor,
88    ) -> Result<ProcessHandleGrant, PluginError>;
89
90    async fn revoke_handle(
91        &self,
92        session_scope: &SessionScope,
93        process_id: &str,
94    ) -> Result<(), PluginError>;
95
96    async fn transfer_handle_grants(
97        &self,
98        from_scope: &SessionScope,
99        to_scope: &SessionScope,
100        process_ids: &[String],
101    ) -> Result<(), PluginError>;
102
103    async fn list_handle_grants(
104        &self,
105        session_scope: &SessionScope,
106    ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError>;
107
108    async fn list_live_handle_grants(
109        &self,
110        session_scope: &SessionScope,
111    ) -> Result<Vec<ProcessHandleGrantEntry>, PluginError> {
112        Ok(self
113            .list_handle_grants(session_scope)
114            .await?
115            .into_iter()
116            .filter(|(_, record)| !record.is_terminal())
117            .collect())
118    }
119
120    async fn has_handle_grant(
121        &self,
122        session_scope: &SessionScope,
123        process_id: &str,
124    ) -> Result<bool, PluginError> {
125        Ok(self
126            .list_handle_grants(session_scope)
127            .await?
128            .into_iter()
129            .any(|(grant, _)| grant.process_id == process_id))
130    }
131
132    async fn handle_grants_for_process(
133        &self,
134        process_id: &str,
135    ) -> Result<Vec<ProcessHandleGrant>, PluginError>;
136
137    async fn delete_session_process_state(
138        &self,
139        session_id: &str,
140    ) -> Result<ProcessSessionDeleteReport, PluginError>;
141
142    async fn append_event(
143        &self,
144        process_id: &str,
145        request: ProcessEventAppendRequest,
146    ) -> Result<ProcessEventAppendResult, PluginError>;
147
148    async fn events_after(
149        &self,
150        process_id: &str,
151        after_sequence: u64,
152    ) -> Result<Vec<ProcessEvent>, PluginError>;
153
154    /// Count events of `event_type` with `sequence <= up_to_sequence`.
155    ///
156    /// This is the signal-ordinal query: the Nth occurrence of a signal event
157    /// resolves the Nth durable wait key. The default scans the event log;
158    /// store backends override it with a COUNT so per-signal cost stays flat
159    /// instead of growing with a long-lived process's history.
160    async fn count_events_through(
161        &self,
162        process_id: &str,
163        event_type: &str,
164        up_to_sequence: u64,
165    ) -> Result<u64, PluginError> {
166        Ok(self
167            .events_after(process_id, 0)
168            .await?
169            .into_iter()
170            .filter(|event| event.sequence <= up_to_sequence && event.event_type == event_type)
171            .count() as u64)
172    }
173
174    /// The most recent `limit` events, in ascending sequence order.
175    ///
176    /// Observation snapshots use this to show a bounded activity tail without
177    /// fetching a process's entire history on every poll. The default scans
178    /// the event log; store backends override it with ORDER BY ... LIMIT.
179    async fn recent_events(
180        &self,
181        process_id: &str,
182        limit: usize,
183    ) -> Result<Vec<ProcessEvent>, PluginError> {
184        let mut events = self.events_after(process_id, 0).await?;
185        if events.len() > limit {
186            events.drain(..events.len() - limit);
187        }
188        Ok(events)
189    }
190
191    async fn wake_events_after(
192        &self,
193        process_id: &str,
194        after_sequence: u64,
195    ) -> Result<Vec<ProcessEvent>, PluginError>;
196
197    /// Complete a process without a Lash process lease, under an explicit,
198    /// auditable completion authority.
199    ///
200    /// This path is reserved for writers whose single-writer discipline lives
201    /// *outside* the Lash lease: an external actor closing an externally-owned
202    /// row, a workflow-key-coalesced substrate completing a row it ran, or the
203    /// sweep reconciling an abandon request. The
204    /// [`ProcessCompletionAuthority`] names which of these applies; the
205    /// implementation MUST call
206    /// [`authority.validate`](ProcessCompletionAuthority::validate) against the
207    /// row's declared [`RecoveryDisposition`](super::model::RecoveryDisposition)
208    /// inside this operation, so a mismatched authority is rejected with a typed
209    /// error before any terminal event is appended, and MUST record the
210    /// authority on the terminal event as audit evidence (via
211    /// [`terminal_append_request`](super::events::terminal_append_request)).
212    ///
213    /// Lash-owned workers must instead use
214    /// [`complete_process_with_lease`](Self::complete_process_with_lease), which
215    /// fences the terminal append and lease release in one atomic operation.
216    async fn complete_process(
217        &self,
218        process_id: &str,
219        await_output: ProcessAwaitOutput,
220        authority: ProcessCompletionAuthority,
221    ) -> Result<ProcessRecord, PluginError>;
222
223    /// Atomically append the terminal output while the supplied process lease
224    /// is still current, then release that lease in the same transaction.
225    ///
226    /// Implementations must validate owner incarnation, lease token, fencing
227    /// token, and expiry against the persisted lease. A stale or expired writer
228    /// is rejected without appending any terminal event or clearing a newer
229    /// owner's lease. Replaying the same terminal event after a successful
230    /// completion returns the existing terminal record.
231    async fn complete_process_with_lease(
232        &self,
233        lease: &ProcessLease,
234        await_output: ProcessAwaitOutput,
235    ) -> Result<ProcessRecord, PluginError>;
236
237    /// Record the durable, lease-fenced "execution started" fact (ADR 0019).
238    ///
239    /// First-writer-wins: the first call stores `started`; a later call is an
240    /// idempotent no-op returning the existing record unchanged (the fact is
241    /// immutable once written, so the sweep can prove an OwnerBound row has
242    /// begun executing). Implementations reject unknown process ids.
243    async fn record_first_started(
244        &self,
245        process_id: &str,
246        started: ProcessStarted,
247    ) -> Result<ProcessRecord, PluginError>;
248
249    /// Set the durable, non-terminal Abandon Request marker (ADR 0019).
250    ///
251    /// First-writer-wins: if a marker is already present the call is an
252    /// idempotent no-op returning the existing record unchanged, preserving the
253    /// original recorded authorization rather than letting a later requester
254    /// clobber it. Setting it on a terminal row is a model error — a terminal
255    /// process has already recorded its outcome, so there is nothing to abandon.
256    async fn request_process_abandon(
257        &self,
258        process_id: &str,
259        request: AbandonRequest,
260    ) -> Result<ProcessRecord, PluginError>;
261
262    async fn set_process_wait(
263        &self,
264        process_id: &str,
265        wait: WaitState,
266    ) -> Result<ProcessRecord, PluginError>;
267
268    async fn clear_process_wait(&self, process_id: &str) -> Result<ProcessRecord, PluginError>;
269
270    async fn get_process(&self, process_id: &str) -> Option<ProcessRecord>;
271
272    /// Fallible process lookup for correctness-critical execution paths where
273    /// a transient store failure must not be mistaken for an absent row.
274    async fn try_get_process(
275        &self,
276        process_id: &str,
277    ) -> Result<Option<ProcessRecord>, PluginError> {
278        Ok(self.get_process(process_id).await)
279    }
280
281    async fn list_processes(
282        &self,
283        filter: &ProcessListFilter,
284    ) -> Result<Vec<ProcessRecord>, PluginError>;
285
286    /// Return process records whose persisted row changed strictly after
287    /// `cursor`, ordered by the backend's per-store change sequence.
288    ///
289    /// This is a host-level completeness read for trusted projectors. It is not
290    /// scoped by handle grants, and the cursor must be treated as opaque outside
291    /// the store that issued it.
292    async fn processes_changed_since(
293        &self,
294        cursor: ProcessChangeCursor,
295        limit: usize,
296    ) -> Result<(Vec<ProcessRecord>, ProcessChangeCursor), PluginError>;
297
298    async fn ack_wake(&self, process_id: &str, sequence: u64) -> Result<(), PluginError>;
299
300    /// All non-terminal process records, in stable `process_id` order.
301    ///
302    /// This is the recovery sweep's worklist: every process that was started
303    /// but has not reached a terminal event is a candidate for re-execution by
304    /// a [`DurableProcessWorker`](crate::DurableProcessWorker) after a crash.
305    /// Terminal processes are excluded — they are already done and idempotent by
306    /// `process_id`, so re-running them would be wasted work.
307    async fn list_non_terminal(&self) -> Result<Vec<ProcessRecord>, PluginError>;
308
309    /// Count non-terminal process rows by their captured definition and
310    /// execution-environment references.
311    async fn live_reference_summary(&self)
312    -> Result<Vec<ProcessLiveReferenceSummary>, PluginError>;
313
314    /// Claim the durable single-owner lease over a non-terminal process.
315    ///
316    /// An unexpired lease held by a *different* owner returns
317    /// [`ProcessLeaseClaimOutcome::Busy`] carrying the observed holder;
318    /// claiming a free or expired lease succeeds and bumps the
319    /// `fencing_token`, and the same incarnation re-entering its own live
320    /// lease extends it without changing token or fence. The returned
321    /// [`ProcessLease`]'s `(owner, lease_token)` plus `fencing_token` are the
322    /// contract a worker presents on every subsequent renew/complete — a stale
323    /// writer is rejected.
324    async fn claim_process_lease(
325        &self,
326        process_id: &str,
327        owner: &crate::LeaseOwnerIdentity,
328        lease_ttl_ms: u64,
329    ) -> Result<ProcessLeaseClaimOutcome, PluginError>;
330
331    /// Reclaim an unexpired process lease whose observed holder is definitely
332    /// dead according to persisted local-process liveness metadata.
333    ///
334    /// Mirrors
335    /// [`RuntimePersistence::reclaim_session_execution_lease`](crate::RuntimePersistence::reclaim_session_execution_lease):
336    /// backends must CAS on `observed_holder` (owner identity, lease token,
337    /// and fencing token) so a stale claimant cannot clear a newer live lease
338    /// that won the race after the busy observation, and a successful reclaim
339    /// must advance the fencing token monotonically.
340    async fn reclaim_process_lease(
341        &self,
342        process_id: &str,
343        owner: &crate::LeaseOwnerIdentity,
344        observed_holder: &ProcessLease,
345        lease_ttl_ms: u64,
346    ) -> Result<ProcessLeaseClaimOutcome, PluginError>;
347
348    /// Extend the expiry of a live lease the caller still owns.
349    ///
350    /// The lease must match the persisted `(owner, lease_token, fencing_token)`
351    /// and be unexpired, else the renewal is rejected (the lease was superseded
352    /// or expired). Workers renew across long-running effects so a healthy
353    /// process is not swept out from under its live owner.
354    async fn renew_process_lease(
355        &self,
356        lease: &ProcessLease,
357        lease_ttl_ms: u64,
358    ) -> Result<ProcessLease, PluginError>;
359
360    /// Read the current lease row for a process without claiming it.
361    ///
362    /// Returns the persisted lease when one is held (owner and token present),
363    /// or `None` when the row is unleased or released. The returned lease may be
364    /// expired: expiry is a raw fact exposed read-side (ADR 0019) so hosts
365    /// classify staleness themselves; this never mutates the lease. Unknown
366    /// process ids return `None`.
367    async fn get_process_lease(
368        &self,
369        process_id: &str,
370    ) -> Result<Option<ProcessLease>, PluginError>;
371
372    /// Release a lease the caller owns, fenced by the completion's
373    /// `(process_id, lease_token)`.
374    ///
375    /// Mirrors clearing a runtime turn lease: a stale completion (whose token no
376    /// longer matches the live lease) is a no-op so it cannot release a lease a
377    /// newer owner now holds. Idempotent — completing an already-released lease
378    /// succeeds.
379    async fn complete_process_lease(
380        &self,
381        completion: &ProcessLeaseCompletion,
382    ) -> Result<(), PluginError>;
383
384    /// Physically delete terminal process rows whose `updated_at_ms` is older
385    /// than `cutoff_epoch_ms`, match `filter` when one is supplied, and have a
386    /// process change sequence no later than `up_to_change_seq` when supplied,
387    /// together with their events, wake acks, handle grants, lease rows, and
388    /// trigger-delivery reservations whose deterministic process id points at a
389    /// pruned row.
390    /// Host-scheduled retention: hosts that project results/events into their
391    /// own store call this to keep the registry bounded. Non-terminal rows are
392    /// never touched. Callers must choose a retention window comfortably longer
393    /// than any waiter lifetime — a pruned process id becomes "unknown process"
394    /// to late awaits. Re-emitting the same trigger occurrence id after its
395    /// process has aged out of retention may reserve a fresh delivery process
396    /// id; occurrence-level idempotency still holds, and ordinary emit replays
397    /// do not straddle a retention window in practice.
398    ///
399    /// ```no_run
400    /// use std::time::{Duration, SystemTime, UNIX_EPOCH};
401    /// use lash_core::{PluginError, ProcessRegistry};
402    ///
403    /// async fn prune_week_old(registry: &dyn ProcessRegistry) -> Result<(), PluginError> {
404    ///     let now_ms = SystemTime::now()
405    ///         .duration_since(UNIX_EPOCH)
406    ///         .expect("clock after epoch")
407    ///         .as_millis() as u64;
408    ///     // Window must exceed any in-flight await's lifetime (ADR 0017).
409    ///     let cutoff = now_ms - Duration::from_secs(7 * 24 * 60 * 60).as_millis() as u64;
410    ///     let report = registry.prune_terminal_processes(cutoff, None, None).await?;
411    ///     eprintln!(
412    ///         "pruned {} processes, {} events",
413    ///         report.pruned_processes, report.pruned_events
414    ///     );
415    ///     Ok(())
416    /// }
417    /// ```
418    async fn prune_terminal_processes(
419        &self,
420        cutoff_epoch_ms: u64,
421        filter: Option<ProcessListFilter>,
422        up_to_change_seq: Option<ProcessChangeCursor>,
423    ) -> Result<ProcessPruneReport, PluginError>;
424}