use serde::{Deserialize, Serialize};
pub const DAEMON_RUNTIME_STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DaemonRuntimeState {
Ok,
NotRunning,
GenerationSkew,
StaleSocket,
StaleLock,
GhostProcess,
Unresponsive,
Unknown,
}
impl DaemonRuntimeState {
pub const fn as_str(self) -> &'static str {
match self {
DaemonRuntimeState::Ok => "ok",
DaemonRuntimeState::NotRunning => "not-running",
DaemonRuntimeState::GenerationSkew => "generation-skew",
DaemonRuntimeState::StaleSocket => "stale-socket",
DaemonRuntimeState::StaleLock => "stale-lock",
DaemonRuntimeState::GhostProcess => "ghost-process",
DaemonRuntimeState::Unresponsive => "unresponsive",
DaemonRuntimeState::Unknown => "unknown",
}
}
pub const fn is_disposable_runtime_artifact(self) -> bool {
matches!(
self,
DaemonRuntimeState::StaleSocket
| DaemonRuntimeState::StaleLock
| DaemonRuntimeState::GhostProcess
| DaemonRuntimeState::Unresponsive
)
}
pub const fn is_usable(self) -> bool {
matches!(self, DaemonRuntimeState::Ok)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DaemonRuntimeObservation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socket_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_dir: Option<String>,
pub run_lock_present: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_lock_acquirable: Option<bool>,
pub socket_present: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socket_connectable: Option<bool>,
pub connect_timed_out: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub responded_to_ping: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_heartbeat_unix_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_heartbeat_age_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_error: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub probe_incomplete: bool,
}
impl DaemonRuntimeObservation {
fn lock_held_by_live(&self) -> bool {
matches!(self.run_lock_acquirable, Some(false))
}
fn lock_is_stale(&self) -> bool {
self.run_lock_present && matches!(self.run_lock_acquirable, Some(true))
}
fn generation_is_stale(&self) -> bool {
matches!(
(self.daemon_generation, self.published_generation),
(Some(served), Some(published)) if served != published
)
}
}
pub fn classify(obs: &DaemonRuntimeObservation) -> DaemonRuntimeState {
if obs.probe_incomplete {
return DaemonRuntimeState::Unknown;
}
if obs.connect_timed_out {
return DaemonRuntimeState::Unresponsive;
}
let connected = matches!(obs.socket_connectable, Some(true));
if connected && matches!(obs.responded_to_ping, Some(false)) {
return DaemonRuntimeState::GhostProcess;
}
if connected && !matches!(obs.responded_to_ping, Some(false)) && obs.generation_is_stale() {
return DaemonRuntimeState::GenerationSkew;
}
if connected && obs.lock_held_by_live() {
return DaemonRuntimeState::Ok;
}
if connected && obs.run_lock_acquirable.is_none() {
return DaemonRuntimeState::Ok;
}
if obs.socket_present && !obs.lock_held_by_live() && !connected {
return DaemonRuntimeState::StaleSocket;
}
if obs.lock_is_stale() {
return DaemonRuntimeState::StaleLock;
}
if !obs.socket_present && !obs.lock_held_by_live() {
return DaemonRuntimeState::NotRunning;
}
DaemonRuntimeState::Unknown
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonRecovery {
pub action_needed: bool,
pub disposable_runtime_artifact: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub why: String,
}
#[cfg(test)]
const SAFE_DAEMON_COMMANDS: &[&str] = &["cass daemon", "cass status --json", "cass health --json"];
pub fn safe_recovery(state: DaemonRuntimeState) -> DaemonRecovery {
match state {
DaemonRuntimeState::Ok => DaemonRecovery {
action_needed: false,
disposable_runtime_artifact: false,
command: None,
why: "daemon is live, responsive, and serving the current generation".to_string(),
},
DaemonRuntimeState::NotRunning => DaemonRecovery {
action_needed: false,
disposable_runtime_artifact: false,
command: None,
why: "no daemon is running; an on-demand client will spawn one when needed".to_string(),
},
DaemonRuntimeState::GenerationSkew => DaemonRecovery {
action_needed: true,
disposable_runtime_artifact: false,
command: Some("cass status --json".to_string()),
why: "daemon is serving an older index generation than published; the searcher reloads \
to the published generation on its next bounded refresh — the canonical archive \
is untouched, so re-check that it caught up"
.to_string(),
},
DaemonRuntimeState::StaleSocket => DaemonRecovery {
action_needed: true,
disposable_runtime_artifact: true,
command: Some("cass daemon".to_string()),
why: "a stale socket from a crashed daemon remains (disposable runtime state); a fresh \
`cass daemon` re-binds and cleans it on startup without touching any archive data \
(the on-demand client also auto-cleans it on next use)"
.to_string(),
},
DaemonRuntimeState::StaleLock => DaemonRecovery {
action_needed: true,
disposable_runtime_artifact: true,
command: Some("cass daemon".to_string()),
why: "a stale run-lock from a crashed daemon remains (disposable runtime state); a fresh \
`cass daemon` reclaims it on startup, no archive change"
.to_string(),
},
DaemonRuntimeState::GhostProcess => DaemonRecovery {
action_needed: true,
disposable_runtime_artifact: true,
command: None,
why: "a live process holds the socket but is not answering; it auto-shuts-down after \
its idle timeout (or can be terminated manually), then the next semantic query \
respawns a fresh daemon — runtime state only, the archive is untouched"
.to_string(),
},
DaemonRuntimeState::Unresponsive => DaemonRecovery {
action_needed: true,
disposable_runtime_artifact: true,
command: None,
why: "the daemon did not answer within the bound; it idle-shuts-down or can be \
terminated, then a fresh daemon respawns on demand (no archive change)"
.to_string(),
},
DaemonRuntimeState::Unknown => DaemonRecovery {
action_needed: false,
disposable_runtime_artifact: false,
command: Some("cass status --json".to_string()),
why: "the daemon probe did not complete; re-check status before acting".to_string(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonRuntimeDiagnostic {
pub schema_version: u32,
pub state: DaemonRuntimeState,
pub observation: DaemonRuntimeObservation,
pub recovery: DaemonRecovery,
}
impl DaemonRuntimeDiagnostic {
pub fn from_observation(observation: DaemonRuntimeObservation) -> Self {
let state = classify(&observation);
let recovery = safe_recovery(state);
Self {
schema_version: DAEMON_RUNTIME_STATE_SCHEMA_VERSION,
state,
observation,
recovery,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SearcherCacheOutcome {
Hit,
ColdMiss,
StaleGenerationMiss,
ForcedReload,
ReloadFailure,
Fallback,
}
impl SearcherCacheOutcome {
pub const fn as_str(self) -> &'static str {
match self {
SearcherCacheOutcome::Hit => "hit",
SearcherCacheOutcome::ColdMiss => "cold-miss",
SearcherCacheOutcome::StaleGenerationMiss => "stale-generation-miss",
SearcherCacheOutcome::ForcedReload => "forced-reload",
SearcherCacheOutcome::ReloadFailure => "reload-failure",
SearcherCacheOutcome::Fallback => "fallback",
}
}
pub const fn served_current(self) -> bool {
matches!(
self,
SearcherCacheOutcome::Hit | SearcherCacheOutcome::ForcedReload
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CacheLookup {
pub cache_hit: bool,
pub cached_generation: Option<u64>,
pub current_generation: u64,
pub reload_attempted: bool,
pub reload_succeeded: bool,
pub served_fallback: bool,
}
pub fn classify_cache_outcome(lookup: &CacheLookup) -> SearcherCacheOutcome {
if lookup.reload_attempted && !lookup.reload_succeeded {
return SearcherCacheOutcome::ReloadFailure;
}
if lookup.served_fallback {
return SearcherCacheOutcome::Fallback;
}
if lookup.reload_attempted && lookup.reload_succeeded {
return SearcherCacheOutcome::ForcedReload;
}
if lookup.cache_hit && lookup.cached_generation == Some(lookup.current_generation) {
return SearcherCacheOutcome::Hit;
}
if lookup.cache_hit
&& matches!(lookup.cached_generation, Some(g) if g != lookup.current_generation)
{
return SearcherCacheOutcome::StaleGenerationMiss;
}
SearcherCacheOutcome::ColdMiss
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SearcherCacheMetrics {
pub hit: u64,
pub cold_miss: u64,
pub stale_generation_miss: u64,
pub forced_reload: u64,
pub reload_failure: u64,
pub fallback: u64,
}
impl SearcherCacheMetrics {
pub fn record(&mut self, outcome: SearcherCacheOutcome) {
match outcome {
SearcherCacheOutcome::Hit => self.hit += 1,
SearcherCacheOutcome::ColdMiss => self.cold_miss += 1,
SearcherCacheOutcome::StaleGenerationMiss => self.stale_generation_miss += 1,
SearcherCacheOutcome::ForcedReload => self.forced_reload += 1,
SearcherCacheOutcome::ReloadFailure => self.reload_failure += 1,
SearcherCacheOutcome::Fallback => self.fallback += 1,
}
}
pub fn total(&self) -> u64 {
self.hit
+ self.cold_miss
+ self.stale_generation_miss
+ self.forced_reload
+ self.reload_failure
+ self.fallback
}
}
#[cfg(test)]
mod tests {
use super::*;
fn live_responsive(generation: u64) -> DaemonRuntimeObservation {
DaemonRuntimeObservation {
socket_path: Some("/tmp/cass-semantic.sock".to_string()),
run_lock_present: true,
run_lock_acquirable: Some(false), socket_present: true,
socket_connectable: Some(true),
connect_timed_out: false,
responded_to_ping: Some(true),
daemon_generation: Some(generation),
published_generation: Some(generation),
..Default::default()
}
}
#[test]
fn live_responsive_current_generation_is_ok() {
let state = classify(&live_responsive(7));
assert_eq!(state, DaemonRuntimeState::Ok);
assert!(state.is_usable());
assert!(!state.is_disposable_runtime_artifact());
assert!(!safe_recovery(state).action_needed);
}
#[test]
fn no_socket_no_lock_is_not_running_not_an_error() {
let obs = DaemonRuntimeObservation {
run_lock_present: false,
run_lock_acquirable: Some(true),
socket_present: false,
socket_connectable: Some(false),
..Default::default()
};
let state = classify(&obs);
assert_eq!(state, DaemonRuntimeState::NotRunning);
let recovery = safe_recovery(state);
assert!(!recovery.action_needed, "absent daemon is not an error");
assert!(recovery.command.is_none());
}
#[test]
fn socket_present_but_no_live_owner_is_stale_socket() {
let obs = DaemonRuntimeObservation {
run_lock_present: true,
run_lock_acquirable: Some(true),
socket_present: true,
socket_connectable: Some(false),
connect_error: Some("Connection refused".to_string()),
..Default::default()
};
let state = classify(&obs);
assert_eq!(state, DaemonRuntimeState::StaleSocket);
assert!(state.is_disposable_runtime_artifact());
}
#[test]
fn lock_present_stale_without_socket_is_stale_lock() {
let obs = DaemonRuntimeObservation {
run_lock_present: true,
run_lock_acquirable: Some(true),
socket_present: false,
socket_connectable: Some(false),
..Default::default()
};
let state = classify(&obs);
assert_eq!(state, DaemonRuntimeState::StaleLock);
assert!(state.is_disposable_runtime_artifact());
}
#[test]
fn connected_but_unanswered_ping_is_ghost_process() {
let obs = DaemonRuntimeObservation {
run_lock_present: true,
run_lock_acquirable: Some(false),
socket_present: true,
socket_connectable: Some(true),
responded_to_ping: Some(false),
..Default::default()
};
let state = classify(&obs);
assert_eq!(state, DaemonRuntimeState::GhostProcess);
assert!(state.is_disposable_runtime_artifact());
}
#[test]
fn connect_timeout_is_unresponsive_outranks_everything() {
let obs = DaemonRuntimeObservation {
run_lock_present: true,
run_lock_acquirable: Some(false),
socket_present: true,
socket_connectable: Some(false),
connect_timed_out: true,
..Default::default()
};
assert_eq!(classify(&obs), DaemonRuntimeState::Unresponsive);
}
#[test]
fn daemon_behind_published_generation_is_generation_skew() {
let mut obs = live_responsive(5);
obs.published_generation = Some(9); let state = classify(&obs);
assert_eq!(state, DaemonRuntimeState::GenerationSkew);
let recovery = safe_recovery(state);
assert!(recovery.action_needed);
assert!(!recovery.disposable_runtime_artifact);
assert!(recovery.why.contains("archive is untouched"));
}
#[test]
fn b7tb0_opaque_generation_mismatch_is_skew_even_when_new_identity_is_smaller() {
let mut obs = live_responsive(9);
obs.published_generation = Some(5);
assert_eq!(classify(&obs), DaemonRuntimeState::GenerationSkew);
}
#[test]
fn incomplete_probe_never_claims_health() {
let mut obs = live_responsive(3);
obs.probe_incomplete = true;
assert_eq!(classify(&obs), DaemonRuntimeState::Unknown);
}
#[test]
fn connected_without_lock_probe_is_usable() {
let mut obs = live_responsive(4);
obs.run_lock_acquirable = None; assert_eq!(classify(&obs), DaemonRuntimeState::Ok);
}
#[test]
fn no_recovery_command_ever_mutates_the_archive() {
let states = [
DaemonRuntimeState::Ok,
DaemonRuntimeState::NotRunning,
DaemonRuntimeState::GenerationSkew,
DaemonRuntimeState::StaleSocket,
DaemonRuntimeState::StaleLock,
DaemonRuntimeState::GhostProcess,
DaemonRuntimeState::Unresponsive,
DaemonRuntimeState::Unknown,
];
let forbidden = [
"index --full",
"rebuild",
"rm ",
"--delete",
"--purge",
"reset",
"drop",
"models backfill",
];
for state in states {
let recovery = safe_recovery(state);
if let Some(cmd) = &recovery.command {
for bad in forbidden {
assert!(
!cmd.contains(bad),
"{}: recovery command {cmd:?} references a non-runtime/destructive op {bad:?}",
state.as_str()
);
}
assert!(
SAFE_DAEMON_COMMANDS.contains(&cmd.as_str()),
"{}: recovery command {cmd:?} is not on the safe daemon command allow-list",
state.as_str()
);
}
}
}
#[test]
fn disposable_runtime_artifacts_are_exactly_the_reclaimable_states() {
assert!(DaemonRuntimeState::StaleSocket.is_disposable_runtime_artifact());
assert!(DaemonRuntimeState::StaleLock.is_disposable_runtime_artifact());
assert!(DaemonRuntimeState::GhostProcess.is_disposable_runtime_artifact());
assert!(DaemonRuntimeState::Unresponsive.is_disposable_runtime_artifact());
assert!(!DaemonRuntimeState::GenerationSkew.is_disposable_runtime_artifact());
assert!(!DaemonRuntimeState::Ok.is_disposable_runtime_artifact());
assert!(!DaemonRuntimeState::NotRunning.is_disposable_runtime_artifact());
}
fn lookup() -> CacheLookup {
CacheLookup {
cache_hit: false,
cached_generation: None,
current_generation: 10,
reload_attempted: false,
reload_succeeded: false,
served_fallback: false,
}
}
#[test]
fn cache_hit_current_generation_is_hit() {
let mut l = lookup();
l.cache_hit = true;
l.cached_generation = Some(10);
let outcome = classify_cache_outcome(&l);
assert_eq!(outcome, SearcherCacheOutcome::Hit);
assert!(outcome.served_current());
}
#[test]
fn cached_older_generation_is_stale_generation_miss_not_cold() {
let mut l = lookup();
l.cache_hit = true;
l.cached_generation = Some(7); let outcome = classify_cache_outcome(&l);
assert_eq!(outcome, SearcherCacheOutcome::StaleGenerationMiss);
assert!(
!outcome.served_current(),
"a stale-generation hit must not read as a current serve"
);
}
#[test]
fn nothing_cached_is_cold_miss() {
assert_eq!(
classify_cache_outcome(&lookup()),
SearcherCacheOutcome::ColdMiss
);
}
#[test]
fn generation_change_forced_reload_is_reported_distinctly() {
let mut l = lookup();
l.reload_attempted = true;
l.reload_succeeded = true;
let outcome = classify_cache_outcome(&l);
assert_eq!(outcome, SearcherCacheOutcome::ForcedReload);
assert!(outcome.served_current());
}
#[test]
fn reload_failure_never_reads_as_a_serve() {
let mut l = lookup();
l.reload_attempted = true;
l.reload_succeeded = false;
l.cache_hit = true; l.cached_generation = Some(10);
let outcome = classify_cache_outcome(&l);
assert_eq!(outcome, SearcherCacheOutcome::ReloadFailure);
assert!(
!outcome.served_current(),
"a failed reload must not serve stale segments as current"
);
}
#[test]
fn degraded_fallback_outranks_a_nominal_hit() {
let mut l = lookup();
l.cache_hit = true;
l.cached_generation = Some(10);
l.served_fallback = true;
assert_eq!(classify_cache_outcome(&l), SearcherCacheOutcome::Fallback);
}
#[test]
fn metrics_record_every_outcome_distinctly() {
let mut m = SearcherCacheMetrics::default();
for outcome in [
SearcherCacheOutcome::Hit,
SearcherCacheOutcome::Hit,
SearcherCacheOutcome::ColdMiss,
SearcherCacheOutcome::StaleGenerationMiss,
SearcherCacheOutcome::ForcedReload,
SearcherCacheOutcome::ReloadFailure,
SearcherCacheOutcome::Fallback,
] {
m.record(outcome);
}
assert_eq!(m.hit, 2);
assert_eq!(m.cold_miss, 1);
assert_eq!(m.stale_generation_miss, 1);
assert_eq!(m.forced_reload, 1);
assert_eq!(m.reload_failure, 1);
assert_eq!(m.fallback, 1);
assert_eq!(m.total(), 7);
}
#[test]
fn diagnostic_serializes_with_stable_fields_and_round_trips() {
let diag = DaemonRuntimeDiagnostic::from_observation(live_responsive(12));
let value = serde_json::to_value(&diag).expect("to_value");
assert_eq!(value["schema_version"], DAEMON_RUNTIME_STATE_SCHEMA_VERSION);
assert_eq!(value["state"], "ok");
assert_eq!(value["recovery"]["action_needed"], false);
assert_eq!(value["observation"]["socket_present"], true);
let back: DaemonRuntimeDiagnostic = serde_json::from_value(value).expect("round-trip");
assert_eq!(back, diag);
}
#[test]
fn metrics_serialize_with_stable_snake_case_keys() {
let mut m = SearcherCacheMetrics::default();
m.record(SearcherCacheOutcome::StaleGenerationMiss);
let value = serde_json::to_value(m).expect("to_value");
assert_eq!(value["stale_generation_miss"], 1);
assert_eq!(value["hit"], 0);
}
#[test]
fn wire_labels_are_stable_kebab() {
for (s, w) in [
(DaemonRuntimeState::Ok, "ok"),
(DaemonRuntimeState::NotRunning, "not-running"),
(DaemonRuntimeState::GenerationSkew, "generation-skew"),
(DaemonRuntimeState::StaleSocket, "stale-socket"),
(DaemonRuntimeState::StaleLock, "stale-lock"),
(DaemonRuntimeState::GhostProcess, "ghost-process"),
(DaemonRuntimeState::Unresponsive, "unresponsive"),
(DaemonRuntimeState::Unknown, "unknown"),
] {
assert_eq!(serde_json::to_string(&s).expect("ser"), format!("\"{w}\""));
assert_eq!(s.as_str(), w);
}
for (o, w) in [
(SearcherCacheOutcome::Hit, "hit"),
(SearcherCacheOutcome::ColdMiss, "cold-miss"),
(
SearcherCacheOutcome::StaleGenerationMiss,
"stale-generation-miss",
),
(SearcherCacheOutcome::ForcedReload, "forced-reload"),
(SearcherCacheOutcome::ReloadFailure, "reload-failure"),
(SearcherCacheOutcome::Fallback, "fallback"),
] {
assert_eq!(serde_json::to_string(&o).expect("ser"), format!("\"{w}\""));
assert_eq!(o.as_str(), w);
}
}
#[test]
fn state_ordering_is_ok_first_for_max_rollup() {
assert!(DaemonRuntimeState::Ok < DaemonRuntimeState::StaleSocket);
assert!(DaemonRuntimeState::Ok < DaemonRuntimeState::GhostProcess);
let worst = [
DaemonRuntimeState::Ok,
DaemonRuntimeState::GenerationSkew,
DaemonRuntimeState::Unresponsive,
]
.into_iter()
.max()
.expect("non-empty");
assert_eq!(worst, DaemonRuntimeState::Unresponsive);
}
}