status_v1.rs (39002B)
1 //! Passive, latest-value Rhi lifecycle and detailed-status publication. 2 3 use core::fmt; 4 use std::{error::Error, sync::Arc, time::Duration}; 5 6 use radroots_service_host::{ 7 BoundedMetricsSnapshot, BuildInfo as HostBuildInfo, 8 BuildInfoEnvironment as HostBuildInfoEnvironment, BuildMode as HostBuildMode, 9 CachedServiceState, CachedServiceStatePublisher, CachedServiceStateReader, CommonMetricGroup, 10 ConfigurationIdentity as HostConfigurationIdentity, 11 ConfigurationSource as HostConfigurationSource, ContractVersions as HostContractVersions, 12 InstanceId, IntegrityState as HostIntegrityState, MetricDescriptor, MetricKind, MetricLabel, 13 MetricLabelKey, MetricName, MetricSample, MetricValue, 14 PersistenceHealth as HostPersistenceHealth, PersistenceSummary as HostPersistenceSummary, 15 Readiness as HostReadiness, ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes, 16 ServiceId, ServiceOperationalState as HostServiceOperationalState, 17 ServicePhase as HostServicePhase, ServiceStatus, ServiceStatusDetail, 18 Sha256Digest as HostSha256Digest, StatusContractError, StatusEncodingError, StatusModelError, 19 UptimeMillis as HostUptimeMillis, cached_service_state, 20 }; 21 use serde::Serialize; 22 23 /// Exact version of the passive Rhi status-cache contract. 24 pub const RHI_STATUS_CACHE_CONTRACT_VERSION: u32 = 1; 25 26 /// Maximum encoded byte length of one detailed Rhi status response. 27 pub const RHI_DETAILED_STATUS_MAX_UTF8_BYTES: usize = 28 radroots_service_host::SERVICE_STATUS_MAX_UTF8_BYTES; 29 30 /// Number of stable reason codes admitted by detailed Rhi status. 31 pub const RHI_STATUS_REASON_CODE_COUNT: usize = 13; 32 33 /// One closed stable source-free status reason code. 34 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] 35 #[serde(rename_all = "snake_case")] 36 pub enum RhiStatusReasonCode { 37 IdentityUnavailable, 38 DatabaseSchemaMismatch, 39 DatabaseReadOnly, 40 DatabaseLowDisk, 41 SourceUnavailable, 42 SubscriptionInactive, 43 RecoveryIncomplete, 44 PublicationRecoveryIncomplete, 45 PresenceStateUnavailable, 46 ReconciliationBacklogExceeded, 47 AdminListenerFailed, 48 OperationsListenerFailed, 49 ShutdownInProgress, 50 } 51 52 impl RhiStatusReasonCode { 53 pub fn new(value: impl AsRef<str>) -> Result<Self, RhiStatusError> { 54 match value.as_ref() { 55 "identity_unavailable" => Ok(Self::IdentityUnavailable), 56 "database_schema_mismatch" => Ok(Self::DatabaseSchemaMismatch), 57 "database_read_only" => Ok(Self::DatabaseReadOnly), 58 "database_low_disk" => Ok(Self::DatabaseLowDisk), 59 "source_unavailable" => Ok(Self::SourceUnavailable), 60 "subscription_inactive" => Ok(Self::SubscriptionInactive), 61 "recovery_incomplete" => Ok(Self::RecoveryIncomplete), 62 "publication_recovery_incomplete" => Ok(Self::PublicationRecoveryIncomplete), 63 "presence_state_unavailable" => Ok(Self::PresenceStateUnavailable), 64 "reconciliation_backlog_exceeded" => Ok(Self::ReconciliationBacklogExceeded), 65 "admin_listener_failed" => Ok(Self::AdminListenerFailed), 66 "operations_listener_failed" => Ok(Self::OperationsListenerFailed), 67 "shutdown_in_progress" => Ok(Self::ShutdownInProgress), 68 _ => Err(RhiStatusError::new(RhiStatusErrorKind::InvalidReasonCode)), 69 } 70 } 71 72 #[must_use] 73 pub const fn as_str(self) -> &'static str { 74 match self { 75 Self::IdentityUnavailable => "identity_unavailable", 76 Self::DatabaseSchemaMismatch => "database_schema_mismatch", 77 Self::DatabaseReadOnly => "database_read_only", 78 Self::DatabaseLowDisk => "database_low_disk", 79 Self::SourceUnavailable => "source_unavailable", 80 Self::SubscriptionInactive => "subscription_inactive", 81 Self::RecoveryIncomplete => "recovery_incomplete", 82 Self::PublicationRecoveryIncomplete => "publication_recovery_incomplete", 83 Self::PresenceStateUnavailable => "presence_state_unavailable", 84 Self::ReconciliationBacklogExceeded => "reconciliation_backlog_exceeded", 85 Self::AdminListenerFailed => "admin_listener_failed", 86 Self::OperationsListenerFailed => "operations_listener_failed", 87 Self::ShutdownInProgress => "shutdown_in_progress", 88 } 89 } 90 } 91 92 /// Canonically ordered, unique, bounded status reasons. 93 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] 94 #[serde(transparent)] 95 pub struct RhiStatusReasonCodes(Vec<RhiStatusReasonCode>); 96 97 impl RhiStatusReasonCodes { 98 #[must_use] 99 pub const fn empty() -> Self { 100 Self(Vec::new()) 101 } 102 103 pub fn new( 104 values: impl IntoIterator<Item = RhiStatusReasonCode>, 105 ) -> Result<Self, RhiStatusError> { 106 let mut bounded = Vec::with_capacity(RHI_STATUS_REASON_CODE_COUNT); 107 for value in values.into_iter().take(RHI_STATUS_REASON_CODE_COUNT + 1) { 108 if bounded.len() == RHI_STATUS_REASON_CODE_COUNT { 109 return Err(RhiStatusError::new(RhiStatusErrorKind::TooManyReasonCodes)); 110 } 111 bounded.push(value); 112 } 113 bounded.sort_unstable(); 114 bounded.dedup(); 115 Ok(Self(bounded)) 116 } 117 118 #[must_use] 119 pub fn as_slice(&self) -> &[RhiStatusReasonCode] { 120 &self.0 121 } 122 123 fn into_host(self) -> Result<HostReasonCodes, RhiStatusError> { 124 let values = self 125 .0 126 .into_iter() 127 .map(|value| HostReasonCode::new(value.as_str()).map_err(map_contract_error)) 128 .collect::<Result<Vec<_>, _>>()?; 129 HostReasonCodes::new(values).map_err(map_contract_error) 130 } 131 } 132 133 /// Closed common lifecycle phase used by the Rhi public boundary. 134 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 135 #[serde(rename_all = "snake_case")] 136 pub enum RhiServicePhase { 137 Starting, 138 Ready, 139 Degraded, 140 Unready, 141 Stopping, 142 Failed, 143 } 144 145 impl RhiServicePhase { 146 const fn into_host(self) -> HostServicePhase { 147 match self { 148 Self::Starting => HostServicePhase::Starting, 149 Self::Ready => HostServicePhase::Ready, 150 Self::Degraded => HostServicePhase::Degraded, 151 Self::Unready => HostServicePhase::Unready, 152 Self::Stopping => HostServicePhase::Stopping, 153 Self::Failed => HostServicePhase::Failed, 154 } 155 } 156 157 const fn from_host(value: HostServicePhase) -> Self { 158 match value { 159 HostServicePhase::Starting => Self::Starting, 160 HostServicePhase::Ready => Self::Ready, 161 HostServicePhase::Degraded => Self::Degraded, 162 HostServicePhase::Unready => Self::Unready, 163 HostServicePhase::Stopping => Self::Stopping, 164 HostServicePhase::Failed => Self::Failed, 165 } 166 } 167 } 168 169 /// Build-metadata admission mode for Rhi status identity. 170 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 171 pub enum RhiStatusBuildMode { 172 Development, 173 Release, 174 } 175 176 /// Complete deterministic build identity retained behind the Rhi boundary. 177 pub struct RhiStatusBuildInfoV1 { 178 inner: HostBuildInfo, 179 } 180 181 impl RhiStatusBuildInfoV1 { 182 /// Validates the complete build/source-lock identity with fixed Rhi contracts. 183 pub fn new( 184 mode: RhiStatusBuildMode, 185 service_version: Option<&str>, 186 service_commit: Option<&str>, 187 lib_revision: Option<&str>, 188 rust_version: Option<&str>, 189 target: Option<&str>, 190 feature_profile: Option<&str>, 191 ) -> Result<Self, RhiStatusError> { 192 let contract_versions = HostContractVersions::new( 193 crate::RHI_CONFIG_SCHEMA_VERSION, 194 crate::RHI_STATE_SCHEMA_VERSION, 195 crate::RHI_ADMIN_CONTRACT_VERSION, 196 crate::RHI_STATUS_CONTRACT_VERSION, 197 crate::RHI_PROVIDER_CONTRACT_VERSION, 198 ) 199 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidBuildInfo))?; 200 HostBuildInfo::from_compile_time( 201 match mode { 202 RhiStatusBuildMode::Development => HostBuildMode::Development, 203 RhiStatusBuildMode::Release => HostBuildMode::Release, 204 }, 205 HostBuildInfoEnvironment { 206 service_version, 207 service_commit, 208 lib_revision, 209 rust_version, 210 target, 211 feature_profile, 212 contract_versions, 213 }, 214 ) 215 .map(|inner| Self { inner }) 216 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidBuildInfo)) 217 } 218 } 219 220 impl fmt::Debug for RhiStatusBuildInfoV1 { 221 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 222 formatter.write_str("RhiStatusBuildInfoV1([redacted])") 223 } 224 } 225 226 /// Exact configuration-source vocabulary exposed by detailed status. 227 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 228 pub enum RhiStatusConfigurationSource { 229 ExplicitConfig, 230 DerivedRepoLocal, 231 } 232 233 /// Safe configuration identity retained behind the Rhi boundary. 234 pub struct RhiStatusConfigurationIdentityV1 { 235 inner: HostConfigurationIdentity, 236 } 237 238 impl RhiStatusConfigurationIdentityV1 { 239 pub fn new( 240 digest: impl AsRef<str>, 241 source: RhiStatusConfigurationSource, 242 ) -> Result<Self, RhiStatusError> { 243 let service = ServiceId::new("rhi") 244 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidConfiguration))?; 245 let digest = HostSha256Digest::new(digest) 246 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidConfiguration))?; 247 HostConfigurationIdentity::for_service( 248 &service, 249 digest, 250 match source { 251 RhiStatusConfigurationSource::ExplicitConfig => { 252 HostConfigurationSource::ExplicitConfig 253 } 254 RhiStatusConfigurationSource::DerivedRepoLocal => { 255 HostConfigurationSource::DerivedRepoLocal 256 } 257 }, 258 ) 259 .map(|inner| Self { inner }) 260 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidConfiguration)) 261 } 262 } 263 264 impl fmt::Debug for RhiStatusConfigurationIdentityV1 { 265 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 266 formatter.write_str("RhiStatusConfigurationIdentityV1([redacted])") 267 } 268 } 269 270 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 271 pub enum RhiPersistenceHealthV1 { 272 Ready, 273 ReadOnly, 274 RepairRequired, 275 Unavailable, 276 } 277 278 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 279 pub enum RhiIntegrityStateV1 { 280 Verified, 281 VerificationRequired, 282 Failed, 283 } 284 285 /// Validated persistence summary retained behind the Rhi boundary. 286 pub struct RhiPersistenceStatusV1 { 287 inner: HostPersistenceSummary, 288 ready: bool, 289 } 290 291 impl RhiPersistenceStatusV1 { 292 pub fn new( 293 health: RhiPersistenceHealthV1, 294 schema_version: u32, 295 generation: u64, 296 integrity: RhiIntegrityStateV1, 297 reason_codes: RhiStatusReasonCodes, 298 ) -> Result<Self, RhiStatusError> { 299 let reason_codes = reason_codes.into_host()?; 300 let ready = 301 health == RhiPersistenceHealthV1::Ready && integrity == RhiIntegrityStateV1::Verified; 302 HostPersistenceSummary::new( 303 match health { 304 RhiPersistenceHealthV1::Ready => HostPersistenceHealth::Ready, 305 RhiPersistenceHealthV1::ReadOnly => HostPersistenceHealth::ReadOnly, 306 RhiPersistenceHealthV1::RepairRequired => HostPersistenceHealth::RepairRequired, 307 RhiPersistenceHealthV1::Unavailable => HostPersistenceHealth::Unavailable, 308 }, 309 schema_version, 310 generation, 311 match integrity { 312 RhiIntegrityStateV1::Verified => HostIntegrityState::Verified, 313 RhiIntegrityStateV1::VerificationRequired => { 314 HostIntegrityState::VerificationRequired 315 } 316 RhiIntegrityStateV1::Failed => HostIntegrityState::Failed, 317 }, 318 reason_codes, 319 ) 320 .map(|inner| Self { inner, ready }) 321 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidPersistence)) 322 } 323 } 324 325 impl fmt::Debug for RhiPersistenceStatusV1 { 326 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 327 formatter.write_str("RhiPersistenceStatusV1([redacted])") 328 } 329 } 330 331 /// One validated Unix timestamp used only for an oldest pending work item. 332 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] 333 #[serde(transparent)] 334 pub struct RhiStatusUnixSeconds(u64); 335 336 impl RhiStatusUnixSeconds { 337 /// Constructs a timestamp representable by SQLite and the frozen wire contract. 338 pub fn new(value: u64) -> Result<Self, RhiStatusError> { 339 if value > i64::MAX as u64 { 340 return Err(RhiStatusError::new(RhiStatusErrorKind::InvalidTime)); 341 } 342 Ok(Self(value)) 343 } 344 345 /// Returns exact whole Unix seconds. 346 #[must_use] 347 pub const fn get(self) -> u64 { 348 self.0 349 } 350 } 351 352 /// Passive availability of one configured Rhi identity role. 353 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 354 pub struct RhiIdentityHealthV1 { 355 configured: bool, 356 available: bool, 357 reason_codes: RhiStatusReasonCodes, 358 } 359 360 impl RhiIdentityHealthV1 { 361 /// Constructs one role observation, rejecting availability without configuration. 362 pub fn new( 363 configured: bool, 364 available: bool, 365 reason_codes: RhiStatusReasonCodes, 366 ) -> Result<Self, RhiStatusError> { 367 if available && !configured { 368 return Err(RhiStatusError::new( 369 RhiStatusErrorKind::InvalidIdentityHealth, 370 )); 371 } 372 Ok(Self { 373 configured, 374 available, 375 reason_codes, 376 }) 377 } 378 379 #[must_use] 380 pub const fn is_configured(&self) -> bool { 381 self.configured 382 } 383 384 #[must_use] 385 pub const fn is_available(&self) -> bool { 386 self.available 387 } 388 389 #[must_use] 390 pub const fn reason_codes(&self) -> &RhiStatusReasonCodes { 391 &self.reason_codes 392 } 393 } 394 395 /// Passive projection for the single configured RHI service identity. 396 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 397 pub struct RhiProviderStatusV1 { 398 health: RhiProviderHealthV1, 399 identity: RhiIdentityHealthV1, 400 reason_codes: RhiStatusReasonCodes, 401 } 402 403 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 404 #[serde(rename_all = "snake_case")] 405 pub enum RhiProviderHealthV1 { 406 Ready, 407 Unavailable, 408 } 409 410 impl RhiProviderStatusV1 { 411 /// Derives provider health from the sole configured service identity. 412 pub fn new( 413 identity: RhiIdentityHealthV1, 414 reason_codes: RhiStatusReasonCodes, 415 ) -> Result<Self, RhiStatusError> { 416 if !identity.configured { 417 return Err(RhiStatusError::new( 418 RhiStatusErrorKind::InvalidProviderState, 419 )); 420 } 421 let health = if identity.available { 422 RhiProviderHealthV1::Ready 423 } else { 424 RhiProviderHealthV1::Unavailable 425 }; 426 Ok(Self { 427 health, 428 identity, 429 reason_codes, 430 }) 431 } 432 433 #[must_use] 434 pub const fn health(&self) -> RhiProviderHealthV1 { 435 self.health 436 } 437 438 #[must_use] 439 pub const fn identity(&self) -> &RhiIdentityHealthV1 { 440 &self.identity 441 } 442 443 #[must_use] 444 pub const fn reason_codes(&self) -> &RhiStatusReasonCodes { 445 &self.reason_codes 446 } 447 } 448 449 /// Passive evidence-source transport projection for detailed status. 450 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 451 pub struct RhiEvidenceTransportStatusV1 { 452 health: RhiTransportHealthV1, 453 required_sources_ready: bool, 454 subscriber_active: bool, 455 configured_source_count: u64, 456 reachable_source_count: u64, 457 reason_codes: RhiStatusReasonCodes, 458 } 459 460 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 461 #[serde(rename_all = "snake_case")] 462 pub enum RhiTransportHealthV1 { 463 Ready, 464 Degraded, 465 Unavailable, 466 } 467 468 impl RhiEvidenceTransportStatusV1 { 469 /// Constructs one transport observation, rejecting contradictory ready state. 470 pub fn new( 471 health: RhiTransportHealthV1, 472 required_sources_ready: bool, 473 subscriber_active: bool, 474 configured_source_count: u64, 475 reachable_source_count: u64, 476 reason_codes: RhiStatusReasonCodes, 477 ) -> Result<Self, RhiStatusError> { 478 if reachable_source_count > configured_source_count 479 || (health == RhiTransportHealthV1::Ready 480 && (!required_sources_ready 481 || !subscriber_active 482 || configured_source_count == 0 483 || reachable_source_count == 0)) 484 { 485 return Err(RhiStatusError::new( 486 RhiStatusErrorKind::InvalidTransportState, 487 )); 488 } 489 Ok(Self { 490 health, 491 required_sources_ready, 492 subscriber_active, 493 configured_source_count, 494 reachable_source_count, 495 reason_codes, 496 }) 497 } 498 499 #[must_use] 500 pub const fn health(&self) -> RhiTransportHealthV1 { 501 self.health 502 } 503 504 #[must_use] 505 pub const fn required_sources_ready(&self) -> bool { 506 self.required_sources_ready 507 } 508 509 #[must_use] 510 pub const fn subscriber_active(&self) -> bool { 511 self.subscriber_active 512 } 513 514 #[must_use] 515 pub const fn configured_source_count(&self) -> u64 { 516 self.configured_source_count 517 } 518 519 #[must_use] 520 pub const fn reachable_source_count(&self) -> u64 { 521 self.reachable_source_count 522 } 523 524 #[must_use] 525 pub const fn reason_codes(&self) -> &RhiStatusReasonCodes { 526 &self.reason_codes 527 } 528 } 529 530 /// Passive bounded reconciliation-work summary. 531 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] 532 pub struct RhiReconciliationStatusV1 { 533 pending: u64, 534 leased: u64, 535 exhausted: u64, 536 #[serde(skip_serializing_if = "Option::is_none")] 537 oldest_pending_at_utc: Option<RhiStatusUnixSeconds>, 538 } 539 540 impl RhiReconciliationStatusV1 { 541 #[must_use] 542 pub const fn new( 543 pending: u64, 544 leased: u64, 545 exhausted: u64, 546 oldest_pending_at_utc: Option<RhiStatusUnixSeconds>, 547 ) -> Self { 548 Self { 549 pending, 550 leased, 551 exhausted, 552 oldest_pending_at_utc, 553 } 554 } 555 556 #[must_use] 557 pub const fn pending(self) -> u64 { 558 self.pending 559 } 560 561 #[must_use] 562 pub const fn leased(self) -> u64 { 563 self.leased 564 } 565 566 #[must_use] 567 pub const fn exhausted(self) -> u64 { 568 self.exhausted 569 } 570 571 #[must_use] 572 pub const fn oldest_pending_at_utc(self) -> Option<RhiStatusUnixSeconds> { 573 self.oldest_pending_at_utc 574 } 575 } 576 577 /// Passive publication-outbox summary derived before cache publication. 578 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] 579 pub struct RhiPublicationStatusV1 { 580 pending: u64, 581 unknown: u64, 582 #[serde(skip_serializing_if = "Option::is_none")] 583 oldest_pending_at_utc: Option<RhiStatusUnixSeconds>, 584 } 585 586 impl RhiPublicationStatusV1 { 587 #[must_use] 588 pub const fn new( 589 pending: u64, 590 unknown: u64, 591 oldest_pending_at_utc: Option<RhiStatusUnixSeconds>, 592 ) -> Self { 593 Self { 594 pending, 595 unknown, 596 oldest_pending_at_utc, 597 } 598 } 599 600 #[must_use] 601 pub const fn pending(self) -> u64 { 602 self.pending 603 } 604 605 #[must_use] 606 pub const fn unknown(self) -> u64 { 607 self.unknown 608 } 609 610 #[must_use] 611 pub const fn oldest_pending_at_utc(self) -> Option<RhiStatusUnixSeconds> { 612 self.oldest_pending_at_utc 613 } 614 } 615 616 /// Passive desired-presence publication summary. 617 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] 618 pub struct RhiPresenceStatusV1 { 619 pending: u64, 620 unknown: u64, 621 } 622 623 impl RhiPresenceStatusV1 { 624 #[must_use] 625 pub const fn new(pending: u64, unknown: u64) -> Self { 626 Self { pending, unknown } 627 } 628 629 #[must_use] 630 pub const fn pending(self) -> u64 { 631 self.pending 632 } 633 634 #[must_use] 635 pub const fn unknown(self) -> u64 { 636 self.unknown 637 } 638 } 639 640 #[derive(Serialize)] 641 struct RhiStatusDetailV1 { 642 identity: RhiIdentityHealthV1, 643 reconciliation: RhiReconciliationStatusV1, 644 publication: RhiPublicationStatusV1, 645 presence: RhiPresenceStatusV1, 646 } 647 648 impl ServiceStatusDetail for RhiStatusDetailV1 { 649 type Provider = RhiProviderStatusV1; 650 type Transport = RhiEvidenceTransportStatusV1; 651 652 const FIELD_NAME: &'static str = "rhi"; 653 } 654 655 /// Validated common fields shared by one detailed status publication. 656 /// 657 /// Construction and publication perform validation and bounded encoding only. 658 /// They do not query SQLite, providers, sources, relays, DNS, credentials, or the clock. 659 pub struct RhiStatusCommonV1 { 660 operational: HostServiceOperationalState, 661 uptime: HostUptimeMillis, 662 build: HostBuildInfo, 663 configuration: HostConfigurationIdentity, 664 persistence: HostPersistenceSummary, 665 persistence_ready: bool, 666 } 667 668 impl RhiStatusCommonV1 { 669 /// Validates the common lifecycle and detailed-status envelope fields. 670 pub fn new( 671 phase: RhiServicePhase, 672 ready: bool, 673 reason_codes: RhiStatusReasonCodes, 674 uptime_millis: u64, 675 build: RhiStatusBuildInfoV1, 676 configuration: RhiStatusConfigurationIdentityV1, 677 persistence: RhiPersistenceStatusV1, 678 ) -> Result<Self, RhiStatusError> { 679 let operational = HostServiceOperationalState::new( 680 phase.into_host(), 681 if ready { 682 HostReadiness::READY 683 } else { 684 HostReadiness::NOT_READY 685 }, 686 reason_codes.into_host()?, 687 ) 688 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidLifecycle))?; 689 let uptime = HostUptimeMillis::from_duration(Duration::from_millis(uptime_millis)) 690 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidTime))?; 691 Ok(Self { 692 operational, 693 uptime, 694 build: build.inner, 695 configuration: configuration.inner, 696 persistence: persistence.inner, 697 persistence_ready: persistence.ready, 698 }) 699 } 700 } 701 702 impl fmt::Debug for RhiStatusCommonV1 { 703 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 704 formatter.write_str("RhiStatusCommonV1([redacted])") 705 } 706 } 707 708 /// One complete, already-observed status publication input. 709 pub struct RhiStatusObservationV1 { 710 common: RhiStatusCommonV1, 711 provider: RhiProviderStatusV1, 712 transport: RhiEvidenceTransportStatusV1, 713 reconciliation: RhiReconciliationStatusV1, 714 publication: RhiPublicationStatusV1, 715 presence: RhiPresenceStatusV1, 716 } 717 718 impl RhiStatusObservationV1 { 719 #[must_use] 720 pub fn new( 721 common: RhiStatusCommonV1, 722 provider: RhiProviderStatusV1, 723 transport: RhiEvidenceTransportStatusV1, 724 reconciliation: RhiReconciliationStatusV1, 725 publication: RhiPublicationStatusV1, 726 presence: RhiPresenceStatusV1, 727 ) -> Self { 728 Self { 729 common, 730 provider, 731 transport, 732 reconciliation, 733 publication, 734 presence, 735 } 736 } 737 738 fn into_cached(self, instance: &InstanceId) -> Result<PreparedRhiStatus, RhiStatusError> { 739 let operational = self.common.operational.clone(); 740 if operational.readiness().is_ready() 741 && (!self.common.persistence_ready 742 || self.provider.health != RhiProviderHealthV1::Ready 743 || !self.transport.required_sources_ready 744 || !self.transport.subscriber_active) 745 { 746 return Err(RhiStatusError::new(RhiStatusErrorKind::InvalidLifecycle)); 747 } 748 if operational.phase() == HostServicePhase::Ready 749 && self.transport.health != RhiTransportHealthV1::Ready 750 { 751 return Err(RhiStatusError::new(RhiStatusErrorKind::InvalidLifecycle)); 752 } 753 let operations_metrics = bounded_operations_metrics(&operational)?; 754 let detail = RhiStatusDetailV1 { 755 identity: self.provider.identity.clone(), 756 reconciliation: self.reconciliation, 757 publication: self.publication, 758 presence: self.presence, 759 }; 760 let service = ServiceId::new("rhi") 761 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidModel))?; 762 let status = ServiceStatus::new( 763 service, 764 instance.clone(), 765 self.common.operational, 766 self.common.uptime, 767 self.common.build, 768 self.common.configuration, 769 self.common.persistence, 770 self.provider, 771 self.transport, 772 detail, 773 ) 774 .map_err(map_model_error)?; 775 let json = status.to_bounded_json().map_err(map_encoding_error)?; 776 Ok(PreparedRhiStatus { 777 detail: CachedServiceState::new( 778 operational.clone(), 779 RhiCachedStatus { 780 json: json.into_boxed_slice(), 781 }, 782 ), 783 operations: CachedServiceState::new(operational, operations_metrics), 784 }) 785 } 786 } 787 788 impl fmt::Debug for RhiStatusObservationV1 { 789 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 790 formatter.write_str("RhiStatusObservationV1([redacted])") 791 } 792 } 793 794 struct RhiCachedStatus { 795 json: Box<[u8]>, 796 } 797 798 struct PreparedRhiStatus { 799 detail: CachedServiceState<RhiCachedStatus>, 800 operations: CachedServiceState<BoundedMetricsSnapshot>, 801 } 802 803 fn bounded_operations_metrics( 804 operational: &HostServiceOperationalState, 805 ) -> Result<BoundedMetricsSnapshot, RhiStatusError> { 806 let phase_name = MetricName::new("radroots_rhi_service_phase").map_err(map_metrics_error)?; 807 let ready_name = MetricName::new("radroots_rhi_service_ready").map_err(map_metrics_error)?; 808 let descriptors = [ 809 MetricDescriptor::new( 810 CommonMetricGroup::Phase, 811 phase_name.clone(), 812 "Current cached Rhi service phase.", 813 MetricKind::Gauge, 814 [MetricLabelKey::Phase], 815 ) 816 .map_err(map_metrics_error)?, 817 MetricDescriptor::new( 818 CommonMetricGroup::Phase, 819 ready_name.clone(), 820 "Current cached Rhi readiness bit.", 821 MetricKind::Gauge, 822 [], 823 ) 824 .map_err(map_metrics_error)?, 825 ]; 826 let samples = [ 827 MetricSample::new( 828 phase_name, 829 MetricValue::Gauge(1), 830 [MetricLabel::phase(operational.phase())], 831 ) 832 .map_err(map_metrics_error)?, 833 MetricSample::new( 834 ready_name, 835 MetricValue::Gauge(i64::from(operational.readiness().is_ready())), 836 [], 837 ) 838 .map_err(map_metrics_error)?, 839 ]; 840 BoundedMetricsSnapshot::new(descriptors, samples).map_err(map_metrics_error) 841 } 842 843 fn map_metrics_error(_: radroots_service_host::MetricsContractError) -> RhiStatusError { 844 RhiStatusError::new(RhiStatusErrorKind::InvalidModel) 845 } 846 847 impl fmt::Debug for RhiCachedStatus { 848 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 849 formatter 850 .debug_struct("RhiCachedStatus") 851 .field("json_utf8_bytes", &self.json.len()) 852 .finish() 853 } 854 } 855 856 /// Sole publication authority for one process-local Rhi status cache. 857 /// 858 /// This type deliberately does not implement `Clone`. A successful publish 859 /// atomically replaces the one retained snapshot. A failed encoding or illegal 860 /// lifecycle transition leaves the previous snapshot unchanged. 861 pub struct RhiStatusPublisher { 862 instance: InstanceId, 863 inner: CachedServiceStatePublisher<RhiCachedStatus>, 864 operations: CachedServiceStatePublisher<BoundedMetricsSnapshot>, 865 } 866 867 impl RhiStatusPublisher { 868 /// Encodes one observation, publishes its passive operations projection, 869 /// and then atomically replaces the detailed-status snapshot. 870 pub fn publish(&mut self, next: RhiStatusObservationV1) -> Result<(), RhiStatusError> { 871 let next = next.into_cached(&self.instance)?; 872 self.operations 873 .publish(next.operations) 874 .map_err(map_contract_error)?; 875 self.inner.publish(next.detail).map_err(map_contract_error) 876 } 877 878 /// Creates another passive reader without sharing publication authority. 879 #[must_use] 880 pub fn subscribe(&self) -> RhiStatusReader { 881 RhiStatusReader { 882 inner: self.inner.subscribe(), 883 operations: self.operations.subscribe(), 884 } 885 } 886 } 887 888 impl fmt::Debug for RhiStatusPublisher { 889 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 890 formatter.write_str("RhiStatusPublisher([sealed])") 891 } 892 } 893 894 /// Cloneable passive reader of the latest Rhi lifecycle and detailed status. 895 pub struct RhiStatusReader { 896 inner: CachedServiceStateReader<RhiCachedStatus>, 897 operations: CachedServiceStateReader<BoundedMetricsSnapshot>, 898 } 899 900 impl Clone for RhiStatusReader { 901 fn clone(&self) -> Self { 902 Self { 903 inner: self.inner.clone(), 904 operations: self.operations.clone(), 905 } 906 } 907 } 908 909 impl RhiStatusReader { 910 /// Returns the latest immutable snapshot without awaiting or probing. 911 #[must_use] 912 pub fn snapshot(&self) -> RhiStatusSnapshot { 913 RhiStatusSnapshot { 914 inner: self.inner.snapshot(), 915 } 916 } 917 918 /// Waits for a later publication and returns the newest retained value. 919 pub async fn changed(&mut self) -> Result<RhiStatusSnapshot, RhiStatusError> { 920 self.inner 921 .changed() 922 .await 923 .map(|inner| RhiStatusSnapshot { inner }) 924 .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::PublisherDropped)) 925 } 926 927 pub(crate) fn operations_cache(&self) -> CachedServiceStateReader<BoundedMetricsSnapshot> { 928 self.operations.clone() 929 } 930 } 931 932 impl fmt::Debug for RhiStatusReader { 933 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 934 formatter.write_str("RhiStatusReader([passive])") 935 } 936 } 937 938 /// One immutable point-in-time status snapshot backed by the retained cache `Arc`. 939 pub struct RhiStatusSnapshot { 940 inner: Arc<CachedServiceState<RhiCachedStatus>>, 941 } 942 943 impl RhiStatusSnapshot { 944 #[must_use] 945 pub fn phase(&self) -> RhiServicePhase { 946 RhiServicePhase::from_host(self.inner.operational().phase()) 947 } 948 949 #[must_use] 950 pub fn is_ready(&self) -> bool { 951 self.inner.operational().readiness().is_ready() 952 } 953 954 /// Returns the already-bounded canonical detailed-status JSON bytes. 955 #[must_use] 956 pub fn detailed_status_json(&self) -> &[u8] { 957 &self.inner.metrics().json 958 } 959 } 960 961 impl fmt::Debug for RhiStatusSnapshot { 962 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 963 formatter 964 .debug_struct("RhiStatusSnapshot") 965 .field("phase", &self.phase()) 966 .field("ready", &self.is_ready()) 967 .field("json_utf8_bytes", &self.detailed_status_json().len()) 968 .finish() 969 } 970 } 971 972 /// Creates the single-writer, one-latest-value Rhi status cache. 973 pub fn rhi_status_cache( 974 instance: InstanceId, 975 initial: RhiStatusObservationV1, 976 ) -> Result<(RhiStatusPublisher, RhiStatusReader), RhiStatusError> { 977 let initial = initial.into_cached(&instance)?; 978 let (inner, reader) = cached_service_state(initial.detail); 979 let (operations, operations_reader) = cached_service_state(initial.operations); 980 Ok(( 981 RhiStatusPublisher { 982 instance, 983 inner, 984 operations, 985 }, 986 RhiStatusReader { 987 inner: reader, 988 operations: operations_reader, 989 }, 990 )) 991 } 992 993 /// Stable source-free status failure category. 994 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 995 pub enum RhiStatusErrorKind { 996 InvalidReasonCode, 997 TooManyReasonCodes, 998 InvalidLifecycle, 999 InvalidBuildInfo, 1000 InvalidConfiguration, 1001 InvalidPersistence, 1002 InvalidIdentityHealth, 1003 InvalidProviderState, 1004 InvalidTransportState, 1005 InvalidTime, 1006 InvalidModel, 1007 Encoding, 1008 ResponseTooLarge, 1009 InvalidTransition, 1010 PublisherDropped, 1011 } 1012 1013 impl RhiStatusErrorKind { 1014 #[must_use] 1015 pub const fn code(self) -> &'static str { 1016 match self { 1017 Self::InvalidReasonCode => "status_reason_code_invalid", 1018 Self::TooManyReasonCodes => "status_reason_count_exceeded", 1019 Self::InvalidLifecycle => "status_lifecycle_invalid", 1020 Self::InvalidBuildInfo => "status_build_info_invalid", 1021 Self::InvalidConfiguration => "status_configuration_invalid", 1022 Self::InvalidPersistence => "status_persistence_invalid", 1023 Self::InvalidIdentityHealth => "status_identity_health_invalid", 1024 Self::InvalidProviderState => "status_provider_state_invalid", 1025 Self::InvalidTransportState => "status_transport_state_invalid", 1026 Self::InvalidTime => "status_time_invalid", 1027 Self::InvalidModel => "status_model_invalid", 1028 Self::Encoding => "status_encoding_failed", 1029 Self::ResponseTooLarge => "status_response_too_large", 1030 Self::InvalidTransition => "status_transition_invalid", 1031 Self::PublisherDropped => "status_publisher_dropped", 1032 } 1033 } 1034 1035 const fn message(self) -> &'static str { 1036 match self { 1037 Self::InvalidReasonCode => "Rhi status reason code is invalid", 1038 Self::TooManyReasonCodes => "Rhi status has too many reason codes", 1039 Self::InvalidLifecycle => "Rhi lifecycle status is invalid", 1040 Self::InvalidBuildInfo => "Rhi status build identity is invalid", 1041 Self::InvalidConfiguration => "Rhi status configuration identity is invalid", 1042 Self::InvalidPersistence => "Rhi persistence status is invalid", 1043 Self::InvalidIdentityHealth => "Rhi identity health is invalid", 1044 Self::InvalidProviderState => "Rhi provider status is invalid", 1045 Self::InvalidTransportState => "Rhi transport status is invalid", 1046 Self::InvalidTime => "Rhi status time is invalid", 1047 Self::InvalidModel => "Rhi detailed status is invalid", 1048 Self::Encoding => "Rhi detailed status encoding failed", 1049 Self::ResponseTooLarge => "Rhi detailed status exceeds its byte limit", 1050 Self::InvalidTransition => "Rhi lifecycle transition is invalid", 1051 Self::PublisherDropped => "Rhi status publisher is unavailable", 1052 } 1053 } 1054 } 1055 1056 /// One redacted source-free status failure. 1057 #[derive(Clone, Copy, PartialEq, Eq)] 1058 pub struct RhiStatusError { 1059 kind: RhiStatusErrorKind, 1060 } 1061 1062 impl RhiStatusError { 1063 const fn new(kind: RhiStatusErrorKind) -> Self { 1064 Self { kind } 1065 } 1066 1067 #[must_use] 1068 pub const fn kind(self) -> RhiStatusErrorKind { 1069 self.kind 1070 } 1071 1072 #[must_use] 1073 pub const fn code(self) -> &'static str { 1074 self.kind.code() 1075 } 1076 } 1077 1078 impl fmt::Debug for RhiStatusError { 1079 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1080 formatter 1081 .debug_struct("RhiStatusError") 1082 .field("kind", &self.kind) 1083 .finish() 1084 } 1085 } 1086 1087 impl fmt::Display for RhiStatusError { 1088 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1089 formatter.write_str(self.kind.message()) 1090 } 1091 } 1092 1093 impl Error for RhiStatusError {} 1094 1095 const fn map_model_error(_error: StatusModelError) -> RhiStatusError { 1096 RhiStatusError::new(RhiStatusErrorKind::InvalidModel) 1097 } 1098 1099 const fn map_encoding_error(error: StatusEncodingError) -> RhiStatusError { 1100 match error { 1101 StatusEncodingError::EncodingFailed => RhiStatusError::new(RhiStatusErrorKind::Encoding), 1102 StatusEncodingError::ResponseTooLarge => { 1103 RhiStatusError::new(RhiStatusErrorKind::ResponseTooLarge) 1104 } 1105 } 1106 } 1107 1108 const fn map_contract_error(_error: StatusContractError) -> RhiStatusError { 1109 RhiStatusError::new(RhiStatusErrorKind::InvalidTransition) 1110 } 1111 1112 #[cfg(test)] 1113 mod tests { 1114 use super::*; 1115 1116 #[test] 1117 fn invalid_status_inputs_fail_with_safe_source_free_errors() { 1118 assert_eq!( 1119 RhiIdentityHealthV1::new(false, true, RhiStatusReasonCodes::empty()), 1120 Err(RhiStatusError::new( 1121 RhiStatusErrorKind::InvalidIdentityHealth 1122 )) 1123 ); 1124 assert_eq!( 1125 RhiProviderStatusV1::new( 1126 RhiIdentityHealthV1::new(false, false, RhiStatusReasonCodes::empty()).unwrap(), 1127 RhiStatusReasonCodes::empty(), 1128 ), 1129 Err(RhiStatusError::new( 1130 RhiStatusErrorKind::InvalidProviderState 1131 )) 1132 ); 1133 assert_eq!( 1134 RhiEvidenceTransportStatusV1::new( 1135 RhiTransportHealthV1::Ready, 1136 false, 1137 true, 1138 1, 1139 0, 1140 RhiStatusReasonCodes::empty(), 1141 ), 1142 Err(RhiStatusError::new( 1143 RhiStatusErrorKind::InvalidTransportState 1144 )) 1145 ); 1146 assert_eq!( 1147 RhiEvidenceTransportStatusV1::new( 1148 RhiTransportHealthV1::Ready, 1149 true, 1150 true, 1151 1, 1152 2, 1153 RhiStatusReasonCodes::empty(), 1154 ), 1155 Err(RhiStatusError::new( 1156 RhiStatusErrorKind::InvalidTransportState 1157 )) 1158 ); 1159 assert_eq!( 1160 RhiStatusUnixSeconds::new(i64::MAX as u64 + 1), 1161 Err(RhiStatusError::new(RhiStatusErrorKind::InvalidTime)) 1162 ); 1163 for kind in [ 1164 RhiStatusErrorKind::InvalidReasonCode, 1165 RhiStatusErrorKind::TooManyReasonCodes, 1166 RhiStatusErrorKind::InvalidLifecycle, 1167 RhiStatusErrorKind::InvalidBuildInfo, 1168 RhiStatusErrorKind::InvalidConfiguration, 1169 RhiStatusErrorKind::InvalidPersistence, 1170 RhiStatusErrorKind::InvalidIdentityHealth, 1171 RhiStatusErrorKind::InvalidProviderState, 1172 RhiStatusErrorKind::InvalidTransportState, 1173 RhiStatusErrorKind::InvalidTime, 1174 RhiStatusErrorKind::InvalidModel, 1175 RhiStatusErrorKind::Encoding, 1176 RhiStatusErrorKind::ResponseTooLarge, 1177 RhiStatusErrorKind::InvalidTransition, 1178 RhiStatusErrorKind::PublisherDropped, 1179 ] { 1180 let error = RhiStatusError::new(kind); 1181 assert!(!error.code().is_empty()); 1182 assert!(Error::source(&error).is_none()); 1183 assert!(!format!("{error} {error:?}").contains("source")); 1184 } 1185 } 1186 }