Skip to main content

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(&current);
177
178        assert!(good.get("id-1").is_some());
179        assert!(good.get("id-2").is_none());
180    }
181}