use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use tokio::sync::watch;
use super::supervisor_config::parse_env;
use super::{EmbedderClient, StdioEmbedderClient};
use crate::embedder_client::EmbedderError;
#[path = "supervisor_recycle_swap.rs"]
mod swap;
pub(crate) use swap::{RecycleCtx, RecycleOutcome, perform_recycle};
const FOOTPRINT_UNREADABLE_LIMIT: u32 = 3;
const MIN_CHECK_INTERVAL: Duration = Duration::from_millis(10);
const MAX_BACKOFF: Duration = Duration::from_secs(3600);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RecycleConfig {
pub footprint_ceiling_mb: u64,
pub max_age_secs: u64,
pub max_calls: u64,
pub check_interval: Duration,
pub drain_secs: u64,
}
impl Default for RecycleConfig {
fn default() -> Self {
Self {
footprint_ceiling_mb: 4096,
max_age_secs: 21_600,
max_calls: 100_000,
check_interval: Duration::from_secs(30),
drain_secs: 60,
}
}
}
impl RecycleConfig {
pub fn from_env() -> Self {
let def = Self::default();
let check_secs: u64 = parse_env(
"TRUSTY_EMBEDDERD_RECYCLE_CHECK_SECS",
def.check_interval.as_secs(),
);
Self {
footprint_ceiling_mb: parse_env(
"TRUSTY_EMBEDDERD_RECYCLE_FOOTPRINT_MB",
def.footprint_ceiling_mb,
),
max_age_secs: parse_env("TRUSTY_EMBEDDERD_RECYCLE_MAX_AGE_SECS", def.max_age_secs),
max_calls: parse_env("TRUSTY_EMBEDDERD_RECYCLE_MAX_CALLS", def.max_calls),
check_interval: Duration::from_secs(check_secs.max(1)),
drain_secs: parse_env("TRUSTY_EMBEDDERD_RECYCLE_DRAIN_SECS", def.drain_secs),
}
}
pub fn enabled(&self) -> bool {
self.footprint_ceiling_mb > 0 || self.max_age_secs > 0 || self.max_calls > 0
}
pub fn effective_check_interval(&self) -> Duration {
self.check_interval.max(MIN_CHECK_INTERVAL)
}
}
pub type FootprintSampler = Arc<dyn Fn(u32) -> Option<u64> + Send + Sync>;
pub type RecycleClock = Arc<dyn Fn() -> Duration + Send + Sync>;
#[derive(Clone)]
pub struct RecycleHooks {
pub sampler: FootprintSampler,
pub clock: RecycleClock,
pub stats: Arc<RecycleStats>,
}
impl Default for RecycleHooks {
fn default() -> Self {
static ORIGIN: OnceLock<Instant> = OnceLock::new();
let origin = *ORIGIN.get_or_init(Instant::now);
Self {
sampler: Arc::new(crate::sys_metrics::process_rss_mb),
clock: Arc::new(move || origin.elapsed()),
stats: process_recycle_stats(),
}
}
}
pub fn process_recycle_stats() -> Arc<RecycleStats> {
static STATS: OnceLock<Arc<RecycleStats>> = OnceLock::new();
Arc::clone(STATS.get_or_init(|| Arc::new(RecycleStats::new())))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecycleTrigger {
Footprint,
MaxAge,
MaxCalls,
FootprintUnreadable,
}
impl RecycleTrigger {
pub fn as_str(self) -> &'static str {
match self {
Self::Footprint => "footprint",
Self::MaxAge => "max_age",
Self::MaxCalls => "max_calls",
Self::FootprintUnreadable => "footprint_unreadable",
}
}
}
#[derive(Debug, Clone, Default)]
struct LastRecycle {
trigger: Option<&'static str>,
old_pid: Option<u32>,
new_pid: Option<u32>,
at_unix_secs: Option<u64>,
}
#[derive(Debug, Clone, Default)]
struct BackoffView {
level: u32,
cause: Option<&'static str>,
until_unix_secs: Option<u64>,
}
fn unix_now() -> Option<u64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
#[derive(Debug, Default)]
pub struct RecycleStats {
footprint: AtomicU64,
max_age: AtomicU64,
max_calls: AtomicU64,
footprint_unreadable: AtomicU64,
spawn_failures: AtomicU64,
footprint_read_errors: AtomicU64,
last: Mutex<LastRecycle>,
backoff: Mutex<BackoffView>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
pub struct RecycleSnapshot {
pub recycles_total: u64,
pub footprint: u64,
pub max_age: u64,
pub max_calls: u64,
pub footprint_unreadable: u64,
pub spawn_failures: u64,
pub footprint_read_errors: u64,
pub last_trigger: Option<String>,
pub last_old_pid: Option<u32>,
pub last_new_pid: Option<u32>,
pub last_recycle_unix_secs: Option<u64>,
pub backoff_level: u32,
pub backoff_cause: Option<String>,
pub backoff_until_unix_secs: Option<u64>,
}
impl RecycleStats {
pub fn new() -> Self {
Self::default()
}
fn record(&self, trigger: RecycleTrigger, old_pid: u32, new_pid: u32) {
let counter = match trigger {
RecycleTrigger::Footprint => &self.footprint,
RecycleTrigger::MaxAge => &self.max_age,
RecycleTrigger::MaxCalls => &self.max_calls,
RecycleTrigger::FootprintUnreadable => &self.footprint_unreadable,
};
counter.fetch_add(1, Ordering::Relaxed);
if let Ok(mut last) = self.last.lock() {
*last = LastRecycle {
trigger: Some(trigger.as_str()),
old_pid: Some(old_pid),
new_pid: Some(new_pid),
at_unix_secs: unix_now(),
};
}
}
fn set_backoff(&self, view: BackoffView) {
if let Ok(mut b) = self.backoff.lock() {
*b = view;
}
}
pub fn snapshot(&self) -> RecycleSnapshot {
let footprint = self.footprint.load(Ordering::Relaxed);
let max_age = self.max_age.load(Ordering::Relaxed);
let max_calls = self.max_calls.load(Ordering::Relaxed);
let unreadable = self.footprint_unreadable.load(Ordering::Relaxed);
let last = self.last.lock().map(|l| l.clone()).unwrap_or_default();
let backoff = self.backoff.lock().map(|b| b.clone()).unwrap_or_default();
RecycleSnapshot {
recycles_total: footprint + max_age + max_calls + unreadable,
footprint,
max_age,
max_calls,
footprint_unreadable: unreadable,
spawn_failures: self.spawn_failures.load(Ordering::Relaxed),
footprint_read_errors: self.footprint_read_errors.load(Ordering::Relaxed),
last_trigger: last.trigger.map(str::to_string),
last_old_pid: last.old_pid,
last_new_pid: last.new_pid,
last_recycle_unix_secs: last.at_unix_secs,
backoff_level: backoff.level,
backoff_cause: backoff.cause.map(str::to_string),
backoff_until_unix_secs: backoff.until_unix_secs,
}
}
}
pub(crate) struct TrackedClient {
inner: StdioEmbedderClient,
calls: AtomicU64,
in_flight: AtomicU32,
finished: AtomicU64,
retired: AtomicBool,
successor: OnceLock<Arc<dyn EmbedderClient>>,
}
impl TrackedClient {
pub(crate) fn new(inner: StdioEmbedderClient) -> Arc<Self> {
Arc::new(Self {
inner,
calls: AtomicU64::new(0),
in_flight: AtomicU32::new(0),
finished: AtomicU64::new(0),
retired: AtomicBool::new(false),
successor: OnceLock::new(),
})
}
pub(crate) fn unhealthy_signal(&self) -> watch::Receiver<bool> {
self.inner.unhealthy_signal()
}
fn retire(&self, successor: Arc<dyn EmbedderClient>) {
let _ = self.successor.set(successor);
self.retired.store(true, Ordering::SeqCst);
}
}
struct InFlight<'a>(&'a TrackedClient);
impl Drop for InFlight<'_> {
fn drop(&mut self) {
self.0.finished.fetch_add(1, Ordering::AcqRel);
self.0.in_flight.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait]
impl EmbedderClient for TrackedClient {
async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbedderError> {
self.in_flight.fetch_add(1, Ordering::SeqCst);
if self.retired.load(Ordering::SeqCst)
&& let Some(next) = self.successor.get()
{
self.in_flight.fetch_sub(1, Ordering::SeqCst);
return next.embed_batch(texts).await;
}
let _guard = InFlight(self);
self.calls.fetch_add(1, Ordering::Relaxed);
self.inner.embed_batch(texts).await
}
fn last_reported_device(&self) -> Option<String> {
self.inner.last_reported_device()
}
}
pub(crate) struct RecycleWatch {
born: Duration,
ticks: u32,
read_failures: u32,
unreadable_latched: bool,
warned_read_failure: bool,
first_look: bool,
quick_streak: u32,
fail_streak: u32,
hold_until: Option<tokio::time::Instant>,
}
impl RecycleWatch {
pub(crate) fn new(hooks: &RecycleHooks) -> Self {
Self {
born: (hooks.clock)(),
ticks: 0,
read_failures: 0,
unreadable_latched: false,
warned_read_failure: false,
first_look: false,
quick_streak: 0,
fail_streak: 0,
hold_until: None,
}
}
pub(crate) fn reset(&mut self, hooks: &RecycleHooks) {
self.born = (hooks.clock)();
self.ticks = 0;
self.read_failures = 0;
}
pub(crate) fn after_swap(&mut self, cfg: &RecycleConfig, hooks: &RecycleHooks) {
self.reset(hooks);
self.fail_streak = 0;
self.quick_streak = if self.first_look {
self.quick_streak.saturating_add(1)
} else {
0
};
self.hold(cfg, hooks, self.quick_streak, "premature_recycle");
}
pub(crate) fn after_spawn_failure(&mut self, cfg: &RecycleConfig, hooks: &RecycleHooks) {
self.fail_streak = self.fail_streak.saturating_add(1);
self.hold(cfg, hooks, self.fail_streak, "spawn_failure");
}
fn hold(&mut self, cfg: &RecycleConfig, hooks: &RecycleHooks, level: u32, cause: &'static str) {
if level == 0 {
self.hold_until = None;
hooks.stats.set_backoff(BackoffView::default());
return;
}
let delay = backoff_delay(cfg.effective_check_interval(), level);
self.hold_until = Some(tokio::time::Instant::now() + delay);
tracing::warn!(
level,
cause,
hold_secs = delay.as_secs_f64(),
"EmbedderSupervisor: recycle back-off — no recycle for {delay:?}"
);
hooks.stats.set_backoff(BackoffView {
level,
cause: Some(cause),
until_unix_secs: unix_now().map(|t| t + delay.as_secs()),
});
}
pub(crate) fn due(
&mut self,
cfg: &RecycleConfig,
hooks: &RecycleHooks,
pid: u32,
tracked: &TrackedClient,
) -> Option<RecycleTrigger> {
if self
.hold_until
.is_some_and(|t| tokio::time::Instant::now() < t)
{
return None;
}
self.ticks = self.ticks.saturating_add(1);
if self.ticks < 2 {
return None;
}
let trigger = self.judge(cfg, hooks, pid, tracked);
self.first_look = trigger.is_some() && self.ticks == 2;
trigger
}
fn judge(
&mut self,
cfg: &RecycleConfig,
hooks: &RecycleHooks,
pid: u32,
tracked: &TrackedClient,
) -> Option<RecycleTrigger> {
let age = (hooks.clock)().saturating_sub(self.born);
if cfg.footprint_ceiling_mb > 0 && pid != 0 {
match (hooks.sampler)(pid) {
Some(mb) => {
self.read_failures = 0;
self.unreadable_latched = false;
self.warned_read_failure = false;
if mb > cfg.footprint_ceiling_mb {
tracing::info!(
pid,
footprint_mb = mb,
ceiling_mb = cfg.footprint_ceiling_mb,
"EmbedderSupervisor: sidecar footprint over the recycle ceiling"
);
return Some(RecycleTrigger::Footprint);
}
}
None => {
hooks
.stats
.footprint_read_errors
.fetch_add(1, Ordering::Relaxed);
self.read_failures += 1;
if !self.warned_read_failure {
self.warned_read_failure = true;
tracing::warn!(
pid,
"EmbedderSupervisor: cannot read the sidecar footprint — \
recycling after {FOOTPRINT_UNREADABLE_LIMIT} failed reads"
);
}
if self.read_failures >= FOOTPRINT_UNREADABLE_LIMIT && !self.unreadable_latched
{
self.unreadable_latched = true;
return Some(RecycleTrigger::FootprintUnreadable);
}
}
}
}
if cfg.max_age_secs > 0 && age >= Duration::from_secs(cfg.max_age_secs) {
return Some(RecycleTrigger::MaxAge);
}
if cfg.max_calls > 0 && tracked.calls.load(Ordering::Relaxed) >= cfg.max_calls {
return Some(RecycleTrigger::MaxCalls);
}
None
}
}
pub(crate) fn backoff_delay(base: Duration, level: u32) -> Duration {
base.saturating_mul(1u32 << level.min(16)).min(MAX_BACKOFF)
}