use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::config::Service;
pub const VERSION: u32 = 1;
pub fn validate_node_id(node_id: &str) -> Result<String, String> {
let ok = !node_id.is_empty()
&& node_id.len() <= 128
&& node_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
if ok {
Ok(node_id.to_string())
} else {
Err("node ids are 1-128 chars of [A-Za-z0-9_-]".to_string())
}
}
pub fn object_name(node_id: &str, services: &[Service]) -> String {
let mut letters: Vec<char> = services.iter().map(|s| s.letter()).collect();
letters.sort_unstable();
letters.dedup();
let letters: String = letters.into_iter().collect();
format!("{node_id}.{letters}.json")
}
pub fn parse_object_name(name: &str) -> Option<(String, Vec<Service>)> {
let stem = name.strip_suffix(".json")?;
let (node_id, letters) = stem.rsplit_once('.')?;
if node_id.is_empty() {
return None;
}
let mut services: Vec<Service> = letters.chars().filter_map(Service::from_letter).collect();
services.sort_unstable();
Some((node_id.to_string(), services))
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(title = "sleet heartbeat")]
pub struct Heartbeat {
pub version: u32,
pub node_id: String,
pub sleet_version: String,
pub slatedb_version: String,
pub services: Vec<ServiceSummary>,
}
impl Heartbeat {
pub fn new(
node_id: impl Into<String>,
slatedb_version: impl Into<String>,
services: Vec<ServiceSummary>,
) -> Self {
Self {
version: VERSION,
node_id: node_id.into(),
sleet_version: env!("CARGO_PKG_VERSION").into(),
slatedb_version: slatedb_version.into(),
services,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
pub struct ServiceSummary {
pub service: Service,
pub running: u64,
pub backoff: u64,
}
impl ServiceSummary {
pub fn empty(service: Service) -> Self {
Self {
service,
running: 0,
backoff: 0,
}
}
}
pub fn schema_json() -> String {
crate::schema_pretty::<Heartbeat>()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn object_name_sorts_and_dedups_service_letters() {
let name = object_name(
"sleet-1",
&[
Service::CompactionWorkers,
Service::Gc,
Service::CompactorCoordinator,
Service::Gc,
],
);
assert_eq!(name, "sleet-1.cgw.json");
assert_eq!(object_name("n", &[Service::Gc]), "n.g.json");
}
#[test]
fn object_names_roundtrip_and_reject_garbage() {
let (id, services) = parse_object_name("sleet-1.cgmw.json").unwrap();
assert_eq!(id, "sleet-1");
assert_eq!(services, Service::ALL.to_vec());
let (_, services) = parse_object_name("sleet-1.cgw.json").unwrap();
assert_eq!(
services,
vec![
Service::Gc,
Service::CompactorCoordinator,
Service::CompactionWorkers
]
);
let (id, services) = parse_object_name("sleet-2.gx.json").unwrap();
assert_eq!(id, "sleet-2");
assert_eq!(services, vec![Service::Gc]);
assert_eq!(parse_object_name("no-extension.cgw"), None);
assert_eq!(parse_object_name("nodot.json"), None);
assert_eq!(parse_object_name(".g.json"), None);
}
#[test]
fn roundtrips() {
let hb = Heartbeat::new(
"sleet-1",
"0.9.0",
vec![ServiceSummary {
service: Service::Gc,
running: 12,
backoff: 1,
}],
);
let json = serde_json::to_string(&hb).unwrap();
let back: Heartbeat = serde_json::from_str(&json).unwrap();
assert_eq!(back.version, VERSION);
assert_eq!(back.node_id, "sleet-1");
assert_eq!(back.slatedb_version, "0.9.0");
assert_eq!(back.services[0].running, 12);
}
#[test]
fn node_ids_are_validated() {
for ok in ["n", "sleet-1", "a_b-c", "A9", &"x".repeat(128)] {
assert!(validate_node_id(ok).is_ok(), "{ok:?}");
}
for bad in ["", "a.b", "a/b", "a b", "a:b", "ΓΌ", &"x".repeat(129)] {
assert!(validate_node_id(bad).is_err(), "{bad:?}");
}
}
#[test]
fn ignores_unknown_fields() {
let json = r#"{
"version": 1,
"node_id": "sleet-1",
"sleet_version": "9.9.9",
"slatedb_version": "9.9.9",
"services": [],
"some_future_field": {"x": 1}
}"#;
let hb: Heartbeat = serde_json::from_str(json).unwrap();
assert_eq!(hb.node_id, "sleet-1");
}
}