1use std::sync::Arc;
2use std::time::{Duration, Instant};
3
4use code_system_graph_model::stable_id;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9pub const DEFAULT_MAX_SCAN_WALL_TIME_MS: u64 = 21_600_000;
11pub const DEFAULT_MAX_NO_PROGRESS_TIME_MS: u64 = 300_000;
13pub const DEFAULT_MAX_CODEGRAPH_SYNC_WALL_TIME_MS_PER_REPO: u64 = 3_600_000;
15pub const DEFAULT_MAX_WORKER_MEMORY_BYTES: u64 = 17_179_869_184;
17pub const DEFAULT_GRACEFUL_TERMINATION_MS: u64 = 5_000;
19pub const DEFAULT_WATCH_IDLE_TIMEOUT_MS: u64 = 28_800_000;
21pub const DEFAULT_MAX_WATCH_SESSION_WALL_TIME_MS: u64 = 86_400_000;
23pub const DEFAULT_MIN_WATCH_RESCAN_INTERVAL_MS: u64 = 10_000;
25pub const DEFAULT_MAX_CHECKPOINT_CACHE_BYTES: u64 = 10_737_418_240;
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct ExecutionPolicyOverrides {
32 pub max_scan_wall_time_ms: Option<u64>,
34 pub max_no_progress_time_ms: Option<u64>,
36 #[serde(rename = "maxCodeGraphSyncWallTimeMsPerRepo")]
38 pub max_codegraph_sync_wall_time_ms_per_repo: Option<u64>,
39 pub max_worker_memory_bytes: Option<u64>,
41 pub graceful_termination_ms: Option<u64>,
43 pub watch_idle_timeout_ms: Option<u64>,
45 pub max_watch_session_wall_time_ms: Option<u64>,
47 pub min_watch_rescan_interval_ms: Option<u64>,
49 pub max_checkpoint_cache_bytes: Option<u64>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(rename_all = "camelCase")]
56pub struct ExecutionPolicy {
57 pub max_scan_wall_time_ms: u64,
59 pub max_no_progress_time_ms: u64,
61 #[serde(rename = "maxCodeGraphSyncWallTimeMsPerRepo")]
63 pub max_codegraph_sync_wall_time_ms_per_repo: u64,
64 pub max_worker_memory_bytes: u64,
66 pub graceful_termination_ms: u64,
68 pub watch_idle_timeout_ms: u64,
70 pub max_watch_session_wall_time_ms: u64,
72 pub min_watch_rescan_interval_ms: u64,
74 pub max_checkpoint_cache_bytes: u64,
76}
77
78impl Default for ExecutionPolicy {
79 fn default() -> Self {
80 Self {
81 max_scan_wall_time_ms: DEFAULT_MAX_SCAN_WALL_TIME_MS,
82 max_no_progress_time_ms: DEFAULT_MAX_NO_PROGRESS_TIME_MS,
83 max_codegraph_sync_wall_time_ms_per_repo:
84 DEFAULT_MAX_CODEGRAPH_SYNC_WALL_TIME_MS_PER_REPO,
85 max_worker_memory_bytes: DEFAULT_MAX_WORKER_MEMORY_BYTES,
86 graceful_termination_ms: DEFAULT_GRACEFUL_TERMINATION_MS,
87 watch_idle_timeout_ms: DEFAULT_WATCH_IDLE_TIMEOUT_MS,
88 max_watch_session_wall_time_ms: DEFAULT_MAX_WATCH_SESSION_WALL_TIME_MS,
89 min_watch_rescan_interval_ms: DEFAULT_MIN_WATCH_RESCAN_INTERVAL_MS,
90 max_checkpoint_cache_bytes: DEFAULT_MAX_CHECKPOINT_CACHE_BYTES,
91 }
92 }
93}
94
95impl ExecutionPolicy {
96 pub fn resolve(
102 overrides: Option<&ExecutionPolicyOverrides>,
103 ) -> Result<Self, InvalidExecutionPolicy> {
104 let mut policy = Self::default();
105 if let Some(values) = overrides {
106 macro_rules! apply {
107 ($field:ident) => {
108 if let Some(value) = values.$field {
109 policy.$field = value;
110 }
111 };
112 }
113 apply!(max_scan_wall_time_ms);
114 apply!(max_no_progress_time_ms);
115 apply!(max_codegraph_sync_wall_time_ms_per_repo);
116 apply!(max_worker_memory_bytes);
117 apply!(graceful_termination_ms);
118 apply!(watch_idle_timeout_ms);
119 apply!(max_watch_session_wall_time_ms);
120 apply!(min_watch_rescan_interval_ms);
121 apply!(max_checkpoint_cache_bytes);
122 }
123 policy.validate()?;
124 Ok(policy)
125 }
126
127 fn validate(&self) -> Result<(), InvalidExecutionPolicy> {
128 for (field, value) in self.canonical_values() {
129 let invalid_bytes = field.ends_with("Bytes") && usize::try_from(value).is_err();
130 let invalid_sqlite_quota =
131 field == "maxCheckpointCacheBytes" && i64::try_from(value).is_err();
132 let invalid_deadline = field.ends_with("Ms")
133 && Instant::now()
134 .checked_add(Duration::from_millis(value))
135 .is_none();
136 if value == 0 || invalid_bytes || invalid_sqlite_quota || invalid_deadline {
137 return Err(InvalidExecutionPolicy::InvalidValue { field, value });
138 }
139 }
140 Self::require_not_greater(
141 "maxNoProgressTimeMs",
142 self.max_no_progress_time_ms,
143 "maxScanWallTimeMs",
144 self.max_scan_wall_time_ms,
145 )?;
146 Self::require_not_greater(
147 "maxCodeGraphSyncWallTimeMsPerRepo",
148 self.max_codegraph_sync_wall_time_ms_per_repo,
149 "maxScanWallTimeMs",
150 self.max_scan_wall_time_ms,
151 )?;
152 Self::require_not_greater(
153 "gracefulTerminationMs",
154 self.graceful_termination_ms,
155 "maxNoProgressTimeMs",
156 self.max_no_progress_time_ms,
157 )?;
158 Self::require_not_greater(
159 "watchIdleTimeoutMs",
160 self.watch_idle_timeout_ms,
161 "maxWatchSessionWallTimeMs",
162 self.max_watch_session_wall_time_ms,
163 )?;
164 Self::require_not_greater(
165 "minWatchRescanIntervalMs",
166 self.min_watch_rescan_interval_ms,
167 "watchIdleTimeoutMs",
168 self.watch_idle_timeout_ms,
169 )
170 }
171
172 fn require_not_greater(
173 field: &'static str,
174 value: u64,
175 maximum_field: &'static str,
176 maximum: u64,
177 ) -> Result<(), InvalidExecutionPolicy> {
178 if value > maximum {
179 return Err(InvalidExecutionPolicy::InvalidRelationship {
180 field,
181 value,
182 maximum_field,
183 maximum,
184 });
185 }
186 Ok(())
187 }
188
189 fn canonical_values(&self) -> [(&'static str, u64); 9] {
190 [
191 ("maxScanWallTimeMs", self.max_scan_wall_time_ms),
192 ("maxNoProgressTimeMs", self.max_no_progress_time_ms),
193 (
194 "maxCodeGraphSyncWallTimeMsPerRepo",
195 self.max_codegraph_sync_wall_time_ms_per_repo,
196 ),
197 ("maxWorkerMemoryBytes", self.max_worker_memory_bytes),
198 ("gracefulTerminationMs", self.graceful_termination_ms),
199 ("watchIdleTimeoutMs", self.watch_idle_timeout_ms),
200 (
201 "maxWatchSessionWallTimeMs",
202 self.max_watch_session_wall_time_ms,
203 ),
204 (
205 "minWatchRescanIntervalMs",
206 self.min_watch_rescan_interval_ms,
207 ),
208 ("maxCheckpointCacheBytes", self.max_checkpoint_cache_bytes),
209 ]
210 }
211
212 #[must_use]
214 pub fn fingerprint(&self) -> String {
215 let canonical = self
216 .canonical_values()
217 .into_iter()
218 .map(|(name, value)| format!("{name}={value}"))
219 .collect::<Vec<_>>()
220 .join(";");
221 stable_id("execution-policy", &canonical)
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Error)]
227pub enum InvalidExecutionPolicy {
228 #[error("execution policy `{field}` must be positive and representable; received {value}")]
230 InvalidValue {
231 field: &'static str,
233 value: u64,
235 },
236 #[error("execution policy `{field}` ({value}) must not exceed `{maximum_field}` ({maximum})")]
238 InvalidRelationship {
239 field: &'static str,
241 value: u64,
243 maximum_field: &'static str,
245 maximum: u64,
247 },
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
252#[serde(rename_all = "snake_case")]
253pub enum JobPhase {
254 Configuration,
256 Discovery,
258 Fingerprinting,
260 Extraction,
262 GraphAssembly,
264 Communities,
266 Publication,
268 CodeGraphSync,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274#[serde(rename_all = "snake_case")]
275pub enum ExecutionResource {
276 WallTimeMs,
278 NoProgressTimeMs,
280 WorkerMemoryBytes,
282 WorkUnits,
284 WorkerProcess,
286 WorkerProtocolBytes,
288 Cancellation,
290}
291
292#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
294#[serde(rename_all = "camelCase")]
295pub struct ExecutionSummary {
296 pub run_id: String,
298 pub duration_ms: u64,
300 pub peak_worker_memory_bytes: u64,
302 pub completed_work_units: u64,
304 pub checkpoint_hits: u64,
306 pub checkpoints_written: u64,
308 pub measured_artifacts: u64,
310 pub artifact_duration_p50_ms: u64,
312 pub artifact_duration_p95_ms: u64,
314 pub artifact_duration_p99_ms: u64,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Error)]
320#[error(
321 "execution `{run_id}` exceeded {resource:?} during {phase:?}: observed {observed}, maximum {maximum}, completed {completed_units} units"
322)]
323pub struct ExecutionLimitExceeded {
324 pub run_id: String,
326 pub phase: JobPhase,
328 pub resource: ExecutionResource,
330 pub observed: u64,
332 pub maximum: u64,
334 pub completed_units: u64,
336}
337
338pub trait MonotonicClock: std::fmt::Debug + Send + Sync {
340 fn now(&self) -> Duration;
342}
343
344#[derive(Debug)]
345struct SystemMonotonicClock {
346 origin: Instant,
347}
348
349impl SystemMonotonicClock {
350 fn new() -> Self {
351 Self {
352 origin: Instant::now(),
353 }
354 }
355}
356
357impl MonotonicClock for SystemMonotonicClock {
358 fn now(&self) -> Duration {
359 self.origin.elapsed()
360 }
361}
362
363#[derive(Debug)]
365pub struct ScanJobTracker {
366 run_id: String,
367 policy: ExecutionPolicy,
368 clock: Arc<dyn MonotonicClock>,
369 started: Duration,
370 last_progress: Duration,
371 phase: JobPhase,
372 completed_units: u64,
373}
374
375impl ScanJobTracker {
376 #[must_use]
378 pub fn new(run_id: impl Into<String>, policy: ExecutionPolicy) -> Self {
379 Self::with_clock(run_id, policy, Arc::new(SystemMonotonicClock::new()))
380 }
381
382 #[must_use]
384 pub fn with_clock(
385 run_id: impl Into<String>,
386 policy: ExecutionPolicy,
387 clock: Arc<dyn MonotonicClock>,
388 ) -> Self {
389 let now = clock.now();
390 Self {
391 run_id: run_id.into(),
392 policy,
393 clock,
394 started: now,
395 last_progress: now,
396 phase: JobPhase::Configuration,
397 completed_units: 0,
398 }
399 }
400
401 pub fn enter_phase(&mut self, phase: JobPhase) -> Result<(), ExecutionLimitExceeded> {
407 self.check_time()?;
408 self.phase = phase;
409 Ok(())
410 }
411
412 pub fn progress(&mut self, amount: u64) -> Result<(), ExecutionLimitExceeded> {
418 let completed_units = self
419 .completed_units
420 .checked_add(amount)
421 .ok_or_else(|| self.exceeded(ExecutionResource::WorkUnits, u64::MAX, u64::MAX - 1))?;
422 self.check_time()?;
423 self.completed_units = completed_units;
424 self.last_progress = self.clock.now();
425 Ok(())
426 }
427
428 pub fn check_time(&self) -> Result<(), ExecutionLimitExceeded> {
434 let now = self.clock.now();
435 self.check_duration(
436 ExecutionResource::WallTimeMs,
437 now.saturating_sub(self.started),
438 self.policy.max_scan_wall_time_ms,
439 )?;
440 self.check_duration(
441 ExecutionResource::NoProgressTimeMs,
442 now.saturating_sub(self.last_progress),
443 self.policy.max_no_progress_time_ms,
444 )
445 }
446
447 fn check_duration(
448 &self,
449 resource: ExecutionResource,
450 observed: Duration,
451 maximum: u64,
452 ) -> Result<(), ExecutionLimitExceeded> {
453 let observed = u64::try_from(observed.as_millis()).unwrap_or(u64::MAX);
454 if observed > maximum {
455 return Err(self.exceeded(resource, observed, maximum));
456 }
457 Ok(())
458 }
459
460 fn exceeded(
461 &self,
462 resource: ExecutionResource,
463 observed: u64,
464 maximum: u64,
465 ) -> ExecutionLimitExceeded {
466 ExecutionLimitExceeded {
467 run_id: self.run_id.clone(),
468 phase: self.phase,
469 resource,
470 observed,
471 maximum,
472 completed_units: self.completed_units,
473 }
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use std::sync::atomic::{AtomicU64, Ordering};
480
481 use super::*;
482
483 #[derive(Debug, Default)]
484 struct FakeClock {
485 milliseconds: AtomicU64,
486 }
487
488 impl FakeClock {
489 fn advance(&self, milliseconds: u64) {
490 self.milliseconds.fetch_add(milliseconds, Ordering::Relaxed);
491 }
492 }
493
494 impl MonotonicClock for FakeClock {
495 fn now(&self) -> Duration {
496 Duration::from_millis(self.milliseconds.load(Ordering::Relaxed))
497 }
498 }
499
500 #[test]
501 fn defaults_should_be_generous_and_finite() {
502 let policy = ExecutionPolicy::default();
503
504 assert_eq!(policy.max_scan_wall_time_ms, 21_600_000);
505 assert_eq!(policy.max_worker_memory_bytes, 17_179_869_184);
506 assert_eq!(policy.max_checkpoint_cache_bytes, 10_737_418_240);
507 }
508
509 #[test]
510 fn partial_override_should_preserve_other_defaults() {
511 let policy = ExecutionPolicy::resolve(Some(&ExecutionPolicyOverrides {
512 max_scan_wall_time_ms: Some(28_800_000),
513 ..ExecutionPolicyOverrides::default()
514 }))
515 .expect("valid override");
516
517 assert_eq!(policy.max_scan_wall_time_ms, 28_800_000);
518 assert_eq!(policy.max_no_progress_time_ms, 300_000);
519 }
520
521 #[test]
522 fn zero_should_be_rejected() {
523 let error = ExecutionPolicy::resolve(Some(&ExecutionPolicyOverrides {
524 max_worker_memory_bytes: Some(0),
525 ..ExecutionPolicyOverrides::default()
526 }))
527 .expect_err("zero must fail");
528
529 assert!(matches!(
530 error,
531 InvalidExecutionPolicy::InvalidValue {
532 field: "maxWorkerMemoryBytes",
533 value: 0
534 }
535 ));
536 }
537
538 #[test]
539 fn technically_unrepresentable_values_should_be_rejected() {
540 let quota = ExecutionPolicy::resolve(Some(&ExecutionPolicyOverrides {
541 max_checkpoint_cache_bytes: Some(u64::MAX),
542 ..ExecutionPolicyOverrides::default()
543 }))
544 .expect_err("SQLite quota overflow must fail");
545
546 assert!(matches!(quota, InvalidExecutionPolicy::InvalidValue { .. }));
547 }
548
549 #[test]
550 fn subordinate_deadline_should_not_exceed_scan_deadline() {
551 let error = ExecutionPolicy::resolve(Some(&ExecutionPolicyOverrides {
552 max_scan_wall_time_ms: Some(1_000),
553 max_no_progress_time_ms: Some(1_001),
554 max_codegraph_sync_wall_time_ms_per_repo: Some(1_000),
555 graceful_termination_ms: Some(500),
556 ..ExecutionPolicyOverrides::default()
557 }))
558 .expect_err("relationship must fail");
559
560 assert!(matches!(
561 error,
562 InvalidExecutionPolicy::InvalidRelationship {
563 field: "maxNoProgressTimeMs",
564 ..
565 }
566 ));
567 }
568
569 #[test]
570 fn fingerprint_should_ignore_yaml_field_order() {
571 let first = ExecutionPolicy::default();
572 let second = ExecutionPolicy::resolve(Some(&ExecutionPolicyOverrides::default()))
573 .expect("defaults valid");
574
575 assert_eq!(first.fingerprint(), second.fingerprint());
576 }
577
578 #[test]
579 fn injected_clock_should_accept_exact_deadline_and_reject_one_unit_over() {
580 let clock = Arc::new(FakeClock::default());
581 let policy = ExecutionPolicy {
582 max_scan_wall_time_ms: 10,
583 max_no_progress_time_ms: 10,
584 ..ExecutionPolicy::default()
585 };
586 let tracker = ScanJobTracker::with_clock("run", policy, clock.clone());
587
588 clock.advance(10);
589 tracker.check_time().expect("exact deadline is inclusive");
590 clock.advance(1);
591 let error = tracker.check_time().expect_err("one over must fail");
592
593 assert_eq!(error.resource, ExecutionResource::WallTimeMs);
594 assert_eq!(error.observed, 11);
595 assert_eq!(error.maximum, 10);
596 }
597
598 #[test]
599 fn phase_changes_should_not_fake_progress() {
600 let clock = Arc::new(FakeClock::default());
601 let policy = ExecutionPolicy {
602 max_scan_wall_time_ms: 100,
603 max_no_progress_time_ms: 5,
604 ..ExecutionPolicy::default()
605 };
606 let mut tracker = ScanJobTracker::with_clock("run", policy, clock.clone());
607
608 clock.advance(5);
609 tracker
610 .enter_phase(JobPhase::Discovery)
611 .expect("exact idle deadline is inclusive");
612 clock.advance(1);
613 let error = tracker
614 .enter_phase(JobPhase::Fingerprinting)
615 .expect_err("phase churn must not renew watchdog");
616
617 assert_eq!(error.resource, ExecutionResource::NoProgressTimeMs);
618 }
619}