feat(adr-110): NodeSyncSnapshot.staleness_ms — sync age in milliseconds
Iter 34 — adds an optional `staleness_ms` field to the iter-23
NodeSyncSnapshot that exposes (Instant::now() - latest_sync_at).
Dashboards / Prometheus exporters / UI badges can now decay sync
freshness without re-deriving it from latest_sync_at on the host.
Wire compatibility: new field is `#[serde(skip_serializing_if =
"Option::is_none")]` so pre-iter-34 clients that strict-parse via
serde + deny_unknown_fields are unaffected (default serde_json
strategy is to ignore unknown fields anyway).
Sensing-server changes:
+ NodeSyncSnapshot.staleness_ms: Option<u64>
+ sync_snapshot() populates it via latest_sync_at.elapsed().as_millis()
+ iter-24 serialization tests now check 8 contract fields, not 7
+ new test `snapshot_staleness_ms_tracks_apply_time` pins
latest_sync_at to a past Instant and asserts the snapshot reports
~750 ms staleness with ±500 ms tolerance for scheduler delay
User-guide updates:
+ REST/WebSocket field table grows a `staleness_ms` row with the
UI-rendering thresholds (fade at 5 s, drop at 9 s to match the
firmware's VALID_WINDOW_MS-derived gate).
Tests passing:
sync_snapshot_helper_tests: 7/7
node_sync_snapshot_serialization_tests: 3/3 (8-field assertion green)
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
bea7edee1f
commit
f6a85fe7db
|
|
@ -669,6 +669,7 @@ Field meanings:
|
|||
| `sequence` | u32 | High-water CSI sequence number stamped when this sync packet was emitted. Pair with the per-frame `sequence` field on incoming CSI to interpolate a mesh-aligned timestamp for any frame. |
|
||||
| `csi_fps_ema` | f64 | Per-node EMA of the observed CSI frame rate. Bench typical ≈ 10 Hz. |
|
||||
| `csi_fps_samples` | u32 | How many inter-frame deltas the EMA has seen. Treat values < 5 as "not yet trustworthy" and fall back to 20 Hz. |
|
||||
| `staleness_ms` | u64 (optional) | Milliseconds since the host last received a sync packet from this node ([iter 34](adr/ADR-110-esp32-c6-firmware-extension.md)). Fade UI badges after 5 000 ms; treat ≥ 9 000 ms as the same condition that the firmware's `c6_sync_espnow_is_valid()` reports as `false`. |
|
||||
|
||||
**When `sync` is omitted entirely**: the node isn't on the mesh (or
|
||||
hasn't heard a peer yet). Non-ESP32 paths — multi-BSSID router scan,
|
||||
|
|
|
|||
|
|
@ -325,6 +325,14 @@ struct NodeSyncSnapshot {
|
|||
/// How many CSI frames have contributed to `csi_fps_ema`. Clients can
|
||||
/// treat <5 as "not yet trustworthy" and fall back to 20 Hz.
|
||||
csi_fps_samples: u32,
|
||||
/// ADR-110 iter 34 — milliseconds since the host last received a sync
|
||||
/// packet from this node. Lets UI dashboards render sync-age decay
|
||||
/// (badge fades after 5 s, drops off after the 9 s mesh_aligned_us
|
||||
/// staleness gate). `None` only when the host never had Instant data
|
||||
/// for this node, which shouldn't happen in normal flow but is
|
||||
/// modeled defensively.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
staleness_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -584,6 +592,7 @@ impl NodeState {
|
|||
sequence: sync.sequence,
|
||||
csi_fps_ema: self.csi_fps_ema,
|
||||
csi_fps_samples: self.csi_fps_samples,
|
||||
staleness_ms: self.latest_sync_at.map(|t| t.elapsed().as_millis() as u64),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -5789,6 +5798,7 @@ mod node_sync_snapshot_serialization_tests {
|
|||
sequence: 20,
|
||||
csi_fps_ema: 10.0,
|
||||
csi_fps_samples: 47,
|
||||
staleness_ms: Some(120),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5807,9 +5817,10 @@ mod node_sync_snapshot_serialization_tests {
|
|||
fn sync_present_serializes_all_seven_fields() {
|
||||
let v = serde_json::to_value(sample_node(Some(sample_sync()))).unwrap();
|
||||
let s = v.get("sync").expect("sync key must be present");
|
||||
// All seven contract fields named exactly as iter 23 documented.
|
||||
// All eight contract fields named exactly as iter 23/34 documented.
|
||||
for key in ["offset_us", "is_leader", "is_valid", "smoothed",
|
||||
"sequence", "csi_fps_ema", "csi_fps_samples"] {
|
||||
"sequence", "csi_fps_ema", "csi_fps_samples",
|
||||
"staleness_ms"] {
|
||||
assert!(s.get(key).is_some(),
|
||||
"sync object missing field `{}` — UI contract broken", key);
|
||||
}
|
||||
|
|
@ -5942,6 +5953,26 @@ mod sync_snapshot_helper_tests {
|
|||
assert_eq!(ns.latest_sync_at, Some(t1)); // staleness clock reset
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_staleness_ms_tracks_apply_time() {
|
||||
// Iter 34: staleness_ms = (Instant::now() - latest_sync_at).as_millis().
|
||||
// We can't pass a synthetic "now" through sync_snapshot, but we can
|
||||
// pin latest_sync_at to a past instant and assert the value lands
|
||||
// in a plausible window.
|
||||
let mut ns = NodeState::new();
|
||||
ns.latest_sync = Some(populated_sync(9));
|
||||
ns.latest_sync_at = std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_millis(750));
|
||||
|
||||
let snap = ns.sync_snapshot().unwrap();
|
||||
let st = snap.staleness_ms.expect("staleness_ms must be present");
|
||||
// Should be approximately 750 ms — give a generous ±500 ms tolerance
|
||||
// for any test-runner scheduling delay between checked_sub() and
|
||||
// elapsed() within sync_snapshot.
|
||||
assert!(st >= 740 && st < 1250,
|
||||
"expected ~750 ms staleness, got {} ms", st);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_aligned_us_honors_9s_staleness_gate() {
|
||||
// The receive helper stores latest_sync_at = Instant::now() each
|
||||
|
|
|
|||
Loading…
Reference in New Issue