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