use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const REDUNDANCY_PATH: &str = "/data/api/v1/redundancy";
pub const ACTIVITY_ACTIVE: &str = "Active";
pub const ACTIVITY_COLD: &str = "Cold";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RedundancyStatusWire {
#[serde(default)]
pub role: String,
#[serde(
rename = "projectState",
alias = "project_state",
default,
skip_serializing_if = "Option::is_none"
)]
pub project_state: Option<String>,
#[serde(
rename = "activityLevel",
alias = "activity_level",
default,
skip_serializing_if = "Option::is_none"
)]
pub activity_level: Option<String>,
#[serde(
rename = "localId",
alias = "local_id",
default,
skip_serializing_if = "Option::is_none"
)]
pub local_id: Option<String>,
#[serde(
rename = "peerId",
alias = "peer_id",
default,
skip_serializing_if = "Option::is_none"
)]
pub peer_id: Option<String>,
#[serde(rename = "peerConnected", alias = "peer_connected", default)]
pub peer_connected: bool,
#[serde(rename = "hasConfigAccess", alias = "has_config_access", default)]
pub has_config_access: bool,
#[serde(rename = "syncPending", alias = "sync_pending", default)]
pub sync_pending: bool,
#[serde(rename = "failoverPending", alias = "failover_pending", default)]
pub failover_pending: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uptime: Option<i64>,
#[serde(
rename = "lastSyncTimestamp",
alias = "last_sync_timestamp",
default,
skip_serializing_if = "Option::is_none"
)]
pub last_sync_timestamp: Option<i64>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
impl RedundancyStatusWire {
pub fn last_sync_epoch_ms(&self) -> Option<i64> {
self.last_sync_timestamp.filter(|value| *value >= 0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActiveNode {
OnlyA,
OnlyB,
Both,
Neither,
}
pub fn active_node(a: &RedundancyStatusWire, b: &RedundancyStatusWire) -> ActiveNode {
let is_active = |w: &RedundancyStatusWire| w.activity_level.as_deref() == Some(ACTIVITY_ACTIVE);
match (is_active(a), is_active(b)) {
(true, false) => ActiveNode::OnlyA,
(false, true) => ActiveNode::OnlyB,
(true, true) => ActiveNode::Both,
(false, false) => ActiveNode::Neither,
}
}
#[cfg(test)]
mod tests {
use super::{ACTIVITY_COLD, ActiveNode, RedundancyStatusWire, active_node};
fn wire(value: serde_json::Value) -> RedundancyStatusWire {
serde_json::from_value(value).expect("the pair body must parse")
}
fn pre_failover() -> (RedundancyStatusWire, RedundancyStatusWire) {
(
wire(
serde_json::json!({"role":"Master","projectState":"Good","activityLevel":"Active","localId":"192.168.97.3","peerId":"spike-a-Backup","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":29279,"lastSyncTimestamp":1791586091827i64}),
),
wire(
serde_json::json!({"role":"Backup","projectState":"Good","activityLevel":"Cold","localId":"192.168.97.2","peerId":"spike-a-Master","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":-1,"lastSyncTimestamp":1791586091875i64}),
),
)
}
fn post_failover() -> (RedundancyStatusWire, RedundancyStatusWire) {
(
wire(
serde_json::json!({"role":"Master","projectState":"Good","activityLevel":"Cold","localId":"192.168.97.3","peerId":"spike-a-Backup","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":-1,"lastSyncTimestamp":1791586358959i64}),
),
wire(
serde_json::json!({"role":"Backup","projectState":"Good","activityLevel":"Active","localId":"192.168.97.2","peerId":"spike-a-Master","peerConnected":true,"hasConfigAccess":true,"syncPending":false,"failoverPending":false,"uptime":30539,"lastSyncTimestamp":1791586359045i64}),
),
)
}
#[test]
fn active_node_follows_activity_not_role() {
let (a, b) = post_failover();
assert_eq!(a.role, "Master");
assert_eq!(b.role, "Backup");
assert_eq!(a.activity_level.as_deref(), Some(ACTIVITY_COLD));
assert_eq!(a.uptime, Some(-1), "a cold node reports uptime -1");
assert_eq!(active_node(&a, &b), ActiveNode::OnlyB);
}
#[test]
fn active_node_pre_failover_is_master() {
let (a, b) = pre_failover();
assert_eq!(a.role, "Master");
assert_eq!(b.role, "Backup");
assert_eq!(active_node(&a, &b), ActiveNode::OnlyA);
}
#[test]
fn active_node_split_brain_and_none() {
let with = |level: Option<&str>| {
let mut v = serde_json::json!({"role":"Independent"});
if let Some(level) = level {
v["activityLevel"] = serde_json::json!(level);
}
wire(v)
};
assert_eq!(
active_node(&with(Some("Active")), &with(Some("Active"))),
ActiveNode::Both
);
assert_eq!(
active_node(&with(Some("Cold")), &with(Some("Cold"))),
ActiveNode::Neither
);
assert_eq!(
active_node(&with(None), &with(Some("Cold"))),
ActiveNode::Neither
);
assert_eq!(
active_node(&with(Some("Warm")), &with(Some("Cold"))),
ActiveNode::Neither
);
assert_eq!(
active_node(&with(Some("Warm")), &with(Some("Active"))),
ActiveNode::OnlyB
);
}
#[test]
fn last_sync_is_epoch_ms_on_the_live_pair() {
let (a, _) = pre_failover();
assert_eq!(a.last_sync_epoch_ms(), Some(1_791_586_091_827));
}
#[test]
fn redundancy_parses_the_live_8_3_6_capture() {
let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
"role": "Independent", "projectState": "Unknown",
"activityLevel": "Active", "localId": "192.168.215.2",
"peerConnected": false, "hasConfigAccess": true,
"syncPending": false, "failoverPending": false,
"uptime": 460959, "lastSyncTimestamp": -1
}))
.expect("the live 8.3.6 shape must parse");
assert_eq!(wire.role, "Independent");
assert_eq!(wire.project_state.as_deref(), Some("Unknown"));
assert_eq!(wire.activity_level.as_deref(), Some("Active"));
assert_eq!(wire.local_id.as_deref(), Some("192.168.215.2"));
assert!(!wire.peer_connected);
assert!(wire.has_config_access);
assert!(!wire.sync_pending);
assert!(!wire.failover_pending);
assert_eq!(wire.uptime, Some(460_959), "ms since gateway start");
assert_eq!(wire.last_sync_timestamp, Some(-1));
assert_eq!(
wire.last_sync_epoch_ms(),
None,
"the -1 sentinel never reads as a timestamp"
);
assert_eq!(wire.peer_id, None, "peer absent on a fresh rig");
}
#[test]
fn redundancy_parses_the_live_8_3_3_capture() {
let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
"role": "Independent", "projectState": "Unknown",
"activityLevel": "Active", "localId": "192.168.171.2",
"peerConnected": false, "hasConfigAccess": true,
"syncPending": false, "failoverPending": false,
"uptime": 617422, "lastSyncTimestamp": -1
}))
.expect("the live 8.3.3 shape must parse");
assert_eq!(wire.uptime, Some(617_422));
assert_eq!(wire.last_sync_epoch_ms(), None);
}
#[test]
fn redundancy_normalizes_and_passes_unknown_keys() {
let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
"role": "Master", "projectState": "Good",
"activityLevel": "Active", "localId": "192.168.97.3",
"peerId": "spike-a-Backup",
"peerConnected": true, "hasConfigAccess": true,
"syncPending": false, "failoverPending": false,
"uptime": 29279, "lastSyncTimestamp": 1791586091827i64,
"futurePointReleaseField": [1, 2]
}))
.expect("peer-shaped + unknown-key variants must parse");
assert_eq!(wire.role, "Master", "a String role rides, never refuses");
assert_eq!(wire.peer_id.as_deref(), Some("spike-a-Backup"));
assert_eq!(
wire.last_sync_epoch_ms(),
Some(1_791_586_091_827),
"non-negative values read epoch-ms (block C-S3)"
);
assert_eq!(
wire.extra.get("futurePointReleaseField"),
Some(&serde_json::json!([1, 2])),
"unknown keys ride flatten passthrough"
);
}
}