Keep job outcomes until the cloud acknowledges them
Windows discovery tool / windows (push) Successful in 1m2s

The gateway now follows the job each printer was handed. Started, finished
and failed are written to its SQLite store and sent as job_event on every
connection until a job_event_ack clears them, so an outcome reached during
an outage, or before a restart, is still delivered.

A start that fails after being accepted goes through the same outbox
instead of a one-shot message to whoever was connected. Nothing is
concluded from a printer's state until the job has been seen running,
because a printer told to start still reports the previous job's FINISH.

The backend still owns the queue; this only reports.
This commit is contained in:
2026-10-30 08:05:04 +00:00
parent 9359add6aa
commit f21ad0d497
7 changed files with 581 additions and 1 deletions
+183
View File
@@ -0,0 +1,183 @@
//! Job outcomes survive the cloud being away: an event in the gateway's store
//! is sent on connect, sent again after a reconnect if nobody acknowledged it,
//! and gone once acknowledged. A start that fails after being accepted is
//! queued the same way instead of being sent once to whoever is listening.
//!
//! Real `uplink::run` against a real websocket server; no printer is touched
//! (the fleet is empty, so the start fails on its lookup before any download).
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use continuum_proxy::config::Config;
use continuum_proxy::store::Store;
use continuum_proxy::uplink;
/// Long enough for a loopback round trip, short enough that a regression
/// fails the test instead of hanging the suite.
const WAIT: Duration = Duration::from_secs(10);
fn config(port: u16) -> Config {
Config {
gateway_id: "test-gw".into(),
farm_id: "test-farm".into(),
gateway_token: "test-token".into(),
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
// is handed its fleet rather than loading one - but Config has no
// Default, so these have to say something.
go2rtc_bin: "/nonexistent-go2rtc".into(),
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
go2rtc_api_listen: "127.0.0.1:1984".into(),
stream_base_url: None,
printers_path: "/nonexistent/printers.toml".into(),
store_path: "/nonexistent/continuum.db".into(),
// Off, which is the default and also the only safe thing in a test:
// an enabled one would sweep whatever network the test host is on.
discovery: Default::default(),
}
}
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
/// callers filter; `interval` fires on its first tick, which means a
/// heartbeat arrives immediately after the hello.
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
loop {
let frame = tokio::time::timeout(WAIT, ws.next())
.await
.expect("timed out waiting for a frame from the gateway")
.expect("the gateway closed the connection")
.expect("websocket error");
if let Message::Text(text) = frame {
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
}
}
}
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
/// the gateway sent its farm and long-lived token, and answers with a JWT.
/// Returns the request text so the caller can assert on it.
async fn answer_token_exchange(listener: &TcpListener) -> String {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
assert!(n > 0, "the gateway hung up before finishing its token request");
request.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&request).into_owned();
if let Some((head, body)) = text.split_once("\r\n\r\n") {
let length = head
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
.unwrap_or(0);
if body.len() >= length {
break;
}
}
}
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
stream
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
.await
.unwrap();
String::from_utf8_lossy(&request).into_owned()
}
/// Answers the token exchange and accepts the websocket, returning it after
/// the gateway's hello.
async fn accept(listener: &TcpListener) -> WebSocketStream<tokio::net::TcpStream> {
answer_token_exchange(listener).await;
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, response: Response| Ok(response)).await.unwrap();
let hello = next_json(&mut ws).await;
assert_eq!(hello["type"], "hello");
ws
}
/// Reads frames until `want` job events have arrived, returning them by job id.
async fn job_events(ws: &mut WebSocketStream<tokio::net::TcpStream>, want: usize) -> Vec<Value> {
let mut events = Vec::new();
while events.len() < want {
let msg = next_json(ws).await;
if msg["type"] == "job_event" {
events.push(msg);
}
}
events.sort_by_key(|e| e["event_id"].as_i64());
events
}
#[tokio::test]
async fn unacknowledged_job_events_are_resent_after_a_reconnect_and_stop_once_acknowledged() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let dir = std::env::temp_dir().join(format!("continuum-outbox-test-{}", std::process::id()));
let store = Arc::new(Store::open(&dir.join("s.db")).unwrap());
// An outcome reached while the cloud was away.
store.outbox_push("11111111-1111-4111-8111-111111111111", "finished", None, 1_000).unwrap();
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
let (outbound, inbox) = uplink::channel();
tokio::spawn(uplink::run(config(port), fleet, Some(store.clone()), outbound, inbox));
let mut ws = accept(&listener).await;
let first = job_events(&mut ws, 1).await;
assert_eq!(first[0]["job_id"], "11111111-1111-4111-8111-111111111111");
assert_eq!(first[0]["event"], "finished");
assert_eq!(first[0]["at_ms"], 1_000);
// A start that fails after being accepted goes through the same outbox.
ws.send(Message::Text(
json!({
"type": "command", "command_id": "cmd-1", "printer_id": "not-in-this-fleet",
"command": { "action": "start_print", "job_id": "22222222-2222-4222-8222-222222222222",
"file_name": "cube.gcode", "url": "http://127.0.0.1:1/never-fetched" }
})
.to_string(),
))
.await
.unwrap();
// Only the new one: the first was already sent on this connection.
let mut both = first.clone();
both.extend(job_events(&mut ws, 1).await);
assert_eq!(both[1]["job_id"], "22222222-2222-4222-8222-222222222222");
assert_eq!(both[1]["event"], "failed");
assert!(both[1]["detail"].as_str().unwrap().contains("not-in-this-fleet"), "{}", both[1]);
// The cloud never answers; the connection drops.
drop(ws);
let mut ws = accept(&listener).await;
let again = job_events(&mut ws, 2).await;
assert_eq!(
again.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
both.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
"after a reconnect everything unacknowledged is sent again"
);
ws.send(Message::Text(json!({ "type": "job_event_ack", "event_id": again[0]["event_id"] }).to_string()))
.await
.unwrap();
let deadline = tokio::time::Instant::now() + WAIT;
while store.outbox_pending().unwrap().len() != 1 {
assert!(tokio::time::Instant::now() < deadline, "the acknowledged event was not cleared");
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert_eq!(store.outbox_pending().unwrap()[0].job_id, "22222222-2222-4222-8222-222222222222");
let _ = std::fs::remove_dir_all(dir);
}