Skip to main content

zeph_durable/
journal.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The append-only journal abstraction and its data model.
5//!
6//! A [`Journal`] records the control flow of an execution as an ordered sequence of
7//! [`JournalEntry`] values. Each entry is one [`EntryKind`] — a closed enum that makes illegal
8//! states unrepresentable: control entries (effect intents, promise creation, timer arming) carry
9//! no ciphertext payload field at all, so a "control entry with payload" cannot be constructed.
10//!
11//! This module defines the *types* only. The concrete journal backends, the writer actor, and the
12//! replay cursor land in follow-up issues.
13
14use std::future::Future;
15
16use bytes::Bytes;
17
18use crate::cipher::EntryKindTag;
19use crate::config::RetentionPolicy;
20use crate::effect::EffectClass;
21use crate::error::DurableError;
22use crate::ids::{
23    ExecutionId, ExecutionKind, IdempotencyKey, JournalSeq, PromiseId, StepId, TimerId,
24};
25
26/// Terminal and in-flight status of a durable execution.
27///
28/// Maps one-to-one to the `status` column `CHECK` constraint
29/// (`'running' | 'completed' | 'failed' | 'aborted' | 'canceled'`).
30///
31/// # Examples
32///
33/// ```
34/// use zeph_durable::ExecutionStatus;
35///
36/// assert_eq!(ExecutionStatus::Completed.as_str(), "completed");
37/// assert!(ExecutionStatus::Running.is_running());
38/// assert!(!ExecutionStatus::Failed.is_running());
39/// ```
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ExecutionStatus {
43    /// The execution is in flight.
44    Running,
45    /// The execution finished successfully.
46    Completed,
47    /// The execution ended in an error.
48    Failed,
49    /// The execution was discarded (e.g. after a replay divergence or step-cap abort).
50    Aborted,
51    /// The execution was deliberately stopped by an operator (`zeph durable cancel`) and must
52    /// never be reopened (INV-16′) — distinct from `Aborted`, which the system may re-drive.
53    Canceled,
54}
55
56impl ExecutionStatus {
57    /// Return the canonical string used in the `status` column.
58    #[must_use]
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::Running => "running",
62            Self::Completed => "completed",
63            Self::Failed => "failed",
64            Self::Aborted => "aborted",
65            Self::Canceled => "canceled",
66        }
67    }
68
69    /// Reconstruct a status from its canonical `status`-column string.
70    ///
71    /// Returns `None` for an unrecognized tag. The `durable_executions.status` column carries a
72    /// `CHECK` constraint over exactly these five values, so a `None` indicates schema corruption
73    /// or drift rather than a routine miss; callers should fail closed.
74    #[must_use]
75    pub fn from_tag(tag: &str) -> Option<Self> {
76        match tag {
77            "running" => Some(Self::Running),
78            "completed" => Some(Self::Completed),
79            "failed" => Some(Self::Failed),
80            "aborted" => Some(Self::Aborted),
81            "canceled" => Some(Self::Canceled),
82            _ => None,
83        }
84    }
85
86    /// Whether the execution is still in flight (not yet in a terminal state).
87    #[must_use]
88    pub fn is_running(self) -> bool {
89        matches!(self, Self::Running)
90    }
91}
92
93/// The kind of a single journal entry.
94///
95/// A closed enum: an exhaustive `match` over its variants is required, which guarantees every
96/// replay-relevant entry shape is handled. Only the variants that genuinely carry data
97/// (`StepResult`, `PromiseResolved`, `Checkpoint`) own a `payload`/`snapshot` field; the control
98/// entries hold identifiers and an optional row-level HMAC instead, so an illegal "control entry
99/// with payload" is unrepresentable.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum EntryKind {
102    /// The committed result of a completed step. The `payload` is AEAD-sealed
103    /// (`nonce || ciphertext || tag`).
104    StepResult {
105        /// Deduplication key for the step's effect.
106        idempotency_key: IdempotencyKey,
107        /// Sealed result bytes.
108        payload: Bytes,
109        /// How the step's side effect behaves under replay.
110        effect: EffectClass,
111        /// Wire-format version discriminator for the sealed payload.
112        payload_version: u8,
113    },
114    /// An intent to run an exactly-once-guarded effect, journaled before the effect fires.
115    EffectIntent {
116        /// Deduplication key for the guarded effect.
117        idempotency_key: IdempotencyKey,
118        /// How the step's side effect behaves under replay.
119        effect: EffectClass,
120        /// Row-level HMAC for shared-DB / Restate deployments; `None` for single-user `SQLite`.
121        hmac: Option<[u8; 32]>,
122    },
123    /// Creation of an external-completion promise.
124    PromiseCreated {
125        /// The new promise's identifier.
126        promise_id: PromiseId,
127        /// BLAKE3 hash of the 32-byte resolver token (the token itself is never journaled).
128        resolver_token_hash: [u8; 32],
129        /// Row-level HMAC for shared-DB / Restate deployments; `None` for single-user `SQLite`.
130        hmac: Option<[u8; 32]>,
131    },
132    /// Resolution of a previously-created promise with its sealed result.
133    PromiseResolved {
134        /// The resolved promise's identifier.
135        promise_id: PromiseId,
136        /// Sealed resolution bytes.
137        payload: Bytes,
138    },
139    /// A durable timer was armed to fire at a persisted instant.
140    TimerArmed {
141        /// The armed timer's identifier.
142        timer_id: TimerId,
143        /// Wake instant, as Unix epoch milliseconds.
144        due_at_ms: i64,
145        /// Row-level HMAC for shared-DB / Restate deployments; `None` for single-user `SQLite`.
146        hmac: Option<[u8; 32]>,
147    },
148    /// A previously-armed timer fired.
149    TimerFired {
150        /// The fired timer's identifier.
151        timer_id: TimerId,
152    },
153    /// A checkpoint fold that compacts the idempotent prefix up to a step.
154    Checkpoint {
155        /// All steps strictly below this id are folded into the snapshot.
156        up_to_step: u32,
157        /// Sealed snapshot bytes.
158        snapshot: Bytes,
159    },
160}
161
162impl EntryKind {
163    /// Return the data-free [`EntryKindTag`] discriminator for this entry.
164    ///
165    /// This is the bridge a backend uses to build the [`PayloadAad`](crate::PayloadAad) for an
166    /// entry without exposing the payload to the cipher binding logic.
167    #[must_use]
168    pub fn tag_enum(&self) -> EntryKindTag {
169        match self {
170            Self::StepResult { .. } => EntryKindTag::StepResult,
171            Self::EffectIntent { .. } => EntryKindTag::EffectIntent,
172            Self::PromiseCreated { .. } => EntryKindTag::PromiseCreated,
173            Self::PromiseResolved { .. } => EntryKindTag::PromiseResolved,
174            Self::TimerArmed { .. } => EntryKindTag::TimerArmed,
175            Self::TimerFired { .. } => EntryKindTag::TimerFired,
176            Self::Checkpoint { .. } => EntryKindTag::Checkpoint,
177        }
178    }
179
180    /// Return the canonical string used in the `entry_kind` column.
181    ///
182    /// The `step_result` tag in particular is the predicate of the unique partial index that
183    /// enforces "at most one committed result per step". Delegates to [`EntryKindTag::as_str`] so
184    /// the column strings have a single source of truth.
185    #[must_use]
186    pub fn tag(&self) -> &'static str {
187        self.tag_enum().as_str()
188    }
189
190    /// Return the entry's [`IdempotencyKey`], for the two step-bearing kinds that carry one.
191    ///
192    /// The replay-divergence guard (INV-3) compares the journaled key of a `StepResult` /
193    /// `EffectIntent` against the key freshly derived from the replayed descriptor; control and
194    /// promise/timer entries have no idempotency key and return `None`.
195    #[must_use]
196    pub fn idempotency_key(&self) -> Option<IdempotencyKey> {
197        match self {
198            Self::StepResult {
199                idempotency_key, ..
200            }
201            | Self::EffectIntent {
202                idempotency_key, ..
203            } => Some(*idempotency_key),
204            Self::PromiseCreated { .. }
205            | Self::PromiseResolved { .. }
206            | Self::TimerArmed { .. }
207            | Self::TimerFired { .. }
208            | Self::Checkpoint { .. } => None,
209        }
210    }
211}
212
213/// One ordered entry in a journal.
214///
215/// `seq` is `None` before the entry is appended and `Some` once the database assigns its global
216/// order. The remaining fields locate the entry within its execution.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct JournalEntry {
219    /// Global append order; `None` until the backend assigns it on append.
220    pub seq: Option<JournalSeq>,
221    /// The execution this entry belongs to.
222    pub execution_id: ExecutionId,
223    /// The category of the owning execution.
224    pub kind: ExecutionKind,
225    /// The step this entry is associated with.
226    pub step_id: StepId,
227    /// The entry payload.
228    pub entry: EntryKind,
229    /// Creation time, as Unix epoch milliseconds.
230    pub created_at_ms: i64,
231}
232
233/// An append-only, ordered journal of execution control flow.
234///
235/// Implementations are `Send + Sync` and route all writes through a dedicated connection so that
236/// appends are serialized. The returned futures are `Send`, so a journal can be shared across
237/// spawned tasks; the trait is consumed via enum dispatch, never as a trait object.
238pub trait Journal: Send + Sync {
239    /// Append an entry and return its database-assigned global sequence number.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`DurableError::JournalUnavailable`] if the write cannot be acknowledged in time,
244    /// or [`DurableError::PayloadTooLarge`] if a payload exceeds the configured limit.
245    fn append(
246        &self,
247        entry: JournalEntry,
248    ) -> impl Future<Output = Result<JournalSeq, DurableError>> + Send;
249
250    /// Read every entry of an execution in append order.
251    ///
252    /// Intended for short executions; long executions use [`Journal::read_execution_range`] to
253    /// bound memory.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`DurableError::Decode`] if a stored entry cannot be decoded, or
258    /// [`DurableError::JournalUnavailable`] if the journal cannot be read.
259    fn read_execution(
260        &self,
261        id: ExecutionId,
262    ) -> impl Future<Output = Result<Vec<JournalEntry>, DurableError>> + Send;
263
264    /// Read up to `limit` entries of an execution starting at `from_step_id`.
265    ///
266    /// The replay cursor calls this repeatedly to walk a long execution with `O(segment)` memory.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`DurableError::Decode`] if a stored entry cannot be decoded, or
271    /// [`DurableError::JournalUnavailable`] if the journal cannot be read.
272    fn read_execution_range(
273        &self,
274        id: ExecutionId,
275        from_step_id: u32,
276        limit: usize,
277    ) -> impl Future<Output = Result<Vec<JournalEntry>, DurableError>> + Send;
278
279    /// Transition an execution to a terminal status.
280    ///
281    /// Idempotent and safe to race: the transition only applies while the execution is still
282    /// `running`, so calling this more than once for the same execution (e.g. a divergence-driven
283    /// `Aborted` racing a caller's own `Completed`/`Failed`) is a no-op after the first call commits
284    /// — whichever status lands first wins and is never overwritten by a later one.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`DurableError::JournalUnavailable`] if the transition cannot be committed.
289    fn finalize(
290        &self,
291        id: ExecutionId,
292        status: ExecutionStatus,
293    ) -> impl Future<Output = Result<(), DurableError>> + Send;
294
295    /// Prune terminal executions according to `policy` and return the number of rows deleted.
296    ///
297    /// Runs exclusively on a background task — never on the dispatch hot path.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`DurableError::JournalUnavailable`] if the prune sweep cannot complete.
302    fn prune(
303        &self,
304        policy: &RetentionPolicy,
305    ) -> impl Future<Output = Result<u64, DurableError>> + Send;
306
307    /// Crash-orphan reclamation (#6254): flock-verify and hard-abort stale `running` rows.
308    ///
309    /// A `status='running'` row whose `updated_at` is older than `policy.stale_running_after_secs`
310    /// is a sweep candidate; it is only hard-aborted after a non-blocking try-acquire of its
311    /// INV-15 `ExecutionLock` succeeds — a live owner (`ExecutionLocked`) short-circuits to skip,
312    /// since staleness alone never proves the owner is dead (INV-17). Runs exclusively on a
313    /// background task, before [`Journal::prune`] on the same tick — never on the dispatch hot
314    /// path.
315    ///
316    /// Returns the number of executions aborted. Returns `Ok(0)` without scanning when
317    /// `policy.stale_running_after_secs == 0` (disabled), and `Ok(0)` with a warn-once log on
318    /// backends without a `lock_dir` (`:memory:`, Postgres, non-Unix) — a documented no-op, never
319    /// a staleness-only abort.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`DurableError::JournalUnavailable`] if the sweep cannot complete.
324    fn sweep_orphans(
325        &self,
326        policy: &RetentionPolicy,
327    ) -> impl Future<Output = Result<u64, DurableError>> + Send;
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::ids::ExecutionId;
334
335    fn sample_entry(entry: EntryKind) -> JournalEntry {
336        JournalEntry {
337            seq: None,
338            execution_id: ExecutionId::new(),
339            kind: ExecutionKind::AgentTurn,
340            step_id: StepId::new(0),
341            entry,
342            created_at_ms: 0,
343        }
344    }
345
346    #[test]
347    fn entry_kind_match_is_exhaustive() {
348        let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(0), b"op");
349        for entry in [
350            EntryKind::StepResult {
351                idempotency_key: key,
352                payload: Bytes::from_static(b"x"),
353                effect: EffectClass::Idempotent,
354                payload_version: 1,
355            },
356            EntryKind::EffectIntent {
357                idempotency_key: key,
358                effect: EffectClass::ExactlyOnceGuarded,
359                hmac: None,
360            },
361            EntryKind::PromiseCreated {
362                promise_id: PromiseId::new(),
363                resolver_token_hash: [0u8; 32],
364                hmac: Some([1u8; 32]),
365            },
366            EntryKind::PromiseResolved {
367                promise_id: PromiseId::new(),
368                payload: Bytes::new(),
369            },
370            EntryKind::TimerArmed {
371                timer_id: TimerId::new(),
372                due_at_ms: 100,
373                hmac: None,
374            },
375            EntryKind::TimerFired {
376                timer_id: TimerId::new(),
377            },
378            EntryKind::Checkpoint {
379                up_to_step: 3,
380                snapshot: Bytes::new(),
381            },
382        ] {
383            // Exhaustive match — no wildcard arm — over every variant.
384            let tag = match &entry {
385                EntryKind::StepResult { .. } => "step_result",
386                EntryKind::EffectIntent { .. } => "effect_intent",
387                EntryKind::PromiseCreated { .. } => "promise_created",
388                EntryKind::PromiseResolved { .. } => "promise_resolved",
389                EntryKind::TimerArmed { .. } => "timer_armed",
390                EntryKind::TimerFired { .. } => "timer_fired",
391                EntryKind::Checkpoint { .. } => "checkpoint",
392            };
393            assert_eq!(tag, entry.tag());
394        }
395    }
396
397    #[test]
398    fn journal_entry_is_clonable_and_comparable() {
399        let entry = sample_entry(EntryKind::TimerFired {
400            timer_id: TimerId::new(),
401        });
402        assert_eq!(entry, entry.clone());
403    }
404
405    #[test]
406    fn execution_status_round_trips_through_str() {
407        for status in [
408            ExecutionStatus::Running,
409            ExecutionStatus::Completed,
410            ExecutionStatus::Failed,
411            ExecutionStatus::Aborted,
412            ExecutionStatus::Canceled,
413        ] {
414            assert!(!status.as_str().is_empty());
415            assert_eq!(ExecutionStatus::from_tag(status.as_str()), Some(status));
416        }
417        assert!(ExecutionStatus::Running.is_running());
418        assert!(!ExecutionStatus::Aborted.is_running());
419        assert!(!ExecutionStatus::Canceled.is_running());
420    }
421}