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}