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}