use std::collections::HashMap;
use serde_json::{Map, Value};
use super::base::{
make_envelope, Continuation, Handler, HandlerError, HandlerOutcome,
};
use crate::ir_nodes::{IRFabric, IRManifest, IRObserve, IRResource};
use crate::source_registry::{lookup_source_adapter, SourceError};
pub struct LiveHandler {
name: String,
resources: HashMap<String, IRResource>,
}
impl LiveHandler {
pub fn new(resources: HashMap<String, IRResource>) -> Self {
LiveHandler {
name: "live".to_string(),
resources,
}
}
fn parse_timeout(raw: &str) -> Result<std::time::Duration, HandlerError> {
let s = raw.trim();
if s.is_empty() {
return Err(HandlerError::caller(
"observe declares no `timeout:` — refusing rather than choosing one for you",
));
}
let (num, unit) = s.split_at(
s.find(|c: char| c.is_alphabetic())
.ok_or_else(|| HandlerError::caller(format!("malformed timeout '{s}'")))?,
);
let n: u64 = num
.parse()
.map_err(|_| HandlerError::caller(format!("malformed timeout '{s}'")))?;
let d = match unit {
"ms" => std::time::Duration::from_millis(n),
"s" => std::time::Duration::from_secs(n),
"m" => std::time::Duration::from_secs(n * 60),
"h" => std::time::Duration::from_secs(n * 3600),
other => {
return Err(HandlerError::caller(format!(
"unknown timeout unit '{other}' in '{s}' — one of: ms | s | m | h"
)))
}
};
Ok(d)
}
}
impl Handler for LiveHandler {
fn name(&self) -> &str {
&self.name
}
fn provision(
&mut self,
manifest: &IRManifest,
_resources: &HashMap<String, IRResource>,
_fabrics: &HashMap<String, IRFabric>,
_continuation: &mut Continuation<'_>,
) -> Result<HandlerOutcome, HandlerError> {
Err(HandlerError::caller(format!(
"`provision` of manifest '{}' is not implemented (§112.a). A `resource` does not yet \
govern anything that runs — `resource.endpoint` and `axonstore.connection` are the \
same fact declared twice, and nothing links them (§111 islands). §113 makes \
`resource` the single source of truth; provisioning lands there. Refusing rather \
than reporting a success that created nothing.",
manifest.name
)))
}
fn observe(
&mut self,
obs: &IRObserve,
manifest: &IRManifest,
_cont: &mut Continuation<'_>,
) -> Result<HandlerOutcome, HandlerError> {
if obs.sources.is_empty() {
return Err(HandlerError::caller(format!(
"observe '{}' declares no `sources:` — there is nothing to look at, and an \
observation of nothing is not an observation of health",
obs.name
)));
}
let timeout = Self::parse_timeout(&obs.timeout)?;
let quorum = obs.quorum.unwrap_or(obs.sources.len() as i64).max(1) as usize;
let mut readings: Vec<(String, f64)> = Vec::new();
let mut failures: Vec<String> = Vec::new();
let mut per_source = Map::new();
for source in &obs.sources {
let Some(adapter) = lookup_source_adapter(source) else {
return Err(HandlerError::caller(
SourceError::Unregistered {
source: source.clone(),
}
.to_string(),
));
};
let resource = self.resources.get(source);
match adapter.probe(resource, timeout) {
Ok(reading) => {
per_source.insert(
source.clone(),
serde_json::json!({
"answered": true,
"certainty": reading.certainty,
"data": Value::Object(reading.data),
}),
);
readings.push((source.clone(), reading.certainty));
}
Err(e) => {
per_source.insert(
source.clone(),
serde_json::json!({ "answered": false, "error": e.to_string() }),
);
failures.push(format!("{e}"));
}
}
}
if readings.len() < quorum {
let detail = format!(
"observe '{}' reached {} of {} source(s), below its declared quorum of {} \
[{}]",
obs.name,
readings.len(),
obs.sources.len(),
quorum,
failures.join("; ")
);
return match obs.on_partition.as_str() {
"fail" | "" => Err(HandlerError::network_partition(detail)),
"shield_quarantine" => Err(HandlerError::network_partition(format!(
"{detail} — `on_partition: shield_quarantine`"
))),
other => Err(HandlerError::caller(format!(
"observe '{}' declares `on_partition: {other}` — one of: fail | \
shield_quarantine",
obs.name
))),
};
}
let certainty = readings
.iter()
.map(|(_, c)| *c)
.fold(f64::INFINITY, f64::min);
if let Some(floor) = obs.certainty_floor {
if certainty < floor {
return Err(HandlerError::callee(format!(
"observe '{}' produced certainty {certainty:.3}, below its declared \
`certainty_floor: {floor}` — refusing. An observation you do not trust is \
not an observation you may act on",
obs.name
)));
}
}
let resources_observed: Vec<Value> = readings
.iter()
.filter(|(name, _)| self.resources.contains_key(name))
.filter(|(name, _)| manifest.resources.contains(name))
.map(|(name, _)| Value::String(name.clone()))
.collect();
let mut data = Map::new();
data.insert("observe".into(), obs.name.clone().into());
data.insert("manifest".into(), manifest.name.clone().into());
data.insert("quorum".into(), (quorum as i64).into());
data.insert("answered".into(), (readings.len() as i64).into());
data.insert("of".into(), (obs.sources.len() as i64).into());
data.insert("sources".into(), Value::Object(per_source));
data.insert(
"resources_observed".into(),
Value::Array(resources_observed),
);
let status = if failures.is_empty() { "ok" } else { "partial" };
Ok(HandlerOutcome::new(
"observe",
obs.name.clone(),
status,
make_envelope(certainty, &self.name, "observed", None),
self.name.clone(),
)
.with_data(data))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handlers::base::{identity_continuation, HandlerErrorKind};
use crate::source_registry::{
register_source_adapter, ResourceProbeAdapter, SourceAdapter, SourceReading,
};
use std::sync::Arc;
fn observe(name: &str, sources: Vec<&str>, quorum: Option<i64>, floor: Option<f64>) -> IRObserve {
IRObserve {
node_type: "observe",
source_line: 0,
source_column: 0,
name: name.into(),
target: "Infra".into(),
sources: sources.into_iter().map(String::from).collect(),
quorum,
timeout: "1s".into(),
on_partition: "fail".into(),
certainty_floor: floor,
}
}
fn manifest() -> IRManifest {
IRManifest {
node_type: "manifest",
source_line: 0,
source_column: 0,
name: "Infra".into(),
resources: vec!["Db".into()],
fabric_ref: String::new(),
region: String::new(),
zones: None,
compliance: Vec::new(),
}
}
fn resource(name: &str, endpoint: &str) -> IRResource {
IRResource {
node_type: "resource",
source_line: 0,
source_column: 0,
name: name.into(),
kind: "postgres".into(),
endpoint: endpoint.into(),
capacity: None,
lifetime: "affine".into(),
certainty_floor: None,
shield_ref: String::new(),
within: String::new(),
}
}
struct Fixed(String, f64);
impl SourceAdapter for Fixed {
fn name(&self) -> &str {
&self.0
}
fn probe(
&self,
_r: Option<&IRResource>,
_t: std::time::Duration,
) -> Result<SourceReading, SourceError> {
Ok(SourceReading::new(self.1, Map::new()))
}
}
struct Down(String);
impl SourceAdapter for Down {
fn name(&self) -> &str {
&self.0
}
fn probe(
&self,
_r: Option<&IRResource>,
_t: std::time::Duration,
) -> Result<SourceReading, SourceError> {
Err(SourceError::Unreachable {
source: self.0.clone(),
detail: "down".into(),
})
}
}
fn handler_with(res: Vec<IRResource>) -> LiveHandler {
let map = res.into_iter().map(|r| (r.name.clone(), r)).collect();
LiveHandler::new(map)
}
#[test]
fn observe_reaches_a_real_endpoint_and_reports_what_it_established() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
register_source_adapter(
"LiveDb",
Arc::new(ResourceProbeAdapter::with_resolver(
"LiveDb",
Arc::new(
crate::resource_resolver::MapResourceResolver::new()
.with("livedb.main", &format!("postgres://{addr}/app")),
),
)),
);
let mut h = handler_with(vec![resource("LiveDb", "livedb.main")]);
let out = h
.observe(&observe("Health", vec!["LiveDb"], None, None), &manifest(), &mut identity_continuation())
.expect("a reachable resource must be observable");
assert_eq!(out.status, "ok");
assert_eq!(out.envelope.c, 1.0);
assert_eq!(out.data["answered"], 1);
}
#[test]
fn an_unregistered_source_refuses() {
let mut h = handler_with(vec![]);
let err = h
.observe(&observe("Health", vec!["prometheus"], None, None), &manifest(), &mut identity_continuation())
.expect_err("an unregistered source must refuse");
let msg = format!("{}", err.message);
assert!(msg.contains("UNKNOWN, not healthy"), "got {msg}");
}
#[test]
fn below_quorum_is_a_partition_not_a_weak_observation() {
register_source_adapter("q_up", Arc::new(Fixed("q_up".into(), 1.0)));
register_source_adapter("q_down", Arc::new(Down("q_down".into())));
let mut h = handler_with(vec![]);
let err = h
.observe(&observe("Health", vec!["q_up", "q_down"], Some(2), None), &manifest(), &mut identity_continuation())
.expect_err("1 of 2 with quorum 2 must be a partition");
assert_eq!(
err.kind,
HandlerErrorKind::NetworkPartition,
"a partition is ⊥ (void) — it must NEVER degrade into an observation with low c"
);
}
#[test]
fn certainty_is_the_minimum_across_answering_sources() {
register_source_adapter("m_hi", Arc::new(Fixed("m_hi".into(), 0.9)));
register_source_adapter("m_lo", Arc::new(Fixed("m_lo".into(), 0.4)));
let mut h = handler_with(vec![]);
let out = h
.observe(&observe("Health", vec!["m_hi", "m_lo"], Some(2), None), &manifest(), &mut identity_continuation())
.expect("both answered");
assert_eq!(out.envelope.c, 0.4);
}
#[test]
fn certainty_below_the_declared_floor_refuses() {
register_source_adapter("f_weak", Arc::new(Fixed("f_weak".into(), 0.5)));
let mut h = handler_with(vec![]);
let err = h
.observe(&observe("Health", vec!["f_weak"], None, Some(0.8)), &manifest(), &mut identity_continuation())
.expect_err("c=0.5 under floor 0.8 must refuse");
assert!(format!("{}", err.message).contains("certainty_floor"));
}
#[test]
fn a_partial_quorum_is_labelled_partial_not_ok() {
register_source_adapter("p_up", Arc::new(Fixed("p_up".into(), 1.0)));
register_source_adapter("p_down", Arc::new(Down("p_down".into())));
let mut h = handler_with(vec![]);
let out = h
.observe(&observe("Health", vec!["p_up", "p_down"], Some(1), None), &manifest(), &mut identity_continuation())
.expect("quorum 1 of 2 is met");
assert_eq!(out.status, "partial", "one source is down — say so");
assert_eq!(out.data["answered"], 1);
assert_eq!(out.data["of"], 2);
}
#[test]
fn provision_refuses_and_points_at_113() {
let mut h = handler_with(vec![]);
let err = h
.provision(
&manifest(),
&HashMap::new(),
&HashMap::new(),
&mut identity_continuation(),
)
.expect_err("provision must refuse in §112.a");
assert!(format!("{}", err.message).contains("§113"));
}
}