1use 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
14pub const TRANSIENT_RETRY_LIMIT: usize = 3;
17pub const DETERMINISTIC_FAILURE_QUARANTINE_THRESHOLD: usize = 2;
19
20#[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#[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
55pub 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#[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
217pub 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
224pub 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
255pub 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
282pub 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}