somatize_tree/journal.rs
1//! What somebody said about a commit: a verdict, a note, a reason for pruning.
2//!
3//! Nothing is ever updated. A store lives on NFS or in a bucket, where making
4//! an index the truth would mean a single writer — so saying something is
5//! claiming the next slot under a commit, and a commit's verdict *is* the last
6//! one anybody claimed. Two machines saying something in the same instant both
7//! succeed, one after the other, and neither loses what the other wrote.
8//!
9//! The store's cost rule decides the rest: a record comes back free on a scan
10//! and a blob is a fetch, so a verdict lives in the record — reading forty
11//! commits is one scan — and the prose in the blob, fetched only by whoever
12//! asked to read it.
13//!
14//! There were four verdicts, and only `invalid` was ever a property of the
15//! *code*: `promising`, `dead-end` and `superseded` were somebody deciding
16//! where to go next, and they now live in layer 2 as
17//! [`Kind::Decision`](crate::moves::Kind::Decision), under the question they
18//! answered. Nothing was migrated, because nothing had to be — an old record
19//! saying `verdict=dead-end` reads as a note with its prose intact.
20//!
21//! That everything under an invalid commit is suspect is **derived**, by
22//! walking git when somebody asks, so a commit made tomorrow under one is
23//! suspect the moment it exists.
24
25use serde::{Deserialize, Serialize};
26use somatize_store::{Digest, Meta, Store};
27use std::collections::BTreeMap;
28use std::fmt;
29
30/// How many slots to try before giving up on being heard. A bound on a race
31/// and not on a queue: more than one turn means somebody claimed the same
32/// instant.
33const PATIENCE: u32 = 32;
34
35/// What somebody found out about a commit itself.
36///
37/// Two, and on purpose: these are the only judgements about the **code and its
38/// measurements** rather than about where to go next.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum Verdict {
42 /// Something here was wrong — a bug in the data, a metric that lied. Every
43 /// commit under it is suspect, and that is worked out and not written down.
44 Invalid,
45 /// Looked at and nothing wrong with it.
46 ///
47 /// A commit nobody judged is already not invalid, so this says nothing on
48 /// its own — its whole use is to be the **last** word after an `invalid`,
49 /// so a mistaken one does not poison a subtree for good.
50 Sound,
51}
52
53impl Verdict {
54 /// A word no longer read comes back as `None`, which makes the saying a
55 /// note with its prose intact.
56 pub fn read(said: &str) -> Option<Self> {
57 match said {
58 "invalid" => Some(Self::Invalid),
59 "sound" => Some(Self::Sound),
60 _ => None,
61 }
62 }
63
64 pub fn as_str(&self) -> &'static str {
65 match self {
66 Self::Invalid => "invalid",
67 Self::Sound => "sound",
68 }
69 }
70
71 /// Whether everything under it inherits doubt.
72 pub fn reaches_down(&self) -> bool {
73 matches!(self, Self::Invalid)
74 }
75}
76
77impl fmt::Display for Verdict {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.write_str(self.as_str())
80 }
81}
82
83/// One thing somebody said, as it comes back from a scan.
84#[derive(Debug, Clone)]
85pub struct Saying {
86 pub commit: String,
87 /// `None` for a note that judged nothing.
88 pub verdict: Option<Verdict>,
89 pub who: String,
90 /// Seconds since the epoch, stamped by the store.
91 pub when: u64,
92 /// Where the prose is. Not the prose: that is a fetch, and a scan is not.
93 pub said: Digest,
94 /// Which slot under its commit, so the last word can be found.
95 pub nth: u32,
96}
97
98/// The sayings of one investigation, kept in a store.
99///
100/// `tree` is what lets several investigations share one store without seeing
101/// each other, and it is in the name for the same reason a study's is: a name
102/// is the one part of this that cannot be refactored later.
103pub struct Journal<'a> {
104 kept: &'a dyn Store,
105 tree: String,
106}
107
108impl<'a> Journal<'a> {
109 pub fn of(tree: impl Into<String>, kept: &'a dyn Store) -> Self {
110 Self {
111 kept,
112 tree: tree.into(),
113 }
114 }
115
116 /// The name a commit's `nth` saying is bound under.
117 ///
118 /// `exp/<tree>/<commit>` **is** a study name, so trials for this version
119 /// land underneath without a line of soma changing.
120 fn named(&self, commit: &str, nth: u32) -> String {
121 format!("exp/{}/{commit}/said/{nth}", self.tree)
122 }
123
124 /// Says something about a commit, and returns which slot it landed in.
125 ///
126 /// Claims rather than binds, so two people saying something at the same
127 /// moment both get heard: whoever is told the slot is taken asks for the
128 /// next one, exactly as a worker does with a trial.
129 pub fn say(
130 &self,
131 commit: &str,
132 verdict: Option<Verdict>,
133 who: &str,
134 prose: &str,
135 ) -> Result<u32, Trouble> {
136 let said = self.kept.put(prose.as_bytes()).map_err(Trouble::Store)?;
137 let first = self.last_of(commit)?.map_or(0, |last| last + 1);
138 // Each turn is the **next slot along**, not a retry of the same one:
139 // being told a slot is taken means somebody else's saying stands in
140 // it, and this one goes after it.
141 for nth in first..first + PATIENCE {
142 let meta: Meta = [
143 ("what".to_string(), "said".to_string()),
144 ("commit".to_string(), commit.to_string()),
145 ("who".to_string(), who.to_string()),
146 ]
147 .into_iter()
148 .chain(verdict.map(|one| ("verdict".to_string(), one.to_string())))
149 .collect();
150 if self
151 .kept
152 .claim(&self.named(commit, nth), &said, meta)
153 .map_err(Trouble::Store)?
154 {
155 return Ok(nth);
156 }
157 }
158 Err(Trouble::Crowded {
159 commit: commit.to_string(),
160 })
161 }
162
163 /// The highest slot already taken under a commit, if any.
164 fn last_of(&self, commit: &str) -> Result<Option<u32>, Trouble> {
165 Ok(self
166 .all()?
167 .iter()
168 .filter(|saying| saying.commit == commit)
169 .map(|saying| saying.nth)
170 .max())
171 }
172
173 /// Everything anybody said in this investigation, oldest first.
174 ///
175 /// A scan and **no fetches**: the prose stays a digest until somebody asks
176 /// to read it.
177 pub fn all(&self) -> Result<Vec<Saying>, Trouble> {
178 let under = format!("exp/{}/", self.tree);
179 let mut said: Vec<Saying> = self
180 .kept
181 .bound()
182 .map_err(Trouble::Store)?
183 .into_iter()
184 .filter_map(|bound| {
185 // A store holds whatever anybody put in it — snapshots, a
186 // cache, another investigation — so this is a question.
187 let rest = bound.name.strip_prefix(&under)?;
188 let (commit, nth) = rest.split_once("/said/")?;
189 Some(Saying {
190 commit: commit.to_string(),
191 verdict: beside(&bound.meta, "verdict").and_then(Verdict::read),
192 who: beside(&bound.meta, "who").unwrap_or("nobody").to_string(),
193 when: bound.when,
194 said: bound.digest,
195 nth: nth.parse().ok()?,
196 })
197 })
198 .collect();
199 said.sort_by_key(|saying| (saying.commit.clone(), saying.nth));
200 Ok(said)
201 }
202
203 /// What each commit's verdict **is**: the last one anybody claimed.
204 ///
205 /// Notes in between are not verdicts and do not overwrite one — somebody
206 /// writing down what they saw has not thereby changed their mind.
207 pub fn verdicts(&self) -> Result<BTreeMap<String, Verdict>, Trouble> {
208 let mut latest = BTreeMap::new();
209 for saying in self.all()? {
210 if let Some(verdict) = saying.verdict {
211 latest.insert(saying.commit, verdict);
212 }
213 }
214 Ok(latest)
215 }
216
217 /// The prose of one saying.
218 pub fn read(&self, saying: &Saying) -> Result<String, Trouble> {
219 let bytes = self
220 .kept
221 .get(&saying.said)
222 .map_err(Trouble::Store)?
223 .unwrap_or_default();
224 Ok(String::from_utf8_lossy(&bytes).into_owned())
225 }
226}
227
228/// One field of a record, if it is there.
229fn beside<'a>(meta: &'a Meta, what: &str) -> Option<&'a str> {
230 meta.iter()
231 .find(|(said, _)| said == what)
232 .map(|(_, value)| value.as_str())
233}
234
235#[derive(Debug)]
236pub enum Trouble {
237 Store(somatize_store::StoreError),
238 /// Thirty-two slots taken while trying to use one. Either a great many
239 /// people are talking about one commit at once, or something is wrong.
240 Crowded {
241 commit: String,
242 },
243}
244
245impl fmt::Display for Trouble {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 match self {
248 Self::Store(why) => write!(f, "the journal could not be reached: {why}"),
249 Self::Crowded { commit } => {
250 write!(f, "too many people saying things about {commit} at once")
251 }
252 }
253 }
254}
255
256impl std::error::Error for Trouble {}