Skip to main content

zeph_durable/
backend.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The sealed execution-backend abstraction and its enum-dispatch front door.
5//!
6//! A backend is the persistence engine behind a durable execution: it journals control flow and,
7//! for the local backend, owns the dedicated `durable.db` pool. The [`ExecutionBackend`] trait is
8//! **sealed** (it requires [`crate::sealed::Sealed`]), so only backends declared inside this crate
9//! can implement it. External crates never name a concrete backend; they hold a
10//! [`DurableBackendEnum`] and dispatch through it.
11//!
12//! # Why enum dispatch instead of `Box<dyn ExecutionBackend>`
13//!
14//! The journal append path is hot. A trait object would force a virtual call and a heap allocation
15//! per dispatch; [`DurableBackendEnum`] resolves the backend with a single `match` and no
16//! allocation (the spec's NEVER list forbids `Box<dyn ExecutionBackend>` on the dispatch path).
17//! Because the trait is sealed, adding methods to it later — when the `DurableContext`, promise,
18//! and timer entry points land — is a non-breaking change.
19//!
20//! # Scope
21//!
22//! This module defines [`BackendCapabilities`], the sealed [`ExecutionBackend`] trait (with its
23//! `capabilities` accessor), and the [`DurableBackendEnum`] dispatcher. The execution-open,
24//! promise-resolution, and timer-scan methods named in the spec land alongside the
25//! `DurableContext` (the trait can gain them without breaking callers).
26
27use std::sync::Arc;
28
29use bytes::Bytes;
30
31use crate::config::RetentionPolicy;
32use crate::error::DurableError;
33use crate::ids::{ExecutionId, IdempotencyKey, JournalSeq, PromiseId, TimerId};
34use crate::journal::{ExecutionStatus, Journal, JournalEntry};
35use crate::promise::PromiseRecord;
36use crate::waiters::NotifyRegistry;
37
38pub mod execution_lock;
39pub mod local;
40
41pub use execution_lock::ExecutionLock;
42pub use local::{CancelOutcome, LocalBackend};
43
44/// A read-only summary of a single durable execution, for operability surfaces.
45///
46/// Returned by [`LocalBackend::list_executions`]. It carries only the execution-level metadata that
47/// the `zeph durable list` CLI and the TUI `DurableView` display — never payload bytes or resolver
48/// tokens (INV-5 redaction). The `kind` is the raw column tag (an [`ExecutionKind::Custom`] cannot
49/// round-trip to a typed value, so the stored string is exposed verbatim for display).
50///
51/// [`ExecutionKind::Custom`]: crate::ExecutionKind::Custom
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
53pub struct ExecutionSummary {
54    /// The execution identity.
55    pub execution_id: ExecutionId,
56    /// The canonical kind tag as stored (`agent_turn`, `dag_run`, …, or a custom literal).
57    pub kind: String,
58    /// The current execution status.
59    pub status: ExecutionStatus,
60    /// Creation time, Unix epoch milliseconds.
61    pub created_at_ms: i64,
62    /// Last-update time, Unix epoch milliseconds.
63    pub updated_at_ms: i64,
64    /// Finalization time, Unix epoch milliseconds; `None` while the execution is non-terminal.
65    pub finalized_at_ms: Option<i64>,
66    /// Number of journal entries recorded for this execution.
67    pub step_count: u64,
68}
69
70/// A redaction-safe view of one journal entry, for the `zeph durable show`/`inspect` CLI.
71///
72/// Returned by [`LocalBackend::read_execution_redacted`]. It deliberately excludes the payload bytes
73/// and full idempotency key — only the metadata the spec's INV-5 redaction rule permits in default
74/// output. To see decrypted payloads a caller must opt in via `--reveal`, which reads through the
75/// AEAD cipher with [`Journal::read_execution`] instead.
76#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
77pub struct RedactedEntry {
78    /// Global append sequence.
79    pub seq: i64,
80    /// The step this entry belongs to.
81    pub step_id: crate::ids::StepId,
82    /// The raw `entry_kind` column tag (`step_result`, `effect_intent`, …).
83    pub entry_kind: String,
84    /// The effect-class tag, when the entry carries one.
85    pub effect_class: Option<String>,
86    /// Hex of the first 8 bytes of the idempotency key, when present (INV-5 prefix only).
87    pub idem_key_prefix: Option<String>,
88    /// Size in bytes of the stored (AEAD-sealed) payload; `0` for control entries.
89    pub payload_len: u64,
90    /// Creation time, Unix epoch milliseconds.
91    pub created_at_ms: i64,
92}
93
94/// The capabilities a backend advertises so callers can adapt their journaling strategy.
95///
96/// The replay cursor and the durable-step primitive read these flags to decide, for example,
97/// whether parallel steps may journal concurrently or must be serialized into reserved-id order.
98///
99/// # Examples
100///
101/// ```
102/// use zeph_durable::BackendCapabilities;
103///
104/// // The local backend journals parallel steps concurrently and stays in-process.
105/// let caps = BackendCapabilities {
106///     parallel_steps: true,
107///     cross_process: false,
108///     max_payload: 1_048_576,
109/// };
110/// assert!(caps.parallel_steps);
111/// ```
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct BackendCapabilities {
114    /// Whether the backend may record parallel steps concurrently. `false` (e.g. Restate) requires
115    /// the durable wrapper to serialize *recording* into reserved-`StepId` order.
116    pub parallel_steps: bool,
117    /// Whether the journal lives on a database shared across processes. Drives the INV-8 encryption
118    /// gate and row-level HMAC requirement.
119    pub cross_process: bool,
120    /// The maximum payload size, in bytes, the backend accepts on append.
121    pub max_payload: usize,
122}
123
124/// A durable-execution persistence backend.
125///
126/// `ExecutionBackend` is the closed set of journal engines Zeph ships. It is sealed via
127/// [`crate::sealed::Sealed`]: external crates cannot implement it and must dispatch through
128/// [`DurableBackendEnum`]. Every backend is also a [`Journal`], so the append/read/finalize/prune
129/// surface is available uniformly.
130///
131/// # Contract for implementors
132///
133/// - [`capabilities`](ExecutionBackend::capabilities) MUST return a stable description of the
134///   backend; callers cache it and adapt their journaling strategy to it.
135/// - The [`Journal`] half MUST serialize writes through a single connection so appends receive a
136///   monotonic [`JournalSeq`].
137///
138/// Additional entry points (execution open, promise resolution, timer scan) are added as the
139/// higher layers land; because the trait is sealed, those additions do not break callers.
140pub trait ExecutionBackend: Journal + Send + Sync + crate::sealed::Sealed {
141    /// Return this backend's stable capability description.
142    fn capabilities(&self) -> BackendCapabilities;
143
144    /// Look up a committed `StepResult` anywhere in an execution by its [`IdempotencyKey`].
145    ///
146    /// This is the point-lookup behind INV-13: after a [`DurableError::ReplayDivergence`] the
147    /// execution restarts fresh, but a guarded effect that already committed its result must not
148    /// re-fire. Before invoking a guarded operation the durable step consults this lookup; a `Some`
149    /// result means the effect already succeeded and its journaled value is returned instead. The
150    /// key uniquely locates the row via the `idx_durable_journal_idem_key` index, so the lookup is
151    /// `O(log n)`.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`DurableError::Decode`] if the located row cannot be reconstructed, or
156    /// [`DurableError::Storage`] if the query fails.
157    fn lookup_committed_result(
158        &self,
159        id: ExecutionId,
160        idem_key: IdempotencyKey,
161    ) -> impl std::future::Future<Output = Result<Option<JournalEntry>, DurableError>> + Send;
162}
163
164/// Closed enum dispatch over the compiled-in backends.
165///
166/// Construct it from a concrete backend and hand it across the crate boundary behind an `Arc`;
167/// callers invoke the [`Journal`] and [`ExecutionBackend`] methods on the enum and the dispatch
168/// resolves to the active variant with a single `match`. The enum is `#[non_exhaustive]`: the
169/// feature-gated `Restate` variant joins it with the `restate` feature without breaking in-crate
170/// matches.
171///
172/// # Examples
173///
174/// ```
175/// use std::sync::Arc;
176/// use zeph_durable::{BackendCapabilities, DurableBackendEnum};
177///
178/// fn max_payload(backend: &DurableBackendEnum) -> usize {
179///     use zeph_durable::ExecutionBackend as _;
180///     backend.capabilities().max_payload
181/// }
182/// # let _ = max_payload;
183/// ```
184#[derive(Debug)]
185#[non_exhaustive]
186pub enum DurableBackendEnum {
187    /// The always-compiled local backend journaling to a dedicated `durable.db`.
188    ///
189    /// Held behind an [`Arc`] so the same backing instance can be shared with the
190    /// [`JournalWriter`](crate::JournalWriter) (which owns the write path) while this enum serves
191    /// the read path consumed by the [`ReplayCursor`](crate::DurableContext) — both observe one
192    /// `durable.db` pool.
193    Local(Arc<LocalBackend>),
194}
195
196impl crate::sealed::Sealed for DurableBackendEnum {}
197
198impl Journal for DurableBackendEnum {
199    async fn append(&self, entry: JournalEntry) -> Result<JournalSeq, DurableError> {
200        match self {
201            Self::Local(backend) => backend.append(entry).await,
202        }
203    }
204
205    async fn read_execution(&self, id: ExecutionId) -> Result<Vec<JournalEntry>, DurableError> {
206        match self {
207            Self::Local(backend) => backend.read_execution(id).await,
208        }
209    }
210
211    async fn read_execution_range(
212        &self,
213        id: ExecutionId,
214        from_step_id: u32,
215        limit: usize,
216    ) -> Result<Vec<JournalEntry>, DurableError> {
217        match self {
218            Self::Local(backend) => backend.read_execution_range(id, from_step_id, limit).await,
219        }
220    }
221
222    async fn finalize(&self, id: ExecutionId, status: ExecutionStatus) -> Result<(), DurableError> {
223        match self {
224            Self::Local(backend) => backend.finalize(id, status).await,
225        }
226    }
227
228    async fn prune(&self, policy: &RetentionPolicy) -> Result<u64, DurableError> {
229        match self {
230            Self::Local(backend) => backend.prune(policy).await,
231        }
232    }
233
234    async fn sweep_orphans(&self, policy: &RetentionPolicy) -> Result<u64, DurableError> {
235        match self {
236            Self::Local(backend) => backend.sweep_orphans(policy).await,
237        }
238    }
239}
240
241impl ExecutionBackend for DurableBackendEnum {
242    fn capabilities(&self) -> BackendCapabilities {
243        match self {
244            Self::Local(backend) => backend.capabilities(),
245        }
246    }
247
248    async fn lookup_committed_result(
249        &self,
250        id: ExecutionId,
251        idem_key: IdempotencyKey,
252    ) -> Result<Option<JournalEntry>, DurableError> {
253        match self {
254            Self::Local(backend) => backend.lookup_committed_result(id, idem_key).await,
255        }
256    }
257}
258
259/// Promise, timer, and retention dispatch.
260///
261/// These methods back the [`DurablePromise`](crate::DurablePromise) /
262/// [`DurableTimerService`](crate::DurableTimerService) /
263/// [`DurableRetentionService`](crate::DurableRetentionService) surfaces. They
264/// are inherent on the enum rather than on the sealed [`ExecutionBackend`] trait because they are
265/// implemented through the local backend's dedicated `durable_promises` / `durable_timers` tables; a
266/// future cross-process backend (Restate) would satisfy the same surface through its own SDK
267/// primitives, so the closed `match` here gains a new arm at that point — a compile-time prompt
268/// rather than a silent gap.
269impl DurableBackendEnum {
270    /// Cancel a `running` execution so it is never resumed. See [`LocalBackend::cancel_execution`].
271    ///
272    /// A plain in-process-callable primitive (#6362 trimmed US-002): no CLI or orchestration
273    /// coupling, just a dispatch to the active backend.
274    ///
275    /// # Errors
276    ///
277    /// Returns any [`DurableError`] [`LocalBackend::cancel_execution`] can return.
278    pub async fn cancel_execution(&self, id: ExecutionId) -> Result<CancelOutcome, DurableError> {
279        match self {
280            Self::Local(backend) => backend.cancel_execution(id).await,
281        }
282    }
283
284    /// Insert a freshly-created promise row. See [`LocalBackend::insert_promise`].
285    pub(crate) async fn insert_promise(
286        &self,
287        id: PromiseId,
288        execution_id: ExecutionId,
289        resolver_token_hash: [u8; 32],
290        created_at_ms: i64,
291    ) -> Result<(), DurableError> {
292        match self {
293            Self::Local(backend) => {
294                backend
295                    .insert_promise(id, execution_id, resolver_token_hash, created_at_ms)
296                    .await
297            }
298        }
299    }
300
301    /// Read a promise's persisted state. See [`LocalBackend::promise_state`].
302    pub(crate) async fn promise_state(
303        &self,
304        id: PromiseId,
305    ) -> Result<Option<PromiseRecord>, DurableError> {
306        match self {
307            Self::Local(backend) => backend.promise_state(id).await,
308        }
309    }
310
311    /// Commit a resolved value to a pending promise. See [`LocalBackend::resolve_promise`].
312    pub(crate) async fn resolve_promise(
313        &self,
314        id: PromiseId,
315        execution_id: ExecutionId,
316        value_plaintext: &[u8],
317        resolved_at_ms: i64,
318    ) -> Result<bool, DurableError> {
319        match self {
320            Self::Local(backend) => {
321                backend
322                    .resolve_promise(id, execution_id, value_plaintext, resolved_at_ms)
323                    .await
324            }
325        }
326    }
327
328    /// Claim a promise's one-time replay notification. See [`LocalBackend::claim_promise_notification`].
329    pub(crate) async fn claim_promise_notification(
330        &self,
331        id: PromiseId,
332        notified_at_ms: i64,
333    ) -> Result<bool, DurableError> {
334        match self {
335            Self::Local(backend) => backend.claim_promise_notification(id, notified_at_ms).await,
336        }
337    }
338
339    /// Open a promise's sealed resolved payload. See [`LocalBackend::open_promise_payload`].
340    pub(crate) fn open_promise_payload(
341        &self,
342        id: PromiseId,
343        execution_id: ExecutionId,
344        sealed: &[u8],
345    ) -> Result<Bytes, DurableError> {
346        match self {
347            Self::Local(backend) => backend.open_promise_payload(id, execution_id, sealed),
348        }
349    }
350
351    /// The in-process promise wakeup registry. See [`LocalBackend::promise_waiters`].
352    pub(crate) fn promise_waiters(&self) -> &NotifyRegistry {
353        match self {
354            Self::Local(backend) => backend.promise_waiters(),
355        }
356    }
357
358    /// Arm a durable timer. See [`LocalBackend::arm_timer`].
359    pub(crate) async fn arm_timer(
360        &self,
361        id: TimerId,
362        execution_id: ExecutionId,
363        due_at_ms: i64,
364        created_at_ms: i64,
365    ) -> Result<(), DurableError> {
366        match self {
367            Self::Local(backend) => {
368                backend
369                    .arm_timer(id, execution_id, due_at_ms, created_at_ms)
370                    .await
371            }
372        }
373    }
374
375    /// Read a timer's `(due_at_ms, fired)` state. See [`LocalBackend::timer_state`].
376    pub(crate) async fn timer_state(
377        &self,
378        id: TimerId,
379    ) -> Result<Option<(i64, bool)>, DurableError> {
380        match self {
381            Self::Local(backend) => backend.timer_state(id).await,
382        }
383    }
384
385    /// List unfired timers due at or before `now_ms`. See [`LocalBackend::due_timers`].
386    pub(crate) async fn due_timers(&self, now_ms: i64) -> Result<Vec<TimerId>, DurableError> {
387        match self {
388            Self::Local(backend) => backend.due_timers(now_ms).await,
389        }
390    }
391
392    /// Mark a timer fired and wake its waiter. See [`LocalBackend::mark_timer_fired`].
393    pub(crate) async fn mark_timer_fired(&self, id: TimerId) -> Result<bool, DurableError> {
394        match self {
395            Self::Local(backend) => backend.mark_timer_fired(id).await,
396        }
397    }
398
399    /// The in-process timer wakeup registry. See [`LocalBackend::timer_waiters`].
400    pub(crate) fn timer_waiters(&self) -> &NotifyRegistry {
401        match self {
402            Self::Local(backend) => backend.timer_waiters(),
403        }
404    }
405
406    /// Fold an execution's idempotent prefix into a checkpoint. See [`LocalBackend::checkpoint_fold`].
407    pub(crate) async fn checkpoint_fold(
408        &self,
409        execution_id: ExecutionId,
410        up_to_step: u32,
411    ) -> Result<u64, DurableError> {
412        match self {
413            Self::Local(backend) => backend.checkpoint_fold(execution_id, up_to_step).await,
414        }
415    }
416
417    /// Reconstruct folded step results from every checkpoint. See [`LocalBackend::read_checkpoints`].
418    pub(crate) async fn read_checkpoints(
419        &self,
420        execution_id: ExecutionId,
421    ) -> Result<Vec<JournalEntry>, DurableError> {
422        match self {
423            Self::Local(backend) => backend.read_checkpoints(execution_id).await,
424        }
425    }
426}