ados_protocol/offload_link.rs
1//! The perception offload-link sidecar: the drone's live "a workstation is
2//! paired + reachable, and I am offloading to it" state.
3//!
4//! The offload reconciler (on the drone) writes this file each tick; the status
5//! surfaces read it staleness-gated and feed a REAL `compute_node_paired` /
6//! `bearer_acceptable` into [`crate`]'s tier decision, so an NPU-less drone with
7//! a reachable workstation reports (and runs) tier `offload` instead of the
8//! hardcoded "no node" default. Absent / stale ⇒ no offload link ⇒ the drone
9//! reports `none` (honest: never a fabricated paired node).
10//!
11//! Producer: the `ados-cloud` offload reconciler. Consumers: `ados-control`
12//! (`/api/status`) and `ados-cloud` (the cloud heartbeat). Single writer, many
13//! readers — the same sidecar shape as compute-heartbeat / atlas-forward.
14
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19/// The offload-link sidecar path (tmpfs, cleared on boot).
20pub const OFFLOAD_LINK_SIDECAR: &str = "/run/ados/offload-link.json";
21
22/// The sidecar schema version, stamped on write and checked (warn-only) on read.
23pub const OFFLOAD_LINK_SIDECAR_VERSION: u16 = 1;
24
25/// A link file not re-written within this window is treated as absent, so a dead
26/// / hung reconciler (whose tmpfs file persists) never keeps a drone reporting a
27/// frozen `offload` tier after the workstation is gone. 4x
28/// the reconciler's ~5 s tick.
29pub const OFFLOAD_LINK_STALE_MS: i64 = 20_000;
30
31/// The drone's live offload-link state, written by the reconciler each tick.
32#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct OffloadLink {
35 /// The sidecar schema version (absent ⇒ `0` from an older writer).
36 #[serde(default)]
37 pub version: u16,
38 /// The reconciler's write time (epoch ms); absent/stale ⇒ treated as gone.
39 pub generated_at_ms: Option<i64>,
40 /// A `profile=workstation` node resolved + is reachable this tick.
41 #[serde(default)]
42 pub paired: bool,
43 /// The LAN bearer to that node is healthy enough to offload over.
44 #[serde(default)]
45 pub bearer_acceptable: bool,
46 /// The node's job-API address (`host:port`) the drone is offloading to.
47 pub target: Option<String>,
48 /// The node's device id, when discovery reported one.
49 pub device_id: Option<String>,
50 /// The detector model id the offload session runs (labels the source).
51 pub model_id: Option<String>,
52 /// The plugin that advertised this link, when a plugin did. While the link
53 /// is fresh no other plugin may replace it.
54 #[serde(default)]
55 pub owner: Option<String>,
56}
57
58impl OffloadLink {
59 /// A link stamped at the current schema version + `now_ms` write time. The
60 /// reconciler builds one of these each tick and writes it; a not-paired tick
61 /// writes `paired: false` (an honest "no link right now") rather than leaving
62 /// a stale paired file behind.
63 pub fn stamped(
64 paired: bool,
65 bearer_acceptable: bool,
66 target: Option<String>,
67 device_id: Option<String>,
68 model_id: Option<String>,
69 now_ms: i64,
70 ) -> Self {
71 Self {
72 version: OFFLOAD_LINK_SIDECAR_VERSION,
73 generated_at_ms: Some(now_ms),
74 paired,
75 bearer_acceptable,
76 target,
77 device_id,
78 model_id,
79 owner: None,
80 }
81 }
82
83 /// This link, recorded as advertised by plugin `owner`.
84 pub fn owned_by(mut self, owner: &str) -> Self {
85 self.owner = Some(owner.to_string());
86 self
87 }
88
89 /// Whether this link should count as an offload path for the tier decision:
90 /// a paired node on an acceptable bearer. (The staleness gate is applied by
91 /// the reader, so a link this method sees is already fresh.)
92 pub fn is_offload_path(&self) -> bool {
93 self.paired && self.bearer_acceptable
94 }
95}
96
97/// Read + parse the offload-link sidecar at `path`, or `None` when it is absent,
98/// unparseable, missing its write-time, or STALE (older than
99/// [`OFFLOAD_LINK_STALE_MS`] at `now_ms`). `now_ms` is injected so the staleness
100/// gate is testable without touching the clock.
101pub fn read_offload_link_from(path: &Path, now_ms: i64) -> Option<OffloadLink> {
102 let text = std::fs::read_to_string(path).ok()?;
103 let link: OffloadLink = serde_json::from_str(&text).ok()?;
104 match link.generated_at_ms {
105 Some(gen) if now_ms.saturating_sub(gen) <= OFFLOAD_LINK_STALE_MS => {
106 // Warn (never reject) on a producer/reader version mismatch, then
107 // fold the link in anyway — the same best-effort drift signal the
108 // other sidecars use.
109 crate::sidecar::check_sidecar_version(
110 "offload-link",
111 link.version,
112 OFFLOAD_LINK_SIDECAR_VERSION,
113 );
114 Some(link)
115 }
116 _ => None,
117 }
118}
119
120/// Read the offload-link sidecar from the default path, staleness-gated at
121/// `now_ms`.
122pub fn read_offload_link(now_ms: i64) -> Option<OffloadLink> {
123 read_offload_link_from(Path::new(OFFLOAD_LINK_SIDECAR), now_ms)
124}
125
126/// Atomically write `link` to `path` (tmp + rename), creating the parent dir if
127/// needed. The reconciler stamps the link ([`OffloadLink::stamped`]) before
128/// calling this.
129pub fn write_offload_link_to(path: &Path, link: &OffloadLink) -> std::io::Result<()> {
130 if let Some(parent) = path.parent() {
131 std::fs::create_dir_all(parent)?;
132 }
133 let body = serde_json::to_vec(link).map_err(std::io::Error::other)?;
134 let tmp = path.with_extension("json.tmp");
135 std::fs::write(&tmp, body)?;
136 std::fs::rename(&tmp, path)
137}
138
139/// Atomically write `link` to the default sidecar path.
140pub fn write_offload_link(link: &OffloadLink) -> std::io::Result<()> {
141 write_offload_link_to(Path::new(OFFLOAD_LINK_SIDECAR), link)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn a_fresh_paired_link_round_trips_and_reads_back() {
150 let dir = tempfile::tempdir().unwrap();
151 let path = dir.path().join("offload-link.json");
152 let now = 1_700_000_000_000i64;
153 let link = OffloadLink::stamped(
154 true,
155 true,
156 Some("192.168.1.5:8092".into()),
157 Some("dev-abc".into()),
158 Some("coco-yolov8n".into()),
159 now,
160 );
161 write_offload_link_to(&path, &link).unwrap();
162 // No leftover temp file.
163 assert!(!dir.path().join("offload-link.json.tmp").exists());
164
165 let back = read_offload_link_from(&path, now + 1_000).unwrap();
166 assert_eq!(back, link);
167 assert!(back.is_offload_path());
168 }
169
170 #[test]
171 fn a_stale_link_reads_as_absent() {
172 let dir = tempfile::tempdir().unwrap();
173 let path = dir.path().join("offload-link.json");
174 let gen = 1_700_000_000_000i64;
175 let link = OffloadLink::stamped(true, true, Some("h:8092".into()), None, None, gen);
176 write_offload_link_to(&path, &link).unwrap();
177
178 // Just inside the window ⇒ present; just past it ⇒ absent.
179 assert!(read_offload_link_from(&path, gen + OFFLOAD_LINK_STALE_MS).is_some());
180 assert!(read_offload_link_from(&path, gen + OFFLOAD_LINK_STALE_MS + 1).is_none());
181 }
182
183 #[test]
184 fn an_absent_file_reads_as_none() {
185 let p = Path::new("/run/ados/does-not-exist-offload-link.json");
186 assert!(read_offload_link_from(p, 1_700_000_000_000).is_none());
187 }
188
189 #[test]
190 fn a_not_paired_link_is_not_an_offload_path() {
191 let link = OffloadLink::stamped(false, false, None, None, None, 1);
192 assert!(!link.is_offload_path());
193 }
194}