use crate::{fault::Fault, jsonrpc::RpcMethod, types::Generation};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleState {
Cold,
Starting,
Ready,
Recovering,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum WorkerHandshakePhase {
Absent,
Starting,
Initializing,
Ready,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RolloutState {
Stable,
Pending,
Reloading,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HealthSnapshot {
pub state: LifecycleState,
pub worker_handshake: WorkerHandshakePhase,
pub generation: Generation,
pub uptime_ms: u64,
pub consecutive_failures: u32,
pub restart_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout: Option<RolloutState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_fault: Option<Fault>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct TelemetryTotals {
request_count: u64,
success_count: u64,
error_count: u64,
response_error_count: u64,
recovery_error_count: u64,
recovery_fault_count: u64,
retry_count: u64,
}
impl TelemetryTotals {
#[must_use]
pub const fn request_count(&self) -> u64 {
self.request_count
}
#[must_use]
pub const fn success_count(&self) -> u64 {
self.success_count
}
#[must_use]
pub const fn error_count(&self) -> u64 {
self.error_count
}
#[must_use]
pub const fn response_error_count(&self) -> u64 {
self.response_error_count
}
#[must_use]
pub const fn recovery_error_count(&self) -> u64 {
self.recovery_error_count
}
#[must_use]
pub const fn recovery_fault_count(&self) -> u64 {
self.recovery_fault_count
}
#[must_use]
pub const fn retry_count(&self) -> u64 {
self.retry_count
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MethodTelemetry {
method: RpcMethod,
request_count: u64,
success_count: u64,
error_count: u64,
response_error_count: u64,
recovery_error_count: u64,
recovery_fault_count: u64,
retry_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
last_latency_ms: Option<u64>,
max_latency_ms: u64,
avg_latency_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
last_error: Option<String>,
}
impl MethodTelemetry {
#[must_use]
pub const fn method(&self) -> &RpcMethod {
&self.method
}
#[must_use]
pub const fn request_count(&self) -> u64 {
self.request_count
}
#[must_use]
pub const fn success_count(&self) -> u64 {
self.success_count
}
#[must_use]
pub const fn error_count(&self) -> u64 {
self.error_count
}
#[must_use]
pub const fn response_error_count(&self) -> u64 {
self.response_error_count
}
#[must_use]
pub const fn recovery_error_count(&self) -> u64 {
self.recovery_error_count
}
#[must_use]
pub const fn recovery_fault_count(&self) -> u64 {
self.recovery_fault_count
}
#[must_use]
pub const fn retry_count(&self) -> u64 {
self.retry_count
}
#[must_use]
pub const fn last_latency_ms(&self) -> Option<u64> {
self.last_latency_ms
}
#[must_use]
pub const fn max_latency_ms(&self) -> u64 {
self.max_latency_ms
}
#[must_use]
pub const fn avg_latency_ms(&self) -> u64 {
self.avg_latency_ms
}
#[must_use]
pub fn last_error(&self) -> Option<&str> {
self.last_error.as_deref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct TelemetrySnapshot {
pub uptime_ms: u64,
pub state: LifecycleState,
pub worker_handshake: WorkerHandshakePhase,
pub generation: Generation,
pub consecutive_failures: u32,
pub restart_count: u64,
pub totals: TelemetryTotals,
pub methods: Vec<MethodTelemetry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_fault: Option<Fault>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct OperationalLedger {
generation: Generation,
consecutive_failures: u32,
restart_count: u64,
totals: TelemetryTotals,
methods: BTreeMap<RpcMethod, MethodLedger>,
last_fault: Option<Fault>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
struct MethodLedger {
request_count: u64,
in_flight: u64,
success_count: u64,
error_count: u64,
response_error_count: u64,
recovery_error_count: u64,
recovery_fault_count: u64,
retry_count: u64,
latency_sample_count: u64,
total_latency_ms: u64,
last_latency_ms: Option<u64>,
max_latency_ms: u64,
last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum OperationalMetricError {
#[error("operational metric counter exhausted: {0}")]
CounterExhausted(&'static str),
#[error("method has no in-flight invocation")]
NoInFlightInvocation,
#[error("replacement worker generation must strictly increase")]
GenerationDidNotAdvance,
#[error("fault generation does not match the active worker generation")]
FaultGenerationMismatch,
}
impl OperationalLedger {
#[must_use]
pub fn new(generation: Generation) -> Self {
Self {
generation,
consecutive_failures: 0,
restart_count: 0,
totals: TelemetryTotals {
request_count: 0,
success_count: 0,
error_count: 0,
response_error_count: 0,
recovery_error_count: 0,
recovery_fault_count: 0,
retry_count: 0,
},
methods: BTreeMap::new(),
last_fault: None,
}
}
pub fn record_request(&mut self, method: RpcMethod) -> Result<(), OperationalMetricError> {
let mut next = self.clone();
let method_metrics = next.methods.entry(method).or_default();
method_metrics.request_count =
checked_increment(method_metrics.request_count, "method requests")?;
method_metrics.in_flight = checked_increment(method_metrics.in_flight, "method in flight")?;
next.totals.request_count = checked_increment(next.totals.request_count, "total requests")?;
*self = next;
Ok(())
}
pub fn record_success(
&mut self,
method: &RpcMethod,
latency_ms: u64,
) -> Result<(), OperationalMetricError> {
let mut next = self.clone();
let metrics = next.live_method_mut(method)?;
record_latency(metrics, latency_ms)?;
metrics.success_count = checked_increment(metrics.success_count, "method successes")?;
metrics.in_flight -= 1;
next.totals.success_count =
checked_increment(next.totals.success_count, "total successes")?;
next.consecutive_failures = 0;
*self = next;
Ok(())
}
pub fn record_error(
&mut self,
method: &RpcMethod,
latency_ms: u64,
detail: impl Into<String>,
) -> Result<(), OperationalMetricError> {
let detail = detail.into();
let mut next = self.clone();
let metrics = next.live_method_mut(method)?;
record_latency(metrics, latency_ms)?;
metrics.error_count = checked_increment(metrics.error_count, "method errors")?;
metrics.in_flight -= 1;
metrics.last_error = Some(detail);
next.totals.error_count = checked_increment(next.totals.error_count, "total errors")?;
*self = next;
Ok(())
}
pub fn record_response_error(
&mut self,
method: &RpcMethod,
latency_ms: u64,
detail: impl Into<String>,
) -> Result<(), OperationalMetricError> {
let detail = detail.into();
let mut next = self.clone();
let metrics = next.live_method_mut(method)?;
record_latency(metrics, latency_ms)?;
metrics.response_error_count =
checked_increment(metrics.response_error_count, "method response errors")?;
metrics.error_count = checked_increment(metrics.error_count, "method errors")?;
metrics.in_flight -= 1;
metrics.last_error = Some(detail);
next.totals.response_error_count =
checked_increment(next.totals.response_error_count, "total response errors")?;
next.totals.error_count = checked_increment(next.totals.error_count, "total errors")?;
*self = next;
Ok(())
}
pub fn record_recovery_error(
&mut self,
method: &RpcMethod,
detail: impl Into<String>,
) -> Result<(), OperationalMetricError> {
let detail = detail.into();
let mut next = self.clone();
let metrics = next.live_method_mut(method)?;
metrics.recovery_error_count =
checked_increment(metrics.recovery_error_count, "method recovery errors")?;
metrics.error_count = checked_increment(metrics.error_count, "method errors")?;
metrics.in_flight -= 1;
metrics.last_error = Some(detail);
next.totals.recovery_error_count =
checked_increment(next.totals.recovery_error_count, "total recovery errors")?;
next.totals.error_count = checked_increment(next.totals.error_count, "total errors")?;
*self = next;
Ok(())
}
pub fn record_recovery_fault(
&mut self,
method: Option<&RpcMethod>,
fault: Fault,
) -> Result<(), OperationalMetricError> {
if fault.generation != self.generation {
return Err(OperationalMetricError::FaultGenerationMismatch);
}
let mut next = self.clone();
if let Some(method) = method {
let metrics = next.live_method_mut(method)?;
metrics.recovery_fault_count =
checked_increment(metrics.recovery_fault_count, "method recovery faults")?;
metrics.last_error = Some(fault.detail.clone());
}
next.totals.recovery_fault_count =
checked_increment(next.totals.recovery_fault_count, "total recovery faults")?;
next.consecutive_failures = next.consecutive_failures.checked_add(1).ok_or(
OperationalMetricError::CounterExhausted("consecutive failures"),
)?;
next.last_fault = Some(fault);
*self = next;
Ok(())
}
pub fn record_replay(&mut self, method: &RpcMethod) -> Result<(), OperationalMetricError> {
let mut next = self.clone();
let metrics = next.live_method_mut(method)?;
metrics.retry_count = checked_increment(metrics.retry_count, "method retries")?;
next.totals.retry_count = checked_increment(next.totals.retry_count, "total retries")?;
*self = next;
Ok(())
}
pub fn replace_worker(&mut self, generation: Generation) -> Result<(), OperationalMetricError> {
if generation <= self.generation {
return Err(OperationalMetricError::GenerationDidNotAdvance);
}
let mut next = self.clone();
next.restart_count = checked_increment(next.restart_count, "worker restarts")?;
next.generation = generation;
*self = next;
Ok(())
}
#[must_use]
pub fn health_snapshot(
&self,
uptime_ms: u64,
state: LifecycleState,
worker_handshake: WorkerHandshakePhase,
rollout: Option<RolloutState>,
) -> HealthSnapshot {
HealthSnapshot {
state,
worker_handshake,
generation: self.generation,
uptime_ms,
consecutive_failures: self.consecutive_failures,
restart_count: self.restart_count,
rollout,
last_fault: self.last_fault.clone(),
}
}
#[must_use]
pub fn telemetry_snapshot(
&self,
uptime_ms: u64,
state: LifecycleState,
worker_handshake: WorkerHandshakePhase,
) -> TelemetrySnapshot {
let methods = self
.methods
.iter()
.map(|(method, metrics)| method_snapshot(method, metrics))
.collect();
TelemetrySnapshot {
uptime_ms,
state,
worker_handshake,
generation: self.generation,
consecutive_failures: self.consecutive_failures,
restart_count: self.restart_count,
totals: self.totals.clone(),
methods,
last_fault: self.last_fault.clone(),
}
}
fn live_method_mut(
&mut self,
method: &RpcMethod,
) -> Result<&mut MethodLedger, OperationalMetricError> {
self.methods
.get_mut(method)
.filter(|metrics| metrics.in_flight > 0)
.ok_or(OperationalMetricError::NoInFlightInvocation)
}
}
fn checked_increment(value: u64, name: &'static str) -> Result<u64, OperationalMetricError> {
value
.checked_add(1)
.ok_or(OperationalMetricError::CounterExhausted(name))
}
fn record_latency(
metrics: &mut MethodLedger,
latency_ms: u64,
) -> Result<(), OperationalMetricError> {
metrics.latency_sample_count =
checked_increment(metrics.latency_sample_count, "method latency samples")?;
metrics.total_latency_ms = metrics.total_latency_ms.checked_add(latency_ms).ok_or(
OperationalMetricError::CounterExhausted("method total latency"),
)?;
metrics.last_latency_ms = Some(latency_ms);
metrics.max_latency_ms = metrics.max_latency_ms.max(latency_ms);
Ok(())
}
fn method_snapshot(method: &RpcMethod, metrics: &MethodLedger) -> MethodTelemetry {
let avg_latency_ms = metrics
.total_latency_ms
.checked_div(metrics.latency_sample_count)
.unwrap_or(0);
MethodTelemetry {
method: method.clone(),
request_count: metrics.request_count,
success_count: metrics.success_count,
error_count: metrics.error_count,
response_error_count: metrics.response_error_count,
recovery_error_count: metrics.recovery_error_count,
recovery_fault_count: metrics.recovery_fault_count,
retry_count: metrics.retry_count,
last_latency_ms: metrics.last_latency_ms,
max_latency_ms: metrics.max_latency_ms,
avg_latency_ms,
last_error: metrics.last_error.clone(),
}
}
#[cfg(test)]
mod tests {
use super::{LifecycleState, OperationalLedger, RolloutState, WorkerHandshakePhase};
use crate::{Fault, FaultClass, FaultCode, Generation, RecoveryHint, RpcMethod};
#[test]
fn ledger_preserves_session_scope_and_deterministic_method_order() {
let mut ledger = OperationalLedger::new(Generation::genesis());
let zeta = RpcMethod::try_new("zeta");
let alpha = RpcMethod::try_new("alpha");
let (Ok(zeta), Ok(alpha)) = (zeta, alpha) else {
return;
};
assert!(ledger.record_request(zeta.clone()).is_ok());
assert!(ledger.record_request(alpha.clone()).is_ok());
assert!(ledger.record_replay(&zeta).is_ok());
assert!(ledger.record_success(&zeta, 12).is_ok());
assert!(
ledger
.record_response_error(&alpha, 8, "downstream")
.is_ok()
);
let replacement = Generation::try_new(2);
let Ok(replacement) = replacement else {
return;
};
assert!(ledger.replace_worker(replacement).is_ok());
let snapshot =
ledger.telemetry_snapshot(4, LifecycleState::Ready, WorkerHandshakePhase::Ready);
assert_eq!(snapshot.generation, replacement);
assert_eq!(snapshot.restart_count, 1);
assert_eq!(snapshot.totals.request_count(), 2);
assert_eq!(snapshot.totals.success_count(), 1);
assert_eq!(snapshot.totals.error_count(), 1);
assert_eq!(snapshot.totals.response_error_count(), 1);
assert_eq!(snapshot.totals.recovery_error_count(), 0);
assert_eq!(snapshot.totals.retry_count(), 1);
assert!(matches!(
snapshot.methods.as_slice(),
[first, second] if first.method().as_str() == "alpha" && second.method().as_str() == "zeta"
));
}
#[test]
fn ledger_separates_host_lifecycle_worker_handshake_and_fault_scope() {
let mut ledger = OperationalLedger::new(Generation::genesis());
let method = RpcMethod::try_new("tools/call");
let code = FaultCode::try_new("worker_lost");
let (Ok(method), Ok(code)) = (method, code) else {
return;
};
assert!(ledger.record_request(method.clone()).is_ok());
let fault = Fault::new(
Generation::genesis(),
FaultClass::Transport,
code,
Some(RecoveryHint::ReplaceWorker),
"worker pipe closed",
);
assert!(ledger.record_recovery_fault(Some(&method), fault).is_ok());
let health = ledger.health_snapshot(
9,
LifecycleState::Recovering,
WorkerHandshakePhase::Starting,
Some(RolloutState::Stable),
);
assert_eq!(health.state, LifecycleState::Recovering);
assert_eq!(health.worker_handshake, WorkerHandshakePhase::Starting);
assert_eq!(health.consecutive_failures, 1);
let telemetry = ledger.telemetry_snapshot(
9,
LifecycleState::Recovering,
WorkerHandshakePhase::Starting,
);
assert_eq!(telemetry.totals.recovery_fault_count(), 1);
assert_eq!(telemetry.totals.error_count(), 0);
assert!(ledger.record_success(&method, 20).is_ok());
let healthy =
ledger.health_snapshot(10, LifecycleState::Ready, WorkerHandshakePhase::Ready, None);
assert_eq!(healthy.consecutive_failures, 0);
}
#[test]
fn ledger_distinguishes_terminal_recovery_errors_from_fault_incidents() {
let mut ledger = OperationalLedger::new(Generation::genesis());
let method = RpcMethod::try_new("tools/call");
let Ok(method) = method else {
return;
};
assert!(ledger.record_request(method.clone()).is_ok());
assert!(
ledger
.record_recovery_error(&method, "ambiguous outcome")
.is_ok()
);
let snapshot =
ledger.telemetry_snapshot(5, LifecycleState::Ready, WorkerHandshakePhase::Ready);
assert_eq!(snapshot.totals.request_count(), 1);
assert_eq!(snapshot.totals.success_count(), 0);
assert_eq!(snapshot.totals.error_count(), 1);
assert_eq!(snapshot.totals.response_error_count(), 0);
assert_eq!(snapshot.totals.recovery_error_count(), 1);
assert_eq!(snapshot.totals.recovery_fault_count(), 0);
assert!(matches!(
snapshot.methods.as_slice(),
[method] if method.error_count() == 1
&& method.recovery_error_count() == 1
&& method.recovery_fault_count() == 0
));
}
#[test]
fn ledger_keeps_host_errors_out_of_downstream_and_recovery_buckets() {
let mut ledger = OperationalLedger::new(Generation::genesis());
let method = RpcMethod::try_new("tools/call");
let Ok(method) = method else {
return;
};
assert!(ledger.record_request(method.clone()).is_ok());
assert!(ledger.record_error(&method, 7, "invalid params").is_ok());
let snapshot =
ledger.telemetry_snapshot(8, LifecycleState::Ready, WorkerHandshakePhase::Ready);
assert_eq!(snapshot.totals.request_count(), 1);
assert_eq!(snapshot.totals.error_count(), 1);
assert_eq!(snapshot.totals.response_error_count(), 0);
assert_eq!(snapshot.totals.recovery_error_count(), 0);
assert_eq!(snapshot.totals.recovery_fault_count(), 0);
assert!(matches!(
snapshot.methods.as_slice(),
[method] if method.error_count() == 1
&& method.response_error_count() == 0
&& method.recovery_error_count() == 0
&& method.last_latency_ms() == Some(7)
));
}
}