majordomus_cli/mesh/journal.rs
1//! The cooperation journal: the one replicated record of what the runtimes of a repository
2//! said to each other. Discovery ([`super::registry`]) answers "which runtimes exist";
3//! the journal answers "what are they doing" — sessions, claims, handovers, reviews —
4//! as signed events that every linked runtime stores, relays and folds into the same
5//! state ([`super::state`]).
6//!
7//! The guarantees are exactly these, and the tests hold each one:
8//!
9//! - **Identity.** An event is written by one *stream* — one run of one runtime of one
10//! node ([`StreamId`]) — and numbered densely from 1 within it. `(stream, seq)` is the
11//! event's identity everywhere.
12//! - **Authenticity end to end.** The origin signs every event with its node key; a relay
13//! forwards the bytes and vouches for nothing, so a relay cannot forge, alter or
14//! re-attribute an event.
15//! - **At-least-once delivery, idempotent application.** An event received twice is a
16//! counted duplicate and changes nothing; the application order within a stream is its
17//! sequence, never the arrival order — an early arrival waits (bounded) for its gap.
18//! - **No loops.** Replication is a comparison of high-water marks, not a flood: a
19//! runtime sends a peer only what the peer's marks say it lacks, so an event reaches
20//! every runtime once per link and stops.
21//! - **StreamLiveness without shared clocks.** Each stream carries a beat its origin raises on
22//! every heartbeat; a mark relays the beat with how long ago the sender saw it rise.
23//! A runtime computes freshness on its own monotonic clock — no two machines' wall
24//! clocks are ever compared.
25//!
26//! What it does not promise: exactly-once delivery, a global order across streams beyond
27//! the Lamport stamp, or consistency under partition. Two sides of a partition may both
28//! act; the fold makes the result deterministic and names the conflict (ADR 0067).
29//!
30//! ```
31//! use majordomus_cli::mesh::identity::NodeIdentity;
32//! use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo};
33//! use std::sync::Arc;
34//!
35//! let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
36//! "repo".into(), None).unwrap();
37//! let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
38//! "repo".into(), None).unwrap();
39//! a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "docs") }).unwrap();
40//!
41//! // replication: B tells A what it has, A answers with what B lacks, B ingests it
42//! let missing = a.missing_for(&b.marks(), 1 << 20);
43//! let report = b.ingest(&missing, &|_| Ok(()));
44//! assert_eq!(report.accepted, 1);
45//! // delivered again, it is a duplicate and nothing changes
46//! assert_eq!(b.ingest(&missing, &|_| Ok(())).duplicate, 1);
47//! ```
48
49use std::collections::{BTreeMap, BTreeSet};
50use std::io::Write;
51use std::path::{Path, PathBuf};
52use std::sync::{Arc, Mutex};
53use std::time::{Duration, Instant};
54
55use schemars::JsonSchema;
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58
59use super::identity::{node_id_of_key, verify, NodeIdentity};
60use super::MeshError;
61
62/// The event format version this executable writes and reads.
63pub const EVENT_VERSION: u32 = 1;
64
65/// The largest event, serialized, in bytes. A handover body is the largest payload and
66/// is bounded below this.
67pub const MAX_EVENT_BYTES: usize = 48 * 1024;
68
69/// The largest handover body an event carries, in bytes.
70pub const MAX_HANDOVER_BYTES: usize = 32 * 1024;
71
72/// The most events one journal holds before dead streams are compacted away.
73pub const MAX_EVENTS: usize = 20_000;
74
75/// The most streams one journal tracks.
76pub const MAX_STREAMS: usize = 1024;
77
78/// The most out-of-order events held per stream while their gap is open.
79pub const MAX_PENDING: usize = 256;
80
81/// The most paths one claim or review names.
82pub const MAX_SCOPE_PATHS: usize = 64;
83
84/// The most streams one node may occupy: runtime and instance ids are free to invent, so a
85/// single trusted key must not be able to fill the stream table.
86pub const MAX_STREAMS_PER_NODE: usize = 64;
87
88/// How many streams of one node make its dead streams due for compaction: three quarters of
89/// [`MAX_STREAMS_PER_NODE`]. Every worktree of a machine signs with the machine's one key
90/// and every server restart opens a new stream, so a machine reaches its quota by running,
91/// not by misbehaving — and one node alone never reaches the journal-wide bounds that
92/// otherwise start a compaction. Compacting the node at three quarters keeps the last
93/// quarter free for the runs to come.
94pub const NODE_COMPACTION_STREAMS: usize = MAX_STREAMS_PER_NODE * 3 / 4;
95
96/// How long past its retention a dead stream is kept for a handover it published that nobody
97/// has taken: a day. A handover is continuity meant to outlive the run that wrote it, so it
98/// holds its stream longer than anything else does, but only this long: a stream held is a
99/// slot of its node's quota ([`MAX_STREAMS_PER_NODE`]), and a machine whose runs each handed
100/// over something nobody took would otherwise fill its quota with the dead and have every
101/// later run refused. A handover somebody has taken holds nothing. One that lapses is still
102/// its author's record, and publishing it again offers it again under the same id.
103pub const HANDOVER_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
104
105/// The most bytes of the journal file a reload reads: 64 MiB.
106///
107/// The journal holds at most [`MAX_EVENTS`] events in memory, and compaction rewrites the
108/// file from what it holds, so a file kept by this executable stays far below the bound. A
109/// file beyond it was written by something else, or grew while compaction could not
110/// replace it; reading it whole would put an unbounded file into memory before a single
111/// bound is checked. A reload reads the first `MAX_RELOAD_BYTES` up to the last whole
112/// line, and the rest is left on disk, counted in [`JournalTallies::unread_bytes`] and
113/// named by `mesh doctor` — never dropped in silence, and kept by compaction.
114pub const MAX_RELOAD_BYTES: u64 = 64 * 1024 * 1024;
115
116/// The most events (held and pending) one node may occupy.
117pub const MAX_EVENTS_PER_NODE: usize = 20_000;
118
119/// The most out-of-order events the whole journal holds at once.
120pub const MAX_PENDING_TOTAL: usize = 4096;
121
122/// How far ahead of this runtime's Lamport clock a received event's stamp may be: 2^32.
123///
124/// A Lamport stamp is what its writer says about itself, and every receiver raises its own
125/// clock to the highest stamp it stores. Unbounded, one event from a trusted key stamped
126/// near `u64::MAX` would drag every clock in the mesh to the end of its range in a single
127/// round, after which every event written anywhere carries the same saturated stamp and the
128/// fold no longer orders them causally. A legitimate stamp is never further ahead than the
129/// number of events written in the repository's whole history — each event rises one above
130/// what its writer had seen — so the bound is far above anything a real mesh reaches, a
131/// fresh runtime joining at clock zero included. What it changes is the cost of the attack:
132/// the clock rises at most this much per event stored, so exhausting it takes 2^32 signed,
133/// stored events rather than one. An event past the bound is refused
134/// [`Rejection::ClockAhead`], on arrival and from a reloaded file alike.
135///
136/// ```
137/// use std::sync::Arc;
138/// use majordomus_cli::mesh::identity::NodeIdentity;
139/// use majordomus_cli::mesh::journal::{EventBody, Journal, Rejection, MAX_LAMPORT_LEAD};
140///
141/// let writer = Arc::new(NodeIdentity::ephemeral().unwrap());
142/// let a = Journal::open(Arc::clone(&writer), "0000000000000001", "repo".into(), None).unwrap();
143/// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
144/// "repo".into(), None).unwrap();
145/// let mut event = a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
146///
147/// // signed by a key B trusts, and stamped further ahead of B's clock than the bound allows
148/// event.lamport = MAX_LAMPORT_LEAD + 1;
149/// event.sig = writer.sign(&event.signing_bytes());
150/// let report = b.ingest(&[event.clone()], &|_| Ok(()));
151/// assert_eq!(report.rejected[&Rejection::ClockAhead], 1);
152/// assert_eq!(b.tallies().lamport, 0, "and B's clock did not move");
153///
154/// // exactly at the bound it is an ordinary event
155/// event.lamport = MAX_LAMPORT_LEAD;
156/// event.sig = writer.sign(&event.signing_bytes());
157/// assert_eq!(b.ingest(&[event], &|_| Ok(())).accepted, 1);
158/// ```
159pub const MAX_LAMPORT_LEAD: u64 = 1 << 32;
160
161/// The domain separator of an event signature: a signature over an event can never be
162/// replayed as a signature over an advertisement or a link message.
163const SIGNING_DOMAIN: &[u8] = b"majordomus-mesh-event/v1\n";
164
165/// One stream: a run of one runtime of one node. `<node 32 hex>-<runtime 16 hex>-<instance
166/// 16 hex>`. The node is the machine's key, the runtime is one checkout's server on that
167/// machine, the instance is one process run of that server — so two worktrees on one
168/// machine are two streams, and a restart is a new stream of the same runtime.
169///
170/// Numbering events within a stream rather than globally is what makes replication cheap
171/// and unambiguous: a gap in one stream is visible without coordinating with anyone, and
172/// `(stream, seq)` identifies an event on every machine that holds it.
173///
174/// ```
175/// use majordomus_cli::mesh::journal::StreamId;
176///
177/// let id = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
178/// assert_eq!(id.node(), "a".repeat(32));
179/// assert_eq!(id.runtime(), "b".repeat(16));
180///
181/// // a restart is a new stream, and the same runtime
182/// let after = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"d".repeat(16)).unwrap();
183/// assert_ne!(after, id);
184/// assert_eq!(after.runtime_key(), id.runtime_key());
185/// ```
186#[derive(
187 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
188)]
189#[serde(try_from = "String", into = "String")]
190pub struct StreamId(String);
191
192/// A stream id off the wire is parsed, never trusted: every accessor slices at fixed
193/// widths, so an unparsed id would be a panic waiting in a peer's marks.
194impl TryFrom<String> for StreamId {
195 type Error = String;
196 fn try_from(text: String) -> Result<Self, Self::Error> {
197 StreamId::parse(&text).ok_or_else(|| format!("'{text}' is not a stream id"))
198 }
199}
200
201impl From<StreamId> for String {
202 fn from(id: StreamId) -> String {
203 id.0
204 }
205}
206
207impl StreamId {
208 /// A stream id from its three parts; `None` unless each is lowercase hex of its width.
209 ///
210 /// The widths are checked here rather than trusted, because every accessor slices the
211 /// text at fixed offsets: an id built from a part of the wrong length would be a panic
212 /// waiting in a peer's marks, and the parts often come from another machine.
213 ///
214 /// ```
215 /// use majordomus_cli::mesh::journal::StreamId;
216 ///
217 /// assert!(StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).is_some());
218 /// assert!(StreamId::new("short", &"b".repeat(16), &"c".repeat(16)).is_none());
219 /// assert!(StreamId::new(&"A".repeat(32), &"b".repeat(16), &"c".repeat(16)).is_none(),
220 /// "one spelling only: lowercase hex");
221 /// ```
222 pub fn new(node: &str, runtime: &str, instance: &str) -> Option<Self> {
223 Self::parse(&format!("{node}-{runtime}-{instance}"))
224 }
225
226 /// Parse a stream id, refusing anything that is not exactly the three hex parts.
227 ///
228 /// ```
229 /// use majordomus_cli::mesh::journal::StreamId;
230 /// assert!(StreamId::parse("nope").is_none());
231 /// let id = format!("{}-{}-{}", "a".repeat(32), "b".repeat(16), "c".repeat(16));
232 /// assert_eq!(StreamId::parse(&id).unwrap().runtime_key(), format!("{}-{}", "a".repeat(32), "b".repeat(16)));
233 /// ```
234 pub fn parse(text: &str) -> Option<Self> {
235 let parts: Vec<&str> = text.split('-').collect();
236 let ok = parts.len() == 3
237 && parts[0].len() == 32
238 && parts[1].len() == 16
239 && parts[2].len() == 16
240 && parts.iter().all(|p| {
241 p.bytes()
242 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
243 });
244 ok.then(|| StreamId(text.to_string()))
245 }
246
247 /// The node (machine key digest) part: which machine's key signs everything this
248 /// stream carries. Two streams that share a node are two servers of one machine, and
249 /// the per-node bounds of the journal are counted against this.
250 ///
251 /// ```
252 /// use majordomus_cli::mesh::journal::StreamId;
253 ///
254 /// let id = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
255 /// let sibling = StreamId::new(&"a".repeat(32), &"e".repeat(16), &"f".repeat(16)).unwrap();
256 /// assert_eq!(id.node(), sibling.node(), "two runtimes of one machine");
257 /// assert_ne!(id.runtime_key(), sibling.runtime_key());
258 /// ```
259 pub fn node(&self) -> &str {
260 &self.0[..32]
261 }
262
263 /// The runtime part: which server of that machine — one per checkout — so two
264 /// worktrees of one clone are told apart and can claim against each other.
265 ///
266 /// ```
267 /// use majordomus_cli::mesh::journal::StreamId;
268 ///
269 /// let id = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
270 /// assert_eq!(id.runtime(), "b".repeat(16));
271 /// assert_eq!(id.runtime_key(), format!("{}-{}", id.node(), id.runtime()));
272 /// ```
273 pub fn runtime(&self) -> &str {
274 &self.0[33..49]
275 }
276
277 /// The instance part: which run of that server. It is what makes a restart visible —
278 /// the same runtime with a new instance has a fresh sequence, so a peer knows to say
279 /// hello again rather than resume a link whose counters no longer mean anything.
280 ///
281 /// ```
282 /// use majordomus_cli::mesh::journal::StreamId;
283 ///
284 /// let before = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
285 /// let after = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"d".repeat(16)).unwrap();
286 /// assert_ne!(before.instance(), after.instance(), "a restart is a new run");
287 /// assert_eq!(before.runtime(), after.runtime(), "of the same server");
288 /// ```
289 pub fn instance(&self) -> &str {
290 &self.0[50..66]
291 }
292
293 /// `<node>-<runtime>`: the durable identity of a runtime across its restarts. It is
294 /// what the peer table, a claim key and `mesh peer` are all keyed by, because a worker
295 /// cares which server it is talking to and not which run of it.
296 ///
297 /// ```
298 /// use majordomus_cli::mesh::journal::StreamId;
299 ///
300 /// let before = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"c".repeat(16)).unwrap();
301 /// let after = StreamId::new(&"a".repeat(32), &"b".repeat(16), &"d".repeat(16)).unwrap();
302 /// assert_eq!(before.runtime_key(), after.runtime_key(), "one peer, two runs");
303 /// assert_eq!(before.runtime_key().len(), 49);
304 /// ```
305 pub fn runtime_key(&self) -> String {
306 self.0[..49].to_string()
307 }
308
309 /// The id as text, in the one spelling every surface carries: a map key, a mark, a
310 /// claim's prefix and a log line all print this, so an id read anywhere can be compared
311 /// with an id read anywhere else.
312 pub fn as_str(&self) -> &str {
313 &self.0
314 }
315}
316
317impl std::fmt::Display for StreamId {
318 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319 f.write_str(&self.0)
320 }
321}
322
323/// What a session tells the mesh about itself. Every field is a public fact about work in
324/// progress; none is a path on the author's disk, a secret or a credential.
325///
326/// Almost everything here is optional, and that is the design: a session says what it
327/// knows, and a field it leaves out is omitted rather than carried empty, so a reader can
328/// tell "no task" from "a task called nothing". Opening the same session id again replaces
329/// this record, which is how a session that learns its branch later tells the mesh.
330///
331/// ```
332/// use majordomus_cli::mesh::journal::SessionInfo;
333///
334/// let mut info = SessionInfo::named("s1", "claude-code");
335/// assert_eq!(info.task, None);
336/// assert!(serde_json::to_value(&info).unwrap().get("task").is_none(), "unsaid, not empty");
337///
338/// info.branch = Some("feature/mesh-cooperation".into());
339/// assert_eq!(serde_json::to_value(&info).unwrap()["branch"], "feature/mesh-cooperation");
340/// ```
341#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342pub struct SessionInfo {
343 /// The session's id within its stream.
344 pub session: String,
345 /// The client: `claude-code`, `codex`, `cli`, ...
346 pub client: String,
347 /// The worker's own name for itself, when it gave one.
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub worker: Option<String>,
350 /// What the session says it is doing.
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub intent: Option<String>,
353 /// The task id (`t-...`) the session works under.
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub task: Option<String>,
356 /// The issue (`I0001`, `#184`) the work belongs to.
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub issue: Option<String>,
359 /// The milestone the work belongs to.
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub milestone: Option<String>,
362 /// The checkout id (a digest, never a path).
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub checkout: Option<String>,
365 /// The branch checked out.
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub branch: Option<String>,
368 /// The commit checked out.
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub head: Option<String>,
371 /// Whether the working tree had uncommitted changes when this was said.
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub dirty: Option<bool>,
374 /// The context revision the session works from (a digest the context compiler names).
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub context: Option<String>,
377}
378
379impl SessionInfo {
380 /// A session with only an id and a client: what a test or an example needs. The two
381 /// arguments are the two facts a session cannot be without — something to refer to it
382 /// by, and what kind of worker it is — and everything else is filled in afterwards by
383 /// whoever knows it.
384 ///
385 /// ```
386 /// use majordomus_cli::mesh::journal::SessionInfo;
387 ///
388 /// let info = SessionInfo::named("s1", "codex");
389 /// assert_eq!((info.session.as_str(), info.client.as_str()), ("s1", "codex"));
390 /// assert_eq!(info.worker, None, "the rest is the session's to say later");
391 /// ```
392 pub fn named(session: &str, client: &str) -> Self {
393 SessionInfo {
394 session: session.into(),
395 client: client.into(),
396 ..SessionInfo::default()
397 }
398 }
399}
400
401/// Whether a claim excludes others or only tells them. Both are useful and they answer
402/// different questions: an exclusive claim is a worker saying "do not edit this while I
403/// am", an advisory one is saying "I am here". Exclusive is the default because a claim
404/// taken without a thought about the mode is a claim somebody expects to be respected.
405///
406/// ```
407/// use majordomus_cli::mesh::journal::ClaimMode;
408///
409/// assert_eq!(ClaimMode::default(), ClaimMode::Exclusive);
410/// assert_eq!(serde_json::to_value(ClaimMode::Advisory).unwrap(), serde_json::json!("advisory"));
411/// ```
412#[derive(
413 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
414)]
415#[serde(rename_all = "snake_case")]
416pub enum ClaimMode {
417 /// Exclusive: a second exclusive claim meeting this scope is refused while this one
418 /// lives, on every runtime that knows of it.
419 #[default]
420 Exclusive,
421 /// Advisory: an announcement, like the peer board's. Overlaps are reported, never
422 /// refused.
423 Advisory,
424}
425
426/// A handover as the mesh carries it: the record's facts and its body, bounded.
427///
428/// What travels is the writing, plus the few facts that let a receiver file it — the task,
429/// the issue, the branch, the commit. No path of the author's disk is among them, because
430/// the checkout that reads this is somewhere else and a path from another machine is at
431/// best noise. Identity is the digest of the body, so a handover published twice by a
432/// retry or by two runtimes is one handover everywhere.
433///
434/// ```
435/// use majordomus_cli::mesh::journal::HandoverBody;
436///
437/// let body = "# Objective\nship the mesh\n".to_string();
438/// let handover = HandoverBody {
439/// id: HandoverBody::digest_of(&body),
440/// task: Some("t-1".into()),
441/// issue: Some("#184".into()),
442/// milestone: None,
443/// branch: Some("feature/mesh-cooperation".into()),
444/// head: None,
445/// created_at: None,
446/// name: None,
447/// body,
448/// };
449/// assert_eq!(handover.id, HandoverBody::digest_of(&handover.body));
450///
451/// let wire = serde_json::to_value(&handover).unwrap();
452/// assert!(wire.get("milestone").is_none(), "what it does not say is omitted");
453/// ```
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
455pub struct HandoverBody {
456 /// The content digest (32 hex): the same handover published twice is one handover.
457 pub id: String,
458 /// The task the handover closes or continues.
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub task: Option<String>,
461 /// The issue it belongs to.
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub issue: Option<String>,
464 /// The milestone it belongs to.
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub milestone: Option<String>,
467 /// The branch it was written on.
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub branch: Option<String>,
470 /// The commit it was written at.
471 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub head: Option<String>,
473 /// When it was written, RFC 3339, as its author's clock said.
474 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub created_at: Option<String>,
476 /// The record's file name at its origin, informational.
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub name: Option<String>,
479 /// The Markdown body (`# Objective`, `# Current State`, `# Next Action`, ...).
480 pub body: String,
481}
482
483impl HandoverBody {
484 /// The content digest a handover body is identified by. Identifying a handover by what
485 /// it says rather than by who published it is what makes publication idempotent across
486 /// the mesh: a retry, a relay and a second publisher all produce the same id, and the
487 /// fold keeps one handover with one list of consumers.
488 ///
489 /// ```
490 /// use majordomus_cli::mesh::journal::HandoverBody;
491 ///
492 /// let body = "# Objective\nship\n";
493 /// assert_eq!(HandoverBody::digest_of(body).len(), 32);
494 /// assert_eq!(HandoverBody::digest_of(body), HandoverBody::digest_of(body));
495 /// assert_ne!(HandoverBody::digest_of(body), HandoverBody::digest_of("# Objective\nwait\n"));
496 /// ```
497 pub fn digest_of(body: &str) -> String {
498 use sha2::{Digest, Sha256};
499 let digest = Sha256::digest(body.as_bytes());
500 digest.iter().take(16).map(|b| format!("{b:02x}")).collect()
501 }
502}
503
504/// What an event says. One vocabulary for every runtime; an event of a kind this
505/// executable does not know is stored and relayed but not interpreted, so a newer peer
506/// never breaks an older one's replication.
507///
508/// The vocabulary is small on purpose: everything the mesh coordinates is a session, a
509/// claim, a handover or a review, and an event that would need a ninth kind is a feature
510/// that has not been agreed. The `kind` tag is what goes on the wire, so it is protocol —
511/// two executables of different ages read the same word for the same event.
512///
513/// ```
514/// use majordomus_cli::mesh::journal::{EventBody, SessionInfo, KNOWN_KINDS};
515///
516/// let opened = EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") };
517/// assert_eq!(opened.kind(), "session_opened");
518/// assert!(KNOWN_KINDS.contains(&opened.kind()));
519/// assert_eq!(serde_json::to_value(&opened).unwrap()["kind"], "session_opened");
520/// ```
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
522#[serde(tag = "kind", rename_all = "snake_case")]
523pub enum EventBody {
524 /// A session began, or said something new about itself (the latest wins).
525 SessionOpened {
526 /// The session.
527 info: SessionInfo,
528 },
529 /// A session ended. Its claims end with it.
530 SessionClosed {
531 /// The session id within the stream.
532 session: String,
533 },
534 /// A session claimed a scope.
535 ClaimAcquired {
536 /// The claim id within the stream.
537 claim: String,
538 /// The holding session within the stream.
539 session: String,
540 /// Repository-relative paths.
541 scope: Vec<String>,
542 /// What the claim is for.
543 #[serde(default, skip_serializing_if = "Option::is_none")]
544 intent: Option<String>,
545 /// Exclusive or advisory.
546 #[serde(default)]
547 mode: ClaimMode,
548 /// The issue the claim is for.
549 #[serde(default, skip_serializing_if = "Option::is_none")]
550 issue: Option<String>,
551 },
552 /// A claim was released by its holder.
553 ClaimReleased {
554 /// The claim id within the stream.
555 claim: String,
556 },
557 /// A handover was published for other runtimes to consume.
558 HandoverPublished {
559 /// The handover.
560 handover: HandoverBody,
561 },
562 /// A session consumed a handover (on any runtime).
563 HandoverConsumed {
564 /// The handover's content digest.
565 handover: String,
566 /// The consuming session within the stream.
567 session: String,
568 },
569 /// A session asked for a review.
570 ReviewRequested {
571 /// The review id within the stream.
572 review: String,
573 /// The requesting session within the stream.
574 session: String,
575 /// What is to be reviewed: a branch, a commit, a pull request.
576 subject: String,
577 /// Paths the review covers.
578 #[serde(default)]
579 scope: Vec<String>,
580 /// The issue.
581 #[serde(default, skip_serializing_if = "Option::is_none")]
582 issue: Option<String>,
583 /// The runtime asked (`<node>-<runtime>`), when one was.
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 reviewer: Option<String>,
586 /// What the reviewer needs beyond the subject line: the evidence, the commands, the
587 /// question. Up to [`REVIEW_BODY_MAX`] bytes. A runtime older than the field reads
588 /// the request without it.
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 body: Option<String>,
591 },
592 /// A session answered a review request (on any runtime).
593 ReviewAnswered {
594 /// The request: `<stream>/<review>`.
595 request: String,
596 /// The answering session within the stream.
597 session: String,
598 /// `approved`, `changes_requested`, `commented`.
599 verdict: String,
600 /// The answer's note.
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 note: Option<String>,
603 },
604 /// The runtime that asked for a review ended its request: done, void, or superseded.
605 /// Only the asking runtime writes it — from any of its sessions — so it is never a
606 /// verdict; its note is the requester's own result, shown with the request.
607 ReviewWithdrawn {
608 /// The request: `<stream>/<review>`.
609 request: String,
610 /// The withdrawing session within the stream.
611 session: String,
612 /// The requester's result or reason, up to [`REVIEW_BODY_MAX`] bytes.
613 #[serde(default, skip_serializing_if = "Option::is_none")]
614 note: Option<String>,
615 },
616}
617
618/// The most a review request's body, or a requester's result note, carries: room for the
619/// evidence a reviewer needs without a second channel, and far under the link's bound.
620pub const REVIEW_BODY_MAX: usize = 8 * 1024;
621
622/// The event kinds this executable interprets. An event naming another kind is stored and
623/// relayed, never folded.
624pub const KNOWN_KINDS: &[&str] = &[
625 "session_opened",
626 "session_closed",
627 "claim_acquired",
628 "claim_released",
629 "handover_published",
630 "handover_consumed",
631 "review_requested",
632 "review_answered",
633 "review_withdrawn",
634];
635
636impl EventBody {
637 /// The kind's wire word: the same snake-case spelling the serialized event carries, so
638 /// a counter, a log line and a peer's JSON all name an event the same way. It is
639 /// written out rather than derived from the variant name, because the word is protocol
640 /// and renaming a variant must not change what another runtime reads.
641 ///
642 /// ```
643 /// use majordomus_cli::mesh::journal::{EventBody, KNOWN_KINDS};
644 ///
645 /// let released = EventBody::ClaimReleased { claim: "c1".into() };
646 /// assert_eq!(released.kind(), "claim_released");
647 /// assert_eq!(serde_json::to_value(&released).unwrap()["kind"], released.kind());
648 /// assert!(KNOWN_KINDS.contains(&released.kind()));
649 /// ```
650 pub fn kind(&self) -> &'static str {
651 match self {
652 EventBody::SessionOpened { .. } => "session_opened",
653 EventBody::SessionClosed { .. } => "session_closed",
654 EventBody::ClaimAcquired { .. } => "claim_acquired",
655 EventBody::ClaimReleased { .. } => "claim_released",
656 EventBody::HandoverPublished { .. } => "handover_published",
657 EventBody::HandoverConsumed { .. } => "handover_consumed",
658 EventBody::ReviewRequested { .. } => "review_requested",
659 EventBody::ReviewAnswered { .. } => "review_answered",
660 EventBody::ReviewWithdrawn { .. } => "review_withdrawn",
661 }
662 }
663
664 /// Check the body's bounds: short identifiers, repository-relative scopes, a bounded
665 /// handover. A body out of bounds is refused before it is signed or stored.
666 ///
667 /// It runs on both sides: on the writer, so a runtime never signs something its peers
668 /// will refuse, and on the reader, so nothing a peer sends is stored unchecked. The
669 /// bounds are not arbitrary — a scope must be repository-relative because a claim over
670 /// `/etc` means nothing on another machine, a handover's id must be the digest of its
671 /// body or identity would be forgeable, and the fields that become front matter where
672 /// a handover is consumed must be single lines.
673 ///
674 /// ```
675 /// use majordomus_cli::mesh::journal::{EventBody, HandoverBody, SessionInfo};
676 ///
677 /// assert!(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }.validate().is_ok());
678 ///
679 /// // a scope that is not repository-relative is refused
680 /// let escaping = EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
681 /// scope: vec!["../../etc".into()], intent: None, mode: Default::default(), issue: None };
682 /// assert!(escaping.validate().is_err());
683 ///
684 /// // and a handover whose id is not the digest of its body is not that handover
685 /// let body = "# Objective\nship\n".to_string();
686 /// let lying = EventBody::HandoverPublished { handover: HandoverBody {
687 /// id: HandoverBody::digest_of("something else"), task: None, issue: None,
688 /// milestone: None, branch: None, head: None, created_at: None, name: None, body } };
689 /// assert!(lying.validate().is_err());
690 /// ```
691 pub fn validate(&self) -> Result<(), String> {
692 match self {
693 EventBody::SessionOpened { info } => {
694 ident("session", &info.session)?;
695 text("client", &info.client, 64)?;
696 for (name, value, max) in [
697 ("worker", &info.worker, 128),
698 ("intent", &info.intent, 512),
699 ("task", &info.task, 64),
700 ("issue", &info.issue, 64),
701 ("milestone", &info.milestone, 64),
702 ("checkout", &info.checkout, 64),
703 ("branch", &info.branch, 256),
704 ("head", &info.head, 64),
705 ("context", &info.context, 128),
706 ] {
707 if let Some(v) = value {
708 line(name, v, max)?;
709 }
710 }
711 Ok(())
712 }
713 EventBody::SessionClosed { session } => ident("session", session),
714 EventBody::ClaimAcquired {
715 claim,
716 session,
717 scope,
718 intent,
719 issue,
720 ..
721 } => {
722 ident("claim", claim)?;
723 ident("session", session)?;
724 paths(scope, true)?;
725 if let Some(v) = intent {
726 text("intent", v, 512)?;
727 }
728 if let Some(v) = issue {
729 text("issue", v, 64)?;
730 }
731 Ok(())
732 }
733 EventBody::ClaimReleased { claim } => ident("claim", claim),
734 EventBody::HandoverPublished { handover } => {
735 if handover.id != HandoverBody::digest_of(&handover.body) {
736 return Err("the handover id is not the digest of its body".into());
737 }
738 if handover.body.len() > MAX_HANDOVER_BYTES {
739 return Err(format!(
740 "a handover body of {} bytes exceeds {MAX_HANDOVER_BYTES}",
741 handover.body.len()
742 ));
743 }
744 for (name, value, max) in [
745 ("task", &handover.task, 64),
746 ("issue", &handover.issue, 64),
747 ("milestone", &handover.milestone, 64),
748 ("branch", &handover.branch, 256),
749 ("head", &handover.head, 64),
750 ("created_at", &handover.created_at, 64),
751 ("name", &handover.name, 256),
752 ] {
753 // These become front matter and a file name wherever the handover is
754 // consumed: one line each, never a place to smuggle a key or a path.
755 if let Some(v) = value {
756 line(name, v, max)?;
757 }
758 }
759 Ok(())
760 }
761 EventBody::HandoverConsumed { handover, session } => {
762 ident("handover", handover)?;
763 ident("session", session)
764 }
765 EventBody::ReviewRequested {
766 review,
767 session,
768 subject,
769 scope,
770 issue,
771 reviewer,
772 body,
773 } => {
774 ident("review", review)?;
775 ident("session", session)?;
776 text("subject", subject, 256)?;
777 paths(scope, false)?;
778 if let Some(v) = issue {
779 text("issue", v, 64)?;
780 }
781 if let Some(v) = reviewer {
782 text("reviewer", v, 64)?;
783 }
784 if let Some(v) = body {
785 text("body", v, REVIEW_BODY_MAX)?;
786 }
787 Ok(())
788 }
789 EventBody::ReviewAnswered {
790 request,
791 session,
792 verdict,
793 note,
794 } => {
795 text("request", request, 128)?;
796 ident("session", session)?;
797 if !["approved", "changes_requested", "commented"].contains(&verdict.as_str()) {
798 return Err(format!(
799 "verdict '{verdict}' is not approved, changes_requested or commented"
800 ));
801 }
802 if let Some(v) = note {
803 text("note", v, 2048)?;
804 }
805 Ok(())
806 }
807 EventBody::ReviewWithdrawn {
808 request,
809 session,
810 note,
811 } => {
812 text("request", request, 128)?;
813 ident("session", session)?;
814 if let Some(v) = note {
815 text("note", v, REVIEW_BODY_MAX)?;
816 }
817 Ok(())
818 }
819 }
820 }
821}
822
823fn ident(name: &str, value: &str) -> Result<(), String> {
824 let ok = !value.is_empty()
825 && value.len() <= 64
826 && value
827 .bytes()
828 .all(|b| b.is_ascii_alphanumeric() || b"._:-#".contains(&b));
829 if ok {
830 Ok(())
831 } else {
832 Err(format!(
833 "{name} '{value}' is not an identifier (1-64 of [A-Za-z0-9._:#-])"
834 ))
835 }
836}
837
838fn text(name: &str, value: &str, max: usize) -> Result<(), String> {
839 if value.len() > max || value.chars().any(|c| c.is_control() && c != '\n') {
840 return Err(format!(
841 "{name} is over {max} bytes or carries control characters"
842 ));
843 }
844 Ok(())
845}
846
847fn line(name: &str, value: &str, max: usize) -> Result<(), String> {
848 if value.len() > max || value.chars().any(char::is_control) {
849 return Err(format!(
850 "{name} is over {max} bytes or is not a single line"
851 ));
852 }
853 Ok(())
854}
855
856fn paths(scope: &[String], required: bool) -> Result<(), String> {
857 if required && scope.is_empty() {
858 return Err("a claim names at least one path".into());
859 }
860 if scope.len() > MAX_SCOPE_PATHS {
861 return Err(format!("more than {MAX_SCOPE_PATHS} paths"));
862 }
863 for path in scope {
864 if path.is_empty()
865 || path.len() > 256
866 || path.starts_with('/')
867 || path.split('/').any(|s| s == "..")
868 || path.chars().any(|c| c.is_control())
869 {
870 return Err(format!(
871 "'{path}' is not a repository-relative path (no leading '/', no '..')"
872 ));
873 }
874 }
875 Ok(())
876}
877
878/// One event as the wire and the store carry it. The body is kept as JSON so that an
879/// event of an unknown kind survives storage and relay byte-for-byte; [`MeshEvent::body`]
880/// interprets it.
881///
882/// Every field above the signature is covered by it, which is what makes a relay harmless:
883/// a runtime that forwards an event cannot alter its stream, its sequence, its repository
884/// or its body without the next reader noticing, and so vouches for nothing. The wall
885/// clock is the one field nothing is decided by — freshness is measured with beats and
886/// monotonic clocks, never by comparing two machines' idea of the time.
887///
888/// ```
889/// use std::sync::Arc;
890/// use majordomus_cli::mesh::identity::NodeIdentity;
891/// use majordomus_cli::mesh::journal::{EventBody, Journal, MeshEvent};
892///
893/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
894/// "repo".into(), None).unwrap();
895/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
896///
897/// let event: &MeshEvent = &j.events()[0];
898/// assert_eq!(event.seq, 1, "dense from 1 within its stream");
899/// assert_eq!(event.kind(), "session_closed");
900/// assert_eq!(event.id(), format!("{}/1", event.stream));
901///
902/// // a relayed byte changed anywhere under the signature is a different event
903/// let mut tampered = event.clone();
904/// tampered.repo = "another-repository".into();
905/// assert_ne!(tampered.signing_bytes(), event.signing_bytes());
906/// ```
907#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
908pub struct MeshEvent {
909 /// The event format version.
910 pub v: u32,
911 /// The stream that wrote it.
912 pub stream: StreamId,
913 /// Dense from 1 within the stream.
914 pub seq: u64,
915 /// The Lamport stamp: above every stamp its writer had seen.
916 pub lamport: u64,
917 /// The writer's wall clock, seconds since the Unix epoch. Informational only: nothing
918 /// is decided by comparing it with another machine's clock.
919 pub at: u64,
920 /// The mesh repository id the event belongs to.
921 pub repo: String,
922 /// The writer's Ed25519 public key, hex; its digest is the stream's node.
923 pub pk: String,
924 /// The body, `kind` tagged.
925 pub body: Value,
926 /// Ed25519 over the canonical bytes of everything above, hex.
927 pub sig: String,
928}
929
930/// The causal position of an event: its Lamport stamp, then its stream, then its sequence.
931///
932/// One definition, because every runtime must apply the same events in the same sequence
933/// for the fold to converge, and two comparators that drift apart would be two different
934/// states with one name. Collections key a `BTreeMap` by it rather than sorting themselves.
935///
936/// ```
937/// use majordomus_cli::mesh::journal::{causal_key, EventBody, Journal};
938/// use majordomus_cli::mesh::identity::NodeIdentity;
939/// use std::sync::Arc;
940///
941/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
942/// "repo".into(), None).unwrap();
943/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
944/// let event = &j.events()[0];
945/// assert_eq!(causal_key(event), (event.lamport, event.stream.clone(), event.seq));
946/// ```
947pub fn causal_key(event: &MeshEvent) -> (u64, StreamId, u64) {
948 (event.lamport, event.stream.clone(), event.seq)
949}
950
951/// The canonical bytes of a JSON value: object keys sorted at every depth, no whitespace.
952/// Signer and verifier compute it identically regardless of how either parser orders keys.
953///
954/// ```
955/// use majordomus_cli::mesh::journal::canonical_json;
956/// let a = serde_json::json!({"b": 1, "a": [{"d": 2, "c": 3}]});
957/// assert_eq!(canonical_json(&a), br#"{"a":[{"c":3,"d":2}],"b":1}"#.to_vec());
958/// ```
959pub fn canonical_json(value: &Value) -> Vec<u8> {
960 let mut out = Vec::new();
961 write_canonical(value, &mut out);
962 out
963}
964
965fn write_canonical(value: &Value, out: &mut Vec<u8>) {
966 match value {
967 Value::Object(map) => {
968 // A BTreeSet, not a sorted vector: the signing order is the byte order of the
969 // keys, decided by the container rather than by a comparator written here.
970 let keys: std::collections::BTreeSet<&String> = map.keys().collect();
971 out.push(b'{');
972 for (i, key) in keys.iter().enumerate() {
973 if i > 0 {
974 out.push(b',');
975 }
976 out.extend(serde_json::to_vec(key).unwrap_or_default());
977 out.push(b':');
978 write_canonical(&map[*key], out);
979 }
980 out.push(b'}');
981 }
982 Value::Array(items) => {
983 out.push(b'[');
984 for (i, item) in items.iter().enumerate() {
985 if i > 0 {
986 out.push(b',');
987 }
988 write_canonical(item, out);
989 }
990 out.push(b']');
991 }
992 other => out.extend(serde_json::to_vec(other).unwrap_or_default()),
993 }
994}
995
996impl MeshEvent {
997 /// The bytes the signature covers: a domain separator, then the canonical JSON of
998 /// every field of the event except the signature itself. Canonical, so that signer and
999 /// verifier agree however their JSON parsers order keys; domain-separated, so that a
1000 /// signature over an event can never be presented as one over an advertisement or a
1001 /// link message.
1002 ///
1003 /// ```
1004 /// use std::sync::Arc;
1005 /// use majordomus_cli::mesh::identity::NodeIdentity;
1006 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1007 ///
1008 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1009 /// "repo".into(), None).unwrap();
1010 /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1011 /// let event = j.events()[0].clone();
1012 ///
1013 /// // the signature is not part of what it covers, so re-signing changes nothing here
1014 /// let mut resigned = event.clone();
1015 /// resigned.sig = "00".repeat(64);
1016 /// assert_eq!(resigned.signing_bytes(), event.signing_bytes());
1017 ///
1018 /// // the body is
1019 /// let mut edited = event.clone();
1020 /// edited.body = serde_json::json!({ "kind": "session_closed", "session": "s2" });
1021 /// assert_ne!(edited.signing_bytes(), event.signing_bytes());
1022 /// ```
1023 pub fn signing_bytes(&self) -> Vec<u8> {
1024 let core = serde_json::json!({
1025 "v": self.v,
1026 "stream": self.stream,
1027 "seq": self.seq,
1028 "lamport": self.lamport,
1029 "at": self.at,
1030 "repo": self.repo,
1031 "pk": self.pk,
1032 "body": self.body,
1033 });
1034 let mut bytes = SIGNING_DOMAIN.to_vec();
1035 bytes.extend(canonical_json(&core));
1036 bytes
1037 }
1038
1039 /// `<stream>/<seq>`: the event's identity everywhere. It is the pair rather than a
1040 /// digest because it is also the address replication works with — a peer's marks name
1041 /// a stream and a sequence, and what is missing is arithmetic rather than a search.
1042 ///
1043 /// ```
1044 /// use std::sync::Arc;
1045 /// use majordomus_cli::mesh::identity::NodeIdentity;
1046 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1047 ///
1048 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1049 /// "repo".into(), None).unwrap();
1050 /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1051 /// j.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
1052 ///
1053 /// let events = j.events();
1054 /// assert_eq!(events[0].id(), format!("{}/1", events[0].stream));
1055 /// assert_ne!(events[0].id(), events[1].id());
1056 /// ```
1057 pub fn id(&self) -> String {
1058 format!("{}/{}", self.stream, self.seq)
1059 }
1060
1061 /// The interpreted body; `None` for a kind this executable does not know. An event
1062 /// this version cannot read is still stored and relayed byte-for-byte, so an older
1063 /// runtime in a mesh with newer ones carries their traffic instead of breaking it —
1064 /// the fold counts what it could not interpret rather than dropping it silently.
1065 ///
1066 /// ```
1067 /// use std::sync::Arc;
1068 /// use majordomus_cli::mesh::identity::NodeIdentity;
1069 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1070 ///
1071 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1072 /// "repo".into(), None).unwrap();
1073 /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1074 /// let event = j.events()[0].clone();
1075 /// assert!(matches!(event.body(), Some(EventBody::SessionClosed { .. })));
1076 ///
1077 /// // a kind from a newer peer: unreadable here, and still an event
1078 /// let mut newer = event.clone();
1079 /// newer.body = serde_json::json!({ "kind": "telepathy_offered" });
1080 /// assert!(newer.body().is_none());
1081 /// assert_eq!(newer.kind(), "telepathy_offered");
1082 /// ```
1083 pub fn body(&self) -> Option<EventBody> {
1084 serde_json::from_value(self.body.clone()).ok()
1085 }
1086
1087 /// The body's `kind` word, known or not. It reads the tag out of the stored JSON
1088 /// rather than going through [`MeshEvent::body`], so that an event this executable
1089 /// cannot interpret can still be counted and shown by name.
1090 ///
1091 /// ```
1092 /// use std::sync::Arc;
1093 /// use majordomus_cli::mesh::identity::NodeIdentity;
1094 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1095 ///
1096 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1097 /// "repo".into(), None).unwrap();
1098 /// j.append_own(EventBody::SessionOpened {
1099 /// info: majordomus_cli::mesh::journal::SessionInfo::named("s1", "cli"),
1100 /// })
1101 /// .unwrap();
1102 /// assert_eq!(j.events()[0].kind(), "session_opened");
1103 /// ```
1104 pub fn kind(&self) -> &str {
1105 self.body.get("kind").and_then(Value::as_str).unwrap_or("")
1106 }
1107}
1108
1109/// Why an event was not stored. A journal that silently drops what it is sent is a journal
1110/// nobody can debug, so every refusal is counted under one of these reasons and shown: a
1111/// mesh whose runtimes will not converge can be diagnosed from the numbers, because the
1112/// reasons distinguish a misconfiguration (`Repository`, `Untrusted`) from an attack
1113/// (`Signature`, `Identity`) from a limit reached (`Capacity`, `Oversized`).
1114///
1115/// ```
1116/// use majordomus_cli::mesh::journal::Rejection;
1117///
1118/// // the wire word is what a counter and a JSON report both carry
1119/// assert_eq!(serde_json::to_value(Rejection::Untrusted).unwrap(), serde_json::json!("untrusted"));
1120/// assert_ne!(Rejection::Untrusted, Rejection::Repository);
1121/// ```
1122#[derive(
1123 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
1124)]
1125#[serde(rename_all = "snake_case")]
1126pub enum Rejection {
1127 /// Over [`MAX_EVENT_BYTES`].
1128 Oversized,
1129 /// An event format version this executable does not read.
1130 Version,
1131 /// The stream id, the key, or the key-to-node binding does not hold.
1132 Identity,
1133 /// The signature does not verify.
1134 Signature,
1135 /// Another repository's event.
1136 Repository,
1137 /// The origin is not trusted by this runtime's policy.
1138 Untrusted,
1139 /// A known kind whose body is out of bounds.
1140 Bounds,
1141 /// The journal's stream or pending bound is full.
1142 Capacity,
1143 /// The Lamport stamp is more than [`MAX_LAMPORT_LEAD`] ahead of this runtime's clock.
1144 ClockAhead,
1145}
1146
1147/// What one ingest did: how many events were stored, how many were deliveries of something
1148/// already held, how many are waiting for a gap ahead of them, and what was refused and
1149/// why. Every event handed in is accounted for in exactly one of those, which is what makes
1150/// a sync round auditable — a peer that sends ten events and is told about ten knows
1151/// nothing went missing between the wire and the store.
1152///
1153/// ```
1154/// use std::sync::Arc;
1155/// use majordomus_cli::mesh::identity::NodeIdentity;
1156/// use majordomus_cli::mesh::journal::{EventBody, IngestReport, Journal, SessionInfo};
1157///
1158/// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1159/// "repo".into(), None).unwrap();
1160/// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
1161/// "repo".into(), None).unwrap();
1162/// a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
1163///
1164/// let events = a.missing_for(&b.marks(), 1 << 20);
1165/// let first: IngestReport = b.ingest(&events, &|_| Ok(()));
1166/// assert_eq!((first.accepted, first.duplicate, first.rejected_total()), (1, 0, 0));
1167///
1168/// // at-least-once delivery: the same event again is absorbed, and changes nothing
1169/// let second = b.ingest(&events, &|_| Ok(()));
1170/// assert_eq!((second.accepted, second.duplicate), (0, 1));
1171/// ```
1172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1173pub struct IngestReport {
1174 /// Newly stored and applied, in stream order.
1175 pub accepted: u64,
1176 /// Already held: at-least-once delivery, absorbed.
1177 pub duplicate: u64,
1178 /// Held back until the gap before them arrives.
1179 pub pending: u64,
1180 /// Refused, by reason.
1181 pub rejected: BTreeMap<Rejection, u64>,
1182}
1183
1184impl IngestReport {
1185 fn reject(&mut self, why: Rejection) {
1186 *self.rejected.entry(why).or_default() += 1;
1187 }
1188
1189 /// Every refused event, summed over reasons.
1190 pub fn rejected_total(&self) -> u64 {
1191 self.rejected.values().sum()
1192 }
1193}
1194
1195/// What a runtime knows of one stream: the contiguous high-water sequence, the highest
1196/// beat with its origin's signature, and how long ago that beat was seen to rise (`None`
1197/// when never).
1198///
1199/// A mark is both halves of replication in one value. The sequence is what a peer compares
1200/// to decide what to send, which is why replication is a comparison rather than a flood.
1201/// The beat is how liveness crosses machines without comparing clocks: the origin signs it,
1202/// a relay forwards the signature and adds how long ago *it* saw the beat rise, and the
1203/// reader converts that to freshness on its own monotonic clock. Because only the origin
1204/// can sign its beat, a relay can carry a stream's liveness without being able to invent it.
1205///
1206/// ```
1207/// use std::sync::Arc;
1208/// use majordomus_cli::mesh::identity::NodeIdentity;
1209/// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamMark};
1210///
1211/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1212/// "repo".into(), None).unwrap();
1213/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1214/// j.beat_own();
1215///
1216/// let marks = j.marks();
1217/// let mark: &StreamMark = &marks[&j.own_stream()];
1218/// assert_eq!(mark.seq, 1, "every event up to here is held");
1219/// assert!(mark.beat > 0 && mark.sig.is_some(), "the beat is the origin's signed statement");
1220/// ```
1221#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1222pub struct StreamMark {
1223 /// Every event up to and including this sequence is held.
1224 pub seq: u64,
1225 /// The highest beat heard.
1226 pub beat: u64,
1227 /// Milliseconds since that beat rose, on the sender's monotonic clock. Clamped by the
1228 /// reader to its expiry: a relay can report a beat stale, never older than dead.
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub age_ms: Option<u64>,
1231 /// The origin's public key, hex: the beat is its statement.
1232 #[serde(default, skip_serializing_if = "Option::is_none")]
1233 pub pk: Option<String>,
1234 /// The origin's signature over the stream and the beat, hex. A relay forwards it
1235 /// verbatim; only the origin can raise its own beat.
1236 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub sig: Option<String>,
1238}
1239
1240/// The bytes a beat signature covers.
1241fn beat_bytes(stream: &StreamId, beat: u64) -> Vec<u8> {
1242 format!("majordomus-mesh-beat/v1\n{stream}\n{beat}").into_bytes()
1243}
1244
1245/// Every stream's mark: what a sync request and answer carry.
1246pub type Marks = BTreeMap<StreamId, StreamMark>;
1247
1248/// Whether a stream is speaking now, on this runtime's clock. It is a verdict about a
1249/// stream and not about a link: a runtime reachable only through a relay is alive, and a
1250/// runtime that crashed stops beating for everyone at once. `Own` is kept apart from `Live`
1251/// because a runtime's own stream needs no evidence — it is the thing doing the beating.
1252///
1253/// ```
1254/// use majordomus_cli::mesh::journal::StreamLiveness;
1255///
1256/// assert!(StreamLiveness::Own.is_alive() && StreamLiveness::Live.is_alive());
1257/// assert!(!StreamLiveness::Expired.is_alive());
1258/// assert_eq!(
1259/// serde_json::to_value(StreamLiveness::Expired).unwrap(),
1260/// serde_json::json!("expired"),
1261/// );
1262/// ```
1263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1264#[serde(rename_all = "snake_case")]
1265pub enum StreamLiveness {
1266 /// This runtime's own current stream.
1267 Own,
1268 /// Its beat rose within the expiry.
1269 Live,
1270 /// Its beat has not risen within the expiry, or was never heard.
1271 Expired,
1272}
1273
1274impl StreamLiveness {
1275 /// Whether the stream counts as alive: its claims hold, its sessions are present. The
1276 /// two living verdicts are collapsed here because everything downstream treats them
1277 /// alike — a claim of this runtime's own and a claim of a peer that still beats both
1278 /// exclude — while the distinction is kept in the enum for anything that reports it.
1279 ///
1280 /// ```
1281 /// use majordomus_cli::mesh::journal::StreamLiveness;
1282 ///
1283 /// assert!(StreamLiveness::Own.is_alive(), "no evidence needed for one's own stream");
1284 /// assert!(StreamLiveness::Live.is_alive());
1285 /// assert!(!StreamLiveness::Expired.is_alive(), "its claims end without anyone releasing them");
1286 /// ```
1287 pub fn is_alive(self) -> bool {
1288 !matches!(self, StreamLiveness::Expired)
1289 }
1290}
1291
1292/// The journal's counters since start: how much it holds, how much it has written,
1293/// received, absorbed, refused, held back and compacted away. They are what makes a
1294/// replication problem diagnosable from one machine — a runtime whose `received` never
1295/// rises is not linked, one whose `rejected` rises is being sent something it will not
1296/// take, and one whose `pending` stays high is missing an event ahead of what it has.
1297///
1298/// ```
1299/// use std::sync::Arc;
1300/// use majordomus_cli::mesh::identity::NodeIdentity;
1301/// use majordomus_cli::mesh::journal::{EventBody, Journal, JournalTallies};
1302///
1303/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1304/// "repo".into(), None).unwrap();
1305/// let before: JournalTallies = j.tallies();
1306/// assert_eq!((before.written, before.events), (0, 0));
1307///
1308/// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1309/// let after = j.tallies();
1310/// assert_eq!((after.written, after.events, after.streams), (1, 1, 1));
1311/// assert!(after.lamport > before.lamport);
1312/// ```
1313#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1314pub struct JournalTallies {
1315 /// Streams tracked.
1316 pub streams: usize,
1317 /// Events held.
1318 pub events: usize,
1319 /// Events written by this runtime since start.
1320 pub written: u64,
1321 /// Events received and stored.
1322 pub received: u64,
1323 /// Duplicate deliveries absorbed.
1324 pub duplicates: u64,
1325 /// Events refused.
1326 pub rejected: u64,
1327 /// Events held for a gap right now.
1328 pub pending: usize,
1329 /// Events dropped by compaction since start.
1330 pub compacted: u64,
1331 /// Events of a kind this executable does not interpret, held and relayed.
1332 pub opaque: usize,
1333 /// The highest Lamport stamp seen.
1334 pub lamport: u64,
1335 /// Appends to the journal file that failed since start: the events were held in memory
1336 /// and replicated, and are not on this machine's disk. Nonzero fails `mesh doctor`.
1337 #[serde(default)]
1338 pub write_failures: u64,
1339 /// Lines of the journal file the last reload could not read: torn, corrupt, or written
1340 /// by something that is not this journal. Counted, never silently dropped.
1341 #[serde(default)]
1342 pub unreadable: u64,
1343 /// Bytes of the journal file the last reload left unread because they lie beyond
1344 /// [`MAX_RELOAD_BYTES`]: on disk and kept by compaction, and not in memory. Nonzero fails
1345 /// `mesh doctor`.
1346 #[serde(default)]
1347 pub unread_bytes: u64,
1348}
1349
1350/// Whether an event this runtime wrote reached this machine's disk: what an append
1351/// acknowledges, and the only thing a caller may say about it.
1352///
1353/// ```
1354/// use majordomus_cli::mesh::journal::Durability;
1355/// assert_eq!(serde_json::to_value(Durability::NotDurable).unwrap(), "not_durable");
1356/// assert_eq!(Durability::default(), Durability::Unknown, "an answer that did not say");
1357/// assert!(Durability::Synced.is_on_disk() && !Durability::NotDurable.is_on_disk());
1358/// ```
1359#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1360#[serde(rename_all = "snake_case")]
1361pub enum Durability {
1362 /// Appended to the journal file and synced (`fsync` of its data) before the append
1363 /// returned.
1364 Synced,
1365 /// The journal has no file: it lives in memory and its peers, by configuration.
1366 Memory,
1367 /// The write to the journal file failed. The event is held in memory and replicated to
1368 /// peers, and is lost to this machine when the runtime restarts unless a peer still holds
1369 /// it. It is not acknowledged as durable.
1370 NotDurable,
1371 /// Not reported: an answer from an executable that predates this field.
1372 #[default]
1373 Unknown,
1374}
1375
1376impl Durability {
1377 /// Whether the event is on this machine's disk: only an append that was synced is.
1378 ///
1379 /// ```
1380 /// use majordomus_cli::mesh::journal::Durability;
1381 /// assert!(Durability::Synced.is_on_disk());
1382 /// for not in [Durability::Memory, Durability::NotDurable, Durability::Unknown] {
1383 /// assert!(!not.is_on_disk(), "{not:?} is not on disk");
1384 /// }
1385 /// ```
1386 pub fn is_on_disk(self) -> bool {
1387 self == Durability::Synced
1388 }
1389}
1390
1391/// An event this runtime wrote, with what its append acknowledges about the disk.
1392///
1393/// ```
1394/// use std::sync::Arc;
1395/// use majordomus_cli::mesh::identity::NodeIdentity;
1396/// use majordomus_cli::mesh::journal::{Appended, Durability, EventBody, Journal};
1397///
1398/// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1399/// "repo".into(), None).unwrap();
1400/// let appended: Appended =
1401/// j.append_acknowledged(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1402/// assert_eq!(appended.event.seq, 1);
1403/// assert_eq!(appended.durability, Durability::Memory);
1404/// ```
1405#[derive(Debug, Clone)]
1406pub struct Appended {
1407 /// The event, signed and stamped, held and offered to peers.
1408 pub event: MeshEvent,
1409 /// Whether it reached this machine's disk.
1410 pub durability: Durability,
1411}
1412
1413struct StreamLog {
1414 events: BTreeMap<u64, MeshEvent>,
1415 pending: BTreeMap<u64, MeshEvent>,
1416 beat: u64,
1417 beat_pk: Option<String>,
1418 beat_sig: Option<String>,
1419 fresh_at: Option<Instant>,
1420 /// When this journal first held the stream: a stream whose beat has never been heard
1421 /// here has been silent since then, not since forever.
1422 learned_at: Instant,
1423}
1424
1425impl StreamLog {
1426 fn new() -> Self {
1427 StreamLog {
1428 events: BTreeMap::new(),
1429 pending: BTreeMap::new(),
1430 beat: 0,
1431 beat_pk: None,
1432 beat_sig: None,
1433 fresh_at: None,
1434 learned_at: Instant::now(),
1435 }
1436 }
1437
1438 fn high_water(&self) -> u64 {
1439 self.events.keys().next_back().copied().unwrap_or(0)
1440 }
1441
1442 /// How long the stream has been silent on this runtime's clock: since its beat last
1443 /// rose, or — never heard — since the journal learned of it. A reloaded journal learns
1444 /// every stream at its start, so a restart does not make a running sibling look dead.
1445 fn silent_for(&self, now: Instant) -> Duration {
1446 now.saturating_duration_since(self.fresh_at.unwrap_or(self.learned_at))
1447 }
1448}
1449
1450/// What is left of a stream compacted away: how far it got, and the beat it was last heard
1451/// at. The sequence is advertised as its mark, so a peer that still holds the stream does not
1452/// send it again; the beat is what the stream must rise above to be heard again.
1453#[derive(Clone, Copy)]
1454struct Tombstone {
1455 seq: u64,
1456 beat: u64,
1457}
1458
1459struct Inner {
1460 /// The bytes of the journal file the last reload left unread, as `start..end` offsets in
1461 /// the file as it is now: beyond [`MAX_RELOAD_BYTES`]. Compaction copies them into the
1462 /// file it writes instead of dropping them.
1463 unread_tail: Option<std::ops::Range<u64>>,
1464 /// Streams compacted away. A stream is here or in `streams`, never in both.
1465 tombstones: BTreeMap<StreamId, Tombstone>,
1466 streams: BTreeMap<StreamId, StreamLog>,
1467 lamport: u64,
1468 tallies: JournalTallies,
1469}
1470
1471/// The journal of one runtime. `Mutex<BTreeMap>`: a handful of events a second, and a
1472/// deterministic order falls out for free.
1473///
1474/// One journal holds every stream this runtime knows of, not only its own: what arrives
1475/// from a peer is stored beside what this runtime wrote, which is what lets it relay. It
1476/// is the only thing in the mesh that writes, and everything a surface shows about
1477/// cooperation is a fold of what it holds.
1478///
1479/// ```
1480/// use std::sync::Arc;
1481/// use majordomus_cli::mesh::identity::NodeIdentity;
1482/// use majordomus_cli::mesh::journal::{EventBody, Journal, SessionInfo};
1483///
1484/// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1485/// "repo".into(), None).unwrap();
1486/// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
1487/// "repo".into(), None).unwrap();
1488/// a.append_own(EventBody::SessionOpened { info: SessionInfo::named("s1", "cli") }).unwrap();
1489///
1490/// // what B receives is held beside what B writes, so B can relay A's stream onwards
1491/// b.ingest(&a.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
1492/// assert_eq!(b.tallies().streams, 2, "B's own stream, and the one it heard from A");
1493/// assert!(b.events().iter().any(|e| &e.stream == a.own_stream()));
1494/// ```
1495pub struct Journal {
1496 identity: Arc<NodeIdentity>,
1497 own: StreamId,
1498 repo: String,
1499 path: Option<PathBuf>,
1500 inner: Mutex<Inner>,
1501 rotation: std::sync::atomic::AtomicUsize,
1502 reloaded: IngestReport,
1503 /// Failed appends; outside `inner`, because an append runs with `inner` held.
1504 write_failures: std::sync::atomic::AtomicU64,
1505 /// Unreadable lines of the last reload.
1506 unreadable: std::sync::atomic::AtomicU64,
1507}
1508
1509impl Journal {
1510 /// Open the journal of this runtime: the own stream is `<node>-<runtime>-<instance>` of
1511 /// the identity given, and `path`, when given, is the JSONL file events persist to and
1512 /// are reloaded from. Reloaded events keep their streams; none of them is fresh until
1513 /// a beat is heard again, so a restart never resurrects anybody's ownership.
1514 ///
1515 /// That last part is the reason persistence is safe here: reloading events would
1516 /// otherwise bring back claims whose holders are long gone, and a restarted runtime
1517 /// would refuse work on behalf of machines that are not running. Persistence is
1518 /// optional because a journal without a path is a complete journal — it simply starts
1519 /// empty and refills from its peers.
1520 ///
1521 /// ```
1522 /// use std::sync::Arc;
1523 /// use std::time::Duration;
1524 /// use majordomus_cli::mesh::identity::NodeIdentity;
1525 /// use majordomus_cli::mesh::journal::{EventBody, Journal, StreamLiveness};
1526 ///
1527 /// let dir = tempfile::tempdir().unwrap();
1528 /// let path = dir.path().join("journal.jsonl");
1529 ///
1530 /// let before = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1531 /// "0000000000000001", "repo".into(), Some(path.clone())).unwrap();
1532 /// before.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1533 ///
1534 /// // another runtime opens the same file: the events are back, and whoever wrote them
1535 /// // is not alive here until a beat is heard again
1536 /// let after = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1537 /// "0000000000000002", "repo".into(), Some(path)).unwrap();
1538 /// assert_eq!(after.events().len(), 1);
1539 /// assert_eq!(
1540 /// after.liveness(before.own_stream(), Duration::from_secs(30)),
1541 /// StreamLiveness::Expired,
1542 /// );
1543 ///
1544 /// // a runtime slot that is not 16 hex is not a runtime
1545 /// let ephemeral = Arc::new(NodeIdentity::ephemeral().unwrap());
1546 /// assert!(Journal::open(ephemeral, "nope", "repo".into(), None).is_err());
1547 /// ```
1548 ///
1549 /// This opening believes every origin the file holds, which is what a journal without a
1550 /// trust policy of its own can do. A runtime that has one opens with
1551 /// [`Journal::open_trusting`], so that a key withdrawn from its allowlist is not
1552 /// believed again from its own disk.
1553 pub fn open(
1554 identity: Arc<NodeIdentity>,
1555 runtime: &str,
1556 repo: String,
1557 path: Option<PathBuf>,
1558 ) -> Result<Self, MeshError> {
1559 Self::open_trusting(identity, runtime, repo, path, &|_| Ok(()))
1560 }
1561
1562 /// [`Journal::open`], with the trust decision the live path makes applied to every
1563 /// event reloaded from `path`.
1564 ///
1565 /// A journal file is input, the same as a sync round: it holds whatever was trusted
1566 /// when it was written, and the policy may have changed since. A key removed from the
1567 /// allowlist and a runtime restarted would otherwise bring that key's sessions, claims
1568 /// and handovers back from disk — events the live path would refuse the moment they
1569 /// arrived over a link. So every reloaded event passes the same checks as a received
1570 /// one and then `accept`, the same predicate the caller hands to [`Journal::ingest`];
1571 /// an event signed by this node's own key is accepted without asking, because the key
1572 /// is the machine's own and a restarted runtime of it is the same machine. What is
1573 /// refused is not dropped silently: it is in [`Journal::reload_report`], by reason.
1574 ///
1575 /// ```
1576 /// use std::sync::Arc;
1577 /// use majordomus_cli::mesh::identity::NodeIdentity;
1578 /// use majordomus_cli::mesh::journal::{EventBody, Journal, Rejection};
1579 ///
1580 /// let dir = tempfile::tempdir().unwrap();
1581 /// let path = dir.path().join("journal.jsonl");
1582 /// let machine = Arc::new(NodeIdentity::ephemeral().unwrap());
1583 /// let peer = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1584 /// "0000000000000001", "repo".into(), None).unwrap();
1585 /// let peers_event = peer.append_own(EventBody::SessionClosed { session: "p".into() })
1586 /// .unwrap();
1587 ///
1588 /// // this machine stores a peer's event and one of its own
1589 /// let before = Journal::open(Arc::clone(&machine), "0000000000000002", "repo".into(),
1590 /// Some(path.clone())).unwrap();
1591 /// before.ingest(&[peers_event], &|_| Ok(()));
1592 /// before.append_own(EventBody::SessionClosed { session: "own".into() }).unwrap();
1593 ///
1594 /// // the peer is no longer trusted when another server of this machine — the same key,
1595 /// // another stream, as a restart is — opens the file
1596 /// let after = Journal::open_trusting(Arc::clone(&machine), "0000000000000003",
1597 /// "repo".into(), Some(path), &|_| Err(Rejection::Untrusted)).unwrap();
1598 /// assert_eq!(after.events().len(), 1, "the machine's own event, and only it");
1599 /// assert_eq!(after.reload_report().rejected[&Rejection::Untrusted], 1);
1600 /// ```
1601 pub fn open_trusting(
1602 identity: Arc<NodeIdentity>,
1603 runtime: &str,
1604 repo: String,
1605 path: Option<PathBuf>,
1606 accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
1607 ) -> Result<Self, MeshError> {
1608 Self::open_bounded(identity, runtime, repo, path, accept, MAX_RELOAD_BYTES)
1609 }
1610
1611 /// [`Journal::open_trusting`] with the reload bound given: what the tests use to prove
1612 /// the bound without writing [`MAX_RELOAD_BYTES`] to disk.
1613 pub(crate) fn open_bounded(
1614 identity: Arc<NodeIdentity>,
1615 runtime: &str,
1616 repo: String,
1617 path: Option<PathBuf>,
1618 accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
1619 reload_limit: u64,
1620 ) -> Result<Self, MeshError> {
1621 let own = StreamId::new(
1622 identity.public.node_id.as_str(),
1623 runtime,
1624 identity.public.instance_id.as_str(),
1625 )
1626 .ok_or_else(|| {
1627 MeshError::Protocol(format!("runtime '{runtime}' is not 16 hex characters"))
1628 })?;
1629 let mut streams = BTreeMap::new();
1630 streams.insert(own.clone(), StreamLog::new());
1631 let mut journal = Journal {
1632 identity,
1633 own,
1634 repo,
1635 path,
1636 inner: Mutex::new(Inner {
1637 unread_tail: None,
1638 tombstones: BTreeMap::new(),
1639 streams,
1640 lamport: 0,
1641 tallies: JournalTallies::default(),
1642 }),
1643 rotation: std::sync::atomic::AtomicUsize::new(0),
1644 reloaded: IngestReport::default(),
1645 write_failures: std::sync::atomic::AtomicU64::new(0),
1646 unreadable: std::sync::atomic::AtomicU64::new(0),
1647 };
1648 if let Some(path) = journal.path.clone() {
1649 journal.reloaded = journal.reload(&path, accept, reload_limit);
1650 }
1651 Ok(journal)
1652 }
1653
1654 fn reload(
1655 &self,
1656 path: &Path,
1657 accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
1658 limit: u64,
1659 ) -> IngestReport {
1660 let mut report = IngestReport::default();
1661 let Some((bytes, unread_tail)) = read_bounded(path, limit) else {
1662 return report;
1663 };
1664 if let Some(tail) = &unread_tail {
1665 tracing::warn!(
1666 path = %path.display(),
1667 unread_bytes = tail.end - tail.start,
1668 limit,
1669 "mesh journal: the file is larger than the reload bound; the rest is left on disk unread"
1670 );
1671 }
1672 self.inner.lock().expect("journal lock").unread_tail = unread_tail;
1673 // Line by line, and a line that does not read is counted rather than dropped in
1674 // silence: a torn tail after a crash, or bytes that are not this journal's.
1675 let mut events: Vec<MeshEvent> = Vec::new();
1676 let mut unreadable = 0u64;
1677 for line in bytes.split(|b| *b == b'\n') {
1678 if line.iter().all(u8::is_ascii_whitespace) {
1679 continue;
1680 }
1681 match serde_json::from_slice::<MeshEvent>(line) {
1682 Ok(event) => events.push(event),
1683 Err(_) => unreadable += 1,
1684 }
1685 }
1686 if unreadable > 0 {
1687 tracing::warn!(
1688 path = %path.display(),
1689 unreadable,
1690 "mesh journal: lines that do not read were skipped on reload"
1691 );
1692 }
1693 self.unreadable
1694 .store(unreadable, std::sync::atomic::Ordering::SeqCst);
1695 // Reloaded events pass the same checks and the same trust decision as received
1696 // ones: a file is input too, written under a policy that may have changed since.
1697 // This node's own key is its own, whatever the policy says of other keys.
1698 let own_key = self.identity.public.public_key.as_str();
1699 let trusted = |event: &MeshEvent| {
1700 if event.pk == own_key {
1701 Ok(())
1702 } else {
1703 accept(event)
1704 }
1705 };
1706 let mut inner = self.inner.lock().expect("journal lock");
1707 for event in events {
1708 self.ingest_locked(&mut inner, event, &trusted, &mut report, false);
1709 }
1710 inner.tallies.received = 0;
1711 inner.tallies.duplicates = 0;
1712 report
1713 }
1714
1715 /// What reloading the journal file did when this journal was opened: how many events
1716 /// came back, and how many were refused and why — a key no longer trusted is refused
1717 /// [`Rejection::Untrusted`] here rather than believed again from disk. Empty for a
1718 /// journal without a file, or one whose file did not exist yet.
1719 ///
1720 /// ```
1721 /// use std::sync::Arc;
1722 /// use majordomus_cli::mesh::identity::NodeIdentity;
1723 /// use majordomus_cli::mesh::journal::Journal;
1724 ///
1725 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1726 /// "repo".into(), None).unwrap();
1727 /// assert_eq!(j.reload_report().accepted + j.reload_report().rejected_total(), 0);
1728 /// ```
1729 pub fn reload_report(&self) -> &IngestReport {
1730 &self.reloaded
1731 }
1732
1733 /// This runtime's own stream: the one stream this journal may write to, and the prefix
1734 /// every claim, session and review key it creates carries. Nothing else in the journal
1735 /// is this runtime's to number or to sign.
1736 pub fn own_stream(&self) -> &StreamId {
1737 &self.own
1738 }
1739
1740 /// The mesh repository id this journal belongs to.
1741 pub fn repository(&self) -> &str {
1742 &self.repo
1743 }
1744
1745 /// Sign, store and persist an event of this runtime. The body's bounds are checked
1746 /// first: nothing out of bounds is ever signed.
1747 ///
1748 /// The sequence is dense from 1 and the Lamport stamp rises above every stamp this
1749 /// runtime has seen, so a peer can tell from the numbers alone whether it is missing
1750 /// something, and every runtime folds concurrent events in the same order. Writing is
1751 /// the only way an event enters the mesh; a relay never creates one.
1752 ///
1753 /// ```
1754 /// use std::sync::Arc;
1755 /// use majordomus_cli::mesh::identity::NodeIdentity;
1756 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1757 ///
1758 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1759 /// "repo".into(), None).unwrap();
1760 /// let first = j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1761 /// let second = j.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
1762 /// assert_eq!((first.seq, second.seq), (1, 2), "dense from 1");
1763 /// assert!(second.lamport > first.lamport);
1764 /// assert_eq!(first.stream, *j.own_stream());
1765 ///
1766 /// // out of bounds is refused before anything is signed or stored
1767 /// let escaping = EventBody::ClaimAcquired { claim: "c1".into(), session: "s1".into(),
1768 /// scope: vec!["/etc".into()], intent: None, mode: Default::default(), issue: None };
1769 /// assert!(j.append_own(escaping).is_err());
1770 /// assert_eq!(j.events().len(), 2, "and nothing was written");
1771 /// ```
1772 ///
1773 /// This answers the event and not what happened on disk; a caller that reports the
1774 /// write to anyone uses [`Journal::append_acknowledged`], which says.
1775 pub fn append_own(&self, body: EventBody) -> Result<MeshEvent, MeshError> {
1776 self.append_acknowledged(body)
1777 .map(|appended| appended.event)
1778 }
1779
1780 /// [`Journal::append_own`], answering whether the event reached this machine's disk.
1781 ///
1782 /// An event whose write failed is still held and replicated — a peer that receives it
1783 /// is the only copy left once this runtime restarts — and is answered
1784 /// [`Durability::NotDurable`]: the append is not acknowledged as durable, the failure is
1785 /// counted in [`JournalTallies::write_failures`], and `mesh doctor` fails on it.
1786 ///
1787 /// ```
1788 /// use std::sync::Arc;
1789 /// use majordomus_cli::mesh::identity::NodeIdentity;
1790 /// use majordomus_cli::mesh::journal::{Durability, EventBody, Journal};
1791 ///
1792 /// let dir = tempfile::tempdir().unwrap();
1793 /// let on_disk = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1794 /// "0000000000000001", "repo".into(), Some(dir.path().join("journal.jsonl"))).unwrap();
1795 /// let written = on_disk.append_acknowledged(EventBody::SessionClosed { session: "s1".into() })
1796 /// .unwrap();
1797 /// assert_eq!(written.durability, Durability::Synced);
1798 ///
1799 /// let in_memory = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()),
1800 /// "0000000000000002", "repo".into(), None).unwrap();
1801 /// let held = in_memory.append_acknowledged(EventBody::SessionClosed { session: "s1".into() })
1802 /// .unwrap();
1803 /// assert_eq!(held.durability, Durability::Memory, "no file, by configuration");
1804 /// ```
1805 pub fn append_acknowledged(&self, body: EventBody) -> Result<Appended, MeshError> {
1806 body.validate().map_err(MeshError::Protocol)?;
1807 let body = serde_json::to_value(&body).map_err(|e| MeshError::Protocol(e.to_string()))?;
1808 let mut inner = self.inner.lock().expect("journal lock");
1809 let log = inner
1810 .streams
1811 .entry(self.own.clone())
1812 .or_insert_with(StreamLog::new);
1813 let seq = log.high_water().saturating_add(1);
1814 let lamport = inner.lamport.saturating_add(1);
1815 let mut event = MeshEvent {
1816 v: EVENT_VERSION,
1817 stream: self.own.clone(),
1818 seq,
1819 lamport,
1820 at: super::protocol::now(),
1821 repo: self.repo.clone(),
1822 pk: self.identity.public.public_key.clone(),
1823 body,
1824 sig: String::new(),
1825 };
1826 event.sig = self.identity.sign(&event.signing_bytes());
1827 let size = serde_json::to_vec(&event)
1828 .map(|b| b.len())
1829 .unwrap_or(usize::MAX);
1830 if size > MAX_EVENT_BYTES {
1831 return Err(MeshError::Protocol(format!(
1832 "an event of {size} bytes exceeds the {MAX_EVENT_BYTES}-byte bound"
1833 )));
1834 }
1835 inner.lamport = lamport;
1836 inner
1837 .streams
1838 .get_mut(&self.own)
1839 .expect("own stream")
1840 .events
1841 .insert(seq, event.clone());
1842 inner.tallies.written += 1;
1843 let durability = self.persist(std::slice::from_ref(&event));
1844 Ok(Appended { event, durability })
1845 }
1846
1847 /// Raise this runtime's own beat: one heartbeat, signed, so that no relay can raise it.
1848 ///
1849 /// The beat is how a runtime says "still here" without writing an event for it — it
1850 /// travels in marks, which every sync round carries anyway, so liveness costs nothing
1851 /// extra. The signature is what makes a relay safe: a runtime can pass on somebody
1852 /// else's beat and cannot invent one, so a crashed runtime stops being alive for
1853 /// everybody at once and its claims end on their own.
1854 ///
1855 /// ```
1856 /// use std::sync::Arc;
1857 /// use majordomus_cli::mesh::identity::NodeIdentity;
1858 /// use majordomus_cli::mesh::journal::Journal;
1859 ///
1860 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1861 /// "repo".into(), None).unwrap();
1862 /// j.beat_own();
1863 /// let first = j.marks()[j.own_stream()].clone();
1864 /// j.beat_own();
1865 /// let second = j.marks()[j.own_stream()].clone();
1866 ///
1867 /// assert!(second.beat > first.beat, "the beat only rises");
1868 /// assert_ne!(second.sig, first.sig, "each beat is its own signed statement");
1869 /// ```
1870 pub fn beat_own(&self) {
1871 let mut inner = self.inner.lock().expect("journal lock");
1872 let log = inner
1873 .streams
1874 .entry(self.own.clone())
1875 .or_insert_with(StreamLog::new);
1876 log.beat += 1;
1877 log.beat_sig = Some(self.identity.sign(&beat_bytes(&self.own, log.beat)));
1878 log.beat_pk = Some(self.identity.public.public_key.clone());
1879 log.fresh_at = Some(Instant::now());
1880 }
1881
1882 /// Every stream's mark, own included, ages measured now. This is the whole of what a
1883 /// runtime tells a peer about its state: a sequence per stream and a beat per stream,
1884 /// from which the peer computes what to send. Nothing is asked for and nothing is
1885 /// flooded, which is why an event reaches every runtime once per link and stops.
1886 ///
1887 /// The ages are measured at the moment of the call rather than stored, because an age
1888 /// is only meaningful relative to when it was taken.
1889 ///
1890 /// ```
1891 /// use std::sync::Arc;
1892 /// use majordomus_cli::mesh::identity::NodeIdentity;
1893 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
1894 ///
1895 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
1896 /// "repo".into(), None).unwrap();
1897 /// assert_eq!(j.marks()[j.own_stream()].seq, 0, "nothing written yet");
1898 ///
1899 /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1900 /// j.beat_own();
1901 /// let mark = j.marks()[j.own_stream()].clone();
1902 /// assert_eq!(mark.seq, 1);
1903 /// assert!(mark.age_ms.is_some(), "a beat has been heard, and how long ago");
1904 /// ```
1905 pub fn marks(&self) -> Marks {
1906 let now = Instant::now();
1907 let inner = self.inner.lock().expect("journal lock");
1908 inner
1909 .streams
1910 .iter()
1911 .map(|(id, log)| {
1912 (
1913 id.clone(),
1914 StreamMark {
1915 seq: log.high_water(),
1916 beat: log.beat,
1917 age_ms: log
1918 .fresh_at
1919 .map(|t| now.saturating_duration_since(t).as_millis() as u64),
1920 pk: log.beat_pk.clone(),
1921 sig: log.beat_sig.clone(),
1922 },
1923 )
1924 })
1925 .chain(inner.tombstones.iter().map(|(id, tombstone)| {
1926 (
1927 id.clone(),
1928 StreamMark {
1929 seq: tombstone.seq,
1930 ..StreamMark::default()
1931 },
1932 )
1933 }))
1934 .collect()
1935 }
1936
1937 /// The marks to tell one peer, whose own marks are `peer`: every stream this runtime
1938 /// holds, and a compacted stream's tombstone only when the peer holds that stream.
1939 ///
1940 /// A tombstone is in the marks to stop a peer re-sending a stream this runtime dropped,
1941 /// so it says something only to a peer that has the stream to send. Told to everyone,
1942 /// the up to four tombstones per stream slot weighed as much as the live marks, and
1943 /// together with a full event budget they took a sync message past the link bound
1944 /// (I2139). A peer whose marks are not known yet is told no tombstone; what it then
1945 /// sends of a dropped stream is absorbed by the tombstone, once, and its marks are known
1946 /// from that round on.
1947 ///
1948 /// ```
1949 /// use std::sync::Arc;
1950 /// use std::time::Duration;
1951 /// use majordomus_cli::mesh::identity::NodeIdentity;
1952 /// use majordomus_cli::mesh::journal::{EventBody, Journal, Marks, HANDOVER_RETENTION};
1953 ///
1954 /// let open = |n: &str| Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), n,
1955 /// "repo".into(), None).unwrap();
1956 /// let (a, b) = (open("0000000000000001"), open("0000000000000002"));
1957 /// a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
1958 /// b.ingest(&a.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
1959 /// let zero = Duration::ZERO;
1960 /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, true), 1);
1961 ///
1962 /// // a peer that holds the dropped stream is told how far it got, so it sends nothing
1963 /// assert_eq!(b.marks_for(&a.marks())[a.own_stream()].seq, 1);
1964 /// // a peer that does not is told nothing about it
1965 /// assert!(!b.marks_for(&Marks::new()).contains_key(a.own_stream()));
1966 /// // and the streams b holds are told to every peer
1967 /// assert!(b.marks_for(&Marks::new()).contains_key(b.own_stream()));
1968 /// ```
1969 pub fn marks_for(&self, peer: &Marks) -> Marks {
1970 let mut marks = self.marks();
1971 let inner = self.inner.lock().expect("journal lock");
1972 marks.retain(|id, _| !inner.tombstones.contains_key(id) || peer.contains_key(id));
1973 marks
1974 }
1975
1976 /// Merge a peer's marks. Only a higher beat carrying its origin's valid signature, from
1977 /// an origin `trusted` accepts, makes a stream fresher here — a relay forwards beats and
1978 /// cannot mint one — and the relayed age is clamped to the expiry, so a stale report can
1979 /// age a stream but never hold a dead one alive past one expiry after its last real beat.
1980 /// Sequences are not merged: only events move high-water marks. A stream is created from
1981 /// a mark only when the mark verifies, only within the stream quotas, and only when the
1982 /// beat it carries is fresh: a mark that can only report a stream dead — a relay still
1983 /// advertising a stream this runtime has compacted — gives the journal nothing to hold,
1984 /// and must not take a slot of its node's quota.
1985 ///
1986 /// A stream this runtime has compacted comes back the same way, and only on a beat above
1987 /// the one it was compacted at: a laptop that slept past the retention wakes as the same
1988 /// stream and beats again, while a relay replaying the old beat is remembering the
1989 /// stream, not hearing it. Heard again, the stream's tombstone is lifted and its mark
1990 /// falls to what is held of it — nothing — so the peers send it again from its first
1991 /// event. The whole stream, not the part after the tombstone's mark: what it said before
1992 /// it was compacted (a claim it still holds, a session still open) is live again with
1993 /// it, and a suffix would leave this runtime admitting claims that conflict with it.
1994 ///
1995 /// ```
1996 /// use std::sync::Arc;
1997 /// use std::time::Duration;
1998 /// use majordomus_cli::mesh::identity::NodeIdentity;
1999 /// use majordomus_cli::mesh::journal::{Journal, StreamLiveness};
2000 ///
2001 /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2002 /// "repo".into(), None).unwrap();
2003 /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2004 /// "repo".into(), None).unwrap();
2005 /// a.beat_own();
2006 ///
2007 /// // B learns that A is beating, because A signed the beat
2008 /// b.merge_marks(&a.marks(), &|_| true, Duration::from_secs(30));
2009 /// assert_eq!(b.liveness(a.own_stream(), Duration::from_secs(30)), StreamLiveness::Live);
2010 ///
2011 /// // an untrusted origin's beat tells B nothing, however well signed
2012 /// let c = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000003",
2013 /// "repo".into(), None).unwrap();
2014 /// c.beat_own();
2015 /// b.merge_marks(&c.marks(), &|_| false, Duration::from_secs(30));
2016 /// assert_eq!(b.liveness(c.own_stream(), Duration::from_secs(30)), StreamLiveness::Expired);
2017 ///
2018 /// // a trusted beat heard a minute ago says only that C is dead: B holds nothing for it
2019 /// let mut stale = c.marks();
2020 /// stale.get_mut(c.own_stream()).unwrap().age_ms = Some(60_000);
2021 /// b.merge_marks(&stale, &|_| true, Duration::from_secs(30));
2022 /// assert_eq!(b.tallies().streams, 2, "B's own stream and A's");
2023 /// ```
2024 pub fn merge_marks(&self, marks: &Marks, trusted: &dyn Fn(&str) -> bool, expiry: Duration) {
2025 let now = Instant::now();
2026 let ceiling = (expiry + Duration::from_secs(1)).as_millis() as u64;
2027 let mut inner = self.inner.lock().expect("journal lock");
2028 for (id, mark) in marks {
2029 if *id == self.own || mark.beat == 0 {
2030 continue;
2031 }
2032 let (Some(pk), Some(sig)) = (&mark.pk, &mark.sig) else {
2033 continue;
2034 };
2035 if node_id_of_key(pk).map(|n| n.as_str().to_string()) != Some(id.node().to_string())
2036 || !verify(pk, &beat_bytes(id, mark.beat), sig)
2037 || !trusted(pk)
2038 {
2039 continue;
2040 }
2041 let tombstone = inner.tombstones.get(id).copied();
2042 if tombstone.is_some_and(|t| mark.beat <= t.beat) {
2043 continue;
2044 }
2045 if !inner.streams.contains_key(id) {
2046 if !mark
2047 .age_ms
2048 .is_some_and(|age| Duration::from_millis(age) <= expiry)
2049 {
2050 continue;
2051 }
2052 let of_node = inner
2053 .streams
2054 .keys()
2055 .filter(|s| s.node() == id.node())
2056 .count();
2057 if inner.streams.len() >= MAX_STREAMS || of_node >= MAX_STREAMS_PER_NODE {
2058 continue;
2059 }
2060 }
2061 if tombstone.is_some() {
2062 // Compacted as dead, and beating again: it is fetched again from its first event.
2063 inner.tombstones.remove(id);
2064 }
2065 let log = inner
2066 .streams
2067 .entry(id.clone())
2068 .or_insert_with(StreamLog::new);
2069 if mark.beat > log.beat {
2070 log.beat = mark.beat;
2071 log.beat_pk = Some(pk.clone());
2072 log.beat_sig = Some(sig.clone());
2073 log.fresh_at = mark
2074 .age_ms
2075 .map(|age| age.min(ceiling))
2076 .and_then(|age| now.checked_sub(Duration::from_millis(age)));
2077 }
2078 }
2079 }
2080
2081 /// The events a peer lacks by its marks, in stream-then-sequence order, at most
2082 /// `budget` serialized bytes (always at least one event when any is missing).
2083 ///
2084 /// This is the half of replication that decides what travels, and it decides it from
2085 /// the peer's own marks rather than from a request: a runtime sends what the peer says
2086 /// it lacks and nothing else, so an event crosses each link once and stops. The budget
2087 /// keeps one round bounded, and the streams are visited from a rotating start, so a
2088 /// stream a peer keeps refusing cannot spend the whole budget round after round and
2089 /// starve the streams behind it.
2090 ///
2091 /// ```
2092 /// use std::sync::Arc;
2093 /// use majordomus_cli::mesh::identity::NodeIdentity;
2094 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2095 ///
2096 /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2097 /// "repo".into(), None).unwrap();
2098 /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2099 /// "repo".into(), None).unwrap();
2100 /// a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2101 /// a.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2102 ///
2103 /// // B has nothing, so both events are missing for it
2104 /// let missing = a.missing_for(&b.marks(), 1 << 20);
2105 /// assert_eq!(missing.len(), 2);
2106 ///
2107 /// // once B holds them, its marks say so and nothing is sent again
2108 /// b.ingest(&missing, &|_| Ok(()));
2109 /// assert!(a.missing_for(&b.marks(), 1 << 20).is_empty());
2110 ///
2111 /// // a budget of nothing still moves one event: progress is never zero
2112 /// assert_eq!(a.missing_for(&Default::default(), 0).len(), 1);
2113 /// ```
2114 pub fn missing_for(&self, peer: &Marks, budget: usize) -> Vec<MeshEvent> {
2115 let inner = self.inner.lock().expect("journal lock");
2116 let mut out = Vec::new();
2117 let mut spent = 0usize;
2118 // Start at a different stream each round, so that one stream a peer refuses — and
2119 // keeps lacking — cannot spend the whole budget round after round and starve the
2120 // streams behind it.
2121 let count = inner.streams.len().max(1);
2122 let start = self
2123 .rotation
2124 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2125 % count;
2126 let order = inner
2127 .streams
2128 .iter()
2129 .skip(start)
2130 .chain(inner.streams.iter().take(start));
2131 for (id, log) in order {
2132 let have = peer.get(id).map(|m| m.seq).unwrap_or(0);
2133 let Some(from) = have.checked_add(1) else {
2134 continue;
2135 };
2136 for (_, event) in log.events.range(from..) {
2137 let size = serde_json::to_vec(event).map(|b| b.len()).unwrap_or(0);
2138 if !out.is_empty() && spent + size > budget {
2139 return out;
2140 }
2141 spent += size;
2142 out.push(event.clone());
2143 }
2144 }
2145 out
2146 }
2147
2148 /// Verify and store received events. `accept` is the trust decision for an event's
2149 /// origin, made above the journal; everything else — size, version, identity,
2150 /// signature, repository, bounds, order, duplicates — is decided here, once, for every
2151 /// transport and relay alike.
2152 ///
2153 /// Trust is the caller's because it is policy, and everything else is decided here
2154 /// because it must be decided the same way for every arrival: a datagram, a sync round
2155 /// and a file reloaded from disk are all input, and none of them is believed. Events
2156 /// are applied in stream order, so one that arrives ahead of its gap waits rather than
2157 /// being applied out of turn, and one that arrives twice is counted and changes nothing.
2158 ///
2159 /// ```
2160 /// use std::sync::Arc;
2161 /// use majordomus_cli::mesh::identity::NodeIdentity;
2162 /// use majordomus_cli::mesh::journal::{EventBody, Journal, Rejection};
2163 ///
2164 /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2165 /// "repo".into(), None).unwrap();
2166 /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2167 /// "repo".into(), None).unwrap();
2168 /// let first = a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2169 /// let second = a.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2170 ///
2171 /// // the second event alone arrives first: it waits for the gap ahead of it
2172 /// let report = b.ingest(&[second.clone()], &|_| Ok(()));
2173 /// assert_eq!((report.accepted, report.pending), (0, 1));
2174 ///
2175 /// // the first arrives and both are applied, in the stream's order
2176 /// let report = b.ingest(&[first], &|_| Ok(()));
2177 /// assert_eq!(report.accepted, 2);
2178 /// assert_eq!(b.events().len(), 2);
2179 ///
2180 /// // an untrusted origin is refused, with the reason the caller gave
2181 /// let c = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000003",
2182 /// "repo".into(), None).unwrap();
2183 /// let theirs = c.append_own(EventBody::SessionClosed { session: "s9".into() }).unwrap();
2184 /// let report = b.ingest(&[theirs], &|_| Err(Rejection::Untrusted));
2185 /// assert_eq!(report.rejected[&Rejection::Untrusted], 1);
2186 /// ```
2187 pub fn ingest(
2188 &self,
2189 events: &[MeshEvent],
2190 accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
2191 ) -> IngestReport {
2192 let mut report = IngestReport::default();
2193 let mut stored = Vec::new();
2194 {
2195 let mut inner = self.inner.lock().expect("journal lock");
2196 for event in events {
2197 let before = inner.tallies.received;
2198 if let Some(applied) =
2199 self.ingest_locked(&mut inner, event.clone(), accept, &mut report, true)
2200 {
2201 stored.extend(applied);
2202 }
2203 debug_assert!(inner.tallies.received >= before);
2204 }
2205 }
2206 self.persist(&stored);
2207 report
2208 }
2209
2210 /// One event under the lock. Returns the events it made contiguous (itself and any
2211 /// pending successors), which the caller persists.
2212 fn ingest_locked(
2213 &self,
2214 inner: &mut Inner,
2215 event: MeshEvent,
2216 accept: &dyn Fn(&MeshEvent) -> Result<(), Rejection>,
2217 report: &mut IngestReport,
2218 count: bool,
2219 ) -> Option<Vec<MeshEvent>> {
2220 let refuse = |inner: &mut Inner, report: &mut IngestReport, why: Rejection| {
2221 report.reject(why);
2222 if count {
2223 inner.tallies.rejected += 1;
2224 }
2225 None
2226 };
2227 if serde_json::to_vec(&event)
2228 .map(|b| b.len())
2229 .unwrap_or(usize::MAX)
2230 > MAX_EVENT_BYTES
2231 {
2232 return refuse(inner, report, Rejection::Oversized);
2233 }
2234 if event.v != EVENT_VERSION {
2235 return refuse(inner, report, Rejection::Version);
2236 }
2237 if StreamId::parse(event.stream.as_str()).is_none()
2238 || node_id_of_key(&event.pk).map(|n| n.as_str().to_string())
2239 != Some(event.stream.node().to_string())
2240 || event.seq == 0
2241 {
2242 return refuse(inner, report, Rejection::Identity);
2243 }
2244 if !verify(&event.pk, &event.signing_bytes(), &event.sig) {
2245 return refuse(inner, report, Rejection::Signature);
2246 }
2247 if event.repo != self.repo {
2248 return refuse(inner, report, Rejection::Repository);
2249 }
2250 if let Some(body) = event.body() {
2251 if body.validate().is_err() {
2252 return refuse(inner, report, Rejection::Bounds);
2253 }
2254 } else if KNOWN_KINDS.contains(&event.kind()) || event.kind().is_empty() {
2255 // A kind this executable knows, in a shape it does not: out of bounds, not new.
2256 return refuse(inner, report, Rejection::Bounds);
2257 }
2258 if let Err(why) = accept(&event) {
2259 return refuse(inner, report, why);
2260 }
2261 if event.lamport > inner.lamport.saturating_add(MAX_LAMPORT_LEAD) {
2262 // A trusted key's stamp, but further ahead of this clock than any history
2263 // reaches: storing it would carry every clock that hears it towards u64::MAX.
2264 return refuse(inner, report, Rejection::ClockAhead);
2265 }
2266 if event.stream == self.own {
2267 // Our own events coming back through a relay: we hold them already, or they
2268 // are from a future we did not write — either way, nothing to store.
2269 report.duplicate += 1;
2270 if count {
2271 inner.tallies.duplicates += 1;
2272 }
2273 return None;
2274 }
2275 if let Some(tombstone) = inner.tombstones.get_mut(&event.stream) {
2276 // Compacted away as dead. A late copy of what was held changes nothing, and neither
2277 // does an event the stream wrote after: until the stream beats again this runtime
2278 // holds nothing of it, and when it does the stream is fetched again from its first
2279 // event. The mark rises over a later event, so a relay holding it stops offering it.
2280 tombstone.seq = tombstone.seq.max(event.seq);
2281 report.duplicate += 1;
2282 if count {
2283 inner.tallies.duplicates += 1;
2284 }
2285 return None;
2286 }
2287 if !inner.streams.contains_key(&event.stream) && inner.streams.len() >= MAX_STREAMS {
2288 return refuse(inner, report, Rejection::Capacity);
2289 }
2290 // Quotas per node: stream, runtime and instance ids cost nothing to invent, so one
2291 // key — even a trusted one — gets a bounded share of the journal.
2292 let node = event.stream.node().to_string();
2293 if !inner.streams.contains_key(&event.stream)
2294 && inner.streams.keys().filter(|s| s.node() == node).count() >= MAX_STREAMS_PER_NODE
2295 {
2296 return refuse(inner, report, Rejection::Capacity);
2297 }
2298 let held_by_node: usize = inner
2299 .streams
2300 .iter()
2301 .filter(|(s, _)| s.node() == node)
2302 .map(|(_, l)| l.events.len() + l.pending.len())
2303 .sum();
2304 if held_by_node >= MAX_EVENTS_PER_NODE {
2305 return refuse(inner, report, Rejection::Capacity);
2306 }
2307 let pending_total: usize = inner.streams.values().map(|l| l.pending.len()).sum();
2308 let lamport = event.lamport;
2309 let log = inner
2310 .streams
2311 .entry(event.stream.clone())
2312 .or_insert_with(StreamLog::new);
2313 let high = log.high_water();
2314 if event.seq <= high || log.pending.contains_key(&event.seq) {
2315 report.duplicate += 1;
2316 if count {
2317 inner.tallies.duplicates += 1;
2318 }
2319 return None;
2320 }
2321 if event.seq > high + 1 {
2322 if log.pending.len() >= MAX_PENDING || pending_total >= MAX_PENDING_TOTAL {
2323 return refuse(inner, report, Rejection::Capacity);
2324 }
2325 log.pending.insert(event.seq, event);
2326 report.pending += 1;
2327 return None;
2328 }
2329 let mut applied = vec![event.clone()];
2330 log.events.insert(event.seq, event);
2331 let mut next = high + 2;
2332 while let Some(successor) = log.pending.remove(&next) {
2333 applied.push(successor.clone());
2334 log.events.insert(next, successor);
2335 next += 1;
2336 }
2337 let max_lamport = applied.iter().map(|e| e.lamport).max().unwrap_or(lamport);
2338 inner.lamport = inner.lamport.max(max_lamport);
2339 report.accepted += applied.len() as u64;
2340 if count {
2341 inner.tallies.received += applied.len() as u64;
2342 }
2343 Some(applied)
2344 }
2345
2346 /// Append `events` to the journal file and sync it, answering whether they reached the
2347 /// disk. A failure is counted and logged here, once, whoever asked.
2348 fn persist(&self, events: &[MeshEvent]) -> Durability {
2349 let Some(path) = &self.path else {
2350 return Durability::Memory;
2351 };
2352 if events.is_empty() {
2353 return Durability::Synced;
2354 }
2355 // An event that does not serialise is left out of the append and counted as lost with
2356 // the ones a failed write loses.
2357 let lines: Vec<String> = events
2358 .iter()
2359 .filter_map(|event| serde_json::to_string(event).ok())
2360 .collect();
2361 let unserialised = events.len() - lines.len();
2362 let mut text = String::new();
2363 for line in &lines {
2364 text.push_str(line);
2365 text.push('\n');
2366 }
2367 let written = append_synced(path, &text);
2368 if written.is_ok() && unserialised == 0 {
2369 return Durability::Synced;
2370 }
2371 let lost = written
2372 .as_ref()
2373 .err()
2374 .map_or(unserialised, |_| events.len());
2375 self.write_failures
2376 .fetch_add(lost as u64, std::sync::atomic::Ordering::SeqCst);
2377 let error = written
2378 .err()
2379 .map_or("an event did not serialise".to_string(), |e| e.to_string());
2380 tracing::warn!(
2381 path = %path.display(),
2382 error = %error,
2383 events = lost,
2384 "mesh journal: an append did not reach the disk; the events are held in memory and replicated only"
2385 );
2386 Durability::NotDurable
2387 }
2388
2389 /// A stream's liveness on this runtime's clock, against `expiry`. The measurement is
2390 /// local and monotonic — how long since this process saw the beat rise — so two
2391 /// machines never compare wall clocks and a machine with a wrong clock cannot hold a
2392 /// dead runtime's claims alive. A stream nobody has ever beaten for is expired, which
2393 /// is the right answer for a stream learned from a relay that has not heard from it.
2394 ///
2395 /// ```
2396 /// use std::sync::Arc;
2397 /// use std::time::Duration;
2398 /// use majordomus_cli::mesh::identity::NodeIdentity;
2399 /// use majordomus_cli::mesh::journal::{Journal, StreamLiveness};
2400 ///
2401 /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2402 /// "repo".into(), None).unwrap();
2403 /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2404 /// "repo".into(), None).unwrap();
2405 /// let expiry = Duration::from_secs(30);
2406 ///
2407 /// assert_eq!(a.liveness(a.own_stream(), expiry), StreamLiveness::Own);
2408 /// assert_eq!(a.liveness(b.own_stream(), expiry), StreamLiveness::Expired, "never heard of");
2409 ///
2410 /// b.beat_own();
2411 /// a.merge_marks(&b.marks(), &|_| true, expiry);
2412 /// assert_eq!(a.liveness(b.own_stream(), expiry), StreamLiveness::Live);
2413 /// ```
2414 pub fn liveness(&self, stream: &StreamId, expiry: Duration) -> StreamLiveness {
2415 if *stream == self.own {
2416 return StreamLiveness::Own;
2417 }
2418 let now = Instant::now();
2419 let inner = self.inner.lock().expect("journal lock");
2420 match inner.streams.get(stream).and_then(|l| l.fresh_at) {
2421 Some(t) if now.saturating_duration_since(t) <= expiry => StreamLiveness::Live,
2422 _ => StreamLiveness::Expired,
2423 }
2424 }
2425
2426 /// Every stream's liveness and time since its beat rose, in stream order. The age
2427 /// travels with the verdict because "expired" alone is not diagnosable: a stream whose
2428 /// beat rose a minute ago is a runtime that stopped, and one whose beat was never heard
2429 /// is a runtime this one has only been told about.
2430 ///
2431 /// ```
2432 /// use std::sync::Arc;
2433 /// use std::time::Duration;
2434 /// use majordomus_cli::mesh::identity::NodeIdentity;
2435 /// use majordomus_cli::mesh::journal::{Journal, StreamLiveness};
2436 ///
2437 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2438 /// "repo".into(), None).unwrap();
2439 /// let all = j.stream_liveness(Duration::from_secs(30));
2440 /// let (liveness, age) = all[j.own_stream()];
2441 /// assert_eq!(liveness, StreamLiveness::Own);
2442 /// assert_eq!(age, None, "no beat has been raised yet");
2443 ///
2444 /// j.beat_own();
2445 /// assert!(j.stream_liveness(Duration::from_secs(30))[j.own_stream()].1.is_some());
2446 /// ```
2447 pub fn stream_liveness(
2448 &self,
2449 expiry: Duration,
2450 ) -> BTreeMap<StreamId, (StreamLiveness, Option<Duration>)> {
2451 let now = Instant::now();
2452 let inner = self.inner.lock().expect("journal lock");
2453 inner
2454 .streams
2455 .iter()
2456 .map(|(id, log)| {
2457 let age = log.fresh_at.map(|t| now.saturating_duration_since(t));
2458 let liveness = if *id == self.own {
2459 StreamLiveness::Own
2460 } else if age.is_some_and(|a| a <= expiry) {
2461 StreamLiveness::Live
2462 } else {
2463 StreamLiveness::Expired
2464 };
2465 (id.clone(), (liveness, age))
2466 })
2467 .collect()
2468 }
2469
2470 /// Every held event, in stream-then-sequence order. Events waiting for a gap are not
2471 /// here: what this returns is what the journal is prepared to stand behind, and the
2472 /// fold takes it whole, which is why the order it comes out in does not matter to the
2473 /// result.
2474 ///
2475 /// ```
2476 /// use std::sync::Arc;
2477 /// use majordomus_cli::mesh::identity::NodeIdentity;
2478 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2479 ///
2480 /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2481 /// "repo".into(), None).unwrap();
2482 /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2483 /// "repo".into(), None).unwrap();
2484 /// let first = a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2485 /// let second = a.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2486 ///
2487 /// // an event still waiting for the gap ahead of it is not yet held
2488 /// b.ingest(&[second], &|_| Ok(()));
2489 /// assert!(b.events().is_empty());
2490 /// b.ingest(&[first], &|_| Ok(()));
2491 /// assert_eq!(b.events().len(), 2);
2492 /// assert_eq!(b.events()[0].seq, 1, "stream order, whatever the arrival order was");
2493 /// ```
2494 pub fn events(&self) -> Vec<MeshEvent> {
2495 let inner = self.inner.lock().expect("journal lock");
2496 inner
2497 .streams
2498 .values()
2499 .flat_map(|log| log.events.values().cloned())
2500 .collect()
2501 }
2502
2503 /// Held events whose Lamport stamp is above `after`, in Lamport order, at most `limit`.
2504 ///
2505 /// The Lamport stamp is what a reader can resume from: it rises with causality rather
2506 /// than with anybody's clock, so a caller that remembers the last stamp it saw reads
2507 /// each event once however the machines involved disagree about the time. This is what
2508 /// `mesh events --after` is.
2509 ///
2510 /// ```
2511 /// use std::sync::Arc;
2512 /// use majordomus_cli::mesh::identity::NodeIdentity;
2513 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2514 ///
2515 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2516 /// "repo".into(), None).unwrap();
2517 /// let first = j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2518 /// j.append_own(EventBody::SessionClosed { session: "s2".into() }).unwrap();
2519 ///
2520 /// assert_eq!(j.events_after(0, 100).len(), 2, "from the beginning");
2521 /// let rest = j.events_after(first.lamport, 100);
2522 /// assert_eq!(rest.len(), 1, "resumed where the reader left off");
2523 /// assert!(rest[0].lamport > first.lamport);
2524 /// assert_eq!(j.events_after(0, 1).len(), 1, "one page");
2525 /// ```
2526 pub fn events_after(&self, after: u64, limit: usize) -> Vec<MeshEvent> {
2527 let ordered: Vec<MeshEvent> = self
2528 .events()
2529 .into_iter()
2530 .filter(|e| e.lamport > after)
2531 .map(|e| (causal_key(&e), e))
2532 .collect::<BTreeMap<_, _>>()
2533 .into_values()
2534 .collect();
2535 // A page never ends inside a run of equal stamps: the next page resumes after the
2536 // last stamp this one carries, so an event that shares it and was cut off would never
2537 // be read. Such a run is as long as the streams that wrote concurrently, not unbounded.
2538 let mut page = limit.min(ordered.len());
2539 if let Some(last) = page.checked_sub(1).map(|i| ordered[i].lamport) {
2540 while page < ordered.len() && ordered[page].lamport == last {
2541 page += 1;
2542 }
2543 }
2544 ordered.into_iter().take(page).collect()
2545 }
2546
2547 /// Drop every stream that has been expired for longer than `retention` and that nothing
2548 /// still needs, when the journal is over its bound or `force` is set; the file is
2549 /// rewritten to what remains. Only dead streams go, so no live claim can lose its
2550 /// release to compaction.
2551 ///
2552 /// A node that holds [`NODE_COMPACTION_STREAMS`] streams is due on its own, whatever the
2553 /// journal's size: its dead streams go by the same rule, the other nodes' stay. One
2554 /// machine's worktrees share its key and every server restart is a new stream, so
2555 /// without this a machine's own runs would fill its quota and nothing would ever free
2556 /// it — the journal-wide bounds are out of one node's reach — and every later run of
2557 /// a sibling would be refused here, its claims with it.
2558 ///
2559 /// What makes dropping events safe is what is kept. Only a stream that has been silent
2560 /// well past its expiry goes, so no claim can lose the release that would have ended it.
2561 /// A dead stream stays while it holds a published handover nobody has taken, because a
2562 /// handover is continuity somebody may still be waiting to pick up — for at most
2563 /// `handovers` past the retention ([`HANDOVER_RETENTION`] in a running server), or its
2564 /// node's quota would fill with the dead. And a dead stream stays while it holds the
2565 /// taking of a handover or the answer to a review that stays, so that a handover that is
2566 /// still offered never looks untaken again and an answered review never opens again.
2567 /// Who took what and who answered what are read by the fold ([`super::state::fold`]),
2568 /// the one reading every surface shows.
2569 ///
2570 /// A stream whose beat was never heard here counts as silent from when this journal
2571 /// learned of it, so the streams a restart reloads are not taken for dead before their
2572 /// runtimes could be heard. What is dropped leaves a tombstone carrying the sequence it
2573 /// reached, so a peer that still holds the stream is not sent it all over again, and the
2574 /// beat it was last heard at, which a stream that returns must rise above
2575 /// ([`Journal::merge_marks`]).
2576 ///
2577 /// ```
2578 /// use std::sync::Arc;
2579 /// use std::time::Duration;
2580 /// use majordomus_cli::mesh::identity::NodeIdentity;
2581 /// use majordomus_cli::mesh::journal::{EventBody, HandoverBody, Journal, HANDOVER_RETENTION};
2582 ///
2583 /// let a = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2584 /// "repo".into(), None).unwrap();
2585 /// let b = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000002",
2586 /// "repo".into(), None).unwrap();
2587 /// a.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2588 /// b.ingest(&a.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
2589 /// assert_eq!(b.events().len(), 1);
2590 ///
2591 /// // under its bounds, compaction does nothing unless it is asked
2592 /// let zero = Duration::ZERO;
2593 /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, false), 0);
2594 ///
2595 /// // asked, the silent stream goes, and its mark stays so it is not re-sent
2596 /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, true), 1);
2597 /// assert!(b.events().is_empty());
2598 /// assert_eq!(b.marks()[a.own_stream()].seq, 1, "the tombstone remembers how far it got");
2599 ///
2600 /// // a handover nobody has taken keeps its stream, until the handover retention passes
2601 /// let c = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000003",
2602 /// "repo".into(), None).unwrap();
2603 /// let body = "# Objective\nship\n".to_string();
2604 /// let handover = HandoverBody { id: HandoverBody::digest_of(&body), task: None, issue: None,
2605 /// milestone: None, branch: None, head: None, created_at: None, name: None, body };
2606 /// c.append_own(EventBody::HandoverPublished { handover }).unwrap();
2607 /// b.ingest(&c.missing_for(&b.marks(), 1 << 20), &|_| Ok(()));
2608 /// assert_eq!(b.compact(zero, zero, HANDOVER_RETENTION, true), 0);
2609 /// assert_eq!(b.compact(zero, zero, zero, true), 1);
2610 /// ```
2611 pub fn compact(
2612 &self,
2613 expiry: Duration,
2614 retention: Duration,
2615 handovers: Duration,
2616 force: bool,
2617 ) -> u64 {
2618 let now = Instant::now();
2619 let mut inner = self.inner.lock().expect("journal lock");
2620 let total: usize = inner.streams.values().map(|l| l.events.len()).sum();
2621 let over = force || total > MAX_EVENTS || inner.streams.len() > MAX_STREAMS * 3 / 4;
2622 let mut per_node: BTreeMap<&str, usize> = BTreeMap::new();
2623 for id in inner.streams.keys() {
2624 *per_node.entry(id.node()).or_default() += 1;
2625 }
2626 let crowded: Vec<String> = per_node
2627 .into_iter()
2628 .filter(|(_, streams)| *streams >= NODE_COMPACTION_STREAMS)
2629 .map(|(node, _)| node.to_string())
2630 .collect();
2631 if !over && crowded.is_empty() {
2632 return 0;
2633 }
2634 let due: BTreeSet<StreamId> = inner
2635 .streams
2636 .iter()
2637 .filter(|(id, log)| {
2638 **id != self.own
2639 && (over || crowded.iter().any(|node| node == id.node()))
2640 && log.silent_for(now) > expiry + retention
2641 })
2642 .map(|(id, _)| id.clone())
2643 .collect();
2644 let offer_lapsed = |log: &StreamLog| log.silent_for(now) > expiry + retention + handovers;
2645 let dead = unneeded(&inner.streams, due, &offer_lapsed);
2646 let mut dropped = 0u64;
2647 for id in dead {
2648 if let Some(log) = inner.streams.remove(&id) {
2649 dropped += log.events.len() as u64;
2650 if inner.tombstones.len() >= MAX_STREAMS * 4 {
2651 if let Some(first) = inner.tombstones.keys().next().cloned() {
2652 inner.tombstones.remove(&first);
2653 }
2654 }
2655 let tombstone = Tombstone {
2656 seq: log.high_water(),
2657 beat: log.beat,
2658 };
2659 inner.tombstones.insert(id.clone(), tombstone);
2660 }
2661 }
2662 inner.tallies.compacted += dropped;
2663 if dropped > 0 {
2664 if let Some(path) = &self.path {
2665 let mut text = String::new();
2666 for event in inner.streams.values().flat_map(|l| l.events.values()) {
2667 if let Ok(line) = serde_json::to_string(event) {
2668 text.push_str(&line);
2669 text.push('\n');
2670 }
2671 }
2672 // A new file, synced, renamed over the old one: a crash leaves the old journal
2673 // or the new one, never a mix of both. What the reload left unread beyond its
2674 // bound is copied into the new file, byte for byte, rather than dropped.
2675 let tmp = path.with_extension("jsonl.tmp");
2676 let tail = inner.unread_tail.clone();
2677 let replaced = match &tail {
2678 None => write_synced(&tmp, &text),
2679 Some(range) => write_synced_with_tail(&tmp, &text, path, range.clone()),
2680 }
2681 .and_then(|()| std::fs::rename(&tmp, path));
2682 match replaced {
2683 Ok(()) => {
2684 sync_parent(path);
2685 inner.unread_tail = tail.map(|range| {
2686 let start = text.len() as u64;
2687 start..start + (range.end - range.start)
2688 });
2689 }
2690 Err(e) => {
2691 let _ = std::fs::remove_file(&tmp);
2692 tracing::warn!(path = %path.display(), error = %e, "mesh journal: compaction could not replace the file; the old one stands");
2693 }
2694 }
2695 }
2696 }
2697 dropped
2698 }
2699
2700 /// The tallies now: a snapshot of the counters, taken under the lock so that the
2701 /// numbers in one answer are consistent with each other rather than read one at a time
2702 /// while the journal moves. This is what `mesh status` and the cooperation status show.
2703 ///
2704 /// ```
2705 /// use std::sync::Arc;
2706 /// use majordomus_cli::mesh::identity::NodeIdentity;
2707 /// use majordomus_cli::mesh::journal::{EventBody, Journal};
2708 ///
2709 /// let j = Journal::open(Arc::new(NodeIdentity::ephemeral().unwrap()), "0000000000000001",
2710 /// "repo".into(), None).unwrap();
2711 /// j.append_own(EventBody::SessionClosed { session: "s1".into() }).unwrap();
2712 ///
2713 /// let snapshot = j.tallies();
2714 /// assert_eq!(snapshot.written, 1);
2715 /// assert_eq!(snapshot.events, j.events().len());
2716 /// assert_eq!(snapshot.received, 0, "nothing came from a peer");
2717 /// ```
2718 pub fn tallies(&self) -> JournalTallies {
2719 let inner = self.inner.lock().expect("journal lock");
2720 let mut tallies = inner.tallies;
2721 tallies.streams = inner.streams.len();
2722 tallies.events = inner.streams.values().map(|l| l.events.len()).sum();
2723 tallies.pending = inner.streams.values().map(|l| l.pending.len()).sum();
2724 tallies.opaque = inner
2725 .streams
2726 .values()
2727 .flat_map(|l| l.events.values())
2728 .filter(|e| e.body().is_none())
2729 .count();
2730 tallies.lamport = inner.lamport;
2731 tallies.write_failures = self
2732 .write_failures
2733 .load(std::sync::atomic::Ordering::SeqCst);
2734 tallies.unreadable = self.unreadable.load(std::sync::atomic::Ordering::SeqCst);
2735 tallies.unread_bytes = inner
2736 .unread_tail
2737 .as_ref()
2738 .map_or(0, |range| range.end - range.start);
2739 tallies
2740 }
2741}
2742
2743/// The first `limit` bytes of the journal at `path`, cut after the last whole line, and the
2744/// byte range left unread when the file is larger: `None` when the file cannot be read. Only
2745/// what is kept is held in memory; the rest is measured by the file's length, not read.
2746fn read_bounded(path: &Path, limit: u64) -> Option<(Vec<u8>, Option<std::ops::Range<u64>>)> {
2747 use std::io::Read;
2748 let (file, len) = std::fs::File::open(path)
2749 .and_then(|file| file.metadata().map(|meta| (file, meta.len())))
2750 .ok()?;
2751 let mut bytes = Vec::new();
2752 file.take(limit).read_to_end(&mut bytes).ok()?;
2753 if len <= limit {
2754 return Some((bytes, None));
2755 }
2756 // A line that straddles the bound is not half-read into an unreadable line: it is the
2757 // first line of what is left on disk.
2758 let kept = bytes
2759 .iter()
2760 .rposition(|b| *b == b'\n')
2761 .map_or(0, |at| at + 1);
2762 bytes.truncate(kept);
2763 Some((bytes, Some(kept as u64..len)))
2764}
2765
2766/// [`write_synced`] of `text`, followed by the bytes `range` of `source` copied as they are:
2767/// the part of the journal a bounded reload left on disk, kept by the compaction that
2768/// replaces the file. Fails when fewer bytes than the range could be copied.
2769fn write_synced_with_tail(
2770 path: &Path,
2771 text: &str,
2772 source: &Path,
2773 range: std::ops::Range<u64>,
2774) -> std::io::Result<()> {
2775 use std::io::{Read, Seek, SeekFrom};
2776 let mut file = owner_only(
2777 std::fs::OpenOptions::new()
2778 .create(true)
2779 .write(true)
2780 .truncate(true),
2781 )
2782 .open(path)?;
2783 file.write_all(text.as_bytes())?;
2784 let mut from = std::fs::File::open(source)?;
2785 from.seek(SeekFrom::Start(range.start))?;
2786 let want = range.end - range.start;
2787 let copied = std::io::copy(&mut from.take(want), &mut file)?;
2788 if copied != want {
2789 return Err(std::io::Error::other(format!(
2790 "copied {copied} of the {want} unread byte(s)"
2791 )));
2792 }
2793 file.sync_all()
2794}
2795
2796/// Open a journal file for writing as its owner alone: it holds other runtimes' events and
2797/// handover bodies, which are nobody else's to read on this machine.
2798fn owner_only(options: &mut std::fs::OpenOptions) -> &mut std::fs::OpenOptions {
2799 #[cfg(unix)]
2800 {
2801 use std::os::unix::fs::OpenOptionsExt;
2802 options.mode(0o600);
2803 }
2804 options
2805}
2806
2807/// Append `text` to the journal at `path` and sync it. A file whose last byte is not a
2808/// newline — the tail a crash tore — gets one first, so the torn line stays one unreadable
2809/// line and the events appended after it read.
2810fn append_synced(path: &Path, text: &str) -> std::io::Result<()> {
2811 if let Some(dir) = path.parent() {
2812 std::fs::create_dir_all(dir)?;
2813 }
2814 let mut file = owner_only(
2815 std::fs::OpenOptions::new()
2816 .create(true)
2817 .read(true)
2818 .append(true),
2819 )
2820 .open(path)?;
2821 append_mended(&mut file, text).and_then(|()| file.sync_data())
2822}
2823
2824/// Append `text` to `file`, open for reading and appending, after a newline first when its
2825/// last byte is not one: the torn tail [`append_synced`] mends.
2826fn append_mended<F>(file: &mut F, text: &str) -> std::io::Result<()>
2827where
2828 F: std::io::Read + std::io::Write + std::io::Seek,
2829{
2830 use std::io::SeekFrom;
2831 if file.seek(SeekFrom::End(0))? > 0 {
2832 let mut last = [0u8; 1];
2833 file.seek(SeekFrom::End(-1))?;
2834 file.read_exact(&mut last)?;
2835 if last[0] != b'\n' {
2836 file.write_all(b"\n")?;
2837 }
2838 }
2839 file.write_all(text.as_bytes())
2840}
2841
2842/// Write `text` to a new file at `path`, owner-only, and sync it before it is renamed anywhere.
2843fn write_synced(path: &Path, text: &str) -> std::io::Result<()> {
2844 let mut file = owner_only(
2845 std::fs::OpenOptions::new()
2846 .create(true)
2847 .write(true)
2848 .truncate(true),
2849 )
2850 .open(path)?;
2851 file.write_all(text.as_bytes())
2852 .and_then(|()| file.sync_all())
2853}
2854
2855/// Sync the directory holding `path`, so a rename into it survives a crash too.
2856fn sync_parent(path: &Path) {
2857 if let Some(dir) = path.parent() {
2858 if let Ok(handle) = std::fs::File::open(dir) {
2859 let _ = handle.sync_all();
2860 }
2861 }
2862}
2863
2864/// Of the streams `due` for compaction, the ones nothing still needs. A due stream is needed
2865/// while it offers a handover nobody has taken and its offer has not lapsed
2866/// (`offer_lapsed`), and while it holds the taking of a handover or the answer to a review
2867/// whose own stream is needed or not due — to a fixed point, because keeping one stream can
2868/// keep the stream that answered it. Who took what and who answered what is the fold's
2869/// reading, so that compaction and every surface agree on it.
2870fn unneeded(
2871 streams: &BTreeMap<StreamId, StreamLog>,
2872 due: BTreeSet<StreamId>,
2873 offer_lapsed: &dyn Fn(&StreamLog) -> bool,
2874) -> Vec<StreamId> {
2875 if due.is_empty() {
2876 return Vec::new();
2877 }
2878 let held: Vec<MeshEvent> = streams
2879 .values()
2880 .flat_map(|log| log.events.values().cloned())
2881 .collect();
2882 let state = super::state::fold(&held, &|_| StreamLiveness::Expired);
2883 let mut publishers: BTreeMap<String, Vec<StreamId>> = BTreeMap::new();
2884 for (id, log) in streams {
2885 let published = log
2886 .events
2887 .values()
2888 .filter(|e| e.kind() == "handover_published");
2889 for event in published {
2890 if let Some(EventBody::HandoverPublished { handover }) = event.body() {
2891 publishers.entry(handover.id).or_default().push(id.clone());
2892 }
2893 }
2894 }
2895 let stream_of = |key: &str| key.split_once('/').and_then(|(s, _)| StreamId::parse(s));
2896 // What each taking and each answer refers to: the streams holding its handover or review.
2897 let mut refers: Vec<(StreamId, Vec<StreamId>)> = Vec::new();
2898 let mut kept: BTreeSet<StreamId> = streams
2899 .keys()
2900 .filter(|id| !due.contains(*id))
2901 .cloned()
2902 .collect();
2903 for handover in &state.handovers {
2904 let by = publishers.get(&handover.id).cloned().unwrap_or_default();
2905 if handover.consumed_by.is_empty() {
2906 let offering = by.iter().filter(|id| due.contains(*id));
2907 kept.extend(offering.filter(|id| !offer_lapsed(&streams[*id])).cloned());
2908 }
2909 for taker in handover.consumed_by.iter().filter_map(|key| stream_of(key)) {
2910 refers.push((taker, by.clone()));
2911 }
2912 }
2913 for review in &state.reviews {
2914 let Some(asked) = stream_of(&review.key) else {
2915 continue;
2916 };
2917 for answerer in review.answers.iter().filter_map(|a| stream_of(&a.session)) {
2918 refers.push((answerer, vec![asked.clone()]));
2919 }
2920 }
2921 loop {
2922 let more: Vec<StreamId> = refers
2923 .iter()
2924 .filter(|(from, to)| !kept.contains(from) && to.iter().any(|t| kept.contains(t)))
2925 .map(|(from, _)| from.clone())
2926 .collect();
2927 if more.is_empty() {
2928 break;
2929 }
2930 kept.extend(more);
2931 }
2932 due.into_iter().filter(|id| !kept.contains(id)).collect()
2933}
2934
2935#[cfg(test)]
2936mod tests {
2937 use super::*;
2938
2939 /// A file in memory whose `fail_at`-th seek, read or write fails, counted from one.
2940 struct Faulty {
2941 inner: std::io::Cursor<Vec<u8>>,
2942 ops: usize,
2943 fail_at: usize,
2944 }
2945
2946 impl Faulty {
2947 fn new(content: &[u8], fail_at: usize) -> Self {
2948 Self {
2949 inner: std::io::Cursor::new(content.to_vec()),
2950 ops: 0,
2951 fail_at,
2952 }
2953 }
2954
2955 fn step(&mut self) -> std::io::Result<()> {
2956 self.ops += 1;
2957 if self.ops == self.fail_at {
2958 return Err(std::io::Error::other(format!(
2959 "operation {} fails",
2960 self.ops
2961 )));
2962 }
2963 Ok(())
2964 }
2965 }
2966
2967 impl std::io::Read for Faulty {
2968 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2969 self.step()?;
2970 self.inner.read(buf)
2971 }
2972 }
2973
2974 impl std::io::Write for Faulty {
2975 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
2976 self.step()?;
2977 self.inner.write(buf)
2978 }
2979
2980 fn flush(&mut self) -> std::io::Result<()> {
2981 Ok(())
2982 }
2983 }
2984
2985 impl std::io::Seek for Faulty {
2986 fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result<u64> {
2987 self.step()?;
2988 self.inner.seek(to)
2989 }
2990 }
2991
2992 #[test]
2993 fn a_torn_tail_is_mended_before_the_append_and_every_failing_step_is_an_error() {
2994 let mut empty = Faulty::new(b"", 0);
2995 append_mended(&mut empty, "a\n").unwrap();
2996 std::io::Write::flush(&mut empty).unwrap();
2997 assert_eq!(
2998 empty.inner.get_ref(),
2999 b"a\n",
3000 "an empty file takes the text as it is"
3001 );
3002 let mut whole = Faulty::new(b"a\n", 0);
3003 append_mended(&mut whole, "b\n").unwrap();
3004 assert_eq!(
3005 whole.inner.get_ref(),
3006 b"a\nb\n",
3007 "a whole last line needs no newline"
3008 );
3009 let mut torn = Faulty::new(b"a\n{\"to", 0);
3010 append_mended(&mut torn, "b\n").unwrap();
3011 assert_eq!(
3012 torn.inner.get_ref(),
3013 b"a\n{\"to\nb\n",
3014 "the torn line stays one line"
3015 );
3016 // the length, the last byte's seek, its read, the mending newline, the text
3017 for step in 1..=5 {
3018 let mut file = Faulty::new(b"torn", step);
3019 let failed = append_mended(&mut file, "b\n").expect_err("the step fails");
3020 assert_eq!(failed.to_string(), format!("operation {step} fails"));
3021 }
3022 }
3023
3024 #[test]
3025 fn a_journal_path_that_cannot_be_written_is_an_error_not_a_panic() {
3026 let dir = tempfile::tempdir().unwrap();
3027 let file = dir.path().join("file");
3028 std::fs::write(&file, "x").unwrap();
3029 // a parent that is a file cannot be created
3030 assert!(append_synced(&file.join("sub").join("journal"), "a\n").is_err());
3031 // a path without a parent is opened as it is, and the empty path does not open
3032 assert!(append_synced(Path::new(""), "a\n").is_err());
3033 assert!(write_synced(&dir.path().join("missing").join("snapshot"), "a").is_err());
3034 // a path without a parent has no directory to sync, and that is not an error
3035 sync_parent(Path::new(""));
3036 let written = dir.path().join("snapshot");
3037 write_synced(&written, "a").unwrap();
3038 sync_parent(&written);
3039 assert_eq!(std::fs::read_to_string(&written).unwrap(), "a");
3040 let appended = dir.path().join("journal");
3041 append_synced(&appended, "a").unwrap();
3042 append_synced(&appended, "b\n").unwrap();
3043 assert_eq!(std::fs::read_to_string(&appended).unwrap(), "a\nb\n");
3044 }
3045
3046 fn journal(runtime: &str) -> Journal {
3047 Journal::open(
3048 Arc::new(NodeIdentity::ephemeral().unwrap()),
3049 runtime,
3050 "repo".into(),
3051 None,
3052 )
3053 .unwrap()
3054 }
3055
3056 fn opened(j: &Journal, session: &str) -> MeshEvent {
3057 j.append_own(EventBody::SessionOpened {
3058 info: SessionInfo::named(session, "test"),
3059 })
3060 .unwrap()
3061 }
3062
3063 fn accept_all(_: &MeshEvent) -> Result<(), Rejection> {
3064 Ok(())
3065 }
3066
3067 #[test]
3068 fn own_events_are_dense_signed_and_lamport_stamped() {
3069 let j = journal("0000000000000001");
3070 let a = opened(&j, "s1");
3071 let b = opened(&j, "s2");
3072 assert_eq!((a.seq, b.seq), (1, 2));
3073 assert!(b.lamport > a.lamport);
3074 assert!(verify(&a.pk, &a.signing_bytes(), &a.sig));
3075 assert_eq!(j.marks()[j.own_stream()].seq, 2);
3076 }
3077
3078 #[test]
3079 fn replication_sends_only_what_the_peer_lacks() {
3080 let a = journal("0000000000000001");
3081 let b = journal("0000000000000002");
3082 opened(&a, "s1");
3083 opened(&a, "s2");
3084 let first = a.missing_for(&b.marks(), usize::MAX);
3085 assert_eq!(first.len(), 2);
3086 assert_eq!(b.ingest(&first, &accept_all).accepted, 2);
3087 assert!(
3088 a.missing_for(&b.marks(), usize::MAX).is_empty(),
3089 "nothing left to send: marks converge, so replication stops"
3090 );
3091 }
3092
3093 #[test]
3094 fn a_relay_carries_events_it_cannot_forge() {
3095 let a = journal("0000000000000001");
3096 let b = journal("0000000000000002");
3097 let c = journal("0000000000000003");
3098 opened(&a, "s1");
3099 b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3100 // B relays A's event to C: C verifies A's signature, not B's word.
3101 let relayed = b.missing_for(&c.marks(), usize::MAX);
3102 assert_eq!(relayed.len(), 1);
3103 assert_eq!(relayed[0].stream, *a.own_stream());
3104 assert_eq!(c.ingest(&relayed, &accept_all).accepted, 1);
3105 // A tampering relay is caught at the consumer.
3106 let mut forged = relayed[0].clone();
3107 forged.body = serde_json::json!({"kind": "session_closed", "session": "s1"});
3108 let d = journal("0000000000000004");
3109 let report = d.ingest(&[forged], &accept_all);
3110 assert_eq!(report.rejected.get(&Rejection::Signature), Some(&1));
3111 }
3112
3113 #[test]
3114 fn out_of_order_delivery_waits_for_the_gap() {
3115 let a = journal("0000000000000001");
3116 let b = journal("0000000000000002");
3117 let e1 = opened(&a, "s1");
3118 let e2 = opened(&a, "s2");
3119 let e3 = opened(&a, "s3");
3120 let report = b.ingest(&[e3.clone(), e2.clone()], &accept_all);
3121 assert_eq!((report.accepted, report.pending), (0, 2));
3122 assert_eq!(b.marks()[a.own_stream()].seq, 0, "a gap holds the mark");
3123 let report = b.ingest(&[e1], &accept_all);
3124 assert_eq!(
3125 report.accepted, 3,
3126 "the gap closes and its successors apply"
3127 );
3128 assert_eq!(b.marks()[a.own_stream()].seq, 3);
3129 }
3130
3131 #[test]
3132 fn hostile_events_are_refused_by_reason() {
3133 let a = journal("0000000000000001");
3134 let b = journal("0000000000000002");
3135 let good = opened(&a, "s1");
3136
3137 let mut other_repo = good.clone();
3138 other_repo.repo = "elsewhere".into();
3139 other_repo.sig = String::new();
3140 let wrong_version = MeshEvent {
3141 v: 2,
3142 ..good.clone()
3143 };
3144 let mut stolen_stream = good.clone();
3145 stolen_stream.stream = b.own_stream().clone();
3146 let mut huge = good.clone();
3147 huge.body = serde_json::json!({"kind": "future", "blob": "x".repeat(MAX_EVENT_BYTES)});
3148
3149 let r = b.ingest(
3150 &[other_repo, wrong_version, stolen_stream, huge],
3151 &accept_all,
3152 );
3153 assert_eq!(r.rejected.get(&Rejection::Signature), Some(&1));
3154 assert_eq!(r.rejected.get(&Rejection::Version), Some(&1));
3155 assert_eq!(r.rejected.get(&Rejection::Identity), Some(&1));
3156 assert_eq!(r.rejected.get(&Rejection::Oversized), Some(&1));
3157 let untrusted = b.ingest(&[good], &|_| Err(Rejection::Untrusted));
3158 assert_eq!(untrusted.rejected.get(&Rejection::Untrusted), Some(&1));
3159 assert!(b.events().is_empty());
3160 }
3161
3162 #[test]
3163 fn a_repository_mismatch_is_refused_even_when_signed() {
3164 let foreign = Journal::open(
3165 Arc::new(NodeIdentity::ephemeral().unwrap()),
3166 "0000000000000001",
3167 "another-repository".into(),
3168 None,
3169 )
3170 .unwrap();
3171 let e = opened(&foreign, "s1");
3172 let ours = journal("0000000000000002");
3173 let r = ours.ingest(&[e], &accept_all);
3174 assert_eq!(r.rejected.get(&Rejection::Repository), Some(&1));
3175 }
3176
3177 #[test]
3178 fn an_unknown_kind_is_held_and_relayed_but_a_bad_known_kind_is_refused() {
3179 let a = journal("0000000000000001");
3180 let b = journal("0000000000000002");
3181 // A future executable's event, signed properly.
3182 let mut future = opened(&a, "s1");
3183 future.body = serde_json::json!({"kind": "vote_cast", "ballot": 7});
3184 future.seq = 1;
3185 future.sig = a.identity.sign(&future.signing_bytes());
3186 assert_eq!(b.ingest(&[future], &accept_all).accepted, 1);
3187 assert_eq!(b.tallies().opaque, 1);
3188
3189 let mut bad = opened(&a, "s2");
3190 bad.body = serde_json::json!({"kind": "claim_acquired", "claim": "c", "session": "s", "scope": ["/etc/passwd"]});
3191 bad.sig = a.identity.sign(&bad.signing_bytes());
3192 let r = b.ingest(&[bad], &accept_all);
3193 assert_eq!(r.rejected.get(&Rejection::Bounds), Some(&1));
3194 }
3195
3196 #[test]
3197 fn bounds_are_checked_before_signing() {
3198 let j = journal("0000000000000001");
3199 let err = j.append_own(EventBody::ClaimAcquired {
3200 claim: "c1".into(),
3201 session: "s1".into(),
3202 scope: vec!["../outside".into()],
3203 intent: None,
3204 mode: ClaimMode::Exclusive,
3205 issue: None,
3206 });
3207 assert!(err.is_err());
3208 assert!(j.events().is_empty());
3209 }
3210
3211 #[test]
3212 fn beats_relay_freshness_without_comparing_clocks() {
3213 let a = journal("0000000000000001");
3214 let b = journal("0000000000000002");
3215 let c = journal("0000000000000003");
3216 let expiry = Duration::from_secs(30);
3217 assert_eq!(
3218 b.liveness(a.own_stream(), expiry),
3219 StreamLiveness::Expired,
3220 "never heard"
3221 );
3222 a.beat_own();
3223 b.merge_marks(&a.marks(), &|_| true, expiry);
3224 assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Live);
3225 // C hears A only through B.
3226 c.merge_marks(&b.marks(), &|_| true, expiry);
3227 assert_eq!(c.liveness(a.own_stream(), expiry), StreamLiveness::Live);
3228 // An old beat relayed with a huge age is clamped to the expiry, never fresh.
3229 let d = journal("0000000000000004");
3230 let mut stale = b.marks();
3231 stale.get_mut(a.own_stream()).unwrap().age_ms = Some(u64::MAX);
3232 d.merge_marks(&stale, &|_| true, expiry);
3233 assert_eq!(d.liveness(a.own_stream(), expiry), StreamLiveness::Expired);
3234 assert_eq!(d.liveness(d.own_stream(), expiry), StreamLiveness::Own);
3235 }
3236
3237 #[test]
3238 fn a_relay_cannot_mint_a_beat_and_an_untrusted_origin_is_not_heard() {
3239 let a = journal("0000000000000001");
3240 let b = journal("0000000000000002");
3241 let expiry = Duration::from_secs(30);
3242 a.beat_own();
3243 let genuine = a.marks();
3244 // A relay raises A's beat to the ceiling: the signature no longer covers it.
3245 let mut forged = genuine.clone();
3246 forged.get_mut(a.own_stream()).unwrap().beat = u64::MAX;
3247 b.merge_marks(&forged, &|_| true, expiry);
3248 assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Expired);
3249 // An unsigned mark is ignored, and creates no stream.
3250 let mut unsigned = genuine.clone();
3251 unsigned.get_mut(a.own_stream()).unwrap().sig = None;
3252 b.merge_marks(&unsigned, &|_| true, expiry);
3253 assert_eq!(b.tallies().streams, 1, "only B's own stream exists");
3254 // A genuine beat from an origin the policy does not trust is not heard.
3255 b.merge_marks(&genuine, &|_| false, expiry);
3256 assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Expired);
3257 // And the genuine, trusted beat is.
3258 b.merge_marks(&genuine, &|_| true, expiry);
3259 assert_eq!(b.liveness(a.own_stream(), expiry), StreamLiveness::Live);
3260 // A later beat after the original one cannot be replayed backwards.
3261 a.beat_own();
3262 b.merge_marks(&a.marks(), &|_| true, expiry);
3263 b.merge_marks(&genuine, &|_| true, expiry);
3264 assert_eq!(b.marks()[a.own_stream()].beat, 2);
3265 }
3266
3267 #[test]
3268 fn a_stream_id_off_the_wire_is_parsed_or_refused() {
3269 let bad: Result<StreamId, _> = serde_json::from_value(serde_json::json!("x"));
3270 assert!(
3271 bad.is_err(),
3272 "an unparsed stream id would panic a later slice"
3273 );
3274 let marks: Result<Marks, _> =
3275 serde_json::from_value(serde_json::json!({"x": {"seq": 1, "beat": 1}}));
3276 assert!(marks.is_err());
3277 }
3278
3279 #[test]
3280 fn one_node_gets_a_bounded_share_of_the_journal() {
3281 let own = journal("0000000000000002");
3282 let identity = Arc::new(NodeIdentity::ephemeral().unwrap());
3283 let mut refused = 0;
3284 for slot in 0..(MAX_STREAMS_PER_NODE + 4) {
3285 let j = Journal::open(
3286 Arc::clone(&identity),
3287 &format!("{slot:016x}"),
3288 "repo".into(),
3289 None,
3290 )
3291 .unwrap();
3292 let event = opened(&j, "s1");
3293 refused += own.ingest(&[event], &accept_all).rejected_total();
3294 }
3295 assert_eq!(
3296 refused, 4,
3297 "the streams past the per-node quota are refused"
3298 );
3299 }
3300
3301 #[test]
3302 fn a_compacted_stream_is_not_sent_again() {
3303 let a = journal("0000000000000001");
3304 let b = journal("0000000000000002");
3305 opened(&a, "s1");
3306 b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3307 assert_eq!(
3308 b.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, true),
3309 1
3310 );
3311 assert_eq!(
3312 b.marks()[a.own_stream()].seq,
3313 1,
3314 "the tombstone keeps the mark"
3315 );
3316 assert!(a.missing_for(&b.marks(), usize::MAX).is_empty());
3317 let again = b.ingest(&a.events(), &accept_all);
3318 assert_eq!((again.accepted, again.duplicate), (0, 1));
3319 }
3320
3321 fn on_disk(path: &Path, key: &Path) -> Journal {
3322 Journal::open(
3323 Arc::new(NodeIdentity::load_or_create(key).unwrap()),
3324 "0000000000000002",
3325 "repo".into(),
3326 Some(path.to_path_buf()),
3327 )
3328 .unwrap()
3329 }
3330
3331 #[test]
3332 fn a_torn_tail_is_one_unreadable_line_and_the_next_append_reads() {
3333 let dir = tempfile::tempdir().unwrap();
3334 let path = dir.path().join("journal.jsonl");
3335 let key = dir.path().join("node.json");
3336 let first = on_disk(&path, &key);
3337 for name in ["s1", "s2"] {
3338 first
3339 .append_own(EventBody::SessionOpened {
3340 info: SessionInfo::named(name, "test"),
3341 })
3342 .unwrap();
3343 }
3344 drop(first);
3345 // a crash in the middle of an append: half a line, no newline
3346 let mut file = std::fs::OpenOptions::new()
3347 .append(true)
3348 .open(&path)
3349 .unwrap();
3350 file.write_all(br#"{"stream":"torn","seq":"#).unwrap();
3351 drop(file);
3352
3353 let second = on_disk(&path, &key);
3354 let after_crash = second.tallies();
3355 assert_eq!(
3356 after_crash.unreadable, 1,
3357 "the torn line is counted, not dropped in silence"
3358 );
3359 assert_eq!(after_crash.events, 2, "every complete event reloads");
3360 second
3361 .append_own(EventBody::SessionOpened {
3362 info: SessionInfo::named("s3", "test"),
3363 })
3364 .unwrap();
3365 drop(second);
3366
3367 let third = on_disk(&path, &key);
3368 let tallies = third.tallies();
3369 assert_eq!(
3370 tallies.unreadable, 1,
3371 "the append after the tear did not fuse into it"
3372 );
3373 assert_eq!(tallies.events, 3);
3374 assert_eq!(tallies.write_failures, 0);
3375 }
3376
3377 #[cfg(unix)]
3378 #[test]
3379 fn a_failed_append_is_counted_and_the_file_is_the_owners_alone() {
3380 use std::os::unix::fs::PermissionsExt;
3381 let dir = tempfile::tempdir().unwrap();
3382 let key = dir.path().join("node.json");
3383 let path = dir.path().join("state").join("journal.jsonl");
3384 let journal = on_disk(&path, &key);
3385 journal
3386 .append_own(EventBody::SessionClosed {
3387 session: "a".into(),
3388 })
3389 .unwrap();
3390 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
3391 assert_eq!(
3392 mode, 0o600,
3393 "the journal holds other runtimes' events: {mode:o}"
3394 );
3395 assert_eq!(journal.tallies().write_failures, 0);
3396
3397 // the file can no longer be written: the event is held, and the loss is counted
3398 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
3399 let held = journal.append_own(EventBody::SessionClosed {
3400 session: "b".into(),
3401 });
3402 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
3403 assert!(held.is_ok(), "the event is held in memory and replicated");
3404 assert_eq!(journal.tallies().write_failures, 1);
3405 assert!(!crate::mesh::doctor::journal_check(&journal.tallies()).ok);
3406 }
3407
3408 #[test]
3409 fn a_failed_append_is_not_acknowledged_as_durable() {
3410 let dir = tempfile::tempdir().unwrap();
3411 let key = dir.path().join("node.json");
3412 let path = dir.path().join("journal.jsonl");
3413 let journal = on_disk(&path, &key);
3414 let first = journal
3415 .append_acknowledged(EventBody::SessionClosed {
3416 session: "a".into(),
3417 })
3418 .unwrap();
3419 assert_eq!(first.durability, Durability::Synced);
3420
3421 // the journal's place is taken by a directory: no write can reach the file, for any
3422 // user, root included — a fault the test can inject without a full disk
3423 std::fs::remove_file(&path).unwrap();
3424 std::fs::create_dir(&path).unwrap();
3425 let lost = journal
3426 .append_acknowledged(EventBody::SessionClosed {
3427 session: "b".into(),
3428 })
3429 .unwrap();
3430 assert_eq!(
3431 lost.durability,
3432 Durability::NotDurable,
3433 "a write that failed is never acknowledged as durable"
3434 );
3435 assert!(!lost.durability.is_on_disk());
3436 assert!(
3437 journal.events().iter().any(|e| e.id() == lost.event.id()),
3438 "the event is still held, and offered to peers"
3439 );
3440 assert_eq!(journal.tallies().write_failures, 1);
3441 assert!(!crate::mesh::doctor::journal_check(&journal.tallies()).ok);
3442 }
3443
3444 /// The bytes of `events`, one line each, as the journal writes them.
3445 fn lines_of(events: &[MeshEvent]) -> Vec<Vec<u8>> {
3446 events
3447 .iter()
3448 .map(|e| {
3449 let mut line = serde_json::to_vec(e).unwrap();
3450 line.push(b'\n');
3451 line
3452 })
3453 .collect()
3454 }
3455
3456 #[test]
3457 fn a_reload_is_bounded_and_the_unread_part_is_counted() {
3458 let dir = tempfile::tempdir().unwrap();
3459 let path = dir.path().join("journal.jsonl");
3460 let writer = on_disk(&path, &dir.path().join("writer.json"));
3461 let written: Vec<MeshEvent> = (1..=5).map(|n| opened(&writer, &format!("s{n}"))).collect();
3462 drop(writer);
3463 let lines = lines_of(&written);
3464 assert_eq!(std::fs::read(&path).unwrap(), lines.concat());
3465 let whole: u64 = lines.iter().map(|l| l.len() as u64).sum();
3466 let kept: u64 = lines[..2].iter().map(|l| l.len() as u64).sum();
3467
3468 // the bound falls three bytes into the third line: two lines are read, and the third
3469 // is not torn into an unreadable one — it is the first line of what is left on disk
3470 let bounded = Journal::open_bounded(
3471 Arc::new(NodeIdentity::ephemeral().unwrap()),
3472 "0000000000000003",
3473 "repo".into(),
3474 Some(path.clone()),
3475 &accept_all,
3476 kept + 3,
3477 )
3478 .unwrap();
3479 let tallies = bounded.tallies();
3480 assert_eq!(tallies.events, 2, "what lies within the bound reloads");
3481 assert_eq!(
3482 tallies.unreadable, 0,
3483 "a line cut by the bound is not unreadable"
3484 );
3485 assert_eq!(
3486 tallies.unread_bytes,
3487 whole - kept,
3488 "the rest is counted, to the byte"
3489 );
3490 let check = crate::mesh::doctor::journal_check(&tallies);
3491 assert!(
3492 !check.ok,
3493 "mesh doctor names a journal it did not read whole"
3494 );
3495 assert!(check.detail.contains(&format!("{} byte(s)", whole - kept)));
3496
3497 // within the bound, nothing is left unread
3498 let whole_read = Journal::open_bounded(
3499 Arc::new(NodeIdentity::ephemeral().unwrap()),
3500 "0000000000000004",
3501 "repo".into(),
3502 Some(path.clone()),
3503 &accept_all,
3504 whole,
3505 )
3506 .unwrap();
3507 assert_eq!(whole_read.tallies().events, 5);
3508 assert_eq!(whole_read.tallies().unread_bytes, 0);
3509 assert!(crate::mesh::doctor::journal_check(&whole_read.tallies()).ok);
3510 }
3511
3512 #[test]
3513 fn a_compaction_keeps_what_a_bounded_reload_left_unread() {
3514 let dir = tempfile::tempdir().unwrap();
3515 let path = dir.path().join("journal.jsonl");
3516 let writer = on_disk(&path, &dir.path().join("writer.json"));
3517 let written: Vec<MeshEvent> = (1..=5).map(|n| opened(&writer, &format!("s{n}"))).collect();
3518 drop(writer);
3519 let lines = lines_of(&written);
3520 let kept: u64 = lines[..2].iter().map(|l| l.len() as u64).sum();
3521 let bounded = Journal::open_bounded(
3522 Arc::new(NodeIdentity::ephemeral().unwrap()),
3523 "0000000000000003",
3524 "repo".into(),
3525 Some(path.clone()),
3526 &accept_all,
3527 kept,
3528 )
3529 .unwrap();
3530 // the writer's stream is silent: compaction drops the two events it reloaded and
3531 // rewrites the file from memory — which must not lose the three it never read
3532 let dropped = bounded.compact(Duration::ZERO, Duration::ZERO, Duration::ZERO, true);
3533 assert_eq!(dropped, 2);
3534 assert_eq!(
3535 std::fs::read(&path).unwrap(),
3536 lines[2..].concat(),
3537 "the unread part is copied into the new file, byte for byte"
3538 );
3539 assert_eq!(
3540 bounded.tallies().unread_bytes,
3541 lines[2..].iter().map(|l| l.len() as u64).sum::<u64>(),
3542 "and is still counted as unread"
3543 );
3544 }
3545
3546 /// A journal that cannot be read is nothing read, never an empty journal read whole: a
3547 /// missing file, and a directory in the file's place, which opens and does not read.
3548 #[test]
3549 fn a_journal_that_cannot_be_read_is_not_read_as_empty() {
3550 let dir = tempfile::tempdir().unwrap();
3551 assert!(read_bounded(&dir.path().join("absent.jsonl"), 1024).is_none());
3552 assert!(read_bounded(dir.path(), 1024).is_none(), "a directory");
3553
3554 let path = dir.path().join("journal.jsonl");
3555 std::fs::write(&path, b"one\ntwo\nthr").unwrap();
3556 assert_eq!(
3557 read_bounded(&path, 6),
3558 Some((b"one\n".to_vec(), Some(4..11))),
3559 "cut after the last whole line, the rest measured"
3560 );
3561 assert_eq!(
3562 read_bounded(&path, 64),
3563 Some((b"one\ntwo\nthr".to_vec(), None))
3564 );
3565 }
3566
3567 /// The rewrite that keeps a bounded reload's unread tail copies the tail whole or fails:
3568 /// a target that cannot be opened, a source that is gone, a start no seek can reach, a
3569 /// source that does not read and a range longer than the source are each an error.
3570 #[test]
3571 fn a_tail_that_cannot_be_copied_whole_fails_the_rewrite() {
3572 let dir = tempfile::tempdir().unwrap();
3573 let source = dir.path().join("journal.jsonl");
3574 std::fs::write(&source, b"0123456789").unwrap();
3575 let out = dir.path().join("journal.jsonl.tmp");
3576
3577 write_synced_with_tail(&out, "head\n", &source, 3..7).unwrap();
3578 assert_eq!(std::fs::read(&out).unwrap(), b"head\n3456");
3579
3580 let blocked = dir.path().join("blocked");
3581 std::fs::create_dir(&blocked).unwrap();
3582 assert!(
3583 write_synced_with_tail(&blocked, "x", &source, 0..1).is_err(),
3584 "a directory stands where the new file goes"
3585 );
3586 assert!(
3587 write_synced_with_tail(&out, "x", &dir.path().join("gone"), 0..1).is_err(),
3588 "the source is gone"
3589 );
3590 assert!(
3591 write_synced_with_tail(&out, "x", &source, u64::MAX..u64::MAX).is_err(),
3592 "a start past any offset a seek can name"
3593 );
3594 assert!(
3595 write_synced_with_tail(&out, "x", dir.path(), 0..1).is_err(),
3596 "a directory opens and seeks, and no byte of it reads"
3597 );
3598 let short = write_synced_with_tail(&out, "x", &source, 8..20).unwrap_err();
3599 assert!(
3600 short
3601 .to_string()
3602 .contains("copied 2 of the 12 unread byte(s)"),
3603 "{short}"
3604 );
3605 }
3606
3607 /// The text before the tail that does not reach the disk fails the rewrite: `/dev/full`
3608 /// opens for writing and refuses every byte.
3609 #[cfg(target_os = "linux")]
3610 #[test]
3611 fn a_text_that_cannot_be_written_fails_the_rewrite() {
3612 let dir = tempfile::tempdir().unwrap();
3613 let source = dir.path().join("journal.jsonl");
3614 std::fs::write(&source, b"0123456789").unwrap();
3615 let full =
3616 write_synced_with_tail(Path::new("/dev/full"), "head\n", &source, 0..1).unwrap_err();
3617 assert_eq!(full.raw_os_error(), Some(libc::ENOSPC), "{full}");
3618 }
3619
3620 #[test]
3621 fn paging_by_stamp_returns_every_event_exactly_once() {
3622 // three runtimes that wrote concurrently: their events share stamps
3623 let reader = journal("0000000000000001");
3624 for runtime in ["0000000000000002", "0000000000000003", "0000000000000004"] {
3625 let writer = journal(runtime);
3626 opened(&writer, "s1");
3627 opened(&writer, "s2");
3628 reader.merge_marks(&writer.marks(), &|_| true, Duration::from_secs(30));
3629 reader.ingest(
3630 &writer.missing_for(&reader.marks(), usize::MAX),
3631 &accept_all,
3632 );
3633 }
3634 let all = reader.events_after(0, usize::MAX);
3635 assert_eq!(all.len(), 6);
3636 assert!(
3637 all.windows(2).any(|w| w[0].lamport == w[1].lamport),
3638 "the fixture holds a tie, or it proves nothing"
3639 );
3640 // page by one, resuming after the last stamp each page carries
3641 let mut read = Vec::new();
3642 let mut after = 0;
3643 loop {
3644 let page = reader.events_after(after, 1);
3645 let Some(last) = page.last() else { break };
3646 after = last.lamport;
3647 read.extend(page);
3648 }
3649 assert_eq!(
3650 read, all,
3651 "every event once, in order, however small the page"
3652 );
3653 }
3654
3655 #[test]
3656 fn an_append_after_a_torn_tail_starts_a_line_of_its_own() {
3657 let dir = tempfile::tempdir().unwrap();
3658 // the parent is created on the first append
3659 let path = dir.path().join("deeper").join("journal.jsonl");
3660 append_synced(&path, "{\"a\":1}\n").unwrap();
3661 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"a\":1}\n");
3662 // a crash tore the last line: the next append begins on a new line
3663 std::fs::write(&path, "{\"a\":1}\n{\"torn\"").unwrap();
3664 append_synced(&path, "{\"b\":2}\n").unwrap();
3665 assert_eq!(
3666 std::fs::read_to_string(&path).unwrap(),
3667 "{\"a\":1}\n{\"torn\"\n{\"b\":2}\n"
3668 );
3669 // an intact tail gets nothing in between
3670 append_synced(&path, "{\"c\":3}\n").unwrap();
3671 assert!(std::fs::read_to_string(&path)
3672 .unwrap()
3673 .ends_with("{\"b\":2}\n{\"c\":3}\n"));
3674 }
3675
3676 #[test]
3677 fn a_synced_write_replaces_the_whole_file_and_its_directory_is_synced() {
3678 let dir = tempfile::tempdir().unwrap();
3679 let path = dir.path().join("journal.jsonl.tmp");
3680 write_synced(&path, "first, and longer\n").unwrap();
3681 write_synced(&path, "second\n").unwrap();
3682 assert_eq!(std::fs::read_to_string(&path).unwrap(), "second\n");
3683 sync_parent(&path);
3684 // a path with no directory to open is not an error
3685 sync_parent(Path::new("journal.jsonl"));
3686 }
3687
3688 #[test]
3689 fn a_compaction_that_cannot_replace_the_file_leaves_the_old_one_and_no_temporary() {
3690 let dir = tempfile::tempdir().unwrap();
3691 let path = dir.path().join("journal.jsonl");
3692 let a = journal("0000000000000001");
3693 opened(&a, "s1");
3694 let b = Journal::open(
3695 Arc::new(NodeIdentity::ephemeral().unwrap()),
3696 "0000000000000002",
3697 "repo".into(),
3698 Some(path.clone()),
3699 )
3700 .unwrap();
3701 b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3702 // the journal's place is taken by a directory that is not empty: the rename must fail
3703 std::fs::remove_file(&path).unwrap();
3704 std::fs::create_dir(&path).unwrap();
3705 std::fs::write(path.join("occupied"), "x").unwrap();
3706 let dropped = b.compact(Duration::ZERO, Duration::ZERO, Duration::ZERO, true);
3707 assert!(
3708 dropped > 0,
3709 "a's silent stream was due and dropped in memory"
3710 );
3711 assert!(
3712 path.join("occupied").exists(),
3713 "what stood there still stands"
3714 );
3715 assert!(
3716 !path.with_extension("jsonl.tmp").exists(),
3717 "the temporary file is removed"
3718 );
3719 }
3720
3721 #[test]
3722 fn a_reloaded_journal_keeps_events_and_resurrects_no_liveness() {
3723 let dir = tempfile::tempdir().unwrap();
3724 let path = dir.path().join("journal.jsonl");
3725 let key = dir.path().join("node.json");
3726 let a = journal("0000000000000001");
3727 a.beat_own();
3728 opened(&a, "s1");
3729 let b = Journal::open(
3730 Arc::new(NodeIdentity::load_or_create(&key).unwrap()),
3731 "0000000000000002",
3732 "repo".into(),
3733 Some(path.clone()),
3734 )
3735 .unwrap();
3736 b.merge_marks(&a.marks(), &|_| true, Duration::from_secs(30));
3737 b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3738 let own_before = b.own_stream().clone();
3739 b.append_own(EventBody::SessionOpened {
3740 info: SessionInfo::named("mine", "test"),
3741 })
3742 .unwrap();
3743 drop(b);
3744
3745 // The same node restarts: a new instance, so a new stream; the old events reload.
3746 let restarted = Journal::open(
3747 Arc::new(NodeIdentity::load_or_create(&key).unwrap()),
3748 "0000000000000002",
3749 "repo".into(),
3750 Some(path),
3751 )
3752 .unwrap();
3753 assert_ne!(restarted.own_stream(), &own_before);
3754 assert_eq!(restarted.events().len(), 2);
3755 let expiry = Duration::from_secs(30);
3756 assert_eq!(
3757 restarted.liveness(a.own_stream(), expiry),
3758 StreamLiveness::Expired
3759 );
3760 assert_eq!(
3761 restarted.liveness(&own_before, expiry),
3762 StreamLiveness::Expired,
3763 "the previous run of this very runtime is dead, not resurrected"
3764 );
3765 }
3766
3767 #[test]
3768 fn compaction_drops_only_dead_streams_without_handovers() {
3769 let a = journal("0000000000000001");
3770 let h = journal("0000000000000003");
3771 let b = journal("0000000000000002");
3772 opened(&a, "s1");
3773 let body = "# Objective\nx\n".to_string();
3774 h.append_own(EventBody::HandoverPublished {
3775 handover: HandoverBody {
3776 id: HandoverBody::digest_of(&body),
3777 task: None,
3778 issue: None,
3779 milestone: None,
3780 branch: None,
3781 head: None,
3782 created_at: None,
3783 name: None,
3784 body,
3785 },
3786 })
3787 .unwrap();
3788 b.ingest(&a.missing_for(&b.marks(), usize::MAX), &accept_all);
3789 b.ingest(&h.missing_for(&b.marks(), usize::MAX), &accept_all);
3790 let dropped = b.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, true);
3791 assert_eq!(dropped, 1, "A's session event goes; the handover stays");
3792 assert_eq!(b.events().len(), 1);
3793 }
3794
3795 /// Every worktree of one machine signs with the machine's one key, and every restart of
3796 /// every server is a new stream of that node: a machine reaches its stream quota by
3797 /// running, not by misbehaving. The supervisor's periodic compaction is never forced, so
3798 /// it alone must keep the node under its quota, or a sibling's run past the quota is
3799 /// refused here for good — and a claim it takes is then invisible to this runtime.
3800 #[test]
3801 fn a_machine_that_restarts_past_its_stream_quota_still_reaches_its_siblings() {
3802 let dir = tempfile::tempdir().unwrap();
3803 let key = dir.path().join("node.json");
3804 let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3805 // One worktree's server, running throughout.
3806 let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3807 for run in 0..(MAX_STREAMS_PER_NODE + 6) {
3808 // Another worktree's server of the same machine, restarted: a new instance.
3809 let restarted =
3810 Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3811 let report = observer.ingest(&[opened(&restarted, "s1")], &accept_all);
3812 assert_eq!(
3813 report.accepted, 1,
3814 "run {run} of a sibling server was refused: {:?}",
3815 report.rejected
3816 );
3817 // The supervisor's compaction, as it runs: unforced, and every earlier run has
3818 // been silent for longer than the retention (zero here).
3819 observer.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, false);
3820 }
3821 }
3822
3823 /// A relay that has not compacted yet keeps advertising the beats of streams this
3824 /// runtime has just compacted. Those beats are stale — the streams are long dead — and
3825 /// a stale beat must not bring a stream back as an empty entry that holds a slot of
3826 /// its node's quota for another retention period.
3827 #[test]
3828 fn a_stale_relayed_beat_takes_no_slot_of_its_node() {
3829 let dir = tempfile::tempdir().unwrap();
3830 let key = dir.path().join("node.json");
3831 let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3832 let expiry = Duration::from_secs(30);
3833 let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3834 let relay = Journal::open(machine(), "0000000000000003", "repo".into(), None).unwrap();
3835 for _ in 0..(MAX_STREAMS_PER_NODE - 1) {
3836 let run = Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3837 run.beat_own();
3838 relay.merge_marks(&run.marks(), &|_| true, expiry);
3839 let event = [opened(&run, "s1")];
3840 assert_eq!(relay.ingest(&event, &accept_all).accepted, 1);
3841 assert_eq!(observer.ingest(&event, &accept_all).accepted, 1);
3842 }
3843 // The relay heard those runs beat, long ago: its marks say how long.
3844 let mut stale = relay.marks();
3845 for mark in stale.values_mut().filter(|m| m.beat > 0) {
3846 mark.age_ms = Some(10 * 60 * 1000);
3847 }
3848 observer.merge_marks(&stale, &|_| true, expiry);
3849 let dropped = observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true);
3850 assert_eq!(dropped, (MAX_STREAMS_PER_NODE - 1) as u64);
3851
3852 // The relay advertises the same stale beats in its next round.
3853 observer.merge_marks(&stale, &|_| true, expiry);
3854 assert_eq!(
3855 observer.tallies().streams,
3856 1,
3857 "a stale beat brought compacted streams back"
3858 );
3859 let next = Journal::open(machine(), "0000000000000004", "repo".into(), None).unwrap();
3860 let report = observer.ingest(&[opened(&next, "s1")], &accept_all);
3861 assert_eq!(report.accepted, 1, "{:?}", report.rejected);
3862 }
3863
3864 /// A restarted runtime reloads its siblings' streams without having heard any of them
3865 /// beat yet. Silence is measured from when the journal learned of a stream, so a
3866 /// sibling that is running but not yet linked is not compacted away — with its claims —
3867 /// the first time compaction runs after the restart.
3868 #[test]
3869 fn a_restart_does_not_compact_the_streams_it_has_not_heard_yet() {
3870 let dir = tempfile::tempdir().unwrap();
3871 let path = dir.path().join("journal.jsonl");
3872 let sibling = journal("0000000000000001");
3873 opened(&sibling, "s1");
3874 let before = Journal::open(
3875 Arc::new(NodeIdentity::ephemeral().unwrap()),
3876 "0000000000000002",
3877 "repo".into(),
3878 Some(path.clone()),
3879 )
3880 .unwrap();
3881 before.ingest(
3882 &sibling.missing_for(&before.marks(), usize::MAX),
3883 &accept_all,
3884 );
3885 drop(before);
3886
3887 let restarted = Journal::open(
3888 Arc::new(NodeIdentity::ephemeral().unwrap()),
3889 "0000000000000002",
3890 "repo".into(),
3891 Some(path),
3892 )
3893 .unwrap();
3894 let (expiry, retention) = (Duration::from_secs(30), Duration::from_secs(15 * 60));
3895 assert_eq!(
3896 restarted.compact(expiry, retention, HANDOVER_RETENTION, true),
3897 0,
3898 "the sibling may still be running: it has not been silent here for a retention"
3899 );
3900 assert_eq!(restarted.events().len(), 1);
3901 // Once it has been silent for as long as any stream must be, it goes.
3902 assert_eq!(
3903 restarted.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, true),
3904 1
3905 );
3906 }
3907
3908 /// A handover saying `text`, published by `j`: the event and the handover's id.
3909 fn publish(j: &Journal, text: &str) -> (MeshEvent, String) {
3910 let body = format!("# Objective\n{text}\n");
3911 let id = HandoverBody::digest_of(&body);
3912 let handover = HandoverBody {
3913 id: id.clone(),
3914 task: None,
3915 issue: None,
3916 milestone: None,
3917 branch: None,
3918 head: None,
3919 created_at: None,
3920 name: None,
3921 body,
3922 };
3923 let event = j
3924 .append_own(EventBody::HandoverPublished { handover })
3925 .unwrap();
3926 (event, id)
3927 }
3928
3929 /// The events `j` wrote itself, without what it holds of other streams.
3930 fn own_events(j: &Journal) -> Vec<MeshEvent> {
3931 let mut own = j.events();
3932 own.retain(|e| e.stream == *j.own_stream());
3933 own
3934 }
3935
3936 /// Tell `observer` that `j` beat once, long ago: it has been silent past any expiry.
3937 fn heard_long_ago(observer: &Journal, j: &Journal, expiry: Duration) {
3938 j.beat_own();
3939 let mut stale = j.marks();
3940 stale.get_mut(j.own_stream()).unwrap().age_ms = Some(10 * 60 * 1000);
3941 observer.merge_marks(&stale, &|_| true, expiry);
3942 }
3943
3944 /// Every server run of a machine that publishes a handover leaves a stream holding one.
3945 /// A handover somebody has taken is continuity that has been picked up: it must not keep
3946 /// its dead stream in the node's quota, or a machine whose runs each handed over — and
3947 /// each was taken — fills its quota with the dead and every later run is refused here.
3948 #[test]
3949 fn a_node_whose_dead_streams_published_taken_handovers_is_not_starved() {
3950 let dir = tempfile::tempdir().unwrap();
3951 let key = dir.path().join("node.json");
3952 let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3953 let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3954 // A runtime on another machine takes every handover. Each is its run's first event,
3955 // Lamport 1, so the taking follows it in every fold.
3956 let taker = journal("0000000000000003");
3957 opened(&taker, "t1");
3958 for run in 0..(MAX_STREAMS_PER_NODE + 6) {
3959 let restarted =
3960 Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3961 let (published, id) = publish(&restarted, &format!("run {run}"));
3962 let report = observer.ingest(&[opened(&restarted, "s1"), published], &accept_all);
3963 assert_eq!(
3964 report.accepted, 2,
3965 "run {run} of a sibling server was refused: {:?}",
3966 report.rejected
3967 );
3968 taker
3969 .append_own(EventBody::HandoverConsumed {
3970 handover: id,
3971 session: "t1".into(),
3972 })
3973 .unwrap();
3974 observer.ingest(
3975 &taker.missing_for(&observer.marks(), usize::MAX),
3976 &accept_all,
3977 );
3978 observer.compact(Duration::ZERO, Duration::ZERO, HANDOVER_RETENTION, false);
3979 }
3980 }
3981
3982 /// A handover nobody takes pins its dead stream, and only for a bounded retention: past
3983 /// it, the stream is compacted like any other, or a machine whose runs each handed over
3984 /// something nobody took would be refused here for good.
3985 #[test]
3986 fn an_untaken_handover_pins_its_stream_only_for_the_handover_retention() {
3987 let dir = tempfile::tempdir().unwrap();
3988 let key = dir.path().join("node.json");
3989 let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
3990 let observer = Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
3991 for run in 0..(MAX_STREAMS_PER_NODE + 6) {
3992 let restarted =
3993 Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
3994 let (published, _) = publish(&restarted, &format!("run {run}"));
3995 let report = observer.ingest(&[opened(&restarted, "s1"), published], &accept_all);
3996 assert_eq!(
3997 report.accepted, 2,
3998 "run {run} of a sibling server was refused: {:?}",
3999 report.rejected
4000 );
4001 // Every earlier run has been silent past the retention and the handover retention
4002 // (both zero here).
4003 observer.compact(Duration::ZERO, Duration::ZERO, Duration::ZERO, false);
4004 }
4005 }
4006
4007 /// Who took a handover and who answered a review are records of the taker's stream, not
4008 /// of the handover's or the review's. When the taker has stopped and the publisher has
4009 /// not, compacting the taker away would make a live handover look untaken and an
4010 /// answered review open again: those records stay as long as what they refer to does.
4011 #[test]
4012 fn compaction_keeps_the_takers_and_the_answers_of_what_it_keeps() {
4013 use crate::mesh::state::{fold, ReviewState};
4014
4015 let expiry = Duration::from_secs(30);
4016 let observer = journal("0000000000000001");
4017 let publisher = journal("0000000000000002");
4018 let taker = journal("0000000000000003");
4019 let reviewer = journal("0000000000000004");
4020 opened(&publisher, "s1");
4021 let (_, id) = publish(&publisher, "carry the mesh");
4022 publisher
4023 .append_own(EventBody::ReviewRequested {
4024 review: "r1".into(),
4025 session: "s1".into(),
4026 subject: "feature/mesh".into(),
4027 scope: Vec::new(),
4028 issue: None,
4029 reviewer: None,
4030 body: None,
4031 })
4032 .unwrap();
4033 // The taker and the reviewer hear of the handover and the request before they act.
4034 for j in [&taker, &reviewer] {
4035 j.ingest(&publisher.events(), &accept_all);
4036 }
4037 opened(&taker, "t1");
4038 taker
4039 .append_own(EventBody::HandoverConsumed {
4040 handover: id,
4041 session: "t1".into(),
4042 })
4043 .unwrap();
4044 opened(&reviewer, "v1");
4045 reviewer
4046 .append_own(EventBody::ReviewAnswered {
4047 request: format!("{}/r1", publisher.own_stream()),
4048 session: "v1".into(),
4049 verdict: "approved".into(),
4050 note: None,
4051 })
4052 .unwrap();
4053 for j in [&publisher, &taker, &reviewer] {
4054 j.beat_own();
4055 observer.merge_marks(&j.marks(), &|_| true, expiry);
4056 observer.ingest(&own_events(j), &accept_all);
4057 }
4058 // The taker and the reviewer stopped long ago; the publisher is beating.
4059 heard_long_ago(&observer, &taker, expiry);
4060 heard_long_ago(&observer, &reviewer, expiry);
4061 publisher.beat_own();
4062 observer.merge_marks(&publisher.marks(), &|_| true, expiry);
4063
4064 let state = |j: &Journal| fold(&j.events(), &|s| j.liveness(s, expiry));
4065 let before = state(&observer);
4066 assert_eq!(before.handovers[0].consumed_by.len(), 1);
4067 assert_eq!(before.reviews[0].state, ReviewState::Answered);
4068 observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true);
4069 let after = state(&observer);
4070 assert_eq!(
4071 after.handovers, before.handovers,
4072 "a live handover looks untaken again"
4073 );
4074 assert_eq!(
4075 after.reviews, before.reviews,
4076 "an answered review is open again"
4077 );
4078 }
4079
4080 /// A laptop that sleeps past the expiry and the retention is compacted away by its peers;
4081 /// it wakes as the same process, so as the same stream, and goes on writing. Its peers
4082 /// must hear it again — the whole stream, from its first event, because the claim it took
4083 /// before it slept holds again the moment it beats and a suffix would not show it.
4084 #[test]
4085 fn a_compacted_stream_that_beats_again_is_fetched_again_from_its_first_event() {
4086 use crate::mesh::state::{fold, ClaimState};
4087
4088 let expiry = Duration::from_secs(30);
4089 let laptop = journal("0000000000000001");
4090 let observer = journal("0000000000000002");
4091 // One sync round as the supervisor runs it: the events the observer lacks, then marks.
4092 let round = || {
4093 observer.ingest(
4094 &laptop.missing_for(&observer.marks(), usize::MAX),
4095 &accept_all,
4096 );
4097 observer.merge_marks(&laptop.marks(), &|_| true, expiry);
4098 };
4099 let claim = |claim: &str, scope: &str| EventBody::ClaimAcquired {
4100 claim: claim.into(),
4101 session: "s1".into(),
4102 scope: vec![scope.into()],
4103 intent: None,
4104 mode: ClaimMode::Exclusive,
4105 issue: None,
4106 };
4107 opened(&laptop, "s1");
4108 laptop.append_own(claim("c1", "apps")).unwrap();
4109 laptop.beat_own();
4110 round();
4111 assert_eq!(observer.events().len(), 2);
4112
4113 // It sleeps: the observer last heard it long ago, and compacts it away.
4114 heard_long_ago(&observer, &laptop, expiry);
4115 assert_eq!(
4116 observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true),
4117 2
4118 );
4119 assert!(observer.events().is_empty());
4120
4121 // It wakes, takes another claim, and beats.
4122 laptop.append_own(claim("c2", "docs")).unwrap();
4123 laptop.beat_own();
4124 for _ in 0..3 {
4125 round();
4126 }
4127 assert_eq!(
4128 observer.events(),
4129 laptop.events(),
4130 "the stream is held again, whole, from its first event"
4131 );
4132 let state = fold(&observer.events(), &|s| observer.liveness(s, expiry));
4133 assert_eq!(state.claims.len(), 2);
4134 assert!(
4135 state.claims.iter().all(|c| c.state == ClaimState::Held),
4136 "the claim taken before the sleep holds again: {:?}",
4137 state.claims
4138 );
4139 }
4140
4141 /// What a peer that has not compacted yet still says about a stream this runtime has
4142 /// compacted brings nothing back: a beat no higher than the one it was compacted at,
4143 /// however fresh the relay says it is, and an event the stream wrote but never beat for.
4144 /// The late event is absorbed into the tombstone's mark, so the relay stops offering it.
4145 #[test]
4146 fn a_replayed_beat_or_a_late_event_of_a_compacted_stream_brings_nothing_back() {
4147 let expiry = Duration::from_secs(30);
4148 let gone = journal("0000000000000001");
4149 let observer = journal("0000000000000002");
4150 opened(&gone, "s1");
4151 gone.beat_own();
4152 observer.merge_marks(&gone.marks(), &|_| true, expiry);
4153 observer.ingest(
4154 &gone.missing_for(&observer.marks(), usize::MAX),
4155 &accept_all,
4156 );
4157 heard_long_ago(&observer, &gone, expiry);
4158 let mut replayed = gone.marks();
4159 assert_eq!(
4160 observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, true),
4161 1
4162 );
4163
4164 // A relay reports the last beat it heard as if it had just risen.
4165 replayed.get_mut(gone.own_stream()).unwrap().age_ms = Some(0);
4166 observer.merge_marks(&replayed, &|_| true, expiry);
4167 assert_eq!(
4168 observer.tallies().streams,
4169 1,
4170 "a replayed beat brought it back"
4171 );
4172 assert_eq!(observer.marks()[gone.own_stream()].seq, 1);
4173
4174 // An event it wrote after the observer compacted it arrives through a relay.
4175 let late = opened(&gone, "s2");
4176 let report = observer.ingest(&[late], &accept_all);
4177 assert_eq!((report.accepted, report.pending), (0, 0));
4178 assert_eq!(observer.tallies().streams, 1, "a late event took a slot");
4179 assert_eq!(
4180 observer.marks()[gone.own_stream()].seq,
4181 2,
4182 "the tombstone covers it, so it is not offered again"
4183 );
4184 }
4185
4186 /// The scopes the fold property's claims are drawn from: some meet, some do not.
4187 const SCOPES: [&str; 4] = ["apps", "apps/majordomus-cli", "docs", "site"];
4188
4189 /// One run's history for the fold property: a session, a claim in it over one of
4190 /// [`SCOPES`], and how it ended — `0` still held, `1` released, `2` its session closed,
4191 /// `3` nothing written at all (a run that only beat).
4192 fn history(j: &Journal, scope: usize, exclusive: bool, ending: u8) -> Vec<MeshEvent> {
4193 if ending == 3 {
4194 return Vec::new();
4195 }
4196 let mode = if exclusive {
4197 ClaimMode::Exclusive
4198 } else {
4199 ClaimMode::Advisory
4200 };
4201 let mut out = vec![opened(j, "s1")];
4202 let acquired = EventBody::ClaimAcquired {
4203 claim: "c1".into(),
4204 session: "s1".into(),
4205 scope: vec![SCOPES[scope].into()],
4206 intent: None,
4207 mode,
4208 issue: None,
4209 };
4210 out.push(j.append_own(acquired).unwrap());
4211 let ended = match ending {
4212 1 => Some(EventBody::ClaimReleased { claim: "c1".into() }),
4213 2 => Some(EventBody::SessionClosed {
4214 session: "s1".into(),
4215 }),
4216 _ => None,
4217 };
4218 out.extend(ended.map(|body| j.append_own(body).unwrap()));
4219 out
4220 }
4221
4222 /// One run of the fold property: its claim's scope, its mode and how it ended (see
4223 /// [`history`]), then what it offers — a bit set, `1` a handover and `2` a review
4224 /// request — and what it takes: `(1, k)` consumes run `k`'s handover, `(2, k)` answers
4225 /// run `k`'s review, `(0, _)` neither. `k` counts over every run, dead and live; a run
4226 /// takes nothing of its own, and nothing of a run that offered nothing.
4227 type Run = (usize, bool, u8, u8, (u8, usize));
4228
4229 fn runs(count: std::ops::Range<usize>) -> impl proptest::strategy::Strategy<Value = Vec<Run>> {
4230 proptest::collection::vec(
4231 (
4232 0usize..4,
4233 proptest::bool::ANY,
4234 0u8..4,
4235 0u8..4,
4236 (0u8..3, 0usize..64),
4237 ),
4238 count,
4239 )
4240 }
4241
4242 proptest::proptest! {
4243 #![proptest_config(proptest::prelude::ProptestConfig::with_cases(32))]
4244
4245 /// Compacting a crowded node takes away the records of the streams it drops — every
4246 /// one of them long dead — and nothing else. The claims that hold or conflict, the
4247 /// sessions still open and the overlaps between them are the same before and after;
4248 /// every record of a stream that stays is the same, a handover with every taker and a
4249 /// review with every answer; a handover nobody has taken stays within its retention;
4250 /// a dead stream stays only while it holds such a handover, or a taking or an answer
4251 /// of something that stays; and the digest moves exactly when compaction forgets
4252 /// something that had been said, because the fold lists ended sessions and claims too.
4253 #[test]
4254 fn compacting_a_crowded_node_changes_nothing_live(
4255 dead in runs(NODE_COMPACTION_STREAMS..MAX_STREAMS_PER_NODE - 8),
4256 live in runs(1..6),
4257 dead_were_silent in proptest::bool::weighted(0.2),
4258 ) {
4259 use crate::mesh::state::{fold, CooperationState, SessionState};
4260 use std::collections::BTreeSet;
4261
4262 let dir = tempfile::tempdir().unwrap();
4263 let key = dir.path().join("node.json");
4264 let machine = || Arc::new(NodeIdentity::load_or_create(&key).unwrap());
4265 let expiry = Duration::from_secs(30);
4266 let observer =
4267 Journal::open(machine(), "0000000000000001", "repo".into(), None).unwrap();
4268 // Earlier runs of one sibling server, then the servers of the machine's other
4269 // worktrees: every journal first, so that any run can take what another offers.
4270 let runs: Vec<(Journal, bool, Run)> = dead
4271 .iter()
4272 .map(|&(scope, exclusive, ending, offers, takes)| {
4273 let run =
4274 Journal::open(machine(), "0000000000000002", "repo".into(), None).unwrap();
4275 let ending = if dead_were_silent { 3 } else { ending };
4276 (run, true, (scope, exclusive, ending, offers, takes))
4277 })
4278 .chain(live.iter().enumerate().map(|(slot, &run)| {
4279 let runtime = format!("{:016x}", 16 + slot);
4280 (Journal::open(machine(), &runtime, "repo".into(), None).unwrap(), false, run)
4281 }))
4282 .collect();
4283 let mut handovers: Vec<Option<String>> = vec![None; runs.len()];
4284 let mut reviews = vec![false; runs.len()];
4285 for (i, (run, _, (scope, exclusive, ending, offers, _))) in runs.iter().enumerate() {
4286 history(run, *scope, *exclusive, *ending);
4287 if *ending == 3 {
4288 continue;
4289 }
4290 if offers & 1 == 1 {
4291 handovers[i] = Some(publish(run, &format!("run {i}")).1);
4292 }
4293 if offers & 2 == 2 {
4294 let asked = EventBody::ReviewRequested {
4295 review: "r1".into(),
4296 session: "s1".into(),
4297 subject: format!("run {i}"),
4298 scope: Vec::new(),
4299 issue: None,
4300 reviewer: None,
4301 body: None,
4302 };
4303 run.append_own(asked).unwrap();
4304 reviews[i] = true;
4305 }
4306 }
4307 for (i, (run, _, (_, _, ending, _, (take, of)))) in runs.iter().enumerate() {
4308 let of = of % runs.len();
4309 if *ending == 3 || of == i {
4310 continue;
4311 }
4312 let body = match (*take, &handovers[of]) {
4313 (1, Some(id)) => EventBody::HandoverConsumed {
4314 handover: id.clone(),
4315 session: "s1".into(),
4316 },
4317 (2, _) if reviews[of] => EventBody::ReviewAnswered {
4318 request: format!("{}/r1", runs[of].0.own_stream()),
4319 session: "s1".into(),
4320 verdict: "approved".into(),
4321 note: None,
4322 },
4323 _ => continue,
4324 };
4325 // It heard of the offer before it took it, as a taker does.
4326 run.ingest(&own_events(&runs[of].0), &accept_all);
4327 run.append_own(body).unwrap();
4328 }
4329 let mut ended: BTreeSet<StreamId> = BTreeSet::new();
4330 for (run, dead, _) in &runs {
4331 run.beat_own();
4332 if *dead {
4333 // Heard beating, then silent long ago.
4334 observer.merge_marks(&run.marks(), &|_| true, expiry);
4335 observer.ingest(&own_events(run), &accept_all);
4336 heard_long_ago(&observer, run, expiry);
4337 ended.insert(run.own_stream().clone());
4338 } else {
4339 // Beating now.
4340 observer.ingest(&own_events(run), &accept_all);
4341 observer.merge_marks(&run.marks(), &|_| true, expiry);
4342 }
4343 }
4344 let state = |j: &Journal| fold(&j.events(), &|s| j.liveness(s, expiry));
4345 let held = |j: &Journal| j.stream_liveness(expiry).into_keys().collect::<BTreeSet<_>>();
4346 let before = state(&observer);
4347 let held_before = held(&observer);
4348
4349 // Unforced, and the journal is far under its own bounds: the node alone is due.
4350 let dropped = observer.compact(expiry, Duration::ZERO, HANDOVER_RETENTION, false);
4351 let after = state(&observer);
4352 let gone: BTreeSet<StreamId> =
4353 held_before.difference(&held(&observer)).cloned().collect();
4354
4355 proptest::prop_assert!(gone.is_subset(&ended), "a stream that is not dead went");
4356 let alive = |s: &CooperationState| {
4357 (
4358 s.claims.iter().filter(|c| c.state.is_live()).cloned().collect::<Vec<_>>(),
4359 s.sessions
4360 .iter()
4361 .filter(|v| v.state == SessionState::Active)
4362 .cloned()
4363 .collect::<Vec<_>>(),
4364 s.overlaps.clone(),
4365 )
4366 };
4367 proptest::prop_assert_eq!(alive(&before), alive(&after));
4368 // A review, a taker and an answerer are keyed `<stream>/<local id>`.
4369 let stream_of = |key: &str| key.split_once('/').map(|(s, _)| s.to_string());
4370 let stays = |key: &str| !gone.iter().any(|g| stream_of(key).as_deref() == Some(g.as_str()));
4371 let kept = |s: &CooperationState| {
4372 (
4373 s.sessions.iter().filter(|v| !gone.contains(&v.stream)).cloned().collect::<Vec<_>>(),
4374 s.claims.iter().filter(|c| !gone.contains(&c.stream)).cloned().collect::<Vec<_>>(),
4375 s.handovers.iter().filter(|h| !gone.contains(&h.stream)).cloned().collect::<Vec<_>>(),
4376 s.reviews.iter().filter(|r| stays(&r.key)).cloned().collect::<Vec<_>>(),
4377 )
4378 };
4379 proptest::prop_assert_eq!(
4380 kept(&before),
4381 (after.sessions.clone(), after.claims.clone(), after.handovers.clone(), after.reviews.clone())
4382 );
4383 proptest::prop_assert!(
4384 before.handovers.iter().filter(|h| h.consumed_by.is_empty()).all(|h| !gone.contains(&h.stream)),
4385 "a handover nobody has taken went within its retention"
4386 );
4387 for stream in ended.difference(&gone) {
4388 let its = |key: &String| stream_of(key).as_deref() == Some(stream.as_str());
4389 let pinned = after.handovers.iter().any(|h| {
4390 (h.stream == *stream && h.consumed_by.is_empty()) || h.consumed_by.iter().any(its)
4391 }) || after.reviews.iter().any(|r| r.answers.iter().any(|a| its(&a.session)));
4392 proptest::prop_assert!(pinned, "the dead stream {} stayed for nothing", stream);
4393 }
4394 proptest::prop_assert_eq!(before.digest == after.digest, dropped == 0);
4395 }
4396 }
4397
4398 proptest::proptest! {
4399 /// Any delivery order and any duplication of one stream's events converges on the
4400 /// same held set as in-order delivery.
4401 #[test]
4402 fn any_permutation_with_duplicates_converges(order in proptest::collection::vec(0usize..6, 1..24)) {
4403 let a = journal("0000000000000001");
4404 let events: Vec<MeshEvent> = (0..6).map(|i| opened(&a, &format!("s{i}"))).collect();
4405 let b = journal("0000000000000002");
4406 let shuffled: Vec<MeshEvent> = order.iter().map(|i| events[*i].clone()).collect();
4407 b.ingest(&shuffled, &accept_all);
4408 b.ingest(&events, &accept_all);
4409 proptest::prop_assert_eq!(b.events(), events.clone());
4410 proptest::prop_assert_eq!(b.tallies().pending, 0);
4411 }
4412
4413 /// Arbitrary JSON posing as an event never panics the ingest path.
4414 #[test]
4415 fn hostile_json_never_panics(seq in 0u64..5, lamport in proptest::prelude::any::<u64>(), kind in "[a-z_]{0,20}", sig in "[0-9a-f]{0,130}") {
4416 let b = journal("0000000000000002");
4417 let event = MeshEvent {
4418 v: 1,
4419 stream: StreamId(format!("{}-{}-{}", "a".repeat(32), "b".repeat(16), "c".repeat(16))),
4420 seq,
4421 lamport,
4422 at: 0,
4423 repo: "repo".into(),
4424 pk: "00".repeat(32),
4425 body: serde_json::json!({"kind": kind}),
4426 sig,
4427 };
4428 let r = b.ingest(&[event], &accept_all);
4429 proptest::prop_assert_eq!(r.accepted, 0);
4430 }
4431 }
4432}