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}