lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 5127c14466bd29b09d2c3d2dd2ed7f33c23e516b
parent 772e24e6b9479fd83ee4f6c1c3301bffa7f58441
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 01:28:45 +0000

service-host: add cached status state

- publish one bounded latest service-state snapshot
- keep update authority singular and readers cloneable
- validate lifecycle transitions before atomic replacement
- cover passive reads, races, shutdown, and drop semantics

Diffstat:
MCargo.lock | 1+
Mcrates/service_host/Cargo.toml | 4++++
Mcrates/service_host/src/lib.rs | 7++++---
Acrates/service_host/src/status/cache.rs | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/src/status/mod.rs | 5+++++
Mcrates/service_host/tests/package_boundary.rs | 8+++++++-
6 files changed, 346 insertions(+), 4 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3883,6 +3883,7 @@ dependencies = [ "radroots_runtime_paths", "serde", "serde_json", + "tokio", ] [[package]] diff --git a/crates/service_host/Cargo.toml b/crates/service_host/Cargo.toml @@ -16,6 +16,10 @@ getrandom = { workspace = true } radroots_runtime_paths = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } +tokio = { workspace = true, features = ["sync"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "sync"] } [lints] workspace = true diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -15,12 +15,13 @@ pub use build_info::{ pub use entropy::{EntropyError, EntropySource, SystemEntropy}; pub use error::{HostError, HostErrorCode, HostErrorKind, SafeHostError}; pub use status::{ - CONFIGURATION_SCHEMA_VERSION, CommonReasonCode, ConfigurationIdentity, ConfigurationSource, + CONFIGURATION_SCHEMA_VERSION, CachedServiceState, CachedServiceStatePublisher, + CachedServiceStateReader, CommonReasonCode, ConfigurationIdentity, ConfigurationSource, IntegrityState, PersistenceHealth, PersistenceSummary, ProviderHealth, Readiness, ReasonCode, ReasonCodes, SERVICE_STATUS_CONTRACT_VERSION, SERVICE_STATUS_MAX_UTF8_BYTES, ServiceOperationalState, ServicePhase, ServiceStatus, ServiceStatusDetail, Sha256Digest, - StatusContractError, StatusEncodingError, StatusId, StatusModelError, TransportHealth, - UptimeMillis, + StatusContractError, StatusEncodingError, StatusId, StatusModelError, StatusPublisherDropped, + TransportHealth, UptimeMillis, cached_service_state, }; pub use time::{ MonotonicClock, MonotonicClockError, MonotonicDeadline, MonotonicTime, SystemMonotonicClock, diff --git a/crates/service_host/src/status/cache.rs b/crates/service_host/src/status/cache.rs @@ -0,0 +1,325 @@ +//! Single-writer, latest-value publication for passive operations reads. + +use core::fmt; +use std::sync::Arc; + +use tokio::sync::watch; + +use super::{ServiceOperationalState, StatusContractError}; + +/// One immutable cached observation and its service-owned metrics metadata. +/// +/// The holder retains only the latest `Arc`, so publication has a fixed +/// one-snapshot capacity. The metadata remains typed and is completed by the +/// bounded metrics contract rather than interpreted by the host cache. +#[derive(Debug)] +pub struct CachedServiceState<M> { + operational: ServiceOperationalState, + metrics: M, +} + +impl<M> CachedServiceState<M> { + #[must_use] + pub const fn new(operational: ServiceOperationalState, metrics: M) -> Self { + Self { + operational, + metrics, + } + } + + #[must_use] + pub const fn operational(&self) -> &ServiceOperationalState { + &self.operational + } + + #[must_use] + pub const fn metrics(&self) -> &M { + &self.metrics + } +} + +/// The sole update authority for one cached service-state channel. +/// +/// This handle deliberately does not implement `Clone`. Services may create +/// any number of readers, but ownership of lifecycle publication stays +/// explicit and singular. +pub struct CachedServiceStatePublisher<M> { + sender: watch::Sender<Arc<CachedServiceState<M>>>, +} + +impl<M> CachedServiceStatePublisher<M> { + /// Publishes one legal lifecycle update and atomically replaces the cache. + pub fn publish(&mut self, next: CachedServiceState<M>) -> Result<(), StatusContractError> { + let current_phase = self.sender.borrow().operational().phase(); + let next_phase = next.operational().phase(); + if !current_phase.can_transition_to(next_phase) { + return Err(StatusContractError::IllegalTransition { + from: current_phase, + to: next_phase, + }); + } + self.sender.send_replace(Arc::new(next)); + Ok(()) + } + + /// Adds a passive reader without sharing publication authority. + #[must_use] + pub fn subscribe(&self) -> CachedServiceStateReader<M> { + CachedServiceStateReader { + receiver: self.sender.subscribe(), + } + } +} + +/// A cloneable passive view of the latest cached service state. +pub struct CachedServiceStateReader<M> { + receiver: watch::Receiver<Arc<CachedServiceState<M>>>, +} + +impl<M> Clone for CachedServiceStateReader<M> { + fn clone(&self) -> Self { + Self { + receiver: self.receiver.clone(), + } + } +} + +impl<M> CachedServiceStateReader<M> { + /// Returns the latest snapshot without executing a probe or awaiting I/O. + #[must_use] + pub fn snapshot(&self) -> Arc<CachedServiceState<M>> { + Arc::clone(&self.receiver.borrow()) + } + + /// Waits for a later publication and then returns that latest snapshot. + pub async fn changed(&mut self) -> Result<Arc<CachedServiceState<M>>, StatusPublisherDropped> { + self.receiver + .changed() + .await + .map_err(|_| StatusPublisherDropped)?; + Ok(self.snapshot_and_mark_seen()) + } + + fn snapshot_and_mark_seen(&mut self) -> Arc<CachedServiceState<M>> { + Arc::clone(&self.receiver.borrow_and_update()) + } +} + +/// Creates a bounded latest-value cache with one publisher and one reader. +#[must_use] +pub fn cached_service_state<M>( + initial: CachedServiceState<M>, +) -> (CachedServiceStatePublisher<M>, CachedServiceStateReader<M>) { + let (sender, receiver) = watch::channel(Arc::new(initial)); + ( + CachedServiceStatePublisher { sender }, + CachedServiceStateReader { receiver }, + ) +} + +/// Indicates that no owner remains able to publish another snapshot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StatusPublisherDropped; + +impl fmt::Display for StatusPublisherDropped { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("cached service-state publisher was dropped") + } +} + +impl std::error::Error for StatusPublisherDropped {} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use super::*; + use crate::{CommonReasonCode, Readiness, ReasonCodes, ServicePhase}; + + #[derive(Debug)] + struct MetricsMetadata { + revision: u64, + probe_calls: Arc<AtomicUsize>, + } + + fn state( + phase: ServicePhase, + readiness: Readiness, + reasons: ReasonCodes, + ) -> ServiceOperationalState { + ServiceOperationalState::new(phase, readiness, reasons).unwrap() + } + + fn metadata(revision: u64, probe_calls: &Arc<AtomicUsize>) -> MetricsMetadata { + MetricsMetadata { + revision, + probe_calls: Arc::clone(probe_calls), + } + } + + #[tokio::test] + async fn concurrent_readers_observe_the_latest_atomic_publication() { + let probe_calls = Arc::new(AtomicUsize::new(0)); + let (mut publisher, reader) = cached_service_state(CachedServiceState::new( + state( + ServicePhase::Starting, + Readiness::NOT_READY, + ReasonCodes::empty(), + ), + metadata(0, &probe_calls), + )); + let readers: Vec<_> = (0..8) + .map(|_| { + let mut reader = reader.clone(); + tokio::spawn(async move { + loop { + let snapshot = reader.changed().await.unwrap(); + if snapshot.metrics().revision == 100 { + return ( + snapshot.operational().phase(), + snapshot.operational().readiness(), + ); + } + } + }) + }) + .collect(); + + publisher + .publish(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(1, &probe_calls), + )) + .unwrap(); + for revision in 2..=100 { + publisher + .publish(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(revision, &probe_calls), + )) + .unwrap(); + } + + for reader in readers { + assert_eq!( + reader.await.unwrap(), + (ServicePhase::Ready, Readiness::READY) + ); + } + assert_eq!(probe_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn shutdown_state_and_illegal_updates_are_explicit() { + let probe_calls = Arc::new(AtomicUsize::new(0)); + let (mut publisher, reader) = cached_service_state(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(4, &probe_calls), + )); + let shutdown_reasons = + ReasonCodes::new([CommonReasonCode::ShutdownInProgress.into()]).unwrap(); + publisher + .publish(CachedServiceState::new( + state( + ServicePhase::Stopping, + Readiness::NOT_READY, + shutdown_reasons, + ), + metadata(5, &probe_calls), + )) + .unwrap(); + + let stopping = reader.snapshot(); + assert_eq!(stopping.operational().phase(), ServicePhase::Stopping); + assert!(!stopping.operational().readiness().is_ready()); + assert_eq!( + stopping.operational().reasons().as_slice()[0].as_str(), + CommonReasonCode::ShutdownInProgress.as_str() + ); + + assert_eq!( + publisher.publish(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty(),), + metadata(6, &probe_calls), + )), + Err(StatusContractError::IllegalTransition { + from: ServicePhase::Stopping, + to: ServicePhase::Ready, + }) + ); + assert_eq!(reader.snapshot().metrics().revision, 5); + } + + #[test] + fn repeated_snapshot_reads_are_passive_and_preserve_one_arc() { + let probe_calls = Arc::new(AtomicUsize::new(0)); + let (_publisher, reader) = cached_service_state(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(1, &probe_calls), + )); + let first = reader.snapshot(); + for _ in 0..1_000 { + let next = reader.snapshot(); + assert!(Arc::ptr_eq(&first, &next)); + assert_eq!(next.metrics().probe_calls.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn reader_clone_and_publisher_drop_retain_the_last_snapshot() { + let probe_calls = Arc::new(AtomicUsize::new(0)); + let (mut publisher, reader) = cached_service_state(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(1, &probe_calls), + )); + let mut clone = reader.clone(); + publisher + .publish(CachedServiceState::new( + state( + ServicePhase::Degraded, + Readiness::READY, + ReasonCodes::new([CommonReasonCode::DatabaseLowDisk.into()]).unwrap(), + ), + metadata(2, &probe_calls), + )) + .unwrap(); + drop(publisher); + + assert_eq!(reader.snapshot().metrics().revision, 2); + assert_eq!(clone.changed().await.unwrap().metrics().revision, 2); + assert!(matches!(clone.changed().await, Err(StatusPublisherDropped))); + assert_eq!( + clone.snapshot().operational().phase(), + ServicePhase::Degraded + ); + } + + #[tokio::test] + async fn race_between_wakeup_and_borrow_marks_the_returned_latest_value_seen() { + let probe_calls = Arc::new(AtomicUsize::new(0)); + let (mut publisher, mut reader) = cached_service_state(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(0, &probe_calls), + )); + publisher + .publish(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(1, &probe_calls), + )) + .unwrap(); + + reader.receiver.changed().await.unwrap(); + publisher + .publish(CachedServiceState::new( + state(ServicePhase::Ready, Readiness::READY, ReasonCodes::empty()), + metadata(2, &probe_calls), + )) + .unwrap(); + + assert_eq!(reader.snapshot_and_mark_seen().metrics().revision, 2); + assert!(!reader.receiver.has_changed().unwrap()); + } +} diff --git a/crates/service_host/src/status/mod.rs b/crates/service_host/src/status/mod.rs @@ -1,9 +1,14 @@ //! Common service lifecycle and status value objects. +mod cache; mod phase; mod reason; mod service; +pub use cache::{ + CachedServiceState, CachedServiceStatePublisher, CachedServiceStateReader, + StatusPublisherDropped, cached_service_state, +}; pub use phase::{Readiness, ServiceOperationalState, ServicePhase, StatusContractError}; pub use reason::{CommonReasonCode, REASON_CODES_MAX_ITEMS, ReasonCode, ReasonCodes}; pub use service::{ diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -25,7 +25,13 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { assert_eq!( dependency_keys(MANIFEST), - BTreeSet::from(["getrandom", "radroots_runtime_paths", "serde", "serde_json"]) + BTreeSet::from([ + "getrandom", + "radroots_runtime_paths", + "serde", + "serde_json", + "tokio", + ]) ); assert_eq!( public_modules(ROOT),