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