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}