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    /// # Errors
277    ///
278    /// Returns [`DurableError::JournalUnavailable`] if the transition cannot be committed.
279    fn finalize(
280        &self,
281        id: ExecutionId,
282        status: ExecutionStatus,
283    ) -> impl Future<Output = Result<(), DurableError>> + Send;
284
285    /// Prune terminal executions according to `policy` and return the number of rows deleted.
286    ///
287    /// Runs exclusively on a background task — never on the dispatch hot path.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`DurableError::JournalUnavailable`] if the prune sweep cannot complete.
292    fn prune(
293        &self,
294        policy: &RetentionPolicy,
295    ) -> impl Future<Output = Result<u64, DurableError>> + Send;
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::ids::ExecutionId;
302
303    fn sample_entry(entry: EntryKind) -> JournalEntry {
304        JournalEntry {
305            seq: None,
306            execution_id: ExecutionId::new(),
307            kind: ExecutionKind::AgentTurn,
308            step_id: StepId::new(0),
309            entry,
310            created_at_ms: 0,
311        }
312    }
313
314    #[test]
315    fn entry_kind_match_is_exhaustive() {
316        let key = IdempotencyKey::derive(ExecutionId::new(), StepId::new(0), b"op");
317        for entry in [
318            EntryKind::StepResult {
319                idempotency_key: key,
320                payload: Bytes::from_static(b"x"),
321                effect: EffectClass::Idempotent,
322                payload_version: 1,
323            },
324            EntryKind::EffectIntent {
325                idempotency_key: key,
326                effect: EffectClass::ExactlyOnceGuarded,
327                hmac: None,
328            },
329            EntryKind::PromiseCreated {
330                promise_id: PromiseId::new(),
331                resolver_token_hash: [0u8; 32],
332                hmac: Some([1u8; 32]),
333            },
334            EntryKind::PromiseResolved {
335                promise_id: PromiseId::new(),
336                payload: Bytes::new(),
337            },
338            EntryKind::TimerArmed {
339                timer_id: TimerId::new(),
340                due_at_ms: 100,
341                hmac: None,
342            },
343            EntryKind::TimerFired {
344                timer_id: TimerId::new(),
345            },
346            EntryKind::Checkpoint {
347                up_to_step: 3,
348                snapshot: Bytes::new(),
349            },
350        ] {
351            // Exhaustive match — no wildcard arm — over every variant.
352            let tag = match &entry {
353                EntryKind::StepResult { .. } => "step_result",
354                EntryKind::EffectIntent { .. } => "effect_intent",
355                EntryKind::PromiseCreated { .. } => "promise_created",
356                EntryKind::PromiseResolved { .. } => "promise_resolved",
357                EntryKind::TimerArmed { .. } => "timer_armed",
358                EntryKind::TimerFired { .. } => "timer_fired",
359                EntryKind::Checkpoint { .. } => "checkpoint",
360            };
361            assert_eq!(tag, entry.tag());
362        }
363    }
364
365    #[test]
366    fn journal_entry_is_clonable_and_comparable() {
367        let entry = sample_entry(EntryKind::TimerFired {
368            timer_id: TimerId::new(),
369        });
370        assert_eq!(entry, entry.clone());
371    }
372
373    #[test]
374    fn execution_status_round_trips_through_str() {
375        for status in [
376            ExecutionStatus::Running,
377            ExecutionStatus::Completed,
378            ExecutionStatus::Failed,
379            ExecutionStatus::Aborted,
380        ] {
381            assert!(!status.as_str().is_empty());
382        }
383        assert!(ExecutionStatus::Running.is_running());
384        assert!(!ExecutionStatus::Aborted.is_running());
385    }
386}