Skip to main content

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 {}