use saddle_core::{ContextLabel, DiagnosticOccurrence, DiagnosticStage, ErrorKind};
use saddle_observability::{SourceOutput, root_diagnostic::{
ComponentSourceKind, LifecycleSourceFailure, RecordedComponentLifecycle, RecordedSaddleError,
}};
use std::time::Duration;
pub struct RecordedComponentBoundary {
application: ContextLabel,
output: SourceOutput,
}
impl RecordedComponentBoundary {
pub fn new(application: ContextLabel, output: SourceOutput) -> Self {
Self { application, output }
}
pub(crate) fn timeout_failure(
&self, elapsed: tokio::time::error::Elapsed, kind: ComponentSourceKind,
primary: Option<DiagnosticOccurrence>,
) -> LifecycleSourceFailure {
let (stage, code, message) = match kind {
ComponentSourceKind::Start => (ComponentSourceKind::Start,
"runtime.lifecycle_timeout.component_start",
"component startup exceeded its deadline"),
ComponentSourceKind::Cleanup => (ComponentSourceKind::Cleanup,
"runtime.lifecycle_timeout.component_shutdown",
"component cleanup exceeded its deadline"),
};
RecordedSaddleError::component_result::<(), _>(Err(elapsed), &self.application,
&self.output, stage, primary, ErrorKind::Infrastructure, code, message)
.expect_err("timeout is always a component failure")
}
pub async fn start(
&self, component: &dyn RecordedComponentLifecycle,
) -> Result<(), LifecycleSourceFailure> {
component.start(&self.application, &self.output).await
.map_err(|failure| failure.require_component_kind(ComponentSourceKind::Start))
}
pub async fn shutdown(
&self,
component: &dyn RecordedComponentLifecycle,
primary: Option<DiagnosticOccurrence>,
) -> Result<(), LifecycleSourceFailure> {
component.shutdown_with_primary(&self.application, &self.output, primary).await
.map_err(|failure| failure.require_component_kind(ComponentSourceKind::Cleanup))
}
pub async fn start_with_timeout(
&self, component: &dyn RecordedComponentLifecycle, timeout: Duration,
) -> Result<(), LifecycleSourceFailure> {
match tokio::time::timeout(timeout, crate::diagnostics::task(
self.start(component), DiagnosticStage::StartupListener,
"runtime.component_start",
)).await {
Ok(result) => result,
Err(elapsed) => RecordedSaddleError::component_result::<(), _>(
Err(elapsed), &self.application, &self.output,
ComponentSourceKind::Start, None, ErrorKind::Infrastructure,
"runtime.lifecycle_timeout.component_start",
"component startup exceeded its deadline",
),
}
}
pub async fn shutdown_with_timeout(
&self, component: &dyn RecordedComponentLifecycle,
primary: Option<DiagnosticOccurrence>, timeout: Duration,
) -> Result<(), LifecycleSourceFailure> {
match tokio::time::timeout(timeout, crate::diagnostics::task(
self.shutdown(component, primary), DiagnosticStage::ShutdownComponent,
"runtime.component_shutdown",
)).await {
Ok(result) => result,
Err(elapsed) => RecordedSaddleError::component_result::<(), _>(
Err(elapsed), &self.application, &self.output,
ComponentSourceKind::Cleanup, primary, ErrorKind::Infrastructure,
"runtime.lifecycle_timeout.component_shutdown",
"component cleanup exceeded its deadline",
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{error::Error, fmt};
use saddle_core::ErrorKind;
use saddle_observability::{DiagnosticShutdown, EmergencyDiagnostics,
FileLoggingConfig, Rotation, root_diagnostic::{ComponentSourceKind,
RecordedLifecycleFuture, RecordedSaddleError}};
struct FailingComponent;
#[derive(Debug)]
struct CauseChain(&'static str, Option<Box<CauseChain>>);
impl fmt::Display for CauseChain {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0) }
}
impl Error for CauseChain {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.1.as_deref().map(|source| source as &(dyn Error + 'static))
}
}
impl RecordedComponentLifecycle for FailingComponent {
fn name(&self) -> &'static str { "recorded-failure" }
fn start<'a>(&'a self, application: &'a ContextLabel,
output: &'a SourceOutput) -> RecordedLifecycleFuture<'a> {
Box::pin(async move {
RecordedSaddleError::component_result::<(), _>(
Err(CauseChain("actual component cause 6293", Some(Box::new(
CauseChain("inner cause 7429", Some(Box::new(
CauseChain("root cause 9531", None),
))),
)))),
application, output, ComponentSourceKind::Start, None,
ErrorKind::Infrastructure, "recorded.boundary",
"safe component failure",
)
})
}
fn shutdown<'a>(&'a self, _application: &'a ContextLabel,
_output: &'a SourceOutput) -> RecordedLifecycleFuture<'a> {
Box::pin(async { Ok(()) })
}
fn shutdown_with_primary<'a>(&'a self, application: &'a ContextLabel,
output: &'a SourceOutput,
_primary: Option<DiagnosticOccurrence>) -> RecordedLifecycleFuture<'a> {
self.shutdown(application, output)
}
}
struct WrongPhaseComponent;
struct PendingComponent;
impl RecordedComponentLifecycle for PendingComponent {
fn name(&self) -> &'static str { "pending-component" }
fn start<'a>(&'a self, _application: &'a ContextLabel,
_output: &'a SourceOutput) -> RecordedLifecycleFuture<'a> {
Box::pin(std::future::pending())
}
fn shutdown<'a>(&'a self, _application: &'a ContextLabel,
_output: &'a SourceOutput) -> RecordedLifecycleFuture<'a> {
Box::pin(std::future::pending())
}
fn shutdown_with_primary<'a>(&'a self, application: &'a ContextLabel,
output: &'a SourceOutput, _primary: Option<DiagnosticOccurrence>)
-> RecordedLifecycleFuture<'a> {
self.shutdown(application, output)
}
}
#[tokio::test]
async fn task_timeout_records_its_own_original_and_cleanup_parent() {
let directory = std::env::temp_dir().join(format!(
"saddle-recorded-timeout-{}-{}", std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.unwrap().as_nanos()));
std::fs::create_dir(&directory).unwrap();
let mut owner = EmergencyDiagnostics::start_checked(
&FileLoggingConfig::new(directory.clone(), Rotation::Daily)).unwrap();
let target = owner.target().to_owned();
let boundary = RecordedComponentBoundary::new(
ContextLabel::checked("timeout-component").unwrap(),
owner.source_output().unwrap());
let start = boundary.start_with_timeout(&PendingComponent,
Duration::from_millis(1)).await.err().unwrap();
assert_eq!(start.safe().code(), "runtime.lifecycle_timeout.component_start");
assert!(start.original_if_unconfirmed().is_none());
let original = std::fs::read_to_string(&target).unwrap();
assert!(original.contains("deadline has elapsed"));
assert!(original.contains(&start.safe().diagnostic().unwrap().id().to_string()));
let cleanup = boundary.shutdown_with_timeout(&PendingComponent,
start.safe().diagnostic().map(|d| d.occurrence()),
Duration::from_millis(1)).await.err().unwrap();
assert_eq!(cleanup.safe().code(), "runtime.lifecycle_timeout.component_shutdown");
assert!(cleanup.original_if_unconfirmed().is_none());
assert_eq!(serde_json::to_value(cleanup.safe().diagnostic().unwrap()).unwrap()
["primary_diagnostic_id"], start.safe().diagnostic().unwrap().id());
assert!(std::fs::read_to_string(&target).unwrap().contains(
&cleanup.safe().diagnostic().unwrap().id().to_string()));
drop(cleanup);
drop(start);
drop(boundary);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while owner.shutdown() == DiagnosticShutdown::Pending
&& std::time::Instant::now() < deadline { tokio::task::yield_now().await; }
assert_eq!(owner.shutdown(), DiagnosticShutdown::Finished);
drop(owner);
std::fs::remove_dir_all(directory).unwrap();
}
impl RecordedComponentLifecycle for WrongPhaseComponent {
fn name(&self) -> &'static str { "wrong-phase" }
fn start<'a>(&'a self, application: &'a ContextLabel,
output: &'a SourceOutput) -> RecordedLifecycleFuture<'a> {
Box::pin(async move {
RecordedSaddleError::component_result::<(), _>(
Err(std::io::Error::other("actual start source")),
application, output, ComponentSourceKind::Cleanup, None,
ErrorKind::Infrastructure, "wrong.phase", "safe",
)
})
}
fn shutdown<'a>(&'a self, _application: &'a ContextLabel,
_output: &'a SourceOutput) -> RecordedLifecycleFuture<'a> {
Box::pin(async { Ok(()) })
}
fn shutdown_with_primary<'a>(&'a self, application: &'a ContextLabel,
output: &'a SourceOutput,
_primary: Option<DiagnosticOccurrence>) -> RecordedLifecycleFuture<'a> {
self.shutdown(application, output)
}
}
#[tokio::test]
async fn start_rejects_cleanup_credential_without_claiming_source() {
let directory = std::env::temp_dir().join(format!(
"saddle-wrong-phase-{}-{}", std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.unwrap().as_nanos()));
std::fs::create_dir(&directory).unwrap();
let mut output = EmergencyDiagnostics::start_checked(
&FileLoggingConfig::new(directory.clone(), Rotation::Daily)).unwrap();
let target = output.target().to_owned();
let boundary = RecordedComponentBoundary::new(
ContextLabel::checked("wrong-phase-test").unwrap(),
output.source_output().unwrap());
let failure = boundary.start(&WrongPhaseComponent).await.err().unwrap();
assert_eq!(failure.safe().code(), "component.source_phase_mismatch");
assert!(failure.safe().source_unavailable());
assert!(std::fs::read_to_string(&target).unwrap().contains("actual start source"));
drop(boundary);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while output.shutdown() == DiagnosticShutdown::Pending
&& std::time::Instant::now() < deadline { std::thread::yield_now(); }
assert_eq!(output.shutdown(), DiagnosticShutdown::Finished);
drop(output);
std::fs::remove_dir_all(directory).unwrap();
}
#[tokio::test]
async fn cross_crate_boundary_requires_original_before_safe_result() {
let directory = std::env::temp_dir().join(format!(
"saddle-recorded-boundary-{}-{}", std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.unwrap().as_nanos()));
std::fs::create_dir(&directory).unwrap();
let mut output = EmergencyDiagnostics::start_checked(
&FileLoggingConfig::new(directory.clone(), Rotation::Daily)).unwrap();
let target = output.target().to_owned();
let boundary = RecordedComponentBoundary::new(
ContextLabel::checked("recorded-test").unwrap(),
output.source_output().unwrap());
let failure = boundary.start(&FailingComponent).await.err().unwrap();
assert_eq!(failure.safe().code(), "recorded.boundary");
assert!(failure.original_if_unconfirmed().is_none());
let record = std::fs::read_to_string(&target).unwrap();
assert!(record.contains("actual component cause 6293"));
assert!(record.contains("inner cause 7429"));
assert!(record.contains("root cause 9531"));
assert!(record.contains(&failure.safe().diagnostic().unwrap().id().to_string()));
let recovered: Result<(), LifecycleSourceFailure> =
if failure.safe().code() == "recorded.boundary" { Ok(()) }
else { Err(failure) };
assert!(recovered.is_ok());
drop(boundary);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while output.shutdown() == DiagnosticShutdown::Pending
&& std::time::Instant::now() < deadline { std::thread::yield_now(); }
assert_eq!(output.shutdown(), DiagnosticShutdown::Finished);
drop(output);
std::fs::remove_dir_all(directory).unwrap();
}
}