Skip to main content

storage/
hot.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 in-memory hot view: what the interface asks
7//! for constantly โ€” which Runs exist, which phase each is in, how far
8//! each has got โ€” answered without touching the disk.
9//!
10//! It is a fold over the ledger, nothing more. Feeding the same record
11//! twice changes nothing, because seq only moves forward; that is what
12//! lets a caller replay from any point without first proving where it
13//! left off.
14
15use std::collections::btree_map::Entry;
16use std::collections::{BTreeMap, BTreeSet};
17
18use kernel::{Address, EventKind, EventRecord, RunId, Seq, TimeMs};
19
20use crate::error::StorageError;
21
22/// A Run's phase as the hot view sees it. Freezing is terminal here:
23/// the ledger may keep appending to a frozen Run's history, but the
24/// phase never travels backwards.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26pub enum RunPhase {
27    Active,
28    Frozen,
29}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32pub struct RunHot {
33    pub phase: RunPhase,
34    pub last_seq: Seq,
35    pub last_kind: EventKind,
36    pub who: String,
37    /// The room this run works in, as its `run_started` record named
38    /// it. `None` until that record is seen: a checkpoint can land before
39    /// the opening, and a window may hold only a tail.
40    pub addr: Option<Address>,
41    /// When the run began, from the same record.
42    pub started: Option<TimeMs>,
43    /// How the run ended, from its `run_frozen` record's `completion`.
44    pub completion: Option<String>,
45    /// The branch of the last pull request the run opened: the city's
46    /// pull requests are named by their branch and carry no number.
47    pub pr: Option<String>,
48    /// What the run waits for the person to allow. Present exactly while
49    /// the run's last record is an `approval_requested`, so it cannot say
50    /// a run waits when `last_kind` says it moved on.
51    pub ask: Option<String>,
52    /// What the person asked for, from the `run_started` record's
53    /// `task`; `None` when that record is unseen or the task is empty.
54    pub task: Option<String>,
55    /// What finishing looks like, from the same record's `goal`, read
56    /// the same way.
57    pub goal: Option<String>,
58}
59
60impl RunHot {
61    fn first_seen(record: &EventRecord) -> RunHot {
62        RunHot {
63            phase: RunPhase::Active,
64            last_seq: record.seq(),
65            last_kind: record.kind(),
66            who: record.who().to_owned(),
67            addr: None,
68            started: None,
69            completion: None,
70            pr: None,
71            ask: None,
72            task: None,
73            goal: None,
74        }
75    }
76
77    /// Folds one record that is newer than everything this run has seen.
78    fn absorb(&mut self, record: &EventRecord) {
79        let kind = record.kind();
80        self.last_seq = record.seq();
81        self.last_kind = kind;
82        if kind == EventKind::RunFrozen {
83            self.phase = RunPhase::Frozen;
84        }
85        if kind == EventKind::RunStarted {
86            self.addr = record.addr().cloned();
87            self.started = Some(record.t());
88        }
89        let stated = |field: &str| {
90            record
91                .data()
92                .as_map()
93                .get(field)
94                .and_then(serde_json::Value::as_str)
95                .map(str::to_owned)
96        };
97        if kind == EventKind::RunStarted {
98            let named = |field: &str| stated(field).filter(|said| !said.is_empty());
99            self.task = named("task");
100            self.goal = named("goal");
101        }
102        if kind == EventKind::RunFrozen {
103            self.completion = stated("completion");
104        }
105        if kind == EventKind::PrOpened {
106            self.pr = stated("branch");
107        }
108        self.ask = (kind == EventKind::ApprovalRequested)
109            .then(|| stated("action_desc"))
110            .flatten();
111    }
112}
113
114/// How many frozen runs the hot view holds besides every active one,
115/// and so how many a city view carries. A bound on the size of an
116/// answer on the wire, not a machine reading, so it is a constant
117/// (`crates/storage/spec/Hot.lean` ยง8-5).
118pub const RECENT_FROZEN: usize = 32;
119
120#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
121pub struct HotView {
122    runs: BTreeMap<RunId, RunHot>,
123    /// Frozen runs pushed out of `runs`, by id alone. Freezing is
124    /// terminal, so a later record on one of these is the tail of an old
125    /// run, never the checkpoint of a new one.
126    evicted: BTreeSet<RunId>,
127}
128
129impl HotView {
130    pub fn new() -> HotView {
131        HotView::default()
132    }
133
134    /// Folds one record in. Records at or below a Run's last seq are
135    /// ignored, so re-feeding a segment is free of consequence.
136    pub fn apply(&mut self, record: &EventRecord) -> Result<(), StorageError> {
137        let run = record.run();
138        // A city-level record belongs to the city, not to a run: raising a
139        // building and the genesis record both carry the nil id, and the
140        // Ledger says so (`kernel::event`: "RunId::CITY (nil) marks
141        // city-level records"). Admitting one here invented a run nobody
142        // started, and the count it fed said a city was working the
143        // moment it existed.
144        if run == RunId::CITY || self.evicted.contains(&run) {
145            return Ok(());
146        }
147        let kind = record.kind();
148        match self.runs.entry(run) {
149            Entry::Occupied(held) => {
150                let hot = held.into_mut();
151                if record.seq() > hot.last_seq {
152                    hot.absorb(record);
153                }
154            }
155            Entry::Vacant(slot) => slot.insert(RunHot::first_seen(record)).absorb(record),
156        }
157        if kind == EventKind::RunFrozen {
158            self.evict_the_oldest_frozen_beyond_recent();
159        }
160        Ok(())
161    }
162
163    /// Keeps at most [`RECENT_FROZEN`] frozen runs, tombstoning the one
164    /// with the lowest `last_seq`. One freeze adds one frozen run, so one
165    /// eviction restores the bound; the scan is O(active + RECENT_FROZEN).
166    fn evict_the_oldest_frozen_beyond_recent(&mut self) {
167        let frozen = || {
168            self.runs
169                .iter()
170                .filter(|(_, hot)| hot.phase == RunPhase::Frozen)
171        };
172        if frozen().count() <= RECENT_FROZEN {
173            return;
174        }
175        let Some(oldest) = frozen()
176            .min_by_key(|(_, hot)| hot.last_seq)
177            .map(|(run, _)| *run)
178        else {
179            return;
180        };
181        self.runs.remove(&oldest);
182        self.evicted.insert(oldest);
183    }
184
185    /// Every active run and the [`RECENT_FROZEN`] frozen runs with the
186    /// latest `last_seq`: what a city view carries. Iteration is in RunId
187    /// order โ€” the same order on every process, so a rendered list never
188    /// reshuffles between restarts.
189    pub fn runs(&self) -> impl Iterator<Item = (&RunId, &RunHot)> {
190        self.runs.iter()
191    }
192
193    pub fn get(&self, run: &RunId) -> Option<&RunHot> {
194        self.runs.get(run)
195    }
196
197    /// Whether `run` froze and was pushed out of the view. Its records
198    /// are still in the Ledger; a reader that needs them goes there.
199    pub fn was_evicted(&self, run: &RunId) -> bool {
200        self.evicted.contains(run)
201    }
202
203    pub fn active_count(&self) -> u64 {
204        self.count_phase(RunPhase::Active)
205    }
206
207    pub fn frozen_count(&self) -> u64 {
208        let evicted = u64::try_from(self.evicted.len()).unwrap_or(u64::MAX);
209        self.count_phase(RunPhase::Frozen).saturating_add(evicted)
210    }
211
212    fn count_phase(&self, phase: RunPhase) -> u64 {
213        u64::try_from(self.runs.values().filter(|h| h.phase == phase).count()).unwrap_or(u64::MAX)
214    }
215}
216
217#[cfg(test)]
218#[allow(
219    clippy::unwrap_used,
220    clippy::expect_used,
221    clippy::panic,
222    clippy::indexing_slicing,
223    reason = "test code"
224)]
225mod tests;