use std::sync::Mutex;
use std::time::{Duration, Instant};
use super::debt::{self, DebtLevel, MaintenanceDebt};
use super::state::{DaemonState, MutationGuard};
pub(crate) const RETRYABLE_HARD_DEBT: &str =
"maintenance debt is Hard; mutation timed out waiting for maintenance -- retry shortly";
pub(super) async fn acquire(
state: &DaemonState,
corpus: &str,
) -> Result<MutationGuard, &'static str> {
let daemon_cfg = &state.baseline().daemon;
let maintenance_disabled = daemon_cfg.maintenance_interval_secs == 0;
gate(
|| probe_admitting_hard_debt_without_maintenance(state, maintenance_disabled),
Duration::from_millis(daemon_cfg.debt_soft_delay_ms),
Duration::from_secs(daemon_cfg.hard_block_wait_secs),
Duration::from_secs(daemon_cfg.debt_cache_ttl_secs.max(1)),
)
.await?;
let corpus_guard = state.lock_corpus(corpus).await;
let permit = state
.write_lane()
.acquire_owned()
.await
.map_err(|_| "write lane closed")?;
Ok(MutationGuard::new(permit, corpus_guard))
}
async fn probe_admitting_hard_debt_without_maintenance(
state: &DaemonState,
maintenance_disabled: bool,
) -> DebtLevel {
let level = observed_level(state).await;
if level == DebtLevel::Hard && maintenance_disabled {
if HARD_DEBT_NO_MAINTENANCE_WARN.should_log() {
tracing::warn!(
target: "hallouminate::daemon",
"maintenance debt is Hard but automatic maintenance is disabled \
(daemon.maintenance_interval_secs = 0); admitting the mutation \
instead of blocking",
);
}
return DebtLevel::Soft;
}
level
}
async fn observed_level(state: &DaemonState) -> DebtLevel {
let daemon_cfg = &state.baseline().daemon;
let ttl = Duration::from_secs(daemon_cfg.debt_cache_ttl_secs);
if let Some(level) = debt::OBSERVED.fresh_level(ttl) {
return level;
}
refresh_observed(state).await
}
const WARN_WINDOW: Duration = Duration::from_secs(60);
struct WarnThrottle(Mutex<Option<Instant>>);
impl WarnThrottle {
const fn new() -> Self {
Self(Mutex::new(None))
}
fn should_log(&self) -> bool {
let mut last = self.0.lock().expect("warn throttle lock");
let now = Instant::now();
let should_log = last.is_none_or(|at| now.duration_since(at) >= WARN_WINDOW);
if should_log {
*last = Some(now);
}
should_log
}
}
static DEBT_READ_FAILURE_WARN: WarnThrottle = WarnThrottle::new();
static HARD_DEBT_NO_MAINTENANCE_WARN: WarnThrottle = WarnThrottle::new();
static HARD_DEBT_BLOCK_WARN: WarnThrottle = WarnThrottle::new();
pub(super) async fn refresh_observed(state: &DaemonState) -> DebtLevel {
let daemon_cfg = &state.baseline().daemon;
match state.store().debt().await {
Ok(lance_debt) => {
let level = debt::classify(
&MaintenanceDebt {
fragments: lance_debt.fragments,
stale_versions: lance_debt.stale_versions,
},
daemon_cfg,
);
debt::OBSERVED.record(level);
level
}
Err(error) => {
if DEBT_READ_FAILURE_WARN.should_log() {
tracing::warn!(
target: "hallouminate::daemon",
error = %error,
"debt read failed; falling open on this observation",
);
}
DebtLevel::Ok
}
}
}
async fn gate<P, Fut>(
mut probe: P,
soft_delay: Duration,
hard_wait: Duration,
poll: Duration,
) -> Result<(), &'static str>
where
P: FnMut() -> Fut,
Fut: std::future::Future<Output = DebtLevel>,
{
match probe().await {
DebtLevel::Ok => Ok(()),
DebtLevel::Soft => {
tokio::time::sleep(soft_delay).await;
Ok(())
}
DebtLevel::Hard => {
if HARD_DEBT_BLOCK_WARN.should_log() {
tracing::warn!(
target: "hallouminate::daemon",
hard_block_wait_secs = hard_wait.as_secs(),
"maintenance debt is Hard; blocking mutation until maintenance catches up",
);
}
let deadline = tokio::time::Instant::now() + hard_wait;
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(RETRYABLE_HARD_DEBT);
}
tokio::time::sleep_until(std::cmp::min(now + poll, deadline)).await;
if probe().await != DebtLevel::Hard {
tracing::debug!(
target: "hallouminate::daemon",
"maintenance debt dropped below Hard; mutation unblocked",
);
return Ok(());
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant as StdInstant;
use super::debt::DebtCache;
use super::*;
use hallouminate_config::{Config, DaemonConfig};
const SOFT_DELAY: Duration = Duration::from_millis(250);
const HARD_WAIT: Duration = Duration::from_secs(30);
const POLL: Duration = Duration::from_secs(5);
fn signals(fragments: u64, stale_versions: u64) -> MaintenanceDebt {
MaintenanceDebt {
fragments,
stale_versions,
}
}
#[test]
fn classify_below_both_soft_thresholds_is_ok() {
let cfg = DaemonConfig::default();
assert_eq!(debt::classify(&signals(99, 49), &cfg), DebtLevel::Ok);
}
#[test]
fn classify_reaching_either_soft_threshold_is_soft() {
let cfg = DaemonConfig::default();
assert_eq!(debt::classify(&signals(100, 0), &cfg), DebtLevel::Soft);
assert_eq!(debt::classify(&signals(0, 50), &cfg), DebtLevel::Soft);
assert_eq!(
debt::classify(&signals(499, 249), &cfg),
DebtLevel::Soft,
"just under both hard thresholds must stay Soft",
);
}
#[test]
fn classify_reaching_either_hard_threshold_is_hard() {
let cfg = DaemonConfig::default();
assert_eq!(debt::classify(&signals(500, 0), &cfg), DebtLevel::Hard);
assert_eq!(
debt::classify(&signals(0, 250), &cfg),
DebtLevel::Hard,
"the worse signal wins even when the other is below Soft",
);
}
#[test]
fn cache_serves_recorded_level_within_ttl_and_expires_at_ttl() {
let cache = DebtCache::new();
let base = StdInstant::now();
cache.record_at(DebtLevel::Hard, base);
let ttl = Duration::from_secs(5);
assert_eq!(
cache.fresh_level_at(ttl, base + Duration::from_millis(4_999)),
Some(DebtLevel::Hard),
);
assert_eq!(
cache.fresh_level_at(ttl, base + ttl),
None,
"a reading exactly ttl old must trigger a re-read",
);
}
#[test]
fn cache_ttl_zero_disables_caching() {
let cache = DebtCache::new();
let base = StdInstant::now();
cache.record_at(DebtLevel::Soft, base);
assert_eq!(cache.fresh_level_at(Duration::ZERO, base), None);
}
#[test]
fn cache_level_defaults_ok_and_outlives_ttl_expiry() {
let cache = DebtCache::new();
assert_eq!(
cache.level(),
DebtLevel::Ok,
"no observation yet must read as Ok, not stall anything",
);
let base = StdInstant::now();
cache.record_at(DebtLevel::Hard, base);
assert_eq!(
cache.fresh_level_at(Duration::from_secs(5), base + Duration::from_secs(60)),
None,
);
assert_eq!(
cache.level(),
DebtLevel::Hard,
"the maintenance loop keys forced runs on the last observation regardless of cache age",
);
}
#[test]
fn recorded_observation_reaches_the_maintenance_loops_level_read() {
debt::OBSERVED.record(DebtLevel::Soft);
assert_eq!(debt::level(), DebtLevel::Soft);
}
#[tokio::test(start_paused = true)]
async fn ok_debt_admits_the_mutation_without_delay() {
let started = tokio::time::Instant::now();
gate(|| async { DebtLevel::Ok }, SOFT_DELAY, HARD_WAIT, POLL)
.await
.expect("Ok debt admits");
assert_eq!(
tokio::time::Instant::now(),
started,
"no debt must cost no time",
);
}
#[tokio::test(start_paused = true)]
async fn soft_debt_charges_the_per_mutation_delay() {
let started = tokio::time::Instant::now();
gate(|| async { DebtLevel::Soft }, SOFT_DELAY, HARD_WAIT, POLL)
.await
.expect("Soft debt admits after the delay");
assert_eq!(tokio::time::Instant::now() - started, SOFT_DELAY);
}
#[tokio::test(start_paused = true)]
async fn hard_debt_fails_retryable_after_exactly_the_bounded_wait() {
let started = tokio::time::Instant::now();
let err = gate(|| async { DebtLevel::Hard }, SOFT_DELAY, HARD_WAIT, POLL)
.await
.expect_err("unrelieved Hard debt must not admit the mutation");
assert_eq!(err, RETRYABLE_HARD_DEBT);
assert_eq!(
tokio::time::Instant::now() - started,
HARD_WAIT,
"the block must be bounded at hard_block_wait, not indefinite",
);
}
#[tokio::test(start_paused = true)]
async fn hard_debt_unblocks_as_soon_as_a_poll_sees_the_level_drop() {
let probes = AtomicUsize::new(0);
let probe = || {
let n = probes.fetch_add(1, Ordering::SeqCst);
async move {
if n < 3 {
DebtLevel::Hard
} else {
DebtLevel::Ok
}
}
};
let started = tokio::time::Instant::now();
gate(probe, SOFT_DELAY, HARD_WAIT, POLL)
.await
.expect("mutation admitted once maintenance pays the debt down");
assert_eq!(
tokio::time::Instant::now() - started,
3 * POLL,
"unblocks at the first poll observing the drop, well before the bound",
);
}
#[tokio::test(start_paused = true)]
async fn hard_debt_gets_a_final_probe_at_the_deadline() {
let probes = AtomicUsize::new(0);
let probe = || {
let n = probes.fetch_add(1, Ordering::SeqCst);
async move {
if n == 0 {
DebtLevel::Hard
} else {
DebtLevel::Ok
}
}
};
let short_wait = Duration::from_secs(2);
let started = tokio::time::Instant::now();
gate(probe, SOFT_DELAY, short_wait, POLL)
.await
.expect("a level drop at the deadline still admits the mutation");
assert_eq!(tokio::time::Instant::now() - started, short_wait);
}
async fn open_state(tmp: &tempfile::TempDir, daemon: DaemonConfig) -> DaemonState {
let mut cfg = Config::default();
cfg.embeddings.enabled = false;
cfg.storage.ground_dir = tmp.path().to_string_lossy().into_owned();
cfg.daemon = daemon;
DaemonState::open(cfg, None)
.await
.expect("open daemon state")
}
#[tokio::test]
async fn acquire_on_a_fresh_store_classifies_ok_and_grants_the_guard_promptly() {
let tmp = tempfile::tempdir().expect("tempdir");
let daemon = DaemonConfig {
debt_cache_ttl_secs: 0,
debt_soft_delay_ms: 10_000,
..DaemonConfig::default()
};
let state = open_state(&tmp, daemon).await;
let started = StdInstant::now();
let _guard = state
.acquire_mutation_guard("wiki")
.await
.expect("fresh store must not be backpressured");
assert!(
started.elapsed() < Duration::from_secs(5),
"an Ok-debt mutation paid a delay it must not pay",
);
}
#[tokio::test]
async fn acquire_charges_the_configured_soft_delay_when_debt_is_soft() {
let tmp = tempfile::tempdir().expect("tempdir");
let daemon = DaemonConfig {
debt_soft_fragments: 0,
debt_cache_ttl_secs: 0,
debt_soft_delay_ms: 250,
..DaemonConfig::default()
};
let state = open_state(&tmp, daemon).await;
let started = StdInstant::now();
let _guard = state
.acquire_mutation_guard("wiki")
.await
.expect("Soft debt admits after the delay");
assert!(
started.elapsed() >= Duration::from_millis(250),
"Soft debt must charge debt_soft_delay_ms per mutation",
);
}
#[tokio::test]
async fn acquire_admits_promptly_when_hard_debt_and_maintenance_disabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let _coord = debt::OBSERVED_HARD_COORD.write().await;
let daemon = DaemonConfig {
maintenance_interval_secs: 0,
debt_hard_fragments: 0,
debt_hard_stale_versions: 0,
debt_cache_ttl_secs: 0,
debt_soft_delay_ms: 10,
hard_block_wait_secs: 30,
..DaemonConfig::default()
};
let state = open_state(&tmp, daemon).await;
let started = StdInstant::now();
let _guard = state
.acquire_mutation_guard("wiki")
.await
.expect("Hard debt with maintenance disabled must not soft-lock the mutation");
debt::OBSERVED.record(DebtLevel::Ok);
assert!(
started.elapsed() < Duration::from_secs(5),
"must admit well under hard_block_wait_secs, not block for it; got {:?}",
started.elapsed(),
);
}
}