zeph_durable/writer.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! The background journal-writer actor.
5//!
6//! Every durable append routes through a single [`JournalWriter`] task so the calling path never
7//! blocks on a database write and writes are serialized into a monotonic [`JournalSeq`]. Callers
8//! hold a cheap, cloneable [`JournalWriterHandle`] and choose one of two durability classes:
9//!
10//! - **Buffered** ([`append_buffered`](JournalWriterHandle::append_buffered)) — fire-and-forget for
11//! `Idempotent`/`AtLeastOnce` effects. Entries accumulate and group-commit on a flush interval,
12//! amortizing the WAL fsync. On a full channel the entry is dropped with a `WARN`: a lost buffered
13//! entry simply re-runs on resume, which is safe by class definition (the durability-on-return
14//! guarantee, spec C-N1).
15//! - **Acked** ([`append_acked`](JournalWriterHandle::append_acked)) — for `ExactlyOnceGuarded`
16//! intents and results. The writer flushes all causally-preceding buffered entries first (INV-4),
17//! commits the entry, and only then returns its [`JournalSeq`] over a oneshot. The call is bounded
18//! by `journal_ack_timeout_ms`: a stalled or unreachable writer yields
19//! [`DurableError::JournalUnavailable`] rather than blocking the agent loop (INV-12, FR-DE-11).
20//!
21//! # Supervision and restart
22//!
23//! [`JournalWriter::run`] is the actor future; the daemon spawns it under a `TaskSupervisor`
24//! (spec-039). On every (re)start the writer reads `MAX(seq)` to anchor itself at the last
25//! committed entry (FR-DE-12); because `seq` is database-assigned, resumed appends continue without
26//! gap or duplication. The writer is bound to the local backend's `durable.db` — Restate journals
27//! through its own SDK and does not use this actor.
28
29use std::sync::Arc;
30use std::time::Duration;
31
32use tokio::sync::{mpsc, oneshot};
33use tracing::Instrument as _;
34
35use crate::backend::local::LocalBackend;
36use crate::config::DurableConfig;
37use crate::error::DurableError;
38use crate::ids::JournalSeq;
39use crate::journal::{Journal, JournalEntry};
40
41/// Bounded capacity of the writer's command channel (1024, per the spec's channel-capacity rule).
42const CHANNEL_CAPACITY: usize = 1024;
43
44/// Maximum entries buffered before an early group-commit, bounding the actor's memory between ticks.
45const MAX_BATCH: usize = 256;
46
47/// A command sent to the [`JournalWriter`] task.
48///
49/// Buffered appends are fire-and-forget; acked appends and flushes carry a oneshot the calling task
50/// awaits. This is the actor's internal protocol — callers use [`JournalWriterHandle`].
51pub(crate) enum JournalMsg {
52 /// Append a `Idempotent`/`AtLeastOnce` entry; group-committed, droppable under backpressure.
53 AppendBuffered(JournalEntry),
54 /// Append an `ExactlyOnceGuarded` entry; flushed-before-committed and acknowledged by seq.
55 AppendAcked(
56 JournalEntry,
57 oneshot::Sender<Result<JournalSeq, DurableError>>,
58 ),
59 /// Drain all buffered entries and acknowledge — a turn-boundary barrier.
60 Flush(oneshot::Sender<()>),
61}
62
63/// The background actor that owns the write path to a [`LocalBackend`]'s `durable.db`.
64///
65/// Construct it with [`JournalWriter::new`] (which also returns the handle), then drive it with
66/// [`JournalWriter::run`] on a supervised task.
67#[derive(Debug)]
68pub struct JournalWriter {
69 backend: Arc<LocalBackend>,
70 rx: mpsc::Receiver<JournalMsg>,
71 flush_interval: Duration,
72 max_batch: usize,
73}
74
75impl JournalWriter {
76 /// Build the writer and its cloneable handle from a backend and the durable configuration.
77 ///
78 /// The flush interval and ACK timeout are taken from `config`; the channel is bounded at the
79 /// spec capacity. Spawn [`JournalWriter::run`] to start processing.
80 ///
81 /// # Examples
82 ///
83 /// ```no_run
84 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
85 /// use std::sync::Arc;
86 /// use zeph_durable::{DurableConfig, LocalBackend, JournalWriter};
87 ///
88 /// let backend = Arc::new(LocalBackend::open("durable.db", 1_048_576).await?);
89 /// backend.init().await?;
90 /// let (writer, handle) = JournalWriter::new(backend, &DurableConfig::default());
91 /// let task = tokio::spawn(writer.run());
92 /// // ... use `handle` to append; drop all handles to stop the writer ...
93 /// # let _ = (task, handle);
94 /// # Ok(()) }
95 /// ```
96 #[must_use]
97 pub fn new(backend: Arc<LocalBackend>, config: &DurableConfig) -> (Self, JournalWriterHandle) {
98 let (tx, rx) = mpsc::channel(CHANNEL_CAPACITY);
99 let handle = JournalWriterHandle {
100 tx,
101 ack_timeout: Duration::from_millis(config.journal_ack_timeout_ms),
102 };
103 let writer = Self {
104 backend,
105 rx,
106 // Tokio's interval panics on a zero period; clamp to at least 1 ms.
107 flush_interval: Duration::from_millis(config.journal_flush_interval_ms.max(1)),
108 max_batch: MAX_BATCH,
109 };
110 (writer, handle)
111 }
112
113 /// Run the actor loop until every [`JournalWriterHandle`] is dropped.
114 ///
115 /// On entry the writer reads `MAX(seq)` to resume from the last committed entry (FR-DE-12). It
116 /// then group-commits buffered entries on each flush tick (or when the batch fills), flushes
117 /// before every acked commit (INV-4), and emits a `durable.journal.writer.queue_depth` gauge per
118 /// commit cycle. When the channel closes it drains any remaining buffered entries and returns,
119 /// so the supervisor can restart it cleanly.
120 #[tracing::instrument(name = "durable.writer.run", skip_all)]
121 pub async fn run(mut self) {
122 let resume = match self.backend.max_seq().await {
123 Ok(seq) => seq,
124 Err(error) => {
125 tracing::error!(%error, "journal writer could not read resume seq; starting at 0");
126 None
127 }
128 };
129 tracing::info!(
130 resume_seq = resume.map(JournalSeq::value),
131 "journal writer started"
132 );
133
134 let mut buffer: Vec<JournalEntry> = Vec::new();
135 let mut flush = tokio::time::interval(self.flush_interval);
136 flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
137
138 loop {
139 let keep_running = async {
140 tokio::select! {
141 maybe_msg = self.rx.recv() => match maybe_msg {
142 Some(JournalMsg::AppendBuffered(entry)) => {
143 buffer.push(entry);
144 if buffer.len() >= self.max_batch {
145 self.flush_buffer(&mut buffer).await;
146 }
147 true
148 }
149 Some(JournalMsg::AppendAcked(entry, reply)) => {
150 // INV-4: every causally-preceding buffered entry is durable before the
151 // exactly-once entry commits.
152 self.flush_buffer(&mut buffer).await;
153 let result = self.backend.append(entry).await;
154 let _ = reply.send(result);
155 true
156 }
157 Some(JournalMsg::Flush(reply)) => {
158 self.flush_buffer(&mut buffer).await;
159 let _ = reply.send(());
160 true
161 }
162 None => {
163 self.flush_buffer(&mut buffer).await;
164 false
165 }
166 },
167 _ = flush.tick() => {
168 self.flush_buffer(&mut buffer).await;
169 true
170 }
171 }
172 }
173 .instrument(tracing::info_span!("durable.writer.run.iter"))
174 .await;
175
176 if !keep_running {
177 break;
178 }
179 }
180 tracing::info!("journal writer stopped");
181 }
182
183 /// Group-commit and clear the buffer, emitting the queue-depth gauge for the cycle.
184 ///
185 /// A failed group-commit drops the buffered entries with a `WARN` (they re-run safely on
186 /// resume) rather than wedging the actor.
187 #[tracing::instrument(name = "durable.writer.flush_buffer", skip_all, fields(batch_size = buffer.len()))]
188 async fn flush_buffer(&self, buffer: &mut Vec<JournalEntry>) {
189 if buffer.is_empty() {
190 return;
191 }
192 let depth = u32::try_from(buffer.len()).unwrap_or(u32::MAX);
193 metrics::gauge!("durable.journal.writer.queue_depth").set(f64::from(depth));
194 if let Err(error) = self.backend.append_batch(buffer).await {
195 tracing::warn!(
196 %error,
197 dropped = buffer.len(),
198 "journal group-commit failed; buffered entries dropped (re-run safely on resume)"
199 );
200 }
201 buffer.clear();
202 }
203}
204
205/// A cheap, cloneable handle to a [`JournalWriter`].
206///
207/// Cloning shares the same underlying channel; the writer stops once the last handle is dropped.
208#[derive(Clone, Debug)]
209pub struct JournalWriterHandle {
210 tx: mpsc::Sender<JournalMsg>,
211 ack_timeout: Duration,
212}
213
214impl JournalWriterHandle {
215 /// Enqueue a buffered, fire-and-forget append.
216 ///
217 /// Returns immediately. On a full channel the entry is dropped with a `WARN` (acceptable for
218 /// `Idempotent`/`AtLeastOnce` effects, which re-run safely on resume); on a stopped writer it is
219 /// likewise dropped. Use [`append_acked`](Self::append_acked) when durability-on-return matters.
220 pub fn append_buffered(&self, entry: JournalEntry) {
221 match self.tx.try_send(JournalMsg::AppendBuffered(entry)) {
222 Ok(()) => {}
223 Err(mpsc::error::TrySendError::Full(_)) => {
224 tracing::warn!(
225 "journal writer channel full; dropping buffered entry (re-runs safely on resume)"
226 );
227 }
228 Err(mpsc::error::TrySendError::Closed(_)) => {
229 tracing::warn!("journal writer stopped; dropping buffered entry");
230 }
231 }
232 }
233
234 /// Append an exactly-once entry and await its committed [`JournalSeq`].
235 ///
236 /// The writer flushes all causally-preceding buffered entries before committing this one
237 /// (INV-4). The whole round-trip is bounded by `journal_ack_timeout_ms`.
238 ///
239 /// # Errors
240 ///
241 /// Returns [`DurableError::JournalUnavailable`] if the writer does not acknowledge within the
242 /// timeout or is unreachable, and propagates a backend [`DurableError`] if the commit itself
243 /// fails. The caller never blocks indefinitely (INV-12).
244 #[tracing::instrument(name = "durable.writer.append_acked", skip_all)]
245 pub async fn append_acked(&self, entry: JournalEntry) -> Result<JournalSeq, DurableError> {
246 let (reply_tx, reply_rx) = oneshot::channel();
247 let send_and_wait = async {
248 self.tx
249 .send(JournalMsg::AppendAcked(entry, reply_tx))
250 .await
251 .map_err(|_| DurableError::JournalUnavailable)?;
252 match reply_rx.await {
253 Ok(result) => result,
254 Err(_) => Err(DurableError::JournalUnavailable),
255 }
256 };
257 tokio::time::timeout(self.ack_timeout, send_and_wait)
258 .await
259 .unwrap_or(Err(DurableError::JournalUnavailable))
260 }
261
262 /// Drain all buffered entries to the database and await confirmation — a turn-boundary barrier.
263 ///
264 /// # Errors
265 ///
266 /// Returns [`DurableError::JournalUnavailable`] if the writer does not confirm within the
267 /// timeout or is unreachable.
268 #[tracing::instrument(name = "durable.writer.flush", skip_all)]
269 pub async fn flush(&self) -> Result<(), DurableError> {
270 let (reply_tx, reply_rx) = oneshot::channel();
271 let send_and_wait = async {
272 self.tx
273 .send(JournalMsg::Flush(reply_tx))
274 .await
275 .map_err(|_| DurableError::JournalUnavailable)?;
276 reply_rx.await.map_err(|_| DurableError::JournalUnavailable)
277 };
278 tokio::time::timeout(self.ack_timeout, send_and_wait)
279 .await
280 .unwrap_or(Err(DurableError::JournalUnavailable))
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::effect::EffectClass;
288 use crate::ids::{ExecutionId, ExecutionKind, IdempotencyKey, StepId};
289 use crate::journal::EntryKind;
290 use bytes::Bytes;
291 use std::assert_matches;
292
293 fn step_result(exec: ExecutionId, step: u32, payload: &[u8]) -> JournalEntry {
294 let step_id = StepId::new(step);
295 JournalEntry {
296 seq: None,
297 execution_id: exec,
298 kind: ExecutionKind::AgentTurn,
299 step_id,
300 entry: EntryKind::StepResult {
301 idempotency_key: IdempotencyKey::derive(exec, step_id, b"tool:read"),
302 payload: Bytes::copy_from_slice(payload),
303 effect: EffectClass::Idempotent,
304 payload_version: 1,
305 },
306 created_at_ms: 100,
307 }
308 }
309
310 #[tokio::test]
311 async fn append_acked_times_out_when_writer_is_stalled() {
312 // A live channel whose receiver is never polled: the oneshot is never answered, so the
313 // bounded ACK wait must elapse and surface JournalUnavailable rather than block forever.
314 let (tx, _rx) = mpsc::channel(4);
315 let handle = JournalWriterHandle {
316 tx,
317 ack_timeout: Duration::from_millis(50),
318 };
319 let result = handle
320 .append_acked(step_result(ExecutionId::new(), 0, b"x"))
321 .await;
322 assert_matches!(result, Err(DurableError::JournalUnavailable));
323 }
324
325 #[tokio::test]
326 async fn append_acked_errors_when_writer_is_gone() {
327 // Dropping the receiver closes the channel; the send fails fast (no need to wait the timeout).
328 let (tx, rx) = mpsc::channel(4);
329 drop(rx);
330 let handle = JournalWriterHandle {
331 tx,
332 ack_timeout: Duration::from_secs(30),
333 };
334 let result = handle
335 .append_acked(step_result(ExecutionId::new(), 0, b"x"))
336 .await;
337 assert_matches!(result, Err(DurableError::JournalUnavailable));
338 }
339
340 #[tokio::test]
341 async fn append_buffered_drops_on_full_channel_without_blocking() {
342 let (tx, mut rx) = mpsc::channel(2);
343 let handle = JournalWriterHandle {
344 tx,
345 ack_timeout: Duration::from_millis(50),
346 };
347 let exec = ExecutionId::new();
348 // Three buffered sends into a capacity-2 channel: the third is dropped with a WARN, never
349 // blocks, and never errors (acceptable for re-runnable buffered entries).
350 handle.append_buffered(step_result(exec, 0, b"a"));
351 handle.append_buffered(step_result(exec, 1, b"b"));
352 handle.append_buffered(step_result(exec, 2, b"c"));
353
354 let mut received = 0;
355 while rx.try_recv().is_ok() {
356 received += 1;
357 }
358 assert_eq!(received, 2, "the over-capacity buffered entry is dropped");
359 }
360
361 #[cfg(feature = "sqlite")]
362 mod with_backend {
363 use super::*;
364 use crate::DurableConfig;
365 use crate::backend::local::LocalBackend;
366 use std::sync::Arc;
367
368 async fn mem_backend() -> Arc<LocalBackend> {
369 let backend = LocalBackend::open(":memory:", 1_048_576).await.unwrap();
370 backend.init().await.unwrap();
371 Arc::new(backend)
372 }
373
374 fn fast_config() -> DurableConfig {
375 DurableConfig {
376 journal_flush_interval_ms: 5,
377 journal_ack_timeout_ms: 2000,
378 ..DurableConfig::default()
379 }
380 }
381
382 #[tokio::test]
383 async fn writer_group_commits_buffered_and_acks_exactly_once() {
384 let backend = mem_backend().await;
385 let exec = ExecutionId::new();
386 backend
387 .open_execution(exec, ExecutionKind::AgentTurn)
388 .await
389 .unwrap();
390
391 let (writer, handle) = JournalWriter::new(backend.clone(), &fast_config());
392 let task = tokio::spawn(writer.run());
393
394 handle.append_buffered(step_result(exec, 0, b"a"));
395 handle.append_buffered(step_result(exec, 1, b"b"));
396 // The acked append flushes the two buffered entries first (INV-4), then commits.
397 let seq = handle
398 .append_acked(step_result(exec, 2, b"c"))
399 .await
400 .unwrap();
401 assert!(seq.value() >= 1);
402 handle.flush().await.unwrap();
403
404 let entries = backend.read_execution(exec).await.unwrap();
405 assert_eq!(
406 entries.len(),
407 3,
408 "all buffered and acked entries are durable"
409 );
410
411 drop(handle);
412 task.await.unwrap();
413 }
414
415 #[tokio::test]
416 async fn writer_resumes_from_max_seq_after_restart() {
417 let backend = mem_backend().await;
418 let exec = ExecutionId::new();
419 backend
420 .open_execution(exec, ExecutionKind::AgentTurn)
421 .await
422 .unwrap();
423 let config = fast_config();
424
425 // First writer commits three acked entries (seq 1, 2, 3), then stops.
426 let (writer1, handle1) = JournalWriter::new(backend.clone(), &config);
427 let task1 = tokio::spawn(writer1.run());
428 for step in 0..3 {
429 handle1
430 .append_acked(step_result(exec, step, b"x"))
431 .await
432 .unwrap();
433 }
434 drop(handle1);
435 task1.await.unwrap();
436 assert_eq!(backend.max_seq().await.unwrap(), Some(JournalSeq::new(3)));
437
438 // A restarted writer resumes from MAX(seq); the next commit continues without a gap.
439 let (writer2, handle2) = JournalWriter::new(backend.clone(), &config);
440 let task2 = tokio::spawn(writer2.run());
441 let seq4 = handle2
442 .append_acked(step_result(exec, 3, b"y"))
443 .await
444 .unwrap();
445 assert_eq!(
446 seq4.value(),
447 4,
448 "resumed appends continue with neither gap nor duplication"
449 );
450
451 drop(handle2);
452 task2.await.unwrap();
453 }
454 }
455}