Skip to main content

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}