use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
#[cfg(doc)]
use super::registry::StatusRefresher;
use super::registry::{ComponentProbe, StatusSettings};
use super::{Component, ComponentObservation, StatusReason};
use crate::backends::BackendFailure;
use crate::backends::control_plane::postgres::ControlPlaneSettings;
use crate::backends::control_plane::{ControlPlaneStore, StatusProbeAdmission};
pub struct ControlPlaneProbe {
store: Arc<dyn ControlPlaneStore>,
}
impl ControlPlaneProbe {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
Self { store }
}
pub fn pacing(settings: &ControlPlaneSettings) -> StatusSettings {
let bounds = settings.status_probe_timeout(1);
let spacing = settings.connect_timeout.clamp(SPACING, MAX_SPACING);
let refresh_interval = bounds.saturating_add(spacing).min(MAX_REFRESH_INTERVAL);
let probe_timeout = bounds.min(refresh_interval.saturating_sub(SPACING));
if probe_timeout < bounds {
tracing::warn!(
component = "control_plane",
store_bound_ms = bounds.as_millis() as u64,
probe_timeout_ms = probe_timeout.as_millis() as u64,
refresh_interval_ms = refresh_interval.as_millis() as u64,
"control plane timeouts exceed the observable cadence; the probe will report \
timeout for calls the store is still entitled to complete"
);
}
StatusSettings {
probe_timeout,
refresh_interval,
staleness_budget: refresh_interval
.saturating_mul(3)
.max(
refresh_interval
.saturating_add(probe_timeout)
.saturating_add(spacing),
)
.min(MAX_STALENESS_BUDGET),
enabled: vec![Component::ControlPlane],
}
}
async fn observe_with_status_probe(
&self,
admission: Option<StatusProbeAdmission>,
) -> ComponentObservation {
match self.store.health_with_status_probe(admission).await {
Ok(()) => ComponentObservation::ok(Component::ControlPlane),
Err(error) => {
let reason = StatusReason::from_failure(error.category());
let detail = format!("{}: {error}", self.store.name());
if reason == StatusReason::Unreachable {
ComponentObservation::unavailable(Component::ControlPlane, reason, detail)
} else {
ComponentObservation::degraded(Component::ControlPlane, reason, detail)
}
}
}
}
}
const SPACING: Duration = Duration::from_secs(1);
const MAX_SPACING: Duration = Duration::from_secs(30);
pub const MAX_REFRESH_INTERVAL: Duration = Duration::from_secs(2 * 60);
pub const MAX_PROBE_TIMEOUT: Duration = Duration::from_secs(4 * 60);
pub const MAX_STALENESS_BUDGET: Duration = Duration::from_secs(5 * 60);
#[async_trait]
impl ComponentProbe for ControlPlaneProbe {
fn component(&self) -> Component {
Component::ControlPlane
}
fn begin<'a>(
&'a self,
fallback: Duration,
) -> (
Duration,
std::pin::Pin<Box<dyn std::future::Future<Output = ComponentObservation> + Send + 'a>>,
) {
let admission = self.store.status_probe_admission();
let timeout = admission
.as_ref()
.map(StatusProbeAdmission::timeout)
.unwrap_or(fallback)
.min(MAX_PROBE_TIMEOUT);
(timeout, Box::pin(self.observe_with_status_probe(admission)))
}
async fn observe(&self) -> ComponentObservation {
self.observe_with_status_probe(None).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::Capabilities;
use crate::backends::control_plane::ControlPlaneError;
use crate::desired_state::oracle::InMemoryControlPlane;
use crate::desired_state::{
AccessDenial, AuditEvent, DenialPage, LoadedRevision, RevisionCandidate, RevisionId,
RevisionManifest,
};
use crate::status::ComponentState;
use crate::status::registry::{CachedStatusRegistry, StatusRefresher};
use std::sync::Mutex;
use tracing_subscriber::layer::SubscriberExt as _;
type Health = Box<dyn Fn() -> Result<(), ControlPlaneError> + Send + Sync>;
#[derive(Clone, Default)]
struct CapturedLogs(Arc<Mutex<Vec<u8>>>);
impl CapturedLogs {
fn rendered(&self) -> String {
String::from_utf8(self.0.lock().expect("not poisoned").clone()).expect("utf-8 logs")
}
}
impl std::io::Write for CapturedLogs {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("not poisoned").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLogs {
type Writer = Self;
fn make_writer(&'writer self) -> Self::Writer {
self.clone()
}
}
struct Answering {
inner: Arc<InMemoryControlPlane>,
health: Health,
health_delay: Option<Duration>,
probe_timeout: Option<Duration>,
}
#[async_trait]
impl ControlPlaneStore for Answering {
fn name(&self) -> &'static str {
self.inner.name()
}
fn capabilities(&self) -> Capabilities {
self.inner.capabilities()
}
fn status_probe_admission(&self) -> Option<StatusProbeAdmission> {
self.probe_timeout.map(StatusProbeAdmission::standalone)
}
async fn health(&self) -> Result<(), ControlPlaneError> {
if let Some(delay) = self.health_delay {
tokio::time::sleep(delay).await;
}
(self.health)()
}
async fn desired_revision(&self) -> Result<Option<RevisionId>, ControlPlaneError> {
self.inner.desired_revision().await
}
async fn load_manifest(
&self,
id: RevisionId,
) -> Result<RevisionManifest, ControlPlaneError> {
self.inner.load_manifest(id).await
}
async fn load_revision(&self, id: RevisionId) -> Result<LoadedRevision, ControlPlaneError> {
self.inner.load_revision(id).await
}
async fn publish_revision(
&self,
candidate: RevisionCandidate,
) -> Result<RevisionManifest, ControlPlaneError> {
self.inner.publish_revision(candidate).await
}
async fn audit_trail(&self, id: RevisionId) -> Result<Vec<AuditEvent>, ControlPlaneError> {
self.inner.audit_trail(id).await
}
async fn record_denial(&self, denial: &AccessDenial) -> Result<(), ControlPlaneError> {
self.inner.record_denial(denial).await
}
async fn denials(
&self,
page: &DenialPage,
limit: usize,
) -> Result<Vec<AccessDenial>, ControlPlaneError> {
self.inner.denials(page, limit).await
}
}
fn probing(health: Health) -> ControlPlaneProbe {
ControlPlaneProbe::new(Arc::new(Answering {
inner: Arc::new(InMemoryControlPlane::new()),
health,
health_delay: None,
probe_timeout: None,
}))
}
fn healthy() -> Health {
Box::new(|| Ok(()))
}
fn failing(error: fn() -> ControlPlaneError) -> Health {
Box::new(move || Err(error()))
}
#[test]
fn the_probe_outlives_every_bound_the_store_is_allowed_to_take() {
let settings = ControlPlaneSettings {
connect_timeout: Duration::from_secs(5),
operation_timeout: Duration::from_secs(30),
..ControlPlaneSettings::default()
};
let pacing = ControlPlaneProbe::pacing(&settings);
let queued_behind_an_operation = settings.operation_timeout;
let reconnect = settings.connect_timeout;
let own_call = settings.operation_timeout;
assert!(
pacing.probe_timeout >= queued_behind_an_operation + reconnect + own_call,
"{:?} cuts a call the store would have completed",
pacing.probe_timeout
);
}
#[test]
fn the_probe_timeout_expands_for_every_operation_already_in_the_queue() {
let settings = ControlPlaneSettings {
connect_timeout: Duration::from_secs(5),
operation_timeout: Duration::from_secs(30),
..ControlPlaneSettings::default()
};
let queued = 3;
let expected = settings.status_probe_timeout(queued);
let probe = ControlPlaneProbe::new(Arc::new(Answering {
inner: Arc::new(InMemoryControlPlane::new()),
health: healthy(),
health_delay: None,
probe_timeout: Some(expected),
}));
let (timeout, observation) = probe.begin(Duration::from_secs(1));
drop(observation);
assert_eq!(
timeout, expected,
"the health probe must budget for all queued operations, not one fixed slot"
);
assert_eq!(expected, Duration::from_secs(125));
}
#[test]
fn a_deep_queue_cannot_extend_a_probe_past_metric_expiration() {
let settings = ControlPlaneSettings {
connect_timeout: Duration::from_secs(5),
operation_timeout: Duration::from_secs(30),
..ControlPlaneSettings::default()
};
let deep_queue = 100;
let uncapped = settings.status_probe_timeout(deep_queue);
let probe = ControlPlaneProbe::new(Arc::new(Answering {
inner: Arc::new(InMemoryControlPlane::new()),
health: healthy(),
health_delay: None,
probe_timeout: Some(uncapped),
}));
let (timeout, observation) = probe.begin(Duration::from_secs(1));
drop(observation);
assert!(uncapped > MAX_PROBE_TIMEOUT);
assert_eq!(timeout, MAX_PROBE_TIMEOUT);
assert!(MAX_PROBE_TIMEOUT < Duration::from_secs(5 * 60));
}
#[tokio::test(start_paused = true)]
async fn a_queued_healthy_probe_is_not_recorded_as_a_timeout() {
let settings = ControlPlaneSettings {
connect_timeout: Duration::from_secs(5),
operation_timeout: Duration::from_secs(30),
..ControlPlaneSettings::default()
};
let queued = 3;
let probe_timeout = settings.status_probe_timeout(queued);
let health_delay = probe_timeout - Duration::from_secs(1);
let probe = ControlPlaneProbe::new(Arc::new(Answering {
inner: Arc::new(InMemoryControlPlane::new()),
health: healthy(),
health_delay: Some(health_delay),
probe_timeout: Some(probe_timeout),
}));
let registry = Arc::new(CachedStatusRegistry::new(
StatusSettings {
refresh_interval: probe_timeout + Duration::from_secs(1),
probe_timeout: Duration::from_secs(1),
staleness_budget: Duration::from_secs(300),
enabled: vec![Component::ControlPlane],
},
Arc::new(crate::convergence::SystemClock),
));
let refresher = StatusRefresher::new(Arc::clone(®istry), vec![Arc::new(probe)]);
let round = tokio::spawn(async move { refresher.refresh_once().await });
tokio::task::yield_now().await;
tokio::time::advance(health_delay).await;
round.await.expect("the refresher does not panic");
let observed = registry
.view()
.components
.into_iter()
.find(|observed| observed.component == Component::ControlPlane)
.expect("control plane is reported");
assert_eq!(observed.state, ComponentState::Ok);
assert_eq!(observed.reason, None);
}
#[test]
fn the_derived_pacing_is_valid_for_every_configurable_bound() {
for (connect_ms, operation_ms) in [
(1_u64, 1_u64),
(100, 250),
(5_000, 30_000),
(60_000, 600_000),
] {
let settings = ControlPlaneSettings {
connect_timeout: Duration::from_millis(connect_ms),
operation_timeout: Duration::from_millis(operation_ms),
..ControlPlaneSettings::default()
};
let pacing = ControlPlaneProbe::pacing(&settings);
assert_eq!(
pacing.validate(),
Ok(()),
"connect {connect_ms}ms, operation {operation_ms}ms produced {pacing:?}"
);
assert_eq!(pacing.enabled, vec![Component::ControlPlane]);
assert!(
pacing.refresh_interval <= MAX_REFRESH_INTERVAL,
"connect {connect_ms}ms, operation {operation_ms}ms outruns the pipeline: \
{pacing:?}"
);
assert!(
pacing.staleness_budget > pacing.refresh_interval + pacing.probe_timeout,
"connect {connect_ms}ms, operation {operation_ms}ms would report stale between two \
healthy rounds: {pacing:?}"
);
assert!(
pacing.staleness_budget <= MAX_STALENESS_BUDGET,
"connect {connect_ms}ms, operation {operation_ms}ms would page for an observation \
the replica still calls fresh: {pacing:?}"
);
}
}
#[test]
fn a_store_slower_than_the_pipeline_is_capped_and_the_capping_is_announced() {
let settings = ControlPlaneSettings {
connect_timeout: Duration::from_secs(60),
operation_timeout: Duration::from_secs(600),
..ControlPlaneSettings::default()
};
let bounds = settings.operation_timeout * 2 + settings.connect_timeout;
let logs = CapturedLogs::default();
let dispatch = tracing::Dispatch::new(
tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(logs.clone()),
),
);
let pacing = {
let _default = tracing::dispatcher::set_default(&dispatch);
ControlPlaneProbe::pacing(&settings)
};
assert_eq!(pacing.refresh_interval, MAX_REFRESH_INTERVAL);
assert_eq!(pacing.probe_timeout, MAX_REFRESH_INTERVAL - SPACING);
assert!(pacing.probe_timeout < bounds);
assert_eq!(pacing.staleness_budget, MAX_STALENESS_BUDGET);
assert!(pacing.staleness_budget > pacing.refresh_interval + pacing.probe_timeout);
assert_eq!(pacing.validate(), Ok(()));
let rendered = logs.rendered();
assert!(
rendered.contains("exceed the observable cadence") && rendered.contains("WARN"),
"capping the probe below the store's bounds is announced: {rendered}"
);
let quiet = CapturedLogs::default();
let dispatch = tracing::Dispatch::new(
tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(quiet.clone()),
),
);
{
let _default = tracing::dispatcher::set_default(&dispatch);
ControlPlaneProbe::pacing(&ControlPlaneSettings::default());
}
assert_eq!(quiet.rendered(), "");
}
#[tokio::test]
async fn a_reachable_control_plane_is_ok_and_says_nothing_else() {
let observation = probing(healthy()).observe().await;
assert_eq!(observation.state, ComponentState::Ok);
assert_eq!(observation.reason, None);
assert_eq!(observation.detail, None);
}
#[tokio::test]
async fn unreachable_and_refusing_are_different_observations() {
let unreachable = probing(failing(|| ControlPlaneError::Unavailable {
backend: "postgres",
message: "connection refused".to_owned(),
}))
.observe()
.await;
assert_eq!(unreachable.state, ComponentState::Unavailable);
assert_eq!(unreachable.reason, Some(StatusReason::Unreachable));
let refusing = probing(failing(|| ControlPlaneError::Denied {
backend: "postgres",
message: "permission denied for relation revisions".to_owned(),
}))
.observe()
.await;
assert_eq!(refusing.state, ComponentState::Degraded);
assert_eq!(refusing.reason, Some(StatusReason::PermissionDenied));
}
#[tokio::test]
async fn the_backend_message_stays_on_the_detail() {
let observation = probing(failing(|| ControlPlaneError::Unavailable {
backend: "postgres",
message: "host=db.internal port=5432: connection refused".to_owned(),
}))
.observe()
.await;
let detail = observation.detail.expect("a failure carries a detail");
assert!(detail.contains("connection refused"), "{detail}");
}
}