storage/sessions.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The session slice: a disposable projection of the Ledger, one file per
7//! room, laid down beside the building that ran it (`crates/storage/spec/Sessions.lean` §8-24).
8//!
9//! **The Ledger is not split.** It stays one chain with one writer, and a
10//! slice is derived from it: one file per room address, holding the
11//! records the Ledger carries for that address, copied byte for byte
12//! from the canonical lines. The product never reads a slice to decide
13//! anything; a person standing in a building finds its history beside it,
14//! and nothing else consumes it.
15//!
16//! **One writer, after durability.** [`Sessions::absorb`] is called by
17//! [`crate::JsonlLedger`] on the accounting thread once a wave is synced,
18//! or, once the ledger has handed the slices off, by the thread that
19//! took them - a served city's view thread (`crates/storage/spec/Sessions.lean` §8-24).
20//! A refusal is reported and skipped: the history already has the record,
21//! and a disposable artifact must never fail history's caller.
22//!
23//! **The header is the only doubt.** A slice opens with `slices v1 <seq>`,
24//! the sequence of its first record. A file whose magic or first sequence
25//! disagrees with its header is not the shape this build writes, so it is
26//! laid down again from the Ledger rather than migrated. A deleted
27//! `sessions/` directory is the same case seen from outside: the next
28//! process to touch each room lays its file down again, and the bytes are
29//! identical because every line came from the Ledger in the first place.
30//!
31//! The path is derived here and nowhere else: [`session_slice`] is private
32//! to this module, so no other module can name where a slice lies, and a
33//! reader of a projection that is deleted and rebuilt cannot be written.
34//! The one fact another module needs - whether a path is a slice, so the
35//! checkpoint never stages one - is [`is_session_projection`].
36
37use std::collections::BTreeMap;
38use std::io;
39use std::path::{Path, PathBuf};
40
41use kernel::layout::CityLayout;
42use kernel::{Address, EventRecord, RESERVED_PREFIX, Seq};
43
44use crate::error::{StorageError, io_err};
45use crate::jsonl::complete_lines;
46use crate::real_fs::RealFs;
47use crate::vfs::Vfs;
48
49mod from_ledger;
50
51/// The first word of every slice, and the shape this build appends to.
52/// Bumped when the shape changes: an older file fails the comparison and
53/// is laid down again in silence, which is this module's standing answer
54/// to any doubt, so no migration code exists.
55pub(crate) const SLICE_MAGIC: &str = "slices v1";
56
57/// The slices over one city's Ledger.
58///
59/// The filesystem adapter is its own, not the Ledger's: the Ledger's
60/// adapter holds the segment handle it appends through, and a projection
61/// that took that handle away between every two records would make the
62/// city's hot path pay for a side artifact.
63pub struct Sessions {
64 layout: CityLayout,
65 ledger: PathBuf,
66 vfs: RealFs,
67 /// One entry per slice this process has written, keyed by its path.
68 open: BTreeMap<PathBuf, Open>,
69 /// The city's own address, which names the city rather than a room:
70 /// a record carrying it is filed nowhere, because no building is
71 /// named after the city inside itself.
72 city: Option<Address>,
73 /// The first ledger sequence of every address the Ledger carries,
74 /// folded from the segments the first time a slice is missing and
75 /// kept current by every absorb, so a room the Ledger never carried
76 /// before is filed from its one record without reading a segment.
77 first_seen: Option<BTreeMap<Address, Seq>>,
78}
79
80/// What one slice file holds, so a later append can continue it.
81struct Open {
82 addr: Address,
83 /// The last ledger sequence the file carries.
84 last: Option<Seq>,
85}
86
87impl Sessions {
88 /// The projection over the city whose Ledger is `dir`, when `dir` is
89 /// a city's; `None` for a store somebody opened directly, which has
90 /// no buildings to file sessions under.
91 pub(crate) fn for_ledger(dir: &Path) -> Option<Sessions> {
92 let layout = CityLayout::of_ledger(dir)?;
93 let city = layout.city_address();
94 Some(Sessions {
95 layout,
96 ledger: dir.to_path_buf(),
97 vfs: RealFs::new(),
98 open: BTreeMap::new(),
99 city,
100 first_seen: None,
101 })
102 }
103
104 /// Files one durable record into its room's slice.
105 ///
106 /// A record the city wrote for itself rather than for a room - no
107 /// address, the city's own address, or an address inside the
108 /// reserved subtree - is filed nowhere, which is not a refusal.
109 ///
110 /// # Errors
111 /// Propagates a slice that cannot be read, laid down or appended. The
112 /// caller reports it; the Ledger is already durable either way.
113 pub fn absorb(&mut self, record: &EventRecord) -> Result<(), StorageError> {
114 let Some(addr) = record.addr() else {
115 return Ok(());
116 };
117 if addr.is_reserved() {
118 return Ok(());
119 }
120 if self.city.as_ref().is_some_and(|city| city == addr) {
121 return Ok(());
122 }
123 if let Some(first_seen) = self.first_seen.as_mut() {
124 first_seen.entry(addr.clone()).or_insert(record.seq());
125 }
126 let path = session_slice(&self.layout, addr);
127 if !self.open.contains_key(&path) {
128 let open = self.attach(addr, &path, record)?;
129 self.open.insert(path.clone(), open);
130 }
131 let up_to_date = self
132 .open
133 .get(&path)
134 .and_then(|open| open.last)
135 .is_some_and(|last| last >= record.seq());
136 if up_to_date {
137 return Ok(());
138 }
139 let mut line = record
140 .canonical_line()
141 .map_err(|source| StorageError::Draft { source })?;
142 line.push(b'\n');
143 self.vfs
144 .append(&path, &line)
145 .map_err(io_err("append a session slice", &path))?;
146 let open = self.open.get_mut(&path).ok_or_else(|| vanished(&path))?;
147 open.last = Some(record.seq());
148 Ok(())
149 }
150
151 /// Brings the file at `path` up to `record`, laying it down from the
152 /// Ledger when it is missing or not this build's shape; a missing
153 /// file for an address `record` is the first of is laid down from
154 /// `record` alone.
155 fn attach(
156 &mut self,
157 addr: &Address,
158 path: &Path,
159 record: &EventRecord,
160 ) -> Result<Open, StorageError> {
161 let tail = record.seq();
162 if !self.vfs.exists(path) {
163 return match self.first_seq_of(addr, tail)? {
164 Some(first) if first < tail => self.lay_down(addr, path, tail),
165 Some(_) | None => {
166 let line = record
167 .canonical_line()
168 .map_err(|source| StorageError::Draft { source })?;
169 self.write_slice(addr, path, &[(tail, line)])
170 }
171 };
172 }
173 let bytes = self
174 .vfs
175 .read(path)
176 .map_err(io_err("read a session slice", path))?;
177 let (lines, consumed) = complete_lines(&bytes);
178 if consumed < bytes.len() {
179 // A tail that stopped mid-line is what a power cut leaves;
180 // the bytes after it are not a record. Trim and carry on from
181 // the complete prefix, the same reading the Ledger's own
182 // recovery takes of a torn segment.
183 let len = u64::try_from(consumed).unwrap_or(u64::MAX);
184 self.vfs
185 .truncate(path, len)
186 .map_err(io_err("trim a torn session slice", path))?;
187 }
188 match resume(addr, &lines) {
189 Some(open) => self.catch_up(open, path, tail),
190 None => self.lay_down(addr, path, tail),
191 }
192 }
193
194 /// Appends the records the Ledger holds after what the file already
195 /// carries, so a slice left behind by a stopped process catches up
196 /// before this one adds to it.
197 fn catch_up(&mut self, mut open: Open, path: &Path, tail: Seq) -> Result<Open, StorageError> {
198 let found = self.segments().lines_of(&open.addr, open.last, tail)?;
199 for (seq, line) in &found {
200 let mut terminated = Vec::with_capacity(line.len().saturating_add(1));
201 terminated.extend_from_slice(line);
202 terminated.push(b'\n');
203 self.vfs
204 .append(path, &terminated)
205 .map_err(io_err("catch up a session slice", path))?;
206 open.last = Some(*seq);
207 }
208 if !found.is_empty() {
209 self.vfs
210 .sync_data(path)
211 .map_err(io_err("flush a session slice", path))?;
212 }
213 Ok(open)
214 }
215
216 /// The first sequence the Ledger carries for `addr`, folding every
217 /// address once when this process has not yet read the segments.
218 ///
219 /// `tail` is the record being absorbed, which is already durable and
220 /// so already in the fold; an answer equal to it means the Ledger
221 /// holds nothing earlier for this address.
222 fn first_seq_of(&mut self, addr: &Address, tail: Seq) -> Result<Option<Seq>, StorageError> {
223 if self.first_seen.is_none() {
224 let mut folded = self.segments().first_seen()?;
225 folded.entry(addr.clone()).or_insert(tail);
226 self.first_seen = Some(folded);
227 }
228 Ok(self
229 .first_seen
230 .as_ref()
231 .and_then(|folded| folded.get(addr))
232 .copied())
233 }
234
235 fn segments(&self) -> from_ledger::Segments<'_> {
236 from_ledger::Segments {
237 vfs: &self.vfs,
238 ledger: &self.ledger,
239 }
240 }
241
242 /// Lays one slice down from the Ledger: the header, then every line
243 /// the Ledger holds for this address up to `tail`, in ledger order.
244 fn lay_down(&mut self, addr: &Address, path: &Path, tail: Seq) -> Result<Open, StorageError> {
245 let found = self.segments().lines_of(addr, None, tail)?;
246 self.write_slice(addr, path, &found)
247 }
248
249 /// Writes one slice from `found`, the lines the Ledger holds for
250 /// `addr` in ledger order, replacing whatever the file held.
251 fn write_slice(
252 &mut self,
253 addr: &Address,
254 path: &Path,
255 found: &[(Seq, Vec<u8>)],
256 ) -> Result<Open, StorageError> {
257 let Some((first_seq, _)) = found.first() else {
258 // Nothing in the Ledger carries this address: no file is a
259 // truthful answer, and the next append tries again.
260 return Ok(Open {
261 addr: addr.clone(),
262 last: None,
263 });
264 };
265 let mut body = format!("{SLICE_MAGIC} {}\n", first_seq.value()).into_bytes();
266 let mut last = *first_seq;
267 for (seq, line) in found {
268 body.extend_from_slice(line);
269 body.push(b'\n');
270 last = *seq;
271 }
272 let Some(dir) = path.parent() else {
273 return Err(no_parent(path));
274 };
275 self.vfs
276 .create_dir_all(dir)
277 .map_err(io_err("create the sessions directory", dir))?;
278 if self.vfs.exists(path) {
279 self.vfs
280 .remove_file(path)
281 .map_err(io_err("replace a session slice", path))?;
282 }
283 self.vfs
284 .append(path, &body)
285 .map_err(io_err("lay down a session slice", path))?;
286 self.vfs
287 .sync_data(path)
288 .map_err(io_err("flush a session slice", path))?;
289 Ok(Open {
290 addr: addr.clone(),
291 last: Some(last),
292 })
293 }
294}
295
296/// What an existing slice claims to hold, or `None` when it is not this
297/// build's shape and must be laid down again.
298fn resume(addr: &Address, lines: &[&[u8]]) -> Option<Open> {
299 let header = lines.first()?;
300 let header_seq = parse_header(header)?;
301 let mut last: Option<Seq> = None;
302 for (index, line) in lines.iter().enumerate().skip(1) {
303 let record = EventRecord::parse_line(line).ok()?;
304 if record.addr() != Some(addr) {
305 return None;
306 }
307 if index == 1 && record.seq() != header_seq {
308 return None;
309 }
310 if last.is_some_and(|seen| record.seq() <= seen) {
311 return None;
312 }
313 last = Some(record.seq());
314 }
315 // A header with no record after it is not a slice: there is no
316 // sequence to continue from, so the file is laid down again.
317 let last = last?;
318 Some(Open {
319 addr: addr.clone(),
320 last: Some(last),
321 })
322}
323
324/// The sequence in a slice's header, or `None` when the line is not one.
325fn parse_header(line: &[u8]) -> Option<Seq> {
326 let text = std::str::from_utf8(line).ok()?;
327 let digits = text.strip_prefix(SLICE_MAGIC)?.strip_prefix(' ')?;
328 if digits.contains(' ') {
329 return None;
330 }
331 digits.parse::<u64>().ok().map(Seq::new)
332}
333
334/// One building's projection of the sessions run in it, under that
335/// building's reserved subtree.
336const SESSIONS_DIR: &str = "sessions";
337
338/// The projection of one session's records: the room address is the
339/// session's identity, and a building is its first segment.
340///
341/// An address below a building becomes one file under that building's
342/// sessions, its name the rooms below the building, so the nesting of
343/// rooms is the nesting of the files and no reader has to know which
344/// segment was a building. A run that named no session works at the
345/// building's own address, and its file then carries the building's name.
346fn session_slice(layout: &CityLayout, room: &Address) -> PathBuf {
347 let raw = room.as_str();
348 let (building, under) = raw.split_once('/').unwrap_or((raw, ""));
349 let name = if under.is_empty() { building } else { under };
350 let mut path = layout.root().join(building);
351 path.push(RESERVED_PREFIX);
352 path.push(SESSIONS_DIR);
353 path.push(format!("{name}.jsonl"));
354 path
355}
356
357/// Whether `relative` names a session slice: a file under some scope's
358/// reserved `sessions` directory.
359///
360/// The city appends to a slice while a wave runs, so the checkpoint never
361/// stages one: git reads a workdir file it believes it knows, and a file
362/// the city itself keeps writing makes that read refuse the whole wave.
363/// The reserved prefix is required immediately before `sessions`, so a
364/// person's own directory that happens to carry that name is not mistaken
365/// for the projection.
366pub(crate) fn is_session_projection(relative: &Path) -> bool {
367 let mut held = false;
368 for component in relative.components() {
369 let Some(name) = component.as_os_str().to_str() else {
370 held = false;
371 continue;
372 };
373 if name.eq_ignore_ascii_case(SESSIONS_DIR) {
374 return held;
375 }
376 held = name.eq_ignore_ascii_case(RESERVED_PREFIX);
377 }
378 false
379}
380
381/// The refusal for a slice path that has no directory, which a path from
382/// [`session_slice`] cannot be.
383fn no_parent(path: &Path) -> StorageError {
384 StorageError::Io {
385 op: "lay a session slice",
386 path: path.to_path_buf(),
387 source: io::Error::other("the slice path has no parent directory"),
388 }
389}
390
391/// The refusal for slice state that vanished from under a reader, which
392/// only a defect in this module can produce.
393fn vanished(path: &Path) -> StorageError {
394 StorageError::Io {
395 op: "continue a session slice",
396 path: path.to_path_buf(),
397 source: io::Error::other("the slice state vanished after it was read"),
398 }
399}
400
401#[cfg(test)]
402#[allow(
403 clippy::unwrap_used,
404 clippy::expect_used,
405 clippy::panic,
406 clippy::indexing_slicing,
407 reason = "test code"
408)]
409mod tests;