Skip to main content

choir_sequencer/
journal.rs

1//! A structured, append-only decision journal: what the writer decided,
2//! why, and how long it took, one JSON object per line.
3//!
4//! # Derived data, and the consequences of saying so
5//!
6//! The journal is **not** the op log and is never authoritative. Nothing
7//! reads it back to reconstruct state, no hash covers it, no signature
8//! commits to it. Two rules follow, and both are load-bearing:
9//!
10//! - **Recording never blocks and never fails a submission.**
11//!   [`Journal::record`] takes `&self`, cannot return an error, and
12//!   [`FileJournal`] drops a record rather than stall the writer. A full
13//!   disk must slow nothing and refuse nothing.
14//! - **Its corruption cannot reach the log.** A truncated final line is a
15//!   truncated final line. Since no one replays it, there is nothing to
16//!   repair and no state to lose.
17//!
18//! # Why the I/O is on another thread
19//!
20//! [`crate::Sequencer`]'s writer thread is the single appender, and
21//! `choir-sequencer/tests/concurrency.rs` asserts p99 decision latency
22//! under 100 ms. A synchronous write per decision would put a disk on
23//! that path and make the assertion measure the filesystem. So the
24//! writer only pushes onto a channel; [`FileJournal`] drains it from its
25//! own thread and does every write and flush there.
26//!
27//! The trade is explicit: a crash loses whatever is still in the channel.
28//! For derived data that is the right side to be wrong on.
29//!
30//! # Examples
31//!
32//! ```
33//! use choir_sequencer::journal::{Event, Journal, MemJournal};
34//!
35//! let journal = MemJournal::new();
36//! journal.record(Event::WindowResize {
37//!     from: 20,
38//!     to: 10,
39//!     cause: "combined build failed".to_string(),
40//! });
41//! let lines = journal.lines();
42//! assert_eq!(lines.len(), 1);
43//! assert!(lines[0].contains("\"window_resize\""));
44//! assert!(lines[0].contains("\"cause\":\"combined build failed\""));
45//! ```
46//!
47//! The operator's guide to this journal and the other records the
48//! node keeps:
49//!
50#![doc = include_str!("../../../docs/operating/observability.md")]
51
52use std::io::Write;
53use std::sync::mpsc;
54use std::sync::Mutex;
55
56/// Journal record format. Bumped only for an incompatible change; new
57/// fields are additive, exactly as the op log's own rule (invariant 1).
58///
59/// Unlike a persisted op this version guards no hash, because nothing
60/// hashes the journal. It is here so a reader can tell which fields to
61/// expect rather than guess from their presence.
62pub const FORMAT_VERSION: u32 = 1;
63
64/// One journalled event.
65///
66/// Every variant serializes to a flat JSON object carrying `kind` and
67/// `format_version`, so `jq 'select(.kind == "decision")'` works without
68/// a schema.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Event {
71    /// The writer accepted or refused one submission.
72    Decision {
73        /// Verified author, when the policy identified one. `None` for a
74        /// submission refused before its signature was resolved, which
75        /// is itself the interesting case.
76        actor_id: Option<String>,
77        /// The channel the op was signed on.
78        workspace: String,
79        /// The op variant, when the policy decoded one.
80        op_type: Option<String>,
81        /// `true` if the op was ordered and appended.
82        accepted: bool,
83        /// The refusal, verbatim, when there was one.
84        reject_reason: Option<String>,
85        /// Position assigned, on acceptance only.
86        seq: Option<u64>,
87        /// The entry this one chained onto, on acceptance only.
88        parent: Option<String>,
89        /// Time from dequeue to decision, in microseconds.
90        decision_latency_us: u64,
91    },
92    /// A sample of how many commands were waiting behind the writer.
93    QueueDepth {
94        /// Commands drained in one wake-up, the writer's own view of
95        /// backlog. Sampled rather than continuous: counting costs
96        /// nothing but recording every value would dominate the file.
97        depth: usize,
98    },
99    /// The speculative window changed size.
100    WindowResize {
101        /// Size before.
102        from: usize,
103        /// Size after.
104        to: usize,
105        /// Why it moved, so a shrinking window is attributable rather
106        /// than merely visible.
107        cause: String,
108    },
109    /// A compare-and-swap precondition did not hold.
110    ///
111    /// Recorded distinctly from the refusal it also produces, because
112    /// "two writers raced this ref" is a fact about contention, and a
113    /// rejection count alone cannot separate it from a client sending
114    /// nonsense.
115    CasFailure {
116        /// The channel the op was signed on.
117        workspace: String,
118        /// What the submitter believed the current value was.
119        expected: Option<String>,
120        /// What it actually was at decision time.
121        actual: Option<String>,
122    },
123}
124
125impl Event {
126    /// Renders one JSONL line, without its newline.
127    #[must_use]
128    pub fn to_line(&self) -> String {
129        let mut o = serde_json::Map::new();
130        o.insert("format_version".into(), serde_json::json!(FORMAT_VERSION));
131        match self {
132            Event::Decision {
133                actor_id,
134                workspace,
135                op_type,
136                accepted,
137                reject_reason,
138                seq,
139                parent,
140                decision_latency_us,
141            } => {
142                o.insert("kind".into(), serde_json::json!("decision"));
143                o.insert("actor_id".into(), serde_json::json!(actor_id));
144                o.insert("workspace".into(), serde_json::json!(workspace));
145                o.insert("op_type".into(), serde_json::json!(op_type));
146                // A string rather than a bool: `decision` reads the same
147                // in a filter as it does in a report, and a third
148                // outcome later is a new value instead of a new field.
149                o.insert(
150                    "decision".into(),
151                    serde_json::json!(if *accepted { "accepted" } else { "rejected" }),
152                );
153                o.insert("reject_reason".into(), serde_json::json!(reject_reason));
154                o.insert("seq".into(), serde_json::json!(seq));
155                o.insert("parent".into(), serde_json::json!(parent));
156                o.insert(
157                    "decision_latency_us".into(),
158                    serde_json::json!(decision_latency_us),
159                );
160            }
161            Event::QueueDepth { depth } => {
162                o.insert("kind".into(), serde_json::json!("queue_depth"));
163                o.insert("depth".into(), serde_json::json!(depth));
164            }
165            Event::WindowResize { from, to, cause } => {
166                o.insert("kind".into(), serde_json::json!("window_resize"));
167                o.insert("from".into(), serde_json::json!(from));
168                o.insert("to".into(), serde_json::json!(to));
169                o.insert("cause".into(), serde_json::json!(cause));
170            }
171            Event::CasFailure {
172                workspace,
173                expected,
174                actual,
175            } => {
176                o.insert("kind".into(), serde_json::json!("cas_failure"));
177                o.insert("workspace".into(), serde_json::json!(workspace));
178                o.insert("expected".into(), serde_json::json!(expected));
179                o.insert("actual".into(), serde_json::json!(actual));
180            }
181        }
182        serde_json::Value::Object(o).to_string()
183    }
184}
185
186/// Somewhere decision events go.
187///
188/// `&self` rather than `&mut self` on purpose: the writer thread holds
189/// this while it owns the log, and a journal that needed exclusive
190/// access would either serialize behind the writer or force a lock onto
191/// the decision path.
192pub trait Journal: Send + Sync {
193    /// Records one event. Must not block and must not panic.
194    fn record(&self, event: Event);
195
196    /// Whether building an [`Event`] for this journal is worth it.
197    ///
198    /// An [`Event`] owns its strings, so constructing one costs several
199    /// allocations *before* `record` can discard it. On the writer
200    /// thread, per op, that is not free: wiring the journal in without
201    /// this guard pushed the submit path from 174 to 212 allocations per
202    /// op and `submit_path_allocation_budget` failed, which is the test
203    /// working. Call sites check this first, so a node with no journal
204    /// pays nothing at all rather than paying to be ignored.
205    fn enabled(&self) -> bool {
206        true
207    }
208}
209
210/// A journal that discards everything.
211///
212/// The default, so a node that configures no journal pays nothing and
213/// every call site can be unconditional.
214#[derive(Debug, Default, Clone, Copy)]
215pub struct NullJournal;
216
217impl Journal for NullJournal {
218    fn record(&self, _event: Event) {}
219
220    /// Nothing is recorded, so nothing should be built.
221    fn enabled(&self) -> bool {
222        false
223    }
224}
225
226/// An in-memory journal, for tests and for the doctest above.
227#[derive(Debug, Default)]
228pub struct MemJournal {
229    lines: Mutex<Vec<String>>,
230}
231
232impl MemJournal {
233    /// An empty journal.
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Every line recorded so far, in order.
240    ///
241    /// # Panics
242    ///
243    /// If a previous holder of the lock panicked.
244    #[must_use]
245    pub fn lines(&self) -> Vec<String> {
246        self.lines.lock().expect("journal lock").clone()
247    }
248}
249
250impl Journal for MemJournal {
251    fn record(&self, event: Event) {
252        if let Ok(mut lines) = self.lines.lock() {
253            lines.push(event.to_line());
254        }
255    }
256}
257
258/// A JSONL file written from its own thread.
259///
260/// [`Journal::record`] only sends on a channel; the thread owns the file
261/// and does every write and flush. See the module docs for why the I/O
262/// is not on the writer thread.
263#[derive(Debug)]
264pub struct FileJournal {
265    tx: Option<mpsc::Sender<Event>>,
266    thread: Option<std::thread::JoinHandle<()>>,
267}
268
269impl FileJournal {
270    /// Opens `path` for append and starts the writing thread.
271    ///
272    /// # Errors
273    ///
274    /// If the file cannot be opened. Once open, later write failures are
275    /// swallowed rather than reported: the caller has no useful response
276    /// to "the journal could not be written", and the one unacceptable
277    /// response is refusing an op over it.
278    pub fn create(path: &std::path::Path) -> std::io::Result<Self> {
279        let file = std::fs::OpenOptions::new()
280            .create(true)
281            .append(true)
282            .open(path)?;
283        let (tx, rx) = mpsc::channel::<Event>();
284        let thread = std::thread::spawn(move || {
285            let mut out = std::io::BufWriter::new(file);
286            // Drain until every sender is gone. Flushing per wake-up
287            // rather than per record keeps a burst to one syscall while
288            // still leaving the file complete whenever the writer idles,
289            // which is the state anyone reading it is in.
290            while let Ok(first) = rx.recv() {
291                let _ = writeln!(out, "{}", first.to_line());
292                for event in rx.try_iter() {
293                    let _ = writeln!(out, "{}", event.to_line());
294                }
295                let _ = out.flush();
296            }
297            let _ = out.flush();
298        });
299        Ok(Self {
300            tx: Some(tx),
301            thread: Some(thread),
302        })
303    }
304}
305
306impl Journal for FileJournal {
307    fn record(&self, event: Event) {
308        // A closed or failed channel drops the record. The alternative
309        // -- surfacing it -- would put the journal's health on the
310        // decision path, which is the coupling this module exists to
311        // avoid.
312        if let Some(tx) = &self.tx {
313            let _ = tx.send(event);
314        }
315    }
316}
317
318impl Drop for FileJournal {
319    fn drop(&mut self) {
320        // Close the channel first so the thread's `recv` returns, then
321        // join it: without the join, a process exiting immediately after
322        // a decision loses the flush that would have recorded it.
323        self.tx = None;
324        if let Some(thread) = self.thread.take() {
325            let _ = thread.join();
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::{Event, FileJournal, Journal, MemJournal, NullJournal};
333
334    fn decision(accepted: bool) -> Event {
335        Event::Decision {
336            actor_id: Some("actor-1".into()),
337            workspace: "ws".into(),
338            op_type: Some("SetRef".into()),
339            accepted,
340            reject_reason: if accepted {
341                None
342            } else {
343                Some("stale_head".into())
344            },
345            seq: accepted.then_some(7),
346            parent: accepted.then(|| "1e-abc".to_string()),
347            decision_latency_us: 42,
348        }
349    }
350
351    #[test]
352    fn a_decision_line_is_one_flat_json_object() {
353        let line = decision(true).to_line();
354        let v: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
355        assert_eq!(v["kind"], "decision");
356        assert_eq!(v["decision"], "accepted");
357        assert_eq!(v["seq"], 7);
358        assert_eq!(v["decision_latency_us"], 42);
359        assert!(!line.contains('\n'), "a JSONL record is one line");
360    }
361
362    /// A refusal carries the reason and no position, because there is no
363    /// position: nothing was ordered.
364    #[test]
365    fn a_rejection_records_the_reason_and_no_seq() {
366        let v: serde_json::Value =
367            serde_json::from_str(&decision(false).to_line()).expect("valid JSON");
368        assert_eq!(v["decision"], "rejected");
369        assert_eq!(v["reject_reason"], "stale_head");
370        assert!(v["seq"].is_null());
371        assert!(v["parent"].is_null());
372    }
373
374    /// Reject reasons are free text and will contain quotes and
375    /// newlines. Anything that hand-rolled the escaping would corrupt
376    /// the file exactly when something interesting happened.
377    #[test]
378    fn a_reason_containing_quotes_and_newlines_stays_one_valid_line() {
379        let event = Event::Decision {
380            actor_id: None,
381            workspace: "ws".into(),
382            op_type: None,
383            accepted: false,
384            reject_reason: Some("bad \"sig\"\nsecond line\ttab".into()),
385            seq: None,
386            parent: None,
387            decision_latency_us: 1,
388        };
389        let line = event.to_line();
390        assert!(
391            !line.contains('\n'),
392            "an embedded newline escaped the record"
393        );
394        let v: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
395        assert_eq!(v["reject_reason"], "bad \"sig\"\nsecond line\ttab");
396    }
397
398    #[test]
399    fn every_variant_names_its_kind() {
400        for (event, kind) in [
401            (Event::QueueDepth { depth: 3 }, "queue_depth"),
402            (
403                Event::WindowResize {
404                    from: 20,
405                    to: 10,
406                    cause: "ci failure".into(),
407                },
408                "window_resize",
409            ),
410            (
411                Event::CasFailure {
412                    workspace: "ws".into(),
413                    expected: Some("aaa".into()),
414                    actual: Some("bbb".into()),
415                },
416                "cas_failure",
417            ),
418        ] {
419            let v: serde_json::Value = serde_json::from_str(&event.to_line()).expect("valid JSON");
420            assert_eq!(v["kind"], kind);
421            assert_eq!(v["format_version"], 1);
422        }
423    }
424
425    #[test]
426    fn the_null_journal_accepts_and_discards() {
427        NullJournal.record(decision(true));
428    }
429
430    /// The file must be complete once the journal is dropped, which is
431    /// the guarantee the off-thread write would otherwise cost.
432    #[test]
433    fn a_dropped_file_journal_has_flushed_everything() {
434        let dir = std::env::temp_dir().join(format!("choir-journal-{}", std::process::id()));
435        std::fs::create_dir_all(&dir).expect("temp dir");
436        let path = dir.join("ops.jsonl");
437        {
438            let journal = FileJournal::create(&path).expect("create");
439            for _ in 0..50 {
440                journal.record(decision(true));
441            }
442        }
443        let body = std::fs::read_to_string(&path).expect("read back");
444        let lines: Vec<&str> = body.lines().collect();
445        assert_eq!(lines.len(), 50, "records were lost across drop");
446        for line in lines {
447            serde_json::from_str::<serde_json::Value>(line).expect("every line is valid JSON");
448        }
449        std::fs::remove_dir_all(&dir).ok();
450    }
451
452    #[test]
453    fn a_mem_journal_keeps_order() {
454        let journal = MemJournal::new();
455        journal.record(Event::QueueDepth { depth: 1 });
456        journal.record(Event::QueueDepth { depth: 2 });
457        let lines = journal.lines();
458        assert!(lines[0].contains("\"depth\":1"));
459        assert!(lines[1].contains("\"depth\":2"));
460    }
461}