didbot_policy_records/last_good.rs
1//! The operator source's last-good-revision state, held across rebuilds.
2//!
3//! From the owner: *"the PDS keeps the current policy it knows about until
4//! a new one replaces it. So you can see what WAS working vs. what won't
5//! compile."* The three ways a bad new revision could be handled are not
6//! symmetric -- dropping the policy widens what an agent may do, and
7//! denying everything it covers turns a typo into an outage -- so
8//! [`crate::merge::build`] keeps enforcing the last revision of a policy
9//! that *did* compile, for exactly as long as its successor keeps failing.
10//!
11//! # What this is not
12//!
13//! Not a cache of compiled artifacts: [`LastGoodPolicies`] holds the last
14//! [`ParsedPolicy`] that compiled cleanly, and [`crate::merge::build`]
15//! recompiles it fresh on every rebuild via
16//! [`crate::compile::resolve_predicate`] -- cheap, and exactly the
17//! "idempotent and re-runnable" property `didbot_policy::Evaluator::compile`
18//! already has to have for an out-of-process evaluator restarting with
19//! nothing. Not a revision-ordering mechanism either: this is the layer
20//! above whatever produced the current view, deciding what to enforce when
21//! the newest observation does not compile.
22//!
23//! # The two states an operator must be able to tell apart
24//!
25//! - **Was working, new revision will not compile.** The last good
26//! revision is enforced; the new one exists and failed. An edit that did
27//! not land, not an outage -- reported as [`crate::merge::LastGoodFallback`].
28//! - **Never compiled.** Nothing to fall back to -- this id has no entry
29//! here yet. Reported the ordinary, loud way,
30//! [`crate::compile::ScopedDenial`], because a first-publish typo denying
31//! its own coverage is a real gap the operator gets no protection from,
32//! and must see.
33
34use std::collections::HashMap;
35use std::sync::Mutex;
36
37use didbot_policy::Revision;
38
39use crate::record::ParsedPolicy;
40
41/// One id's last successfully-compiled revision, held across rebuilds.
42#[derive(Debug, Clone)]
43struct Good {
44 revision: Revision,
45 policy: ParsedPolicy,
46}
47
48/// The operator source's last-good-revision state. Empty and legitimate at
49/// startup -- nothing has compiled yet, which is the "never compiled" state
50/// every id starts in, not an error.
51///
52/// Held by whoever repeatedly rebuilds the tree from a fresh view of the
53/// operator's records, the same way that view itself is held -- long-lived,
54/// across many
55/// [`crate::merge::build`] calls, never reconstructed per rebuild, or there
56/// would be nothing to fall back to.
57#[derive(Default)]
58pub struct LastGoodPolicies {
59 good: Mutex<HashMap<String, Good>>,
60}
61
62impl LastGoodPolicies {
63 /// No id has a last-good revision yet.
64 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 /// The last successfully-compiled [`ParsedPolicy`] for `id`, and the
70 /// revision it was observed at, if this id has ever compiled.
71 pub(crate) fn get(&self, id: &str) -> Option<(Revision, ParsedPolicy)> {
72 self.good
73 .lock()
74 .expect("LastGoodPolicies mutex poisoned")
75 .get(id)
76 .map(|good| (good.revision, good.policy.clone()))
77 }
78
79 /// Record that `policy` (at `id`) compiled successfully at its own
80 /// revision -- the new last-good, replacing whatever was stored before.
81 pub(crate) fn record_success(&self, id: &str, policy: &ParsedPolicy) {
82 self.good
83 .lock()
84 .expect("LastGoodPolicies mutex poisoned")
85 .insert(
86 id.to_string(),
87 Good {
88 revision: policy.revision,
89 policy: policy.clone(),
90 },
91 );
92 }
93
94 /// Drop every id not present in `current` -- a record the current view
95 /// no longer holds at all (deleted, or
96 /// never re-observed after a source outage recovered onto a view that
97 /// no longer names it) has nothing left to fall back *to*, and keeping
98 /// it here would let a stale policy affect a future id that happens to
99 /// reuse the same at-uri. Called once per rebuild, after every id in
100 /// `current` has had a chance to update its own entry via
101 /// [`record_success`](Self::record_success).
102 pub(crate) fn prune(&self, current: &HashMap<String, ParsedPolicy>) {
103 self.good
104 .lock()
105 .expect("LastGoodPolicies mutex poisoned")
106 .retain(|id, _| current.contains_key(id));
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use didbot_policy::{ActionSet, Applicability, KindSet, Scope, Staleness};
114 use serde_json::json;
115
116 fn policy(revision: u64) -> ParsedPolicy {
117 ParsedPolicy {
118 applicability: Applicability::Write {
119 kinds: KindSet::ALL,
120 collections: Scope::Any,
121 actions: ActionSet::ALL,
122 path_prefixes: Scope::Any,
123 },
124 observes: Applicability::Write {
125 kinds: KindSet::ALL,
126 collections: Scope::Any,
127 actions: ActionSet::ALL,
128 path_prefixes: Scope::Any,
129 },
130 staleness: Staleness::Linearized,
131 language: "regex".to_string(),
132 predicate: json!({}),
133 revision: Revision(revision),
134 lookups: Vec::new(),
135 }
136 }
137
138 #[test]
139 fn nothing_is_stored_until_a_success_is_recorded() {
140 let good = LastGoodPolicies::new();
141 assert!(good.get("id-1").is_none());
142 }
143
144 #[test]
145 fn a_recorded_success_is_retrievable_by_id() {
146 let good = LastGoodPolicies::new();
147 good.record_success("id-1", &policy(1));
148 let (revision, stored) = good.get("id-1").expect("recorded");
149 assert_eq!(revision, Revision(1));
150 assert_eq!(stored.revision, Revision(1));
151 }
152
153 /// A later success replaces the earlier one -- this is the state a bad
154 /// N+1 falls back past to reach the good N, and a good N+2 later
155 /// replacing N is exactly the ordinary "policy got fixed" case.
156 #[test]
157 fn a_newer_success_replaces_the_prior_one() {
158 let good = LastGoodPolicies::new();
159 good.record_success("id-1", &policy(1));
160 good.record_success("id-1", &policy(2));
161 let (revision, _) = good.get("id-1").expect("recorded");
162 assert_eq!(revision, Revision(2));
163 }
164
165 /// An id absent from a fresh `current` view is pruned, not left to
166 /// linger for a future, unrelated record that happens to reuse the
167 /// same at-uri.
168 #[test]
169 fn pruning_drops_ids_absent_from_the_current_view() {
170 let good = LastGoodPolicies::new();
171 good.record_success("id-1", &policy(1));
172 good.record_success("id-2", &policy(1));
173
174 let mut current = HashMap::new();
175 current.insert("id-1".to_string(), policy(2));
176 good.prune(¤t);
177
178 assert!(good.get("id-1").is_some());
179 assert!(good.get("id-2").is_none());
180 }
181}