Skip to main content

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}