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;