Skip to main content

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;