1use crate::db::{SqliteStore, TrackedConnection};
8use rusqlite::{params, Connection, OptionalExtension};
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Mutex;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub const ZERO_CREDIT_DEATH_LIMIT: u64 = 3;
15pub const CREDITED_DEATH_LIMIT: u64 = 6;
16pub const IN_BUILD_BURN_LIMIT_MS: u64 = 30 * 60 * 1_000;
17pub const TRIP_TTL_MS: u64 = 24 * 60 * 60 * 1_000;
18pub const ATTEMPT_MARKER_HEARTBEAT_INTERVAL_MS: u64 = 5_000;
19pub const ATTEMPT_MARKER_RECENT_HEARTBEAT_MS: u64 = 15_000;
20pub const TEMP_DELETE_AGE_FLOOR_MS: u64 = 24 * 60 * 60 * 1_000;
21pub const SWEEP_AMBIGUITY_TTL_MS: u64 = 7 * 24 * 60 * 60 * 1_000;
22pub const SWEEP_STAT_CHECK_CAP: usize = 64;
23pub const BREAKER_CONFIGURATION_VERSION: &str = "v1";
24
25static NEXT_ATTEMPT: AtomicU64 = AtomicU64::new(1);
26
27#[cfg(test)]
31thread_local! {
32 static OPEN_CALLS_FOR_TEST: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
33}
34#[cfg(test)]
35static FAIL_NEXT_ACTIVE_SUSPENSIONS_FOR_TEST: std::sync::atomic::AtomicBool =
36 std::sync::atomic::AtomicBool::new(false);
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
41pub enum BuildDomain {
42 CallgraphCold,
43 SearchCold,
44 SemanticSeed,
45 Tier2Scan,
46}
47
48impl BuildDomain {
49 pub const ALL: [Self; 4] = [
50 Self::CallgraphCold,
51 Self::SearchCold,
52 Self::SemanticSeed,
53 Self::Tier2Scan,
54 ];
55
56 pub const fn as_str(self) -> &'static str {
57 match self {
58 Self::CallgraphCold => "callgraph_cold",
59 Self::SearchCold => "search_cold",
60 Self::SemanticSeed => "semantic_seed",
61 Self::Tier2Scan => "tier2_scan",
62 }
63 }
64
65 fn from_persisted(value: &str) -> Option<Self> {
66 match value {
67 "callgraph_cold" => Some(Self::CallgraphCold),
68 "search_cold" => Some(Self::SearchCold),
69 "semantic_seed" => Some(Self::SemanticSeed),
70 "tier2_scan" => Some(Self::Tier2Scan),
71 _ => None,
72 }
73 }
74}
75
76#[derive(Clone, Debug, Eq, PartialEq, Hash)]
79pub struct BreakerKey {
80 pub root_id: String,
81 pub domain: BuildDomain,
82 pub corpus_fingerprint: String,
83}
84
85impl BreakerKey {
86 pub fn new(
87 root_id: impl Into<String>,
88 domain: BuildDomain,
89 corpus_fingerprint: impl Into<String>,
90 ) -> Self {
91 Self {
92 root_id: root_id.into(),
93 domain,
94 corpus_fingerprint: corpus_fingerprint.into(),
95 }
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct BuildAttempt {
101 pub attempt_id: String,
102 pub start_committed_extracted_bytes: u64,
103}
104
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub struct BuildSuspension {
107 pub domain: BuildDomain,
108 pub reason: String,
109 pub death_count: u64,
110 pub suspended_since_unix_ms: u64,
111}
112
113impl BuildSuspension {
114 pub fn age_millis_at(&self, now_ms: u64) -> u64 {
115 now_ms.saturating_sub(self.suspended_since_unix_ms)
116 }
117
118 pub fn age_seconds_at(&self, now_ms: u64) -> u64 {
119 self.age_millis_at(now_ms) / 1_000
120 }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub enum BreakerAdmission {
125 Admitted(BuildAttempt),
126 Suspended(BuildSuspension),
127}
128
129#[derive(Debug)]
130pub enum BuildBreakerError {
131 Sqlite(rusqlite::Error),
132}
133
134impl From<rusqlite::Error> for BuildBreakerError {
135 fn from(error: rusqlite::Error) -> Self {
136 Self::Sqlite(error)
137 }
138}
139
140impl std::fmt::Display for BuildBreakerError {
141 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 Self::Sqlite(error) => write!(formatter, "build breaker database error: {error}"),
144 }
145 }
146}
147
148impl std::error::Error for BuildBreakerError {}
149
150pub type Result<T> = std::result::Result<T, BuildBreakerError>;
151
152pub struct BuildDeathBreaker {
154 path: PathBuf,
155 connection: Mutex<TrackedConnection>,
161}
162
163impl BuildDeathBreaker {
164 pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
165 #[cfg(test)]
166 OPEN_CALLS_FOR_TEST.with(|calls| calls.set(calls.get() + 1));
167 let path = path.into();
168 if let Some(parent) = path.parent() {
169 std::fs::create_dir_all(parent).map_err(|error| {
170 BuildBreakerError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(error)))
171 })?;
172 }
173 let connection = TrackedConnection::open(&path, SqliteStore::BreakerFile)?;
174 connection.busy_timeout(std::time::Duration::from_secs(5))?;
175 connection.execute_batch(
176 "PRAGMA journal_mode=WAL;
177 PRAGMA synchronous=NORMAL;
178 CREATE TABLE IF NOT EXISTS breaker_records (
179 root_id TEXT NOT NULL,
180 domain TEXT NOT NULL,
181 corpus_fingerprint TEXT NOT NULL,
182 configuration_version TEXT NOT NULL,
183 zero_credit_deaths INTEGER NOT NULL DEFAULT 0,
184 credited_deaths INTEGER NOT NULL DEFAULT 0,
185 in_build_burn_ms INTEGER NOT NULL DEFAULT 0,
186 suspended_reason TEXT,
187 suspended_since_ms INTEGER,
188 suspended_until_ms INTEGER,
189 PRIMARY KEY(root_id, domain, corpus_fingerprint)
190 );
191 CREATE TABLE IF NOT EXISTS breaker_attempts (
192 root_id TEXT NOT NULL,
193 domain TEXT NOT NULL,
194 corpus_fingerprint TEXT NOT NULL,
195 attempt_id TEXT NOT NULL,
196 start_committed_extracted_bytes INTEGER NOT NULL,
197 death_charged INTEGER NOT NULL DEFAULT 0,
198 PRIMARY KEY(root_id, domain, corpus_fingerprint, attempt_id)
199 );",
200 )?;
201 Ok(Self {
202 path,
203 connection: Mutex::new(connection),
204 })
205 }
206
207 pub fn path(&self) -> &Path {
208 &self.path
209 }
210
211 pub fn admit(
212 &self,
213 key: &BreakerKey,
214 committed_extracted_bytes: u64,
215 ) -> Result<BreakerAdmission> {
216 self.admit_at(key, committed_extracted_bytes, unix_millis_now())
217 }
218
219 pub fn admit_at(
220 &self,
221 key: &BreakerKey,
222 committed_extracted_bytes: u64,
223 now_ms: u64,
224 ) -> Result<BreakerAdmission> {
225 self.with_connection(|conn| {
226 let tx = conn.transaction()?;
227 ensure_record(&tx, key)?;
228 let suspension = suspension_in_tx(&tx, key, now_ms)?;
229 if let Some(suspension) = suspension {
230 tx.commit()?;
231 return Ok(BreakerAdmission::Suspended(suspension));
232 }
233 let attempt = BuildAttempt {
234 attempt_id: format!(
235 "{}-{}-{}",
236 std::process::id(),
237 now_ms,
238 NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed)
239 ),
240 start_committed_extracted_bytes: committed_extracted_bytes,
241 };
242 tx.execute(
243 "INSERT INTO breaker_attempts(
244 root_id, domain, corpus_fingerprint, attempt_id, start_committed_extracted_bytes
245 ) VALUES(?1, ?2, ?3, ?4, ?5)",
246 params![
247 key.root_id,
248 key.domain.as_str(),
249 key.corpus_fingerprint,
250 attempt.attempt_id,
251 committed_extracted_bytes as i64,
252 ],
253 )?;
254 tx.commit()?;
255 Ok(BreakerAdmission::Admitted(attempt))
256 })
257 }
258
259 pub fn record_attributed_death(
263 &self,
264 key: &BreakerKey,
265 attempt_id: &str,
266 committed_extracted_bytes_at_death: u64,
267 durable_burn_ms: u64,
268 ) -> Result<Option<BuildSuspension>> {
269 self.record_attributed_death_at(
270 key,
271 attempt_id,
272 committed_extracted_bytes_at_death,
273 durable_burn_ms,
274 unix_millis_now(),
275 )
276 }
277
278 pub fn record_attributed_death_at(
279 &self,
280 key: &BreakerKey,
281 attempt_id: &str,
282 committed_extracted_bytes_at_death: u64,
283 durable_burn_ms: u64,
284 now_ms: u64,
285 ) -> Result<Option<BuildSuspension>> {
286 self.with_connection(|conn| {
287 let tx = conn.transaction()?;
288 ensure_record(&tx, key)?;
289 let attempt = tx
290 .query_row(
291 "SELECT start_committed_extracted_bytes, death_charged
292 FROM breaker_attempts
293 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3 AND attempt_id = ?4",
294 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint, attempt_id],
295 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)? != 0)),
296 )
297 .optional()?;
298 let Some((start, already_charged)) = attempt else {
299 tx.commit()?;
300 return Ok(None);
301 };
302 if already_charged {
303 let current = suspension_in_tx(&tx, key, now_ms)?;
304 tx.commit()?;
305 return Ok(current);
306 }
307
308 let credited = committed_extracted_bytes_at_death > start.max(0) as u64;
311 tx.execute(
312 "UPDATE breaker_attempts SET death_charged = 1
313 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3 AND attempt_id = ?4",
314 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint, attempt_id],
315 )?;
316 tx.execute(
317 "UPDATE breaker_records
318 SET zero_credit_deaths = zero_credit_deaths + ?4,
319 credited_deaths = credited_deaths + ?5,
320 in_build_burn_ms = in_build_burn_ms + ?6
321 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
322 params![
323 key.root_id,
324 key.domain.as_str(),
325 key.corpus_fingerprint,
326 i64::from(!credited),
327 i64::from(credited),
328 durable_burn_ms.min(i64::MAX as u64) as i64,
329 ],
330 )?;
331 let suspension = trip_if_needed(&tx, key, now_ms)?;
332 tx.commit()?;
333 Ok(suspension)
334 })
335 }
336
337 pub fn record_durable_burn_at(
340 &self,
341 key: &BreakerKey,
342 durable_burn_ms: u64,
343 now_ms: u64,
344 ) -> Result<Option<BuildSuspension>> {
345 self.with_connection(|conn| {
346 let tx = conn.transaction()?;
347 ensure_record(&tx, key)?;
348 tx.execute(
349 "UPDATE breaker_records SET in_build_burn_ms = in_build_burn_ms + ?4
350 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
351 params![
352 key.root_id,
353 key.domain.as_str(),
354 key.corpus_fingerprint,
355 durable_burn_ms.min(i64::MAX as u64) as i64,
356 ],
357 )?;
358 let suspension = trip_if_needed(&tx, key, now_ms)?;
359 tx.commit()?;
360 Ok(suspension)
361 })
362 }
363
364 pub fn record_ready_publication(&self, key: &BreakerKey) -> Result<()> {
367 self.with_connection(|conn| {
368 let tx = conn.transaction()?;
369 ensure_record(&tx, key)?;
370 reset_record(&tx, key)?;
371 tx.commit()?;
372 Ok(())
373 })
374 }
375
376 pub fn explicit_reset(&self, key: &BreakerKey) -> Result<()> {
378 self.record_ready_publication(key)
379 }
380
381 pub fn suspension(&self, key: &BreakerKey) -> Result<Option<BuildSuspension>> {
382 self.suspension_at(key, unix_millis_now())
383 }
384
385 pub fn suspension_at(&self, key: &BreakerKey, now_ms: u64) -> Result<Option<BuildSuspension>> {
386 self.with_connection(|conn| {
387 let tx = conn.transaction()?;
388 ensure_record(&tx, key)?;
389 let suspension = suspension_in_tx(&tx, key, now_ms)?;
390 tx.commit()?;
391 Ok(suspension)
392 })
393 }
394
395 pub fn active_suspensions_for_root(&self, root_id: &str) -> Result<Vec<BuildSuspension>> {
396 self.active_suspensions_for_root_at(root_id, unix_millis_now())
397 }
398
399 pub fn active_suspensions_for_root_at(
404 &self,
405 root_id: &str,
406 now_ms: u64,
407 ) -> Result<Vec<BuildSuspension>> {
408 #[cfg(test)]
409 if FAIL_NEXT_ACTIVE_SUSPENSIONS_FOR_TEST.swap(false, Ordering::SeqCst) {
410 return Err(BuildBreakerError::Sqlite(rusqlite::Error::InvalidQuery));
411 }
412 self.with_connection(|conn| {
413 let mut statement = conn.prepare(
414 "SELECT domain, zero_credit_deaths, credited_deaths, suspended_reason, suspended_since_ms
415 FROM breaker_records
416 WHERE root_id = ?1
417 AND configuration_version = ?2
418 AND suspended_reason IS NOT NULL
419 AND suspended_since_ms IS NOT NULL
420 AND suspended_until_ms > ?3
421 ORDER BY domain",
422 )?;
423 let rows = statement.query_map(
424 params![root_id, BREAKER_CONFIGURATION_VERSION, now_ms.min(i64::MAX as u64) as i64],
425 |row| {
426 Ok((
427 row.get::<_, String>(0)?,
428 row.get::<_, i64>(1)?,
429 row.get::<_, i64>(2)?,
430 row.get::<_, String>(3)?,
431 row.get::<_, i64>(4)?,
432 ))
433 },
434 )?;
435 let mut suspensions = Vec::new();
436 for row in rows {
437 let (domain, zero_credit_deaths, credited_deaths, reason, since) = row?;
438 let Some(domain) = BuildDomain::from_persisted(&domain) else {
439 continue;
440 };
441 suspensions.push(BuildSuspension {
442 domain,
443 reason,
444 death_count: (zero_credit_deaths.max(0) as u64)
445 .saturating_add(credited_deaths.max(0) as u64),
446 suspended_since_unix_ms: since.max(0) as u64,
447 });
448 }
449 Ok(suspensions)
450 })
451 }
452
453 #[cfg(test)]
454 pub(crate) fn reset_open_calls_for_test() {
455 OPEN_CALLS_FOR_TEST.with(|calls| calls.set(0));
456 }
457
458 #[cfg(test)]
459 pub(crate) fn open_calls_for_test() -> u64 {
460 OPEN_CALLS_FOR_TEST.with(|calls| calls.get())
461 }
462
463 #[cfg(test)]
464 pub(crate) fn fail_next_active_suspensions_for_test() {
465 FAIL_NEXT_ACTIVE_SUSPENSIONS_FOR_TEST.store(true, Ordering::SeqCst);
466 }
467
468 fn with_connection<T>(&self, work: impl FnOnce(&mut Connection) -> Result<T>) -> Result<T> {
469 let mut connection = self
470 .connection
471 .lock()
472 .unwrap_or_else(|error| error.into_inner());
473 work(&mut connection)
474 }
475}
476
477fn ensure_record(tx: &rusqlite::Transaction<'_>, key: &BreakerKey) -> Result<()> {
478 tx.execute(
479 "INSERT INTO breaker_records(root_id, domain, corpus_fingerprint, configuration_version)
480 VALUES(?1, ?2, ?3, ?4)
481 ON CONFLICT(root_id, domain, corpus_fingerprint) DO UPDATE SET
482 configuration_version = excluded.configuration_version,
483 zero_credit_deaths = 0,
484 credited_deaths = 0,
485 in_build_burn_ms = 0,
486 suspended_reason = NULL,
487 suspended_since_ms = NULL,
488 suspended_until_ms = NULL
489 WHERE breaker_records.configuration_version != excluded.configuration_version",
490 params![
491 key.root_id,
492 key.domain.as_str(),
493 key.corpus_fingerprint,
494 BREAKER_CONFIGURATION_VERSION,
495 ],
496 )?;
497 Ok(())
498}
499
500fn reset_record(tx: &rusqlite::Transaction<'_>, key: &BreakerKey) -> Result<()> {
501 tx.execute(
502 "UPDATE breaker_records
503 SET zero_credit_deaths = 0, credited_deaths = 0, in_build_burn_ms = 0,
504 suspended_reason = NULL, suspended_since_ms = NULL, suspended_until_ms = NULL
505 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
506 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint],
507 )?;
508 Ok(())
509}
510
511fn suspension_in_tx(
512 tx: &rusqlite::Transaction<'_>,
513 key: &BreakerKey,
514 now_ms: u64,
515) -> Result<Option<BuildSuspension>> {
516 let record = tx.query_row(
517 "SELECT zero_credit_deaths, credited_deaths, in_build_burn_ms,
518 suspended_reason, suspended_since_ms, suspended_until_ms
519 FROM breaker_records
520 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
521 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint],
522 |row| {
523 Ok((
524 row.get::<_, i64>(0)? as u64,
525 row.get::<_, i64>(1)? as u64,
526 row.get::<_, i64>(2)? as u64,
527 row.get::<_, Option<String>>(3)?,
528 row.get::<_, Option<i64>>(4)?,
529 row.get::<_, Option<i64>>(5)?,
530 ))
531 },
532 )?;
533 let (zero_credit_deaths, credited_deaths, _burn, reason, since, until) = record;
534 let (Some(reason), Some(since), Some(until)) = (reason, since, until) else {
535 return Ok(None);
536 };
537 if until.max(0) as u64 <= now_ms {
538 tx.execute(
540 "UPDATE breaker_records
541 SET suspended_reason = NULL, suspended_since_ms = NULL, suspended_until_ms = NULL
542 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
543 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint],
544 )?;
545 return Ok(None);
546 }
547 Ok(Some(BuildSuspension {
548 domain: key.domain,
549 reason,
550 death_count: zero_credit_deaths.saturating_add(credited_deaths),
551 suspended_since_unix_ms: since.max(0) as u64,
552 }))
553}
554
555fn trip_if_needed(
556 tx: &rusqlite::Transaction<'_>,
557 key: &BreakerKey,
558 now_ms: u64,
559) -> Result<Option<BuildSuspension>> {
560 let (zero_credit_deaths, credited_deaths, burn) = tx.query_row(
561 "SELECT zero_credit_deaths, credited_deaths, in_build_burn_ms
562 FROM breaker_records WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
563 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint],
564 |row| {
565 Ok((
566 row.get::<_, i64>(0)? as u64,
567 row.get::<_, i64>(1)? as u64,
568 row.get::<_, i64>(2)? as u64,
569 ))
570 },
571 )?;
572 let reason = if zero_credit_deaths >= ZERO_CREDIT_DEATH_LIMIT {
573 Some("zero_credit_death_limit")
574 } else if credited_deaths >= CREDITED_DEATH_LIMIT {
575 Some("credited_death_limit")
576 } else if burn >= IN_BUILD_BURN_LIMIT_MS {
577 Some("in_build_burn_limit")
578 } else {
579 None
580 };
581 let Some(reason) = reason else {
582 return Ok(None);
583 };
584 let existing = tx.query_row(
585 "SELECT suspended_since_ms FROM breaker_records
586 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
587 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint],
588 |row| row.get::<_, Option<i64>>(0),
589 )?;
590 let since = existing
591 .unwrap_or(now_ms.min(i64::MAX as u64) as i64)
592 .max(0) as u64;
593 tx.execute(
594 "UPDATE breaker_records
595 SET suspended_reason = ?4, suspended_since_ms = ?5, suspended_until_ms = ?6
596 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
597 params![
598 key.root_id,
599 key.domain.as_str(),
600 key.corpus_fingerprint,
601 reason,
602 since as i64,
603 now_ms.saturating_add(TRIP_TTL_MS).min(i64::MAX as u64) as i64,
604 ],
605 )?;
606 Ok(Some(BuildSuspension {
607 domain: key.domain,
608 reason: reason.to_string(),
609 death_count: zero_credit_deaths.saturating_add(credited_deaths),
610 suspended_since_unix_ms: since,
611 }))
612}
613
614fn unix_millis_now() -> u64 {
615 SystemTime::now()
616 .duration_since(UNIX_EPOCH)
617 .unwrap_or_default()
618 .as_millis()
619 .min(u128::from(u64::MAX)) as u64
620}
621
622#[cfg(test)]
623#[path = "build_breaker_audit_tests.rs"]
624mod audit_matrix_tests;
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use tempfile::tempdir;
630
631 fn breaker() -> BuildDeathBreaker {
632 BuildDeathBreaker::open(tempdir().unwrap().keep().join("breaker.sqlite")).unwrap()
633 }
634
635 fn key(domain: BuildDomain) -> BreakerKey {
636 BreakerKey::new("root-a", domain, "corpus-a")
637 }
638
639 fn admitted(breaker: &BuildDeathBreaker, key: &BreakerKey, now: u64) -> BuildAttempt {
640 match breaker.admit_at(key, 10, now).unwrap() {
641 BreakerAdmission::Admitted(attempt) => attempt,
642 BreakerAdmission::Suspended(suspension) => {
643 panic!("unexpected suspension: {suspension:?}")
644 }
645 }
646 }
647
648 fn durable_tallies(breaker: &BuildDeathBreaker, key: &BreakerKey) -> (u64, u64, u64) {
649 Connection::open(breaker.path())
650 .unwrap()
651 .query_row(
652 "SELECT zero_credit_deaths, credited_deaths, in_build_burn_ms
653 FROM breaker_records
654 WHERE root_id = ?1 AND domain = ?2 AND corpus_fingerprint = ?3",
655 params![key.root_id, key.domain.as_str(), key.corpus_fingerprint],
656 |row| {
657 Ok((
658 row.get::<_, i64>(0)? as u64,
659 row.get::<_, i64>(1)? as u64,
660 row.get::<_, i64>(2)? as u64,
661 ))
662 },
663 )
664 .unwrap()
665 }
666
667 #[test]
668 fn three_zero_credit_deaths_trip_once_and_are_idempotent() {
669 let breaker = breaker();
670 let key = key(BuildDomain::CallgraphCold);
671 let mut final_attempt = None;
672 for now in 1..=3 {
673 let attempt = admitted(&breaker, &key, now);
674 let suspension = breaker
675 .record_attributed_death_at(&key, &attempt.attempt_id, 10, 0, now)
676 .unwrap();
677 if now == 3 {
678 final_attempt = Some((attempt, suspension.unwrap()));
679 } else {
680 assert!(suspension.is_none());
681 }
682 }
683 let (attempt, suspension) = final_attempt.unwrap();
684 assert_eq!(suspension.reason, "zero_credit_death_limit");
685 assert_eq!(suspension.death_count, 3);
686 assert_eq!(
687 breaker
688 .record_attributed_death_at(&key, &attempt.attempt_id, 10, 0, 4)
689 .unwrap(),
690 Some(suspension),
691 "recovery may repeat marker cleanup but must not charge it twice"
692 );
693 }
694
695 #[test]
696 fn one_batch_per_death_still_trips_after_six_credited_attempts() {
697 let breaker = breaker();
698 let key = key(BuildDomain::CallgraphCold);
699 for now in 1..=5 {
700 let attempt = admitted(&breaker, &key, now);
701 assert!(breaker
702 .record_attributed_death_at(&key, &attempt.attempt_id, 11, 0, now)
703 .unwrap()
704 .is_none());
705 }
706 assert_eq!(durable_tallies(&breaker, &key), (0, 5, 0));
707 assert!(breaker.suspension_at(&key, 6).unwrap().is_none());
708
709 let sixth = admitted(&breaker, &key, 6);
710 let suspension = breaker
711 .record_attributed_death_at(&key, &sixth.attempt_id, 11, 0, 6)
712 .unwrap()
713 .unwrap();
714 assert_eq!(suspension.reason, "credited_death_limit");
715 assert_eq!(suspension.death_count, 6);
716 assert_eq!(durable_tallies(&breaker, &key), (0, 6, 0));
717 }
718
719 #[test]
720 fn ttl_lifts_only_suspension_and_retains_death_history() {
721 let breaker = breaker();
722 let key = key(BuildDomain::CallgraphCold);
723 for now in 1..=3 {
724 let attempt = admitted(&breaker, &key, now);
725 breaker
726 .record_attributed_death_at(&key, &attempt.attempt_id, 10, 0, now)
727 .unwrap();
728 }
729 assert_eq!(durable_tallies(&breaker, &key), (3, 0, 0));
730
731 let BreakerAdmission::Admitted(retry) =
732 breaker.admit_at(&key, 10, TRIP_TTL_MS + 4).unwrap()
733 else {
734 panic!("expired suspension must admit exactly one probe");
735 };
736 assert_eq!(
737 durable_tallies(&breaker, &key),
738 (3, 0, 0),
739 "TTL expiry lifts scheduling without erasing durable history"
740 );
741
742 let suspension = breaker
743 .record_attributed_death_at(&key, &retry.attempt_id, 10, 0, TRIP_TTL_MS + 5)
744 .unwrap()
745 .unwrap();
746 assert_eq!(suspension.reason, "zero_credit_death_limit");
747 assert_eq!(suspension.death_count, 4);
748 assert_eq!(durable_tallies(&breaker, &key), (4, 0, 0));
749 }
750
751 #[test]
752 fn root_and_domain_histories_are_isolated() {
753 for tripped_domain in BuildDomain::ALL {
754 let breaker = breaker();
755 let tripped = BreakerKey::new(
756 format!("root-{}", tripped_domain.as_str()),
757 tripped_domain,
758 "corpus-a",
759 );
760 for now in 1..=3 {
761 let attempt = admitted(&breaker, &tripped, now);
762 breaker
763 .record_attributed_death_at(&tripped, &attempt.attempt_id, 10, 0, now)
764 .unwrap();
765 }
766 let report = breaker.suspension_at(&tripped, 4).unwrap().unwrap();
767 assert_eq!(report.domain, tripped_domain);
768 assert_eq!(report.death_count, 3);
769
770 let mut siblings = BuildDomain::ALL
771 .into_iter()
772 .filter(|domain| *domain != tripped_domain)
773 .map(|domain| BreakerKey::new(tripped.root_id.clone(), domain, "corpus-a"))
774 .collect::<Vec<_>>();
775 siblings.push(BreakerKey::new(
776 format!("{}-sibling", tripped.root_id),
777 tripped_domain,
778 "corpus-a",
779 ));
780
781 for (index, sibling) in siblings.iter().enumerate() {
782 assert!(breaker.suspension_at(sibling, 4).unwrap().is_none());
783 let attempt = admitted(&breaker, sibling, 10 + index as u64);
784 breaker
785 .record_attributed_death_at(
786 sibling,
787 &attempt.attempt_id,
788 10,
789 0,
790 10 + index as u64,
791 )
792 .unwrap();
793 assert_eq!(durable_tallies(&breaker, sibling), (1, 0, 0));
794 }
795
796 breaker.explicit_reset(&siblings[0]).unwrap();
797 assert_eq!(durable_tallies(&breaker, &siblings[0]), (0, 0, 0));
798 assert!(breaker.suspension_at(&tripped, 20).unwrap().is_some());
799
800 breaker.explicit_reset(&tripped).unwrap();
801 assert!(breaker.suspension_at(&tripped, 21).unwrap().is_none());
802 assert_eq!(durable_tallies(&breaker, &siblings[1]), (1, 0, 0));
803 }
804 }
805
806 #[test]
807 fn burn_limit_trips_without_counter_credit() {
808 let breaker = breaker();
809 let key = key(BuildDomain::Tier2Scan);
810 let suspension = breaker
811 .record_durable_burn_at(&key, IN_BUILD_BURN_LIMIT_MS, 77)
812 .unwrap()
813 .unwrap();
814 assert_eq!(suspension.reason, "in_build_burn_limit");
815 assert_eq!(suspension.domain, BuildDomain::Tier2Scan);
816 }
817
818 #[test]
819 fn durable_trip_agrees_across_navigation_inspect_and_health_snapshots() {
820 let storage = tempdir().unwrap();
821 let root = storage.path().join("root");
822 std::fs::create_dir_all(&root).unwrap();
823 let project_key = crate::search_index::artifact_cache_key(&root);
824 let breaker_path = storage
825 .path()
826 .join("callgraph")
827 .join(&project_key)
828 .join("build-breaker.sqlite");
829 let breaker = BuildDeathBreaker::open(&breaker_path).unwrap();
830 let key = BreakerKey::new(
831 root.display().to_string(),
832 BuildDomain::CallgraphCold,
833 "corpus-a",
834 );
835 let trip_at = 1_000_000;
836 let mut trip_decision = None;
837 for death_at in (trip_at - 2)..=trip_at {
838 let attempt = admitted(&breaker, &key, death_at);
839 trip_decision = breaker
840 .record_attributed_death_at(&key, &attempt.attempt_id, 10, 0, death_at)
841 .unwrap();
842 }
843 let trip_decision = trip_decision.expect("third zero-credit death trips the breaker");
844 let snapshot_at = trip_at + 5_000;
845 let durable_rows = breaker
846 .active_suspensions_for_root_at(&root.display().to_string(), snapshot_at)
847 .unwrap();
848 assert_eq!(durable_rows, vec![trip_decision.clone()]);
849 let suspension = &durable_rows[0];
850
851 let navigation = crate::commands::callgraph_store_adapter::suspended_response_at(
852 "surface-agreement",
853 "callers",
854 suspension,
855 snapshot_at,
856 );
857 assert_eq!(navigation.data["code"], "build_suspended");
858 assert_eq!(
859 navigation.data["message"],
860 "callers: build_suspended domain=callgraph_cold deaths=3 age_ms=5000 reason=zero_credit_death_limit; run doctor reset-build-breaker to resume"
861 );
862
863 let manager = crate::inspect::InspectManager::new();
864 manager.record_tier2_build_suspension_for_test(
865 crate::inspect::InspectCategory::DeadCode,
866 suspension.clone(),
867 );
868 assert_eq!(
869 manager.tier2_builder_state_detail_at(
870 crate::inspect::InspectCategory::DeadCode,
871 snapshot_at,
872 ),
873 "suspended domain=callgraph_cold deaths=3 age_s=5 reason=zero_credit_death_limit"
874 );
875
876 let config = crate::config::Config {
877 project_root: Some(root.clone()),
878 storage_dir: Some(storage.path().to_path_buf()),
879 ..crate::config::Config::default()
880 };
881 let context = crate::context::AppContext::new(
882 Box::new(crate::parser::TreeSitterProvider::new()),
883 config,
884 );
885 context.set_heavy_root_work_allowed(false);
888 assert_eq!(context.storage_dir(), storage.path());
889 context.refresh_build_suspensions_for_health_at(&root, Some(&project_key), snapshot_at);
890 let health = context.try_health_snapshot(&root);
891 assert_eq!(health.suspended_domains.len(), 1);
892 assert_eq!(health.suspended_domains[0].domain, "callgraph_cold");
893 assert_eq!(
894 health.suspended_domains[0].reason,
895 "zero_credit_death_limit"
896 );
897 assert_eq!(health.suspended_domains[0].death_count, 3);
898 assert_eq!(health.suspended_domains[0].age_s, 5);
899
900 assert!(breaker
901 .active_suspensions_for_root_at(&root.display().to_string(), trip_at + TRIP_TTL_MS + 5,)
902 .unwrap()
903 .is_empty());
904 assert!(matches!(
905 breaker
906 .admit_at(&key, 10, trip_at + TRIP_TTL_MS + 5)
907 .unwrap(),
908 BreakerAdmission::Admitted(_)
909 ));
910 }
911}