Skip to main content

ignition_core/client/
redundancy.rs

1//! Redundancy capability model (09-04, EXT-02) — `GET
2//! /data/api/v1/redundancy`, the FLAT 11-field body live-captured on
3//! both rigs (09-LIVE-CAPTURES §3: 8.3.6 rig A + 8.3.3 rig B). The
4//! TrialWire recipe throughout: `rename` + snake_case `alias` +
5//! `default` on every camelCase key, unknown keys never refuse the
6//! parse, flatten remainder.
7//!
8//! **The units are capture-locked (09-LIVE-CAPTURES §3 + Decisions 4):**
9//! - `uptime` = **milliseconds since gateway (JVM) start** — wall-clock
10//!   proven TWICE: rig A 460 959 ms + gateway start 02:29:30Z = exactly
11//!   the 02:37:11Z capture moment; rig B 617 422 ms same proof. A
12//!   minutes-old-restarted rig rules out epoch units AND
13//!   seconds-since-start.
14//! - `lastSyncTimestamp` = **epoch-ms**, proven by the live 8.3.6
15//!   Master/Backup pair (`20-LIVE-CAPTURES.md` block C-S3, spike file
16//!   `/Users/pmannion/data/worktrees/whk-environment-orchestration/
17//!   gateway-config-spec/spikes/gateway-modes/results/S3.md` lines 6-7
18//!   and 40-41: `1791586091827` and `1791586358959`). `-1` stays the
19//!   "never synced" sentinel seen on both fresh Independent rigs, and
20//!   `-1`/absent normalizes to `None` via
21//!   [`RedundancyStatusWire::last_sync_epoch_ms`].
22//!
23//! **Role versus activity (block C-S3).** `role` is configuration:
24//! `Independent` (fresh single rig, 09 captures), `Master` and `Backup`
25//! (the C-S3 pair). It is a String, NOT an enum: an unknown future role
26//! must RIDE, never refuse. `role` does NOT say which node serves.
27//! `activityLevel` does: `Active` on the serving node, `Cold` on the
28//! standby, and it MOVES on failover while `role` stays fixed (S3.md
29//! 6-7 pre-failover: a Master Active, b Backup Cold; S3.md 40-41 after
30//! failover: a Master Cold, b Backup Active). A cold node reports
31//! `uptime: -1`. Decide the active node with [`active_node`], never from
32//! `role`.
33
34use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37
38/// GET path of the redundancy capability (83-api collection + live
39/// capture; the /config, /events, /providers and /gwaction routes are
40/// out of this phase's scope — `api call` covers them).
41pub const REDUNDANCY_PATH: &str = "/data/api/v1/redundancy";
42
43/// `activityLevel` of the serving node (block C-S3: S3.md 6-7 node a,
44/// S3.md 40-41 node b; also every fresh Independent rig).
45pub const ACTIVITY_ACTIVE: &str = "Active";
46
47/// `activityLevel` of the standby node (block C-S3: S3.md 6-7 node b,
48/// S3.md 40-41 node a). A cold node also reports `uptime: -1`.
49pub const ACTIVITY_COLD: &str = "Cold";
50
51/// GET `/data/api/v1/redundancy` — the redundancy status. Live-captured
52/// 200 on BOTH rigs (09-LIVE-CAPTURES §3); byte-shape identical, values
53/// differ only in `localId` + `uptime`.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct RedundancyStatusWire {
56    /// `"Independent"` (both fresh rigs) / `"Master"` / `"Backup"` (the
57    /// live pair, block C-S3). Configuration only: it does not say
58    /// which node is active (see [`active_node`]). A String on purpose:
59    /// an unknown future role must ride, not refuse.
60    #[serde(default)]
61    pub role: String,
62    /// `"Unknown"` on both captures (a fresh Independent rig has no
63    /// project state to report).
64    #[serde(
65        rename = "projectState",
66        alias = "project_state",
67        default,
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub project_state: Option<String>,
71    /// `"Active"` on the serving node, `"Cold"` on the standby (block
72    /// C-S3); `"Active"` on both fresh Independent rigs. Moves on
73    /// failover while `role` stays fixed.
74    #[serde(
75        rename = "activityLevel",
76        alias = "activity_level",
77        default,
78        skip_serializing_if = "Option::is_none"
79    )]
80    pub activity_level: Option<String>,
81    /// This gateway's id — the container IP on both captures
82    /// (`192.168.215.2` / `192.168.171.2`).
83    #[serde(
84        rename = "localId",
85        alias = "local_id",
86        default,
87        skip_serializing_if = "Option::is_none"
88    )]
89    pub local_id: Option<String>,
90    /// The peer's id — ABSENT on both fresh-rig captures (no peer
91    /// exists); the flat research shape documents it, so it rides
92    /// Option. Peer-connected shapes were NOT capturable on a single
93    /// fresh rig — everything peer-shaped stays Option/lenient.
94    #[serde(
95        rename = "peerId",
96        alias = "peer_id",
97        default,
98        skip_serializing_if = "Option::is_none"
99    )]
100    pub peer_id: Option<String>,
101    /// `false` on both captures (no peer on a fresh rig).
102    #[serde(rename = "peerConnected", alias = "peer_connected", default)]
103    pub peer_connected: bool,
104    /// `true` on both captures (an Independent gateway owns its config).
105    #[serde(rename = "hasConfigAccess", alias = "has_config_access", default)]
106    pub has_config_access: bool,
107    /// `false` on both captures.
108    #[serde(rename = "syncPending", alias = "sync_pending", default)]
109    pub sync_pending: bool,
110    /// `false` on both captures.
111    #[serde(rename = "failoverPending", alias = "failover_pending", default)]
112    pub failover_pending: bool,
113    /// **Milliseconds since gateway (JVM) start** — capture-proven by
114    /// wall-clock cross-check on BOTH rigs (module doc; 09-LIVE-CAPTURES
115    /// §3). Option: present on both captures, defaulted for tolerance.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub uptime: Option<i64>,
118    /// Epoch-ms (block C-S3: `1791586091827` on a live 8.3.6 pair);
119    /// `-1` is the NEVER-SYNCED sentinel (both fresh Independent
120    /// rigs). Use [`Self::last_sync_epoch_ms`] — raw `-1` rides here,
121    /// never misread as a real timestamp.
122    #[serde(
123        rename = "lastSyncTimestamp",
124        alias = "last_sync_timestamp",
125        default,
126        skip_serializing_if = "Option::is_none"
127    )]
128    pub last_sync_timestamp: Option<i64>,
129    /// Unknown keys round-trip (version tolerance).
130    #[serde(flatten)]
131    pub extra: BTreeMap<String, serde_json::Value>,
132}
133
134impl RedundancyStatusWire {
135    /// `lastSyncTimestamp` normalized: the `-1` sentinel ("never
136    /// synced" — both fresh rigs) and absent keys → `None`; a
137    /// non-negative value is epoch-ms (block C-S3).
138    pub fn last_sync_epoch_ms(&self) -> Option<i64> {
139        self.last_sync_timestamp.filter(|value| *value >= 0)
140    }
141}
142
143/// Which node of a redundant pair is serving, decided by `activityLevel`
144/// alone (block C-S3).
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ActiveNode {
147    /// Only the first node is `Active`.
148    OnlyA,
149    /// Only the second node is `Active`.
150    OnlyB,
151    /// Both nodes report `Active`: a split-brain signal. Reported, never
152    /// resolved; the caller must refuse on it.
153    Both,
154    /// Neither node reports `Active`.
155    Neither,
156}
157
158/// Decide which node of a pair is active. Pure: no I/O, no clock.
159///
160/// Compares `activity_level` against [`ACTIVITY_ACTIVE`] on each side and
161/// NEVER reads `role`: on failover `role` stays fixed (a stays Master)
162/// while activity moves (block C-S3, S3.md 40-41), so a role-based
163/// decision would pick the wrong node. An absent or unrecognised level
164/// (anything but `Active`) is not active.
165pub fn active_node(a: &RedundancyStatusWire, b: &RedundancyStatusWire) -> ActiveNode {
166    let is_active = |w: &RedundancyStatusWire| w.activity_level.as_deref() == Some(ACTIVITY_ACTIVE);
167    match (is_active(a), is_active(b)) {
168        (true, false) => ActiveNode::OnlyA,
169        (false, true) => ActiveNode::OnlyB,
170        (true, true) => ActiveNode::Both,
171        (false, false) => ActiveNode::Neither,
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::{ACTIVITY_COLD, ActiveNode, RedundancyStatusWire, active_node};
178
179    fn wire(value: serde_json::Value) -> RedundancyStatusWire {
180        serde_json::from_value(value).expect("the pair body must parse")
181    }
182
183    // C-S3, S3.md:6-7 (paired, pre-failover), copied verbatim.
184    fn pre_failover() -> (RedundancyStatusWire, RedundancyStatusWire) {
185        (
186            wire(
187                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}),
188            ),
189            wire(
190                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}),
191            ),
192        )
193    }
194
195    // C-S3, S3.md:40-41 (after failover), copied verbatim.
196    fn post_failover() -> (RedundancyStatusWire, RedundancyStatusWire) {
197        (
198            wire(
199                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}),
200            ),
201            wire(
202                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}),
203            ),
204        )
205    }
206
207    /// C-S3 S3.md:40-41: role stays Master on a while activity moves to
208    /// b; deciding by role would pick the wrong node.
209    #[test]
210    fn active_node_follows_activity_not_role() {
211        let (a, b) = post_failover();
212        assert_eq!(a.role, "Master");
213        assert_eq!(b.role, "Backup");
214        assert_eq!(a.activity_level.as_deref(), Some(ACTIVITY_COLD));
215        assert_eq!(a.uptime, Some(-1), "a cold node reports uptime -1");
216        assert_eq!(active_node(&a, &b), ActiveNode::OnlyB);
217    }
218
219    /// C-S3 S3.md:6-7: before failover the Master is the active node.
220    #[test]
221    fn active_node_pre_failover_is_master() {
222        let (a, b) = pre_failover();
223        assert_eq!(a.role, "Master");
224        assert_eq!(b.role, "Backup");
225        assert_eq!(active_node(&a, &b), ActiveNode::OnlyA);
226    }
227
228    /// Split-brain is reported, no active node is `Neither`, and an
229    /// absent or unrecognised level never counts as active (A-07a/b).
230    #[test]
231    fn active_node_split_brain_and_none() {
232        let with = |level: Option<&str>| {
233            let mut v = serde_json::json!({"role":"Independent"});
234            if let Some(level) = level {
235                v["activityLevel"] = serde_json::json!(level);
236            }
237            wire(v)
238        };
239        assert_eq!(
240            active_node(&with(Some("Active")), &with(Some("Active"))),
241            ActiveNode::Both
242        );
243        assert_eq!(
244            active_node(&with(Some("Cold")), &with(Some("Cold"))),
245            ActiveNode::Neither
246        );
247        assert_eq!(
248            active_node(&with(None), &with(Some("Cold"))),
249            ActiveNode::Neither
250        );
251        assert_eq!(
252            active_node(&with(Some("Warm")), &with(Some("Cold"))),
253            ActiveNode::Neither
254        );
255        assert_eq!(
256            active_node(&with(Some("Warm")), &with(Some("Active"))),
257            ActiveNode::OnlyB
258        );
259    }
260
261    /// C-S3 S3.md:6-7: a live pair's lastSyncTimestamp is epoch-ms.
262    #[test]
263    fn last_sync_is_epoch_ms_on_the_live_pair() {
264        let (a, _) = pre_failover();
265        assert_eq!(a.last_sync_epoch_ms(), Some(1_791_586_091_827));
266    }
267
268    /// THE 8.3.6 live capture (rig A, 09-LIVE-CAPTURES §3) — verbatim
269    /// one-line body.
270    #[test]
271    fn redundancy_parses_the_live_8_3_6_capture() {
272        let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
273            "role": "Independent", "projectState": "Unknown",
274            "activityLevel": "Active", "localId": "192.168.215.2",
275            "peerConnected": false, "hasConfigAccess": true,
276            "syncPending": false, "failoverPending": false,
277            "uptime": 460959, "lastSyncTimestamp": -1
278        }))
279        .expect("the live 8.3.6 shape must parse");
280        assert_eq!(wire.role, "Independent");
281        assert_eq!(wire.project_state.as_deref(), Some("Unknown"));
282        assert_eq!(wire.activity_level.as_deref(), Some("Active"));
283        assert_eq!(wire.local_id.as_deref(), Some("192.168.215.2"));
284        assert!(!wire.peer_connected);
285        assert!(wire.has_config_access);
286        assert!(!wire.sync_pending);
287        assert!(!wire.failover_pending);
288        assert_eq!(wire.uptime, Some(460_959), "ms since gateway start");
289        assert_eq!(wire.last_sync_timestamp, Some(-1));
290        assert_eq!(
291            wire.last_sync_epoch_ms(),
292            None,
293            "the -1 sentinel never reads as a timestamp"
294        );
295        assert_eq!(wire.peer_id, None, "peer absent on a fresh rig");
296    }
297
298    /// THE 8.3.3 live capture (rig B, 09-LIVE-CAPTURES §3) — same
299    /// shape, different localId + uptime (no point-release drift).
300    #[test]
301    fn redundancy_parses_the_live_8_3_3_capture() {
302        let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
303            "role": "Independent", "projectState": "Unknown",
304            "activityLevel": "Active", "localId": "192.168.171.2",
305            "peerConnected": false, "hasConfigAccess": true,
306            "syncPending": false, "failoverPending": false,
307            "uptime": 617422, "lastSyncTimestamp": -1
308        }))
309        .expect("the live 8.3.3 shape must parse");
310        assert_eq!(wire.uptime, Some(617_422));
311        assert_eq!(wire.last_sync_epoch_ms(), None);
312    }
313
314    /// A non-negative `lastSyncTimestamp` (epoch-ms, block C-S3)
315    /// normalizes through, a peer-shaped Master body parses, and unknown
316    /// keys ride `extra` (the round-trip proof).
317    #[test]
318    fn redundancy_normalizes_and_passes_unknown_keys() {
319        let wire: RedundancyStatusWire = serde_json::from_value(serde_json::json!({
320            "role": "Master", "projectState": "Good",
321            "activityLevel": "Active", "localId": "192.168.97.3",
322            "peerId": "spike-a-Backup",
323            "peerConnected": true, "hasConfigAccess": true,
324            "syncPending": false, "failoverPending": false,
325            "uptime": 29279, "lastSyncTimestamp": 1791586091827i64,
326            "futurePointReleaseField": [1, 2]
327        }))
328        .expect("peer-shaped + unknown-key variants must parse");
329        assert_eq!(wire.role, "Master", "a String role rides, never refuses");
330        assert_eq!(wire.peer_id.as_deref(), Some("spike-a-Backup"));
331        assert_eq!(
332            wire.last_sync_epoch_ms(),
333            Some(1_791_586_091_827),
334            "non-negative values read epoch-ms (block C-S3)"
335        );
336        assert_eq!(
337            wire.extra.get("futurePointReleaseField"),
338            Some(&serde_json::json!([1, 2])),
339            "unknown keys ride flatten passthrough"
340        );
341    }
342}