use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, Notify};
use tokio_util::sync::CancellationToken;
use vl_convert_rs::converter::VlcConfig;
use crate::health::ReadinessState;
use crate::types::{ConfigPatch, ConfigValidationError, FieldError, FieldErrorCode};
#[derive(Debug)]
pub(crate) enum DrainError {
Cancelled,
Timeout { inflight: usize },
}
pub(crate) struct ReconfigCoordinator {
gate_closed: AtomicBool,
inflight: AtomicUsize,
drained: Notify,
reconfig_lock: Mutex<()>,
shutdown_token: CancellationToken,
drain_timeout: Duration,
}
impl ReconfigCoordinator {
pub(crate) fn new(shutdown_token: CancellationToken, drain_timeout: Duration) -> Arc<Self> {
Arc::new(Self {
gate_closed: AtomicBool::new(false),
inflight: AtomicUsize::new(0),
drained: Notify::new(),
reconfig_lock: Mutex::new(()),
shutdown_token,
drain_timeout,
})
}
pub(crate) async fn lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.reconfig_lock.lock().await
}
pub(crate) fn close_gate(&self) {
self.gate_closed.store(true, Ordering::SeqCst);
}
pub(crate) fn open_gate(&self) {
self.gate_closed.store(false, Ordering::SeqCst);
}
#[cfg(test)]
pub(crate) fn is_gate_closed(&self) -> bool {
self.gate_closed.load(Ordering::SeqCst)
}
#[cfg(test)]
pub(crate) fn inflight(&self) -> usize {
self.inflight.load(Ordering::SeqCst)
}
pub(crate) fn try_admit(self: &Arc<Self>) -> Result<InflightGuard, ()> {
self.inflight.fetch_add(1, Ordering::SeqCst);
if self.gate_closed.load(Ordering::SeqCst) {
self.inflight.fetch_sub(1, Ordering::SeqCst);
self.drained.notify_waiters();
return Err(());
}
Ok(InflightGuard {
coord: self.clone(),
})
}
pub(crate) async fn drain(&self) -> Result<(), DrainError> {
self.close_gate();
let deadline = tokio::time::Instant::now() + self.drain_timeout;
loop {
let notified = self.drained.notified();
tokio::pin!(notified);
if self.inflight.load(Ordering::SeqCst) == 0 {
return Ok(());
}
tokio::select! {
biased;
_ = self.shutdown_token.cancelled() => return Err(DrainError::Cancelled),
_ = notified => continue,
_ = tokio::time::sleep_until(deadline) => {
return Err(DrainError::Timeout {
inflight: self.inflight.load(Ordering::SeqCst),
});
}
}
}
}
}
pub(crate) struct InflightGuard {
coord: Arc<ReconfigCoordinator>,
}
impl Drop for InflightGuard {
fn drop(&mut self) {
self.coord.inflight.fetch_sub(1, Ordering::SeqCst);
self.coord.drained.notify_waiters();
}
}
pub(crate) struct ReconfigScopeGuard<'a> {
coord: &'a Arc<ReconfigCoordinator>,
readiness: &'a Arc<ReadinessState>,
gate_was_closed: bool,
}
impl<'a> ReconfigScopeGuard<'a> {
pub(crate) fn new(
coord: &'a Arc<ReconfigCoordinator>,
readiness: &'a Arc<ReadinessState>,
) -> Self {
Self {
coord,
readiness,
gate_was_closed: false,
}
}
pub(crate) fn mark_gate_closed(&mut self) {
self.gate_was_closed = true;
self.readiness
.reconfig_in_progress
.store(true, Ordering::Release);
}
}
impl<'a> Drop for ReconfigScopeGuard<'a> {
fn drop(&mut self) {
if self.gate_was_closed {
self.coord.open_gate();
self.readiness
.reconfig_in_progress
.store(false, Ordering::Release);
}
}
}
#[derive(Debug)]
pub(crate) enum PatchRejection {
NonNullable(ConfigValidationError),
#[allow(dead_code)]
Invalid(ConfigValidationError),
}
pub(crate) fn apply_patch(
current: &VlcConfig,
patch: &ConfigPatch,
) -> Result<VlcConfig, PatchRejection> {
let mut new = current.clone();
let mut null_fields: Vec<FieldError> = Vec::new();
if let Some(v) = patch.max_v8_heap_size_mb.as_ref() {
new.max_v8_heap_size_mb = *v;
}
if let Some(v) = patch.google_font_variant_threshold.as_ref() {
new.google_font_variant_threshold = *v;
}
if let Some(v) = patch.max_v8_execution_time_secs.as_ref() {
new.max_v8_execution_time_secs = *v;
}
if let Some(v) = patch.max_ephemeral_workers.as_ref() {
new.max_ephemeral_workers = *v;
}
if let Some(v) = patch.default_theme.as_ref() {
new.default_theme = v.clone();
}
if let Some(v) = patch.default_format_locale.as_ref() {
new.default_format_locale = v.clone();
}
if let Some(v) = patch.default_time_format_locale.as_ref() {
new.default_time_format_locale = v.clone();
}
macro_rules! apply_non_nullable {
($field:ident, $apply:expr) => {
match patch.$field.as_ref() {
None => {}
Some(None) => null_fields.push(FieldError {
path: stringify!($field).to_string(),
code: FieldErrorCode::NonNullable,
message: format!("field '{}' is not nullable", stringify!($field),),
}),
Some(Some(v)) => $apply(&mut new, v),
}
};
}
apply_non_nullable!(num_workers, |n: &mut VlcConfig, v: &_| n.num_workers = *v);
apply_non_nullable!(
base_url,
|n: &mut VlcConfig, v: &vl_convert_rs::converter::BaseUrlSetting| {
n.base_url = v.clone();
}
);
apply_non_nullable!(allowed_base_urls, |n: &mut VlcConfig, v: &Vec<String>| {
n.allowed_base_urls = v.clone();
});
apply_non_nullable!(auto_google_fonts, |n: &mut VlcConfig, v: &bool| {
n.auto_google_fonts = *v;
});
apply_non_nullable!(embed_local_fonts, |n: &mut VlcConfig, v: &bool| {
n.embed_local_fonts = *v;
});
apply_non_nullable!(subset_fonts, |n: &mut VlcConfig, v: &bool| n.subset_fonts =
*v);
apply_non_nullable!(missing_fonts, |n: &mut VlcConfig, v: &_| {
n.missing_fonts = *v;
});
apply_non_nullable!(google_fonts, |n: &mut VlcConfig, v: &Vec<_>| {
n.google_fonts = v.clone();
});
apply_non_nullable!(gc_after_conversion, |n: &mut VlcConfig, v: &bool| {
n.gc_after_conversion = *v;
});
apply_non_nullable!(vega_plugins, |n: &mut VlcConfig, v: &Vec<String>| {
n.vega_plugins = v.clone();
});
apply_non_nullable!(
plugin_import_domains,
|n: &mut VlcConfig, v: &Vec<String>| {
n.plugin_import_domains = v.clone();
}
);
apply_non_nullable!(allow_per_request_plugins, |n: &mut VlcConfig, v: &bool| {
n.allow_per_request_plugins = *v;
});
apply_non_nullable!(allow_google_fonts, |n: &mut VlcConfig, v: &bool| {
n.allow_google_fonts = *v;
});
apply_non_nullable!(
per_request_plugin_import_domains,
|n: &mut VlcConfig, v: &Vec<String>| {
n.per_request_plugin_import_domains = v.clone();
}
);
apply_non_nullable!(themes, |n: &mut VlcConfig,
v: &std::collections::HashMap<
String,
serde_json::Value,
>| {
n.themes = v.clone();
});
if !null_fields.is_empty() {
return Err(PatchRejection::NonNullable(ConfigValidationError {
error: "null received on non-nullable field(s)".to_string(),
field_errors: null_fields,
}));
}
Ok(new)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicU32;
fn coord_with_timeout(ms: u64) -> Arc<ReconfigCoordinator> {
ReconfigCoordinator::new(CancellationToken::new(), Duration::from_millis(ms))
}
#[tokio::test]
async fn test_drain_returns_immediately_when_no_inflight() {
let coord = coord_with_timeout(5_000);
assert!(matches!(coord.drain().await, Ok(())));
assert!(coord.is_gate_closed());
}
#[tokio::test]
async fn test_drain_waits_for_guards_to_drop() {
let coord = coord_with_timeout(5_000);
let guard = coord.try_admit().expect("gate should be open before drain");
assert_eq!(coord.inflight(), 1);
let c = coord.clone();
let drain_handle = tokio::spawn(async move { c.drain().await });
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(coord.try_admit().is_err());
drop(guard);
assert!(matches!(drain_handle.await.unwrap(), Ok(())));
assert_eq!(coord.inflight(), 0);
}
#[tokio::test]
async fn test_drain_aborts_on_shutdown_cancel() {
let shutdown = CancellationToken::new();
let coord = Arc::new(ReconfigCoordinator {
gate_closed: AtomicBool::new(false),
inflight: AtomicUsize::new(0),
drained: Notify::new(),
reconfig_lock: Mutex::new(()),
shutdown_token: shutdown.clone(),
drain_timeout: Duration::from_secs(60),
});
let _guard = coord.try_admit().unwrap();
let c = coord.clone();
let drain_handle = tokio::spawn(async move { c.drain().await });
tokio::time::sleep(Duration::from_millis(20)).await;
shutdown.cancel();
let result = drain_handle.await.unwrap();
assert!(matches!(result, Err(DrainError::Cancelled)));
}
#[tokio::test]
async fn test_drain_returns_timeout_error_when_bounded_time_exceeded() {
let coord = coord_with_timeout(50);
let _guard = coord.try_admit().unwrap();
let start = std::time::Instant::now();
let result = coord.drain().await;
let elapsed = start.elapsed();
match result {
Err(DrainError::Timeout { inflight }) => {
assert_eq!(inflight, 1);
}
other => panic!("expected Timeout, got {other:?}"),
}
assert!(
elapsed >= Duration::from_millis(40) && elapsed < Duration::from_millis(500),
"drain returned in {elapsed:?}; expected ~50ms"
);
}
#[tokio::test]
async fn test_admission_race_regression() {
const TASKS: usize = 200;
let coord = coord_with_timeout(2_000);
let accepted = Arc::new(AtomicU32::new(0));
let rejected = Arc::new(AtomicU32::new(0));
let mut handles = Vec::with_capacity(TASKS);
for _ in 0..TASKS {
let c = coord.clone();
let a = accepted.clone();
let r = rejected.clone();
handles.push(tokio::spawn(async move {
tokio::task::yield_now().await;
match c.try_admit() {
Ok(_guard) => {
a.fetch_add(1, Ordering::SeqCst);
}
Err(()) => {
r.fetch_add(1, Ordering::SeqCst);
}
}
}));
}
let drain_handle = {
let c = coord.clone();
tokio::spawn(async move { c.drain().await })
};
for h in handles {
h.await.unwrap();
}
let drain_result = drain_handle.await.unwrap();
assert!(
matches!(drain_result, Ok(()) | Err(DrainError::Timeout { .. })),
"unexpected drain result: {drain_result:?}"
);
let total = accepted.load(Ordering::SeqCst) + rejected.load(Ordering::SeqCst);
assert_eq!(total as usize, TASKS, "lost admit/reject accounting");
assert_eq!(coord.inflight(), 0);
}
use vl_convert_rs::converter::MissingFontsPolicy;
#[test]
fn apply_patch_empty_preserves_current() {
let cur = VlcConfig::default();
let patch = ConfigPatch::default();
let new = apply_patch(&cur, &patch).unwrap();
assert_eq!(new, cur);
}
#[test]
fn apply_patch_sets_non_optional_field() {
let cur = VlcConfig::default();
let patch = ConfigPatch {
auto_google_fonts: Some(Some(true)),
..Default::default()
};
let new = apply_patch(&cur, &patch).unwrap();
assert!(new.auto_google_fonts);
assert_eq!(new.num_workers, cur.num_workers);
}
#[test]
fn apply_patch_sets_option_field_to_some() {
let cur = VlcConfig::default();
let patch = ConfigPatch {
default_theme: Some(Some("dark".to_string())),
..Default::default()
};
let new = apply_patch(&cur, &patch).unwrap();
assert_eq!(new.default_theme, Some("dark".to_string()));
}
#[test]
fn apply_patch_clears_option_field_to_none_on_null() {
let cur = VlcConfig {
default_theme: Some("dark".to_string()),
..Default::default()
};
let patch = ConfigPatch {
default_theme: Some(None),
..Default::default()
};
let new = apply_patch(&cur, &patch).unwrap();
assert_eq!(new.default_theme, None);
}
#[test]
fn apply_patch_missing_fonts_enum() {
let cur = VlcConfig::default();
let patch = ConfigPatch {
missing_fonts: Some(Some(MissingFontsPolicy::Warn)),
..Default::default()
};
let new = apply_patch(&cur, &patch).unwrap();
assert_eq!(new.missing_fonts, MissingFontsPolicy::Warn);
}
#[test]
fn apply_patch_null_on_non_nullable_is_rejected() {
let cur = VlcConfig::default();
let patch = ConfigPatch {
allowed_base_urls: Some(None),
..Default::default()
};
let err = apply_patch(&cur, &patch).unwrap_err();
match err {
PatchRejection::NonNullable(e) => {
assert_eq!(e.field_errors.len(), 1);
assert_eq!(e.field_errors[0].path, "allowed_base_urls");
assert_eq!(e.field_errors[0].code, FieldErrorCode::NonNullable);
}
PatchRejection::Invalid(_) => panic!("expected NonNullable, got Invalid"),
}
}
#[test]
fn apply_patch_multiple_nulls_on_non_nullables_collected() {
let cur = VlcConfig::default();
let patch = ConfigPatch {
allowed_base_urls: Some(None),
subset_fonts: Some(None),
themes: Some(None),
..Default::default()
};
let err = apply_patch(&cur, &patch).unwrap_err();
match err {
PatchRejection::NonNullable(e) => {
assert_eq!(e.field_errors.len(), 3);
let paths: Vec<&str> = e.field_errors.iter().map(|fe| fe.path.as_str()).collect();
assert!(paths.contains(&"allowed_base_urls"));
assert!(paths.contains(&"subset_fonts"));
assert!(paths.contains(&"themes"));
}
PatchRejection::Invalid(_) => panic!("expected NonNullable"),
}
}
}