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
+185
View File
@@ -0,0 +1,185 @@
//! Following the jobs the cloud has handed this gateway, so their outcomes
//! reach the cloud even if it was unreachable when they happened.
//!
//! The cloud owns the queue; this only reports. When a `start_print` is
//! accepted the job is recorded against its printer, and each tick the
//! printer's state is compared with it. What the cloud should hear -
//! started, finished, failed - goes into the store's outbox, and the uplink
//! re-sends the outbox on every connection until the cloud acknowledges each
//! event. An outcome reached during an outage, or before a restart, is
//! therefore still delivered, instead of the cloud guessing from whatever
//! telemetry it happens to see next.
use std::time::Duration;
use tracing::{info, warn};
use crate::printer::GenericPrinter;
use crate::store::{ActiveJob, Store};
use crate::uplink::SharedFleet;
const TICK: Duration = Duration::from_secs(5);
/// A job whose printer never reported printing within this long is given up on.
const NEVER_STARTED_AFTER_MS: i64 = 30 * 60 * 1000;
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[derive(Debug, PartialEq)]
pub enum Step {
Nothing,
Started,
Finished,
Failed(String),
}
/// What a printer's reported state means for the job it was handed.
///
/// Nothing is concluded from a state until the job has been seen running: a
/// printer that has just been told to start still reports the *previous*
/// job's `FINISH` (or `FAILED`) for a while, and reading that as this job's
/// outcome would report a print that has not begun as done.
pub fn next_step(job: &ActiveJob, state: Option<&str>, now_ms: i64) -> Step {
let state = state.map(str::to_ascii_uppercase);
let running = matches!(state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE" | "SLICING"));
if !job.started {
if running {
return Step::Started;
}
if now_ms - job.since_ms > NEVER_STARTED_AFTER_MS {
return Step::Failed("the printer never started printing it".into());
}
return Step::Nothing;
}
match state.as_deref() {
Some("FINISH") => Step::Finished,
Some("FAILED") => Step::Failed("the printer reported a fault".into()),
// Not a finish, not a fault, and no longer printing: stopped on the
// machine itself. Klipper and OctoPrint report a cancelled print as
// idle.
Some("IDLE") => Step::Failed("the printer stopped without finishing it".into()),
_ => Step::Nothing,
}
}
/// The gateway accepted a `start_print` for `job_id`.
pub fn accepted(store: &Store, printer_id: &str, job_id: &str) {
if let Err(err) = store.active_job_set(printer_id, job_id, now_ms()) {
warn!(%err, %printer_id, %job_id, "could not record the job against its printer");
}
}
/// The accepted start then failed (download, transfer, start).
pub fn start_failed(store: &Store, printer_id: &str, job_id: &str, reason: &str) {
if let Err(err) = store.outbox_push(job_id, "failed", Some(reason), now_ms()) {
warn!(%err, %job_id, "could not queue the failure for the cloud");
}
let _ = store.active_job_clear(printer_id);
}
pub async fn run(fleet: SharedFleet, store: std::sync::Arc<Store>) {
let mut ticker = tokio::time::interval(TICK);
loop {
ticker.tick().await;
let Ok(active) = store.active_jobs() else { continue };
if active.is_empty() {
continue;
}
let states: Vec<(ActiveJob, Option<String>)> = {
let fleet = fleet.lock().await;
let mut out = Vec::new();
for job in active {
let state = match fleet.iter().find(|p| p.base().id == job.printer_id) {
Some(printer) => printer.status().await.and_then(|s| s.state),
None => None,
};
out.push((job, state));
}
out
};
for (job, state) in states {
apply(&store, &job, next_step(&job, state.as_deref(), now_ms()));
}
}
}
fn apply(store: &Store, job: &ActiveJob, step: Step) {
let result = match &step {
Step::Nothing => return,
Step::Started => store
.outbox_push(&job.job_id, "started", None, now_ms())
.and_then(|_| store.active_job_started(&job.printer_id)),
Step::Finished => store
.outbox_push(&job.job_id, "finished", None, now_ms())
.and_then(|_| store.active_job_clear(&job.printer_id)),
Step::Failed(reason) => store
.outbox_push(&job.job_id, "failed", Some(reason), now_ms())
.and_then(|_| store.active_job_clear(&job.printer_id)),
};
match result {
Ok(_) => info!(printer = %job.printer_id, job = %job.job_id, ?step, "job event queued for the cloud"),
Err(err) => warn!(%err, job = %job.job_id, "could not queue a job event"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn job(started: bool, since_ms: i64) -> ActiveJob {
ActiveJob { printer_id: "p1".into(), job_id: "j1".into(), since_ms, started }
}
#[test]
fn a_stale_finish_from_the_previous_job_is_not_this_jobs_outcome() {
assert_eq!(next_step(&job(false, 0), Some("FINISH"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(false, 0), Some("FAILED"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(false, 0), Some("IDLE"), 10_000), Step::Nothing);
}
#[test]
fn running_marks_it_started_and_then_finish_finishes_it() {
assert_eq!(next_step(&job(false, 0), Some("RUNNING"), 10_000), Step::Started);
assert_eq!(next_step(&job(true, 0), Some("RUNNING"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(true, 0), Some("PAUSE"), 10_000), Step::Nothing);
assert_eq!(next_step(&job(true, 0), Some("FINISH"), 10_000), Step::Finished);
}
#[test]
fn a_fault_or_a_stop_after_starting_is_a_failure() {
assert!(matches!(next_step(&job(true, 0), Some("FAILED"), 1), Step::Failed(_)));
assert!(matches!(next_step(&job(true, 0), Some("IDLE"), 1), Step::Failed(_)));
}
#[test]
fn a_printer_that_goes_quiet_leaves_the_job_alone() {
assert_eq!(next_step(&job(true, 0), None, 1), Step::Nothing);
}
#[test]
fn a_job_that_never_starts_is_eventually_given_up_on() {
assert_eq!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS), Step::Nothing);
assert!(matches!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS + 1), Step::Failed(_)));
}
#[test]
fn applying_steps_fills_the_outbox_and_clears_the_active_job() {
let dir = std::env::temp_dir().join(format!("continuum-jobs-test-{}", std::process::id()));
let store = Store::open(&dir.join("s.db")).unwrap();
accepted(&store, "p1", "j1");
let active = store.active_jobs().unwrap().remove(0);
apply(&store, &active, Step::Started);
let active = store.active_jobs().unwrap().remove(0);
assert!(active.started);
apply(&store, &active, Step::Finished);
assert!(store.active_jobs().unwrap().is_empty());
let events: Vec<_> = store.outbox_pending().unwrap().into_iter().map(|e| e.event).collect();
assert_eq!(events, ["started", "finished"]);
let _ = std::fs::remove_dir_all(dir);
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod config;
pub mod config_import; pub mod config_import;
pub mod discovery; pub mod discovery;
pub mod fleet; pub mod fleet;
pub mod jobs;
pub mod printer; pub mod printer;
pub mod provision; pub mod provision;
pub mod store; pub mod store;
+2 -1
View File
@@ -13,7 +13,7 @@ use continuum_proxy::bootstrap::{self, Bootstrap};
use continuum_proxy::config::Config; use continuum_proxy::config::Config;
use continuum_proxy::config_import; use continuum_proxy::config_import;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{camera, completion, fleet, uplink}; use continuum_proxy::{camera, completion, fleet, jobs, uplink};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -177,6 +177,7 @@ async fn main() -> anyhow::Result<()> {
restart_go2rtc.clone(), restart_go2rtc.clone(),
) => {} ) => {}
_ = completion::run(fleet.clone()) => {} _ = completion::run(fleet.clone()) => {}
_ = jobs::run(fleet.clone(), store.clone()) => {}
// Returns only after installing a new config from a stick inserted // Returns only after installing a new config from a stick inserted
// while running. Settings are read once at startup, so the process // while running. Settings are read once at startup, so the process
// ends here and relies on its supervisor (systemd `Restart=always` // ends here and relies on its supervisor (systemd `Restart=always`
+139
View File
@@ -42,6 +42,22 @@ impl Store {
"CREATE TABLE IF NOT EXISTS printers ( "CREATE TABLE IF NOT EXISTS printers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
json TEXT NOT NULL json TEXT NOT NULL
);
-- What happened to jobs the cloud handed this gateway, until the
-- cloud has acknowledged it (see `jobs`). Survives a restart.
CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
event TEXT NOT NULL,
detail TEXT,
at_ms INTEGER NOT NULL
);
-- The job each printer is working on, as far as this gateway knows.
CREATE TABLE IF NOT EXISTS active_jobs (
printer_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
since_ms INTEGER NOT NULL,
started INTEGER NOT NULL DEFAULT 0
);", );",
)?; )?;
Ok(Self { conn: Mutex::new(conn) }) Ok(Self { conn: Mutex::new(conn) })
@@ -78,6 +94,97 @@ impl Store {
} }
} }
/// A job outcome waiting for the cloud's acknowledgement.
#[derive(Debug, Clone, PartialEq)]
pub struct OutboxEvent {
pub id: i64,
pub job_id: String,
pub event: String,
pub detail: Option<String>,
pub at_ms: i64,
}
/// A job a printer was handed and has not yet finished with.
#[derive(Debug, Clone, PartialEq)]
pub struct ActiveJob {
pub printer_id: String,
pub job_id: String,
pub since_ms: i64,
pub started: bool,
}
impl Store {
/// Queues an outcome to tell the cloud. Returns its id.
pub fn outbox_push(&self, job_id: &str, event: &str, detail: Option<&str>, at_ms: i64) -> anyhow::Result<i64> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute(
"INSERT INTO outbox (job_id, event, detail, at_ms) VALUES (?1, ?2, ?3, ?4)",
params![job_id, event, detail, at_ms],
)?;
Ok(conn.last_insert_rowid())
}
/// Everything the cloud has not acknowledged, oldest first.
pub fn outbox_pending(&self) -> anyhow::Result<Vec<OutboxEvent>> {
let conn = self.conn.lock().expect("store lock poisoned");
let mut statement =
conn.prepare("SELECT id, job_id, event, detail, at_ms FROM outbox ORDER BY id LIMIT 500")?;
let rows = statement.query_map([], |row| {
Ok(OutboxEvent {
id: row.get(0)?,
job_id: row.get(1)?,
event: row.get(2)?,
detail: row.get(3)?,
at_ms: row.get(4)?,
})
})?;
Ok(rows.collect::<Result<_, _>>()?)
}
pub fn outbox_ack(&self, id: i64) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute("DELETE FROM outbox WHERE id = ?1", params![id])?;
Ok(())
}
/// Records that `printer_id` was handed `job_id`, replacing any earlier job.
pub fn active_job_set(&self, printer_id: &str, job_id: &str, now_ms: i64) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute(
"INSERT INTO active_jobs (printer_id, job_id, since_ms, started) VALUES (?1, ?2, ?3, 0)
ON CONFLICT(printer_id) DO UPDATE SET job_id = excluded.job_id, since_ms = excluded.since_ms, started = 0",
params![printer_id, job_id, now_ms],
)?;
Ok(())
}
pub fn active_job_started(&self, printer_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute("UPDATE active_jobs SET started = 1 WHERE printer_id = ?1", params![printer_id])?;
Ok(())
}
pub fn active_job_clear(&self, printer_id: &str) -> anyhow::Result<()> {
let conn = self.conn.lock().expect("store lock poisoned");
conn.execute("DELETE FROM active_jobs WHERE printer_id = ?1", params![printer_id])?;
Ok(())
}
pub fn active_jobs(&self) -> anyhow::Result<Vec<ActiveJob>> {
let conn = self.conn.lock().expect("store lock poisoned");
let mut statement = conn.prepare("SELECT printer_id, job_id, since_ms, started FROM active_jobs")?;
let rows = statement.query_map([], |row| {
Ok(ActiveJob {
printer_id: row.get(0)?,
job_id: row.get(1)?,
since_ms: row.get(2)?,
started: row.get::<_, i64>(3)? != 0,
})
})?;
Ok(rows.collect::<Result<_, _>>()?)
}
}
/// Creates the file empty and owner-only if it does not exist yet. /// Creates the file empty and owner-only if it does not exist yet.
#[cfg(unix)] #[cfg(unix)]
fn create_private(path: &Path) -> anyhow::Result<()> { fn create_private(path: &Path) -> anyhow::Result<()> {
@@ -196,4 +303,36 @@ mod tests {
std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap(); std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap();
assert!(Store::open(&path).is_err()); assert!(Store::open(&path).is_err());
} }
#[test]
fn an_unacknowledged_event_survives_a_restart_and_an_acknowledged_one_does_not() {
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
let first = store.outbox_push("job-1", "finished", None, 10).unwrap();
let second = store.outbox_push("job-2", "failed", Some("download failed"), 20).unwrap();
store.outbox_ack(first).unwrap();
drop(store);
let reopened = Store::open(&path).unwrap();
let pending = reopened.outbox_pending().unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].id, second);
assert_eq!(pending[0].detail.as_deref(), Some("download failed"));
}
#[test]
fn a_printer_has_one_active_job_and_a_new_one_replaces_it() {
let (_dir, path) = temp_db();
let store = Store::open(&path).unwrap();
store.active_job_set("p1", "job-1", 100).unwrap();
store.active_job_started("p1").unwrap();
assert!(store.active_jobs().unwrap()[0].started);
store.active_job_set("p1", "job-2", 200).unwrap();
let jobs = store.active_jobs().unwrap();
assert_eq!(jobs, vec![ActiveJob { printer_id: "p1".into(), job_id: "job-2".into(), since_ms: 200, started: false }]);
store.active_job_clear("p1").unwrap();
assert!(store.active_jobs().unwrap().is_empty());
}
} }
+58
View File
@@ -33,6 +33,7 @@ pub mod protocol;
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage}; pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -82,6 +83,8 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
/// a lot of traffic to describe a machine whose state changes on the scale of /// a lot of traffic to describe a machine whose state changes on the scale of
/// a layer, so this samples the cached report instead of chasing every push. /// a layer, so this samples the cached report instead of chasing every push.
const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5); const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5);
/// How often the job-event outbox is looked at for anything new to send.
const OUTBOX_INTERVAL: Duration = Duration::from_secs(3);
/// The printer fleet, shared between `main` (which owns it and keeps every /// The printer fleet, shared between `main` (which owns it and keeps every
/// connection alive) and this module (which reads status and dispatches /// connection alive) and this module (which reads status and dispatches
@@ -225,9 +228,34 @@ async fn serve(
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL); let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL); let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
let mut seq: u64 = 0; let mut seq: u64 = 0;
// Outbox events already written to THIS socket. Per connection on
// purpose: after a reconnect everything the cloud has not acknowledged
// goes out again, which is the point of keeping them.
let mut outbox_tick = tokio::time::interval(OUTBOX_INTERVAL);
let mut sent: HashSet<i64> = HashSet::new();
loop { loop {
tokio::select! { tokio::select! {
_ = outbox_tick.tick() => {
let Some(store) = store else { continue };
let pending = match store.outbox_pending() {
Ok(pending) => pending,
Err(err) => { warn!(%err, "could not read the job-event outbox"); continue }
};
let unsent: Vec<_> = pending.into_iter().filter(|e| !sent.contains(&e.id)).collect();
for event in unsent {
let msg = ClientMessage::JobEvent {
event_id: event.id,
job_id: event.job_id,
event: event.event,
detail: event.detail,
at_ms: event.at_ms,
};
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
sent.insert(event.id);
}
}
_ = heartbeat.tick() => { _ = heartbeat.tick() => {
seq += 1; seq += 1;
let msg = ClientMessage::Heartbeat { seq }; let msg = ClientMessage::Heartbeat { seq };
@@ -275,6 +303,7 @@ async fn serve(
command, command,
command_id.clone(), command_id.clone(),
outbound.clone(), outbound.clone(),
store.cloned(),
); );
ClientMessage::CommandResult { ClientMessage::CommandResult {
command_id, ok: true, error: None, command_id, ok: true, error: None,
@@ -328,6 +357,14 @@ async fn serve(
Ok(ServerMessage::ProvisionPrinter { request_id, printer }) => { Ok(ServerMessage::ProvisionPrinter { request_id, printer }) => {
spawn_provision(fleet.clone(), store.cloned(), request_id, Some(printer), outbound.clone()); spawn_provision(fleet.clone(), store.cloned(), request_id, Some(printer), outbound.clone());
} }
Ok(ServerMessage::JobEventAck { event_id }) => {
sent.remove(&event_id);
if let Some(store) = store
&& let Err(err) = store.outbox_ack(event_id)
{
warn!(%err, event_id, "could not clear an acknowledged job event");
}
}
Ok(ServerMessage::RemovePrinter { request_id, printer_id }) => { Ok(ServerMessage::RemovePrinter { request_id, printer_id }) => {
spawn_remove(fleet.clone(), store.cloned(), request_id, printer_id, outbound.clone()); spawn_remove(fleet.clone(), store.cloned(), request_id, printer_id, outbound.clone());
} }
@@ -460,10 +497,31 @@ fn spawn_long_command(
command: PrinterCommand, command: PrinterCommand,
command_id: String, command_id: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>, outbound: tokio::sync::mpsc::Sender<ClientMessage>,
store: Option<Arc<Store>>,
) { ) {
// The job is followed from here on, so its outcome is reported even if
// the cloud is unreachable when it happens (see `jobs`).
let job_id = match (&command, &store) {
(PrinterCommand::StartPrint { job_id, .. }, Some(store)) => {
crate::jobs::accepted(store, &printer_id, job_id);
Some(job_id.clone())
}
_ => None,
};
tokio::spawn(async move { tokio::spawn(async move {
match dispatch(&fleet, &printer_id, command).await { match dispatch(&fleet, &printer_id, command).await {
Ok(()) => info!(%printer_id, "long-running command finished"), Ok(()) => info!(%printer_id, "long-running command finished"),
Err(err) if job_id.is_some() => {
// Still logged locally; the cloud hears of it from the
// outbox, which survives it being down right now.
tracing::error!(
%printer_id, %err,
"long-running command failed after the cloud was already told it was accepted"
);
if let (Some(store), Some(job_id)) = (&store, &job_id) {
crate::jobs::start_failed(store, &printer_id, job_id, &err);
}
}
Err(err) => { Err(err) => {
// Still logged locally, and still at error level: the queue // Still logged locally, and still at error level: the queue
// only reaches a cloud that is up, and this is the copy that // only reaches a cloud that is up, and this is the copy that
+13
View File
@@ -32,6 +32,17 @@ pub enum ClientMessage {
stream_base_url: Option<String>, stream_base_url: Option<String>,
}, },
Heartbeat { seq: u64 }, Heartbeat { seq: u64 },
/// What happened to a job the cloud handed this gateway. Kept in the
/// store and re-sent on every connection until a `JobEventAck`.
JobEvent {
event_id: i64,
job_id: String,
/// `started`, `finished` or `failed`.
event: String,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
at_ms: i64,
},
/// One snapshot per printer, on a timer. Sent as a batch rather than a /// One snapshot per printer, on a timer. Sent as a batch rather than a
/// message per printer: a farm has one uplink and many printers, and the /// message per printer: a farm has one uplink and many printers, and the
/// cloud wants a coherent picture of the farm at an instant more than it /// cloud wants a coherent picture of the farm at an instant more than it
@@ -213,6 +224,8 @@ pub struct PrinterTelemetry {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage { pub enum ServerMessage {
HelloAck { session_id: String }, HelloAck { session_id: String },
/// The cloud has dealt with a `JobEvent`; the gateway can forget it.
JobEventAck { event_id: i64 },
/// The handshake was rejected - a bad, expired or wrong-farm token. The /// The handshake was rejected - a bad, expired or wrong-farm token. The
/// backend closes the connection right after sending this. /// backend closes the connection right after sending this.
/// ///
+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);
}