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::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) / [`DurableRetentionService`] surfaces. They
263/// are inherent on the enum rather than on the sealed [`ExecutionBackend`] trait because they are
264/// implemented through the local backend's dedicated `durable_promises` / `durable_timers` tables; a
265/// future cross-process backend (Restate) would satisfy the same surface through its own SDK
266/// primitives, so the closed `match` here gains a new arm at that point — a compile-time prompt
267/// rather than a silent gap.
268impl DurableBackendEnum {
269    /// Insert a freshly-created promise row. See [`LocalBackend::insert_promise`].
270    pub(crate) async fn insert_promise(
271        &self,
272        id: PromiseId,
273        execution_id: ExecutionId,
274        resolver_token_hash: [u8; 32],
275        created_at_ms: i64,
276    ) -> Result<(), DurableError> {
277        match self {
278            Self::Local(backend) => {
279                backend
280                    .insert_promise(id, execution_id, resolver_token_hash, created_at_ms)
281                    .await
282            }
283        }
284    }
285
286    /// Read a promise's persisted state. See [`LocalBackend::promise_state`].
287    pub(crate) async fn promise_state(
288        &self,
289        id: PromiseId,
290    ) -> Result<Option<PromiseRecord>, DurableError> {
291        match self {
292            Self::Local(backend) => backend.promise_state(id).await,
293        }
294    }
295
296    /// Commit a resolved value to a pending promise. See [`LocalBackend::resolve_promise`].
297    pub(crate) async fn resolve_promise(
298        &self,
299        id: PromiseId,
300        execution_id: ExecutionId,
301        value_plaintext: &[u8],
302        resolved_at_ms: i64,
303    ) -> Result<bool, DurableError> {
304        match self {
305            Self::Local(backend) => {
306                backend
307                    .resolve_promise(id, execution_id, value_plaintext, resolved_at_ms)
308                    .await
309            }
310        }
311    }
312
313    /// Claim a promise's one-time replay notification. See [`LocalBackend::claim_promise_notification`].
314    pub(crate) async fn claim_promise_notification(
315        &self,
316        id: PromiseId,
317        notified_at_ms: i64,
318    ) -> Result<bool, DurableError> {
319        match self {
320            Self::Local(backend) => backend.claim_promise_notification(id, notified_at_ms).await,
321        }
322    }
323
324    /// Open a promise's sealed resolved payload. See [`LocalBackend::open_promise_payload`].
325    pub(crate) fn open_promise_payload(
326        &self,
327        id: PromiseId,
328        execution_id: ExecutionId,
329        sealed: &[u8],
330    ) -> Result<Bytes, DurableError> {
331        match self {
332            Self::Local(backend) => backend.open_promise_payload(id, execution_id, sealed),
333        }
334    }
335
336    /// The in-process promise wakeup registry. See [`LocalBackend::promise_waiters`].
337    pub(crate) fn promise_waiters(&self) -> &NotifyRegistry {
338        match self {
339            Self::Local(backend) => backend.promise_waiters(),
340        }
341    }
342
343    /// Arm a durable timer. See [`LocalBackend::arm_timer`].
344    pub(crate) async fn arm_timer(
345        &self,
346        id: TimerId,
347        execution_id: ExecutionId,
348        due_at_ms: i64,
349        created_at_ms: i64,
350    ) -> Result<(), DurableError> {
351        match self {
352            Self::Local(backend) => {
353                backend
354                    .arm_timer(id, execution_id, due_at_ms, created_at_ms)
355                    .await
356            }
357        }
358    }
359
360    /// Read a timer's `(due_at_ms, fired)` state. See [`LocalBackend::timer_state`].
361    pub(crate) async fn timer_state(
362        &self,
363        id: TimerId,
364    ) -> Result<Option<(i64, bool)>, DurableError> {
365        match self {
366            Self::Local(backend) => backend.timer_state(id).await,
367        }
368    }
369
370    /// List unfired timers due at or before `now_ms`. See [`LocalBackend::due_timers`].
371    pub(crate) async fn due_timers(&self, now_ms: i64) -> Result<Vec<TimerId>, DurableError> {
372        match self {
373            Self::Local(backend) => backend.due_timers(now_ms).await,
374        }
375    }
376
377    /// Mark a timer fired and wake its waiter. See [`LocalBackend::mark_timer_fired`].
378    pub(crate) async fn mark_timer_fired(&self, id: TimerId) -> Result<bool, DurableError> {
379        match self {
380            Self::Local(backend) => backend.mark_timer_fired(id).await,
381        }
382    }
383
384    /// The in-process timer wakeup registry. See [`LocalBackend::timer_waiters`].
385    pub(crate) fn timer_waiters(&self) -> &NotifyRegistry {
386        match self {
387            Self::Local(backend) => backend.timer_waiters(),
388        }
389    }
390
391    /// Fold an execution's idempotent prefix into a checkpoint. See [`LocalBackend::checkpoint_fold`].
392    pub(crate) async fn checkpoint_fold(
393        &self,
394        execution_id: ExecutionId,
395        up_to_step: u32,
396    ) -> Result<u64, DurableError> {
397        match self {
398            Self::Local(backend) => backend.checkpoint_fold(execution_id, up_to_step).await,
399        }
400    }
401
402    /// Reconstruct folded step results from every checkpoint. See [`LocalBackend::read_checkpoints`].
403    pub(crate) async fn read_checkpoints(
404        &self,
405        execution_id: ExecutionId,
406    ) -> Result<Vec<JournalEntry>, DurableError> {
407        match self {
408            Self::Local(backend) => backend.read_checkpoints(execution_id).await,
409        }
410    }
411}