use std::time::{Duration, Instant};
pub const TIER2_REFRESH_DEBOUNCE: Duration = Duration::from_secs(45);
pub const TIER2_REFRESH_MAX_STALENESS: Duration = Duration::from_secs(10 * 60);
pub const TIER2_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(5 * 60);
pub const TIER2_REFRESH_COLD_CACHE_DELAY: Duration = Duration::from_secs(90);
pub const TIER2_REFRESH_STORM_DEBOUNCE: Duration = Duration::from_secs(120);
pub const TIER2_REFRESH_STORM_PATH_THRESHOLD: usize = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier2TriggerReason {
Debounce,
Ceiling,
Pull,
ConfigureWarm,
}
impl Tier2TriggerReason {
pub fn as_str(self) -> &'static str {
match self {
Self::Debounce => "debounce",
Self::Ceiling => "ceiling",
Self::Pull => "pull",
Self::ConfigureWarm => "configure_warm",
}
}
}
#[derive(Debug, Clone)]
pub struct Tier2RefreshScheduler {
configured_at: Option<Instant>,
last_change_at: Option<Instant>,
activity_started_at: Option<Instant>,
debounce_delay: Duration,
last_scan_started_at: Option<Instant>,
pull_demand_pending: bool,
configure_warm_pending: bool,
last_trigger_reason: Option<Tier2TriggerReason>,
}
impl Tier2RefreshScheduler {
pub fn new() -> Self {
Self {
configured_at: None,
last_change_at: None,
activity_started_at: None,
debounce_delay: TIER2_REFRESH_DEBOUNCE,
last_scan_started_at: None,
pull_demand_pending: false,
configure_warm_pending: false,
last_trigger_reason: None,
}
}
pub fn reset_after_configure(&mut self, now: Instant) {
self.configured_at = Some(now);
self.last_change_at = None;
self.activity_started_at = None;
self.debounce_delay = TIER2_REFRESH_DEBOUNCE;
self.last_scan_started_at = None;
self.pull_demand_pending = false;
self.configure_warm_pending = true;
self.last_trigger_reason = None;
}
pub fn request_pull(&mut self, can_write: bool) -> bool {
if !can_write {
return false;
}
self.pull_demand_pending = true;
true
}
pub fn tick(
&mut self,
now: Instant,
changed_path_count: usize,
can_write: bool,
in_flight: bool,
) -> Option<Tier2TriggerReason> {
if changed_path_count > 0 {
self.record_changes(now, changed_path_count);
}
if !can_write || in_flight || !self.min_interval_elapsed(now) {
return None;
}
if self.pull_demand_pending {
return Some(self.record_scan_start(now, Tier2TriggerReason::Pull));
}
let cold_delay_elapsed = self.cold_delay_elapsed(now);
if cold_delay_elapsed {
if self.ceiling_elapsed(now) {
return Some(self.record_scan_start(now, Tier2TriggerReason::Ceiling));
}
if self.debounce_elapsed(now) {
return Some(self.record_scan_start(now, Tier2TriggerReason::Debounce));
}
if self.configure_warm_pending && self.last_change_at.is_none() {
return Some(self.record_scan_start(now, Tier2TriggerReason::ConfigureWarm));
}
}
None
}
pub fn note_external_scan_started(&mut self, now: Instant) {
self.last_scan_started_at = Some(now);
self.pull_demand_pending = false;
self.configure_warm_pending = false;
self.clear_activity_window();
}
pub fn last_trigger_reason(&self) -> Option<Tier2TriggerReason> {
self.last_trigger_reason
}
pub fn pull_demand_pending(&self) -> bool {
self.pull_demand_pending
}
fn record_changes(&mut self, now: Instant, changed_path_count: usize) {
if self.activity_started_at.is_none() {
self.activity_started_at = Some(now);
self.debounce_delay = TIER2_REFRESH_DEBOUNCE;
}
self.last_change_at = Some(now);
if changed_path_count > TIER2_REFRESH_STORM_PATH_THRESHOLD {
self.debounce_delay = self.debounce_delay.max(TIER2_REFRESH_STORM_DEBOUNCE);
}
}
fn min_interval_elapsed(&self, now: Instant) -> bool {
self.last_scan_started_at
.map(|started| elapsed_since(now, started) >= TIER2_REFRESH_MIN_INTERVAL)
.unwrap_or(true)
}
fn cold_delay_elapsed(&self, now: Instant) -> bool {
self.last_scan_started_at.is_some()
|| self
.configured_at
.map(|configured| elapsed_since(now, configured) >= TIER2_REFRESH_COLD_CACHE_DELAY)
.unwrap_or(false)
}
fn ceiling_elapsed(&self, now: Instant) -> bool {
self.activity_started_at
.map(|started| elapsed_since(now, started) >= TIER2_REFRESH_MAX_STALENESS)
.unwrap_or(false)
}
fn debounce_elapsed(&self, now: Instant) -> bool {
self.last_change_at
.map(|changed| elapsed_since(now, changed) >= self.debounce_delay)
.unwrap_or(false)
}
fn record_scan_start(
&mut self,
now: Instant,
reason: Tier2TriggerReason,
) -> Tier2TriggerReason {
self.last_scan_started_at = Some(now);
self.pull_demand_pending = false;
self.configure_warm_pending = false;
self.last_trigger_reason = Some(reason);
self.clear_activity_window();
reason
}
fn clear_activity_window(&mut self) {
self.last_change_at = None;
self.activity_started_at = None;
self.debounce_delay = TIER2_REFRESH_DEBOUNCE;
}
}
impl Default for Tier2RefreshScheduler {
fn default() -> Self {
Self::new()
}
}
fn elapsed_since(now: Instant, earlier: Instant) -> Duration {
now.checked_duration_since(earlier)
.unwrap_or(Duration::ZERO)
}
#[cfg(test)]
mod tests {
use super::*;
fn configured_scheduler() -> (Tier2RefreshScheduler, Instant) {
let base = Instant::now();
let mut scheduler = Tier2RefreshScheduler::new();
scheduler.reset_after_configure(base);
(scheduler, base)
}
#[test]
fn debounce_resets_on_each_change() {
let (mut scheduler, base) = configured_scheduler();
let warm = base + TIER2_REFRESH_COLD_CACHE_DELAY;
assert_eq!(scheduler.tick(warm, 1, true, false), None);
assert_eq!(
scheduler.tick(
warm + TIER2_REFRESH_DEBOUNCE - Duration::from_secs(1),
1,
true,
false
),
None
);
assert_eq!(
scheduler.tick(warm + TIER2_REFRESH_DEBOUNCE, 0, true, false),
None,
"second change should reset the debounce deadline"
);
assert_eq!(
scheduler.tick(
warm + TIER2_REFRESH_DEBOUNCE + TIER2_REFRESH_DEBOUNCE,
0,
true,
false,
),
Some(Tier2TriggerReason::Debounce)
);
}
#[test]
fn ceiling_fires_during_continuous_activity() {
let (mut scheduler, base) = configured_scheduler();
let start = base + TIER2_REFRESH_COLD_CACHE_DELAY;
assert_eq!(scheduler.tick(start, 1, true, false), None);
let mut now = start;
while now < start + TIER2_REFRESH_MAX_STALENESS {
now += Duration::from_secs(30);
let changed_paths = if now < start + TIER2_REFRESH_MAX_STALENESS {
1
} else {
0
};
let decision = scheduler.tick(now, changed_paths, true, false);
if now < start + TIER2_REFRESH_MAX_STALENESS {
assert_eq!(decision, None);
} else {
assert_eq!(decision, Some(Tier2TriggerReason::Ceiling));
}
}
}
#[test]
fn min_interval_throttles_second_scan() {
let (mut scheduler, base) = configured_scheduler();
let first = base + TIER2_REFRESH_COLD_CACHE_DELAY;
assert_eq!(
scheduler.tick(first, 0, true, false),
Some(Tier2TriggerReason::ConfigureWarm)
);
let change = first + Duration::from_secs(1);
assert_eq!(scheduler.tick(change, 1, true, false), None);
assert_eq!(
scheduler.tick(change + TIER2_REFRESH_DEBOUNCE, 0, true, false),
None,
"min interval should throttle scans inside five minutes"
);
assert_eq!(
scheduler.tick(first + TIER2_REFRESH_MIN_INTERVAL, 0, true, false),
Some(Tier2TriggerReason::Debounce)
);
}
#[test]
fn storm_extends_debounce_window() {
let (mut scheduler, base) = configured_scheduler();
let warm = base + TIER2_REFRESH_COLD_CACHE_DELAY;
assert_eq!(
scheduler.tick(warm, TIER2_REFRESH_STORM_PATH_THRESHOLD + 1, true, false),
None
);
assert_eq!(
scheduler.tick(
warm + TIER2_REFRESH_STORM_DEBOUNCE - Duration::from_secs(1),
0,
true,
false
),
None
);
assert_eq!(
scheduler.tick(warm + TIER2_REFRESH_STORM_DEBOUNCE, 0, true, false),
Some(Tier2TriggerReason::Debounce)
);
}
#[test]
fn worktree_bridge_never_schedules_write() {
let (mut scheduler, base) = configured_scheduler();
let warm = base + TIER2_REFRESH_COLD_CACHE_DELAY;
assert_eq!(scheduler.tick(warm, 1, false, false), None);
assert_eq!(
scheduler.tick(warm + TIER2_REFRESH_MAX_STALENESS, 0, false, false),
None
);
assert!(!scheduler.request_pull(false));
assert_eq!(
scheduler.tick(warm + TIER2_REFRESH_MAX_STALENESS * 2, 0, false, false),
None
);
}
#[test]
fn pull_demand_sets_but_respects_min_interval_and_in_flight() {
let (mut scheduler, base) = configured_scheduler();
let first = base + TIER2_REFRESH_COLD_CACHE_DELAY;
assert_eq!(
scheduler.tick(first, 0, true, false),
Some(Tier2TriggerReason::ConfigureWarm)
);
assert!(scheduler.request_pull(true));
assert!(scheduler.pull_demand_pending());
assert_eq!(
scheduler.tick(first + Duration::from_secs(60), 0, true, false),
None,
"pull demand should wait for the min interval"
);
assert!(scheduler.pull_demand_pending());
assert_eq!(
scheduler.tick(first + TIER2_REFRESH_MIN_INTERVAL, 0, true, true),
None,
"pull demand should wait for in-flight tier2 work to finish"
);
assert!(scheduler.pull_demand_pending());
assert_eq!(
scheduler.tick(first + TIER2_REFRESH_MIN_INTERVAL, 0, true, false),
Some(Tier2TriggerReason::Pull)
);
}
}