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}