saddle-runtime 0.3.29

Saddle managed asynchronous runtime and lifecycle
Documentation
use saddle_core::{ContextLabel, DiagnosticOccurrence, DiagnosticStage, ErrorKind};
use saddle_observability::{SourceOutput, root_diagnostic::{
    ComponentSourceKind, LifecycleSourceFailure, RecordedComponentLifecycle, RecordedSaddleError,
}};
use std::time::Duration;

/// The mandatory-output consumer for a formal component. The Core lifecycle
/// trait and its freely constructed SaddleError cannot enter this boundary.
///
/// ```compile_fail
/// use saddle_core::{ErrorKind, SaddleError};
/// use saddle_observability::root_diagnostic::LifecycleSourceFailure;
/// fn fake() -> LifecycleSourceFailure {
///     SaddleError::new(ErrorKind::Internal, "fake", "no original").into()
/// }
/// ```
///
/// ```compile_fail
/// use saddle_core::{ErrorKind, SaddleError};
/// use saddle_observability::root_diagnostic::LifecycleSourceFailure;
/// fn fake() -> LifecycleSourceFailure {
///     let light = SaddleError::new(ErrorKind::Internal, "fake", "safe")
///         .with_source_receipt(7_u8);
///     light.into()
/// }
/// ```
///
/// ```compile_fail
/// use saddle_observability::root_diagnostic::LifecycleSourceFailure;
/// fn forge() -> LifecycleSourceFailure { LifecycleSourceFailure(()) }
/// ```
///
/// ```compile_fail
/// use saddle_runtime::RecordedComponentBoundary;
/// use saddle_core::ContextLabel;
/// fn missing_output(app: ContextLabel) -> RecordedComponentBoundary {
///     RecordedComponentBoundary::new(app, None)
/// }
/// ```
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))
    }

    /// The task envelope preserves the sealed failure. Its own timeout has a
    /// distinct original, recorded before the cancelled task is reported.
    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()));
        // The caller may recover after a confirmed original; the historical
        // source remains in the controlled file and is never relabeled.
        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();
    }
}