Skip to main content

aft/refresh/
mod.rs

1//! Plane-worker refresh coordination.
2//!
3//! Watchers only enqueue invalidated paths. Plane workers receive prepared byte
4//! payloads, deduplicate them by complete blob key, perform expensive work once,
5//! and fan the result out to every affected path.
6
7use std::collections::{BTreeMap, HashMap, HashSet};
8use std::fmt;
9use std::time::Duration;
10
11use crate::blob_store::{BlobPlane, FullKey};
12use crate::path_status::{PathStatusError, PathStatusStore};
13
14/// A transient failure is retried three times with exponential backoff, then
15/// increments the family-and-plane failure count that can trip its circuit breaker.
16pub const TRANSIENT_RETRY_LIMIT: usize = 3;
17/// Two deterministic failures of one full key quarantine that key.
18pub const DETERMINISTIC_FAILURE_QUARANTINE_THRESHOLD: usize = 2;
19
20/// A prepared path is created by a plane worker after it has made a stable read
21/// and derived that plane's full key. Watchers never construct blob payloads.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct PreparedWork {
24    pub rel_path: Vec<u8>,
25    pub full_key: FullKey,
26    pub payload: Vec<u8>,
27}
28
29/// One expensive operation and every path that consumes its result.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct FanoutWork {
32    pub full_key: FullKey,
33    pub payload: Vec<u8>,
34    pub rel_paths: Vec<Vec<u8>>,
35}
36
37#[derive(Debug, Eq, PartialEq)]
38pub enum DedupError {
39    ConflictingPayload { full_key: String },
40}
41
42impl fmt::Display for DedupError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::ConflictingPayload { full_key } => write!(
46                f,
47                "prepared payloads for full key {full_key} differ; refusing to publish ambiguous work"
48            ),
49        }
50    }
51}
52
53impl std::error::Error for DedupError {}
54
55/// Groups byte-identical full keys before expensive extraction, embedding, or
56/// blob insertion. The returned paths are bytewise ordered for deterministic
57/// manifest assembly and telemetry correlation.
58pub fn deduplicate_full_keys(
59    work: impl IntoIterator<Item = PreparedWork>,
60) -> Result<Vec<FanoutWork>, DedupError> {
61    let mut grouped = BTreeMap::<(String, String), FanoutWork>::new();
62    for item in work {
63        let sort_key = (
64            item.full_key.plane().as_str().to_owned(),
65            item.full_key.to_hex(),
66        );
67        match grouped.get_mut(&sort_key) {
68            Some(existing) => {
69                if existing.payload != item.payload {
70                    return Err(DedupError::ConflictingPayload {
71                        full_key: item.full_key.to_hex(),
72                    });
73                }
74                existing.rel_paths.push(item.rel_path);
75            }
76            None => {
77                grouped.insert(
78                    sort_key,
79                    FanoutWork {
80                        full_key: item.full_key,
81                        payload: item.payload,
82                        rel_paths: vec![item.rel_path],
83                    },
84                );
85            }
86        }
87    }
88    let mut result = grouped.into_values().collect::<Vec<_>>();
89    for item in &mut result {
90        item.rel_paths.sort();
91        item.rel_paths.dedup();
92    }
93    Ok(result)
94}
95
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum FailureClass {
98    Transient,
99    NonTransient,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct WorkerFailure {
104    pub class: FailureClass,
105    pub reason: String,
106}
107
108impl WorkerFailure {
109    pub fn transient(reason: impl Into<String>) -> Self {
110        Self {
111            class: FailureClass::Transient,
112            reason: reason.into(),
113        }
114    }
115
116    pub fn non_transient(reason: impl Into<String>) -> Self {
117        Self {
118            class: FailureClass::NonTransient,
119            reason: reason.into(),
120        }
121    }
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub enum WorkDisposition {
126    Published,
127    Retry { retry: usize, backoff: Duration },
128    Failed { failures: usize },
129    Quarantined,
130    BreakerRecorded { failures: usize },
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
134pub struct ProcessedFanout {
135    pub full_key: FullKey,
136    pub rel_paths: Vec<Vec<u8>>,
137    pub disposition: WorkDisposition,
138    pub reason: Option<String>,
139}
140
141/// Records per-key deterministic failures and per-family, per-plane transient
142/// breaker input. This is intentionally state-only: the BlobStore performs the
143/// durable quarantine write after `Quarantined` is returned.
144#[derive(Debug, Default)]
145pub struct FailureTracker {
146    deterministic_failures: HashMap<FullKey, usize>,
147    transient_retries: HashMap<FullKey, usize>,
148    quarantined: HashSet<FullKey>,
149    breaker_failures: HashMap<(String, BlobPlane), usize>,
150}
151
152impl FailureTracker {
153    pub fn record_failure(
154        &mut self,
155        family: &str,
156        full_key: &FullKey,
157        class: FailureClass,
158    ) -> WorkDisposition {
159        if self.quarantined.contains(full_key) {
160            return WorkDisposition::Quarantined;
161        }
162        match class {
163            FailureClass::Transient => {
164                let retry = self.transient_retries.entry(full_key.clone()).or_default();
165                if *retry < TRANSIENT_RETRY_LIMIT {
166                    *retry += 1;
167                    WorkDisposition::Retry {
168                        retry: *retry,
169                        backoff: transient_backoff(*retry),
170                    }
171                } else {
172                    self.transient_retries.remove(full_key);
173                    let failures = self
174                        .breaker_failures
175                        .entry((family.to_owned(), full_key.plane()))
176                        .or_default();
177                    *failures += 1;
178                    WorkDisposition::BreakerRecorded {
179                        failures: *failures,
180                    }
181                }
182            }
183            FailureClass::NonTransient => {
184                let failures = self
185                    .deterministic_failures
186                    .entry(full_key.clone())
187                    .or_default();
188                *failures += 1;
189                if *failures >= DETERMINISTIC_FAILURE_QUARANTINE_THRESHOLD {
190                    self.quarantined.insert(full_key.clone());
191                    WorkDisposition::Quarantined
192                } else {
193                    WorkDisposition::Failed {
194                        failures: *failures,
195                    }
196                }
197            }
198        }
199    }
200
201    pub fn record_success(&mut self, full_key: &FullKey) {
202        self.transient_retries.remove(full_key);
203    }
204
205    pub fn is_quarantined(&self, full_key: &FullKey) -> bool {
206        self.quarantined.contains(full_key)
207    }
208
209    pub fn breaker_failures(&self, family: &str, plane: BlobPlane) -> usize {
210        self.breaker_failures
211            .get(&(family.to_owned(), plane))
212            .copied()
213            .unwrap_or_default()
214    }
215}
216
217/// The retry number is one-based. The values are deliberately small enough for
218/// worker scheduling tests while still providing a strict exponential sequence.
219pub fn transient_backoff(retry: usize) -> Duration {
220    let exponent = retry.saturating_sub(1).min(10) as u32;
221    Duration::from_millis(100_u64.saturating_mul(1_u64 << exponent))
222}
223
224/// Runs a plane-worker operation once per full key and fans its disposition out
225/// to all paths sharing the key. `operation` is where the worker hashes, puts,
226/// derives, and publishes; this coordinator never runs on a watcher thread.
227pub fn execute_plane_batch(
228    tracker: &mut FailureTracker,
229    family: &str,
230    work: impl IntoIterator<Item = PreparedWork>,
231    mut operation: impl FnMut(&FullKey, &[u8]) -> Result<(), WorkerFailure>,
232) -> Result<Vec<ProcessedFanout>, DedupError> {
233    deduplicate_full_keys(work)?
234        .into_iter()
235        .map(|work| match operation(&work.full_key, &work.payload) {
236            Ok(()) => {
237                tracker.record_success(&work.full_key);
238                Ok(ProcessedFanout {
239                    full_key: work.full_key,
240                    rel_paths: work.rel_paths,
241                    disposition: WorkDisposition::Published,
242                    reason: None,
243                })
244            }
245            Err(failure) => Ok(ProcessedFanout {
246                disposition: tracker.record_failure(family, &work.full_key, failure.class),
247                full_key: work.full_key,
248                rel_paths: work.rel_paths,
249                reason: Some(failure.reason),
250            }),
251        })
252        .collect()
253}
254
255/// Applies plane-worker outcomes to the derived table. A publish removes the
256/// annotation; retryable work remains pending, and exhausted or deterministic
257/// failures remain failed while the last complete generation stays current.
258pub fn apply_path_status(
259    statuses: &mut PathStatusStore,
260    outcomes: &[ProcessedFanout],
261    since_generation: u64,
262) -> Result<(), PathStatusError> {
263    for outcome in outcomes {
264        let reason = outcome.reason.as_deref().unwrap_or("refresh failed");
265        for rel_path in &outcome.rel_paths {
266            match outcome.disposition {
267                WorkDisposition::Published => statuses.clear(rel_path)?,
268                WorkDisposition::Retry { .. } => {
269                    statuses.mark_pending(rel_path, reason, since_generation)?
270                }
271                WorkDisposition::Failed { .. }
272                | WorkDisposition::Quarantined
273                | WorkDisposition::BreakerRecorded { .. } => {
274                    statuses.mark_failed(rel_path, reason, since_generation)?
275                }
276            }
277        }
278    }
279    Ok(())
280}
281
282/// Records a persistent stable-read mismatch without publishing the path. The
283/// next watcher event simply puts the path back through collection and can clear
284/// this row after a later plane-worker publication.
285pub fn preserve_pending_after_unstable_read(
286    statuses: &mut PathStatusStore,
287    rel_path: &[u8],
288    since_generation: u64,
289) -> Result<(), PathStatusError> {
290    statuses.mark_pending(
291        rel_path,
292        "file changed while reading; awaiting a later watcher event",
293        since_generation,
294    )
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::blob_store::{CallgraphKey, SemanticKey};
301
302    #[test]
303    fn full_key_dedup_runs_one_operation_and_fans_out_bytewise_sorted_paths() {
304        let key = CallgraphKey::for_current(b"same source", "rust").full_key();
305        let mut tracker = FailureTracker::default();
306        let mut operations = 0;
307        let outcomes = execute_plane_batch(
308            &mut tracker,
309            "family-a",
310            [
311                PreparedWork {
312                    rel_path: b"src/z.rs".to_vec(),
313                    full_key: key.clone(),
314                    payload: b"parse payload".to_vec(),
315                },
316                PreparedWork {
317                    rel_path: b"src/a.rs".to_vec(),
318                    full_key: key.clone(),
319                    payload: b"parse payload".to_vec(),
320                },
321            ],
322            |_, _| {
323                operations += 1;
324                Ok(())
325            },
326        )
327        .expect("deduplicate work");
328
329        assert_eq!(operations, 1);
330        assert_eq!(outcomes.len(), 1);
331        assert_eq!(outcomes[0].disposition, WorkDisposition::Published);
332        assert_eq!(
333            outcomes[0].rel_paths,
334            vec![b"src/a.rs".to_vec(), b"src/z.rs".to_vec()]
335        );
336    }
337
338    #[test]
339    fn two_non_transient_failures_quarantine_only_that_full_key() {
340        let first = SemanticKey::for_current(b"source", b"src/lib.rs", "model").full_key();
341        let changed = SemanticKey::for_current(b"changed", b"src/lib.rs", "model").full_key();
342        let mut tracker = FailureTracker::default();
343
344        assert_eq!(
345            tracker.record_failure("family-a", &first, FailureClass::NonTransient),
346            WorkDisposition::Failed { failures: 1 }
347        );
348        assert_eq!(
349            tracker.record_failure("family-a", &first, FailureClass::NonTransient),
350            WorkDisposition::Quarantined
351        );
352        assert!(tracker.is_quarantined(&first));
353        assert!(!tracker.is_quarantined(&changed));
354        assert_eq!(
355            tracker.record_failure("family-a", &changed, FailureClass::NonTransient),
356            WorkDisposition::Failed { failures: 1 }
357        );
358    }
359
360    #[test]
361    fn three_transient_retries_back_off_before_the_plane_breaker_counts_failure() {
362        let key = CallgraphKey::for_current(b"source", "rust").full_key();
363        let mut tracker = FailureTracker::default();
364
365        for retry in 1..=TRANSIENT_RETRY_LIMIT {
366            assert_eq!(
367                tracker.record_failure("family-a", &key, FailureClass::Transient),
368                WorkDisposition::Retry {
369                    retry,
370                    backoff: transient_backoff(retry),
371                }
372            );
373        }
374        assert_eq!(
375            tracker.record_failure("family-a", &key, FailureClass::Transient),
376            WorkDisposition::BreakerRecorded { failures: 1 }
377        );
378        assert_eq!(
379            tracker.breaker_failures("family-a", BlobPlane::Callgraph),
380            1
381        );
382        assert_eq!(
383            tracker.breaker_failures("other-family", BlobPlane::Callgraph),
384            0
385        );
386    }
387
388    #[test]
389    fn persistent_read_and_worker_failures_annotate_without_a_publish() {
390        let dir = tempfile::tempdir().expect("create view dir");
391        let mut statuses = PathStatusStore::open(dir.path()).expect("open statuses");
392        preserve_pending_after_unstable_read(&mut statuses, b"src/dirty.rs", 4)
393            .expect("mark pending");
394        let key = CallgraphKey::for_current(b"broken", "rust").full_key();
395        let mut tracker = FailureTracker::default();
396        let outcomes = execute_plane_batch(
397            &mut tracker,
398            "family-a",
399            [PreparedWork {
400                rel_path: b"src/broken.rs".to_vec(),
401                full_key: key,
402                payload: Vec::new(),
403            }],
404            |_, _| Err(WorkerFailure::non_transient("parse panic")),
405        )
406        .expect("process failed work");
407        apply_path_status(&mut statuses, &outcomes, 4).expect("record failed status");
408
409        let summary = statuses.summary().expect("summarize statuses");
410        assert_eq!(summary.pending_count, 1);
411        assert_eq!(summary.failed_count, 1);
412        assert_eq!(summary.paths[0].rel_path, b"src/broken.rs");
413        assert_eq!(summary.paths[1].rel_path, b"src/dirty.rs");
414    }
415}