use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::{Notify, Semaphore};
use crate::runtime::metrics::MetricsRecorder;
pub const ENV_DEBOUNCE_MS: &str = "UDB_CONTROL_DEBOUNCE_MS";
pub const ENV_DEBOUNCE_MAX_MS: &str = "UDB_CONTROL_DEBOUNCE_MAX_MS";
pub const ENV_MAX_CONCURRENT_PUSH: &str = "UDB_CONTROL_MAX_CONCURRENT_PUSH";
const DEFAULT_DEBOUNCE_MS: u64 = 250;
const DEFAULT_DEBOUNCE_MAX_MS: u64 = 2_000;
const DEFAULT_MAX_CONCURRENT_PUSH: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScalingConfig {
pub debounce: Duration,
pub debounce_max: Duration,
pub max_concurrent_push: usize,
}
impl Default for ScalingConfig {
fn default() -> Self {
Self {
debounce: Duration::from_millis(DEFAULT_DEBOUNCE_MS),
debounce_max: Duration::from_millis(DEFAULT_DEBOUNCE_MAX_MS),
max_concurrent_push: DEFAULT_MAX_CONCURRENT_PUSH,
}
}
}
impl ScalingConfig {
pub fn from_env() -> Self {
let defaults = Self::default();
let debounce_ms = env_u64(ENV_DEBOUNCE_MS)
.unwrap_or(DEFAULT_DEBOUNCE_MS)
.clamp(1, 60_000);
let mut max_ms = env_u64(ENV_DEBOUNCE_MAX_MS)
.unwrap_or(DEFAULT_DEBOUNCE_MAX_MS)
.clamp(1, 300_000);
if max_ms < debounce_ms {
max_ms = debounce_ms;
}
let max_concurrent_push = env_u64(ENV_MAX_CONCURRENT_PUSH)
.map(|value| value.clamp(1, 4_096) as usize)
.unwrap_or(defaults.max_concurrent_push);
Self {
debounce: Duration::from_millis(debounce_ms),
debounce_max: Duration::from_millis(max_ms),
max_concurrent_push,
}
}
}
fn env_u64(key: &str) -> Option<u64> {
std::env::var(key)
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
}
#[derive(Clone)]
pub struct PushDebouncer {
inner: Arc<DebouncerInner>,
}
struct DebouncerInner {
notify: Notify,
pending: AtomicU64,
first_pending_nanos: AtomicU64,
last_signal_nanos: AtomicU64,
debounce: Duration,
debounce_max: Duration,
baseline: Instant,
metrics: Option<Arc<dyn MetricsRecorder>>,
}
impl PushDebouncer {
pub fn new(config: ScalingConfig, metrics: Option<Arc<dyn MetricsRecorder>>) -> Self {
Self {
inner: Arc::new(DebouncerInner {
notify: Notify::new(),
pending: AtomicU64::new(0),
first_pending_nanos: AtomicU64::new(0),
last_signal_nanos: AtomicU64::new(0),
debounce: config.debounce,
debounce_max: config.debounce_max,
baseline: Instant::now(),
metrics,
}),
}
}
fn now_nanos(&self) -> u64 {
self.inner.baseline.elapsed().as_nanos() as u64
}
pub fn notify(&self) {
let now = self.now_nanos();
let prev = self.inner.pending.fetch_add(1, Ordering::SeqCst);
if prev == 0 {
self.inner.first_pending_nanos.store(now, Ordering::SeqCst);
} else {
if let Some(metrics) = self.inner.metrics.as_ref() {
metrics.inc_control_debounce_coalesced();
}
}
self.inner.last_signal_nanos.store(now, Ordering::SeqCst);
self.inner.notify.notify_one();
}
#[cfg(test)]
pub fn pending(&self) -> u64 {
self.inner.pending.load(Ordering::SeqCst)
}
pub async fn wait(&self) {
loop {
while self.inner.pending.load(Ordering::SeqCst) == 0 {
self.inner.notify.notified().await;
}
let now = self.now_nanos();
let first = self.inner.first_pending_nanos.load(Ordering::SeqCst);
let last = self.inner.last_signal_nanos.load(Ordering::SeqCst);
let debounce = self.inner.debounce.as_nanos() as u64;
let ceiling = self.inner.debounce_max.as_nanos() as u64;
let quiet_deadline = last.saturating_add(debounce);
let ceiling_deadline = first.saturating_add(ceiling);
let deadline = quiet_deadline.min(ceiling_deadline);
if now >= deadline {
self.drain();
return;
}
let sleep_for = Duration::from_nanos(deadline - now);
tokio::select! {
_ = tokio::time::sleep(sleep_for) => {}
_ = self.inner.notify.notified() => {}
}
}
}
fn drain(&self) {
self.inner.pending.store(0, Ordering::SeqCst);
}
}
#[derive(Clone)]
pub struct PushThrottle {
semaphore: Arc<Semaphore>,
capacity: usize,
metrics: Option<Arc<dyn MetricsRecorder>>,
}
impl PushThrottle {
pub fn new(config: ScalingConfig, metrics: Option<Arc<dyn MetricsRecorder>>) -> Self {
Self {
semaphore: Arc::new(Semaphore::new(config.max_concurrent_push)),
capacity: config.max_concurrent_push,
metrics,
}
}
pub fn available(&self) -> usize {
self.semaphore.available_permits()
}
pub fn in_flight(&self) -> usize {
self.capacity.saturating_sub(self.available())
}
pub async fn acquire(&self) -> PushPermit {
if self.semaphore.available_permits() == 0 {
if let Some(metrics) = self.metrics.as_ref() {
metrics.inc_control_push_throttled();
}
}
let permit = self
.semaphore
.clone()
.acquire_owned()
.await
.expect("control-plane push semaphore is never closed");
if let Some(metrics) = self.metrics.as_ref() {
metrics.set_control_push_queue_depth(self.in_flight() as u64);
}
PushPermit {
_permit: permit,
throttle: self.clone(),
}
}
}
pub struct PushPermit {
_permit: tokio::sync::OwnedSemaphorePermit,
throttle: PushThrottle,
}
impl Drop for PushPermit {
fn drop(&mut self) {
if let Some(metrics) = self.throttle.metrics.as_ref() {
let depth = self.throttle.in_flight().saturating_sub(1) as u64;
metrics.set_control_push_queue_depth(depth);
}
}
}
#[derive(Clone)]
pub struct PushScaler {
pub debouncer: PushDebouncer,
pub throttle: PushThrottle,
}
impl PushScaler {
pub fn new(config: ScalingConfig, metrics: Option<Arc<dyn MetricsRecorder>>) -> Self {
Self {
debouncer: PushDebouncer::new(config, metrics.clone()),
throttle: PushThrottle::new(config, metrics),
}
}
pub fn from_env(metrics: Option<Arc<dyn MetricsRecorder>>) -> Self {
Self::new(ScalingConfig::from_env(), metrics)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
fn fast_config() -> ScalingConfig {
ScalingConfig {
debounce: Duration::from_millis(20),
debounce_max: Duration::from_millis(200),
max_concurrent_push: 2,
}
}
#[test]
fn scaling_config_clamps_bad_env_values() {
let cfg = ScalingConfig {
debounce: Duration::from_millis(100),
debounce_max: Duration::from_millis(10),
max_concurrent_push: 8,
};
assert!(cfg.debounce_max < cfg.debounce, "precondition for the rule");
let coerced_max = cfg.debounce_max.max(cfg.debounce);
assert_eq!(coerced_max, cfg.debounce);
}
#[tokio::test(start_paused = true)]
async fn debounce_coalesces_burst_into_one_push() {
let debouncer = PushDebouncer::new(fast_config(), None);
for _ in 0..5 {
debouncer.notify();
}
assert_eq!(debouncer.pending(), 5, "all 5 bumps are pending");
tokio::time::advance(Duration::from_millis(25)).await;
debouncer.wait().await;
assert_eq!(
debouncer.pending(),
0,
"the whole burst is consumed by a single push"
);
}
#[tokio::test(start_paused = true)]
async fn debounce_ceiling_drains_continuous_stream() {
let debouncer = PushDebouncer::new(fast_config(), None); debouncer.notify();
let waiter = debouncer.clone();
let handle = tokio::spawn(async move { waiter.wait().await });
for _ in 0..40 {
tokio::time::advance(Duration::from_millis(10)).await;
tokio::task::yield_now().await;
if handle.is_finished() {
break;
}
debouncer.notify();
}
handle.await.expect("wait resolves under the ceiling");
assert_eq!(debouncer.pending(), 0, "ceiling drained the burst");
}
#[tokio::test(start_paused = true)]
async fn throttle_caps_concurrent_pushes() {
let throttle = PushThrottle::new(fast_config(), None); let p1 = throttle.acquire().await;
let p2 = throttle.acquire().await;
assert_eq!(throttle.in_flight(), 2, "two pushes in flight");
assert_eq!(throttle.available(), 0, "cap reached");
let acquired = Arc::new(AtomicUsize::new(0));
let acquired_clone = acquired.clone();
let throttle_clone = throttle.clone();
let handle = tokio::spawn(async move {
let _p3 = throttle_clone.acquire().await;
acquired_clone.fetch_add(1, Ordering::SeqCst);
});
tokio::time::advance(Duration::from_millis(5)).await;
tokio::task::yield_now().await;
assert_eq!(
acquired.load(Ordering::SeqCst),
0,
"the third push is throttled while the cap is full"
);
drop(p1);
tokio::task::yield_now().await;
handle
.await
.expect("third push proceeds once a permit frees");
assert_eq!(acquired.load(Ordering::SeqCst), 1);
drop(p2);
assert_eq!(throttle.in_flight(), 0, "all permits released");
}
}