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}