Simplify boilerplate for learning: stub inference and Postgres write
Removed ort/ndarray/image (ONNX Runtime inference) and sqlx (Postgres audit write). inference::detect() and the Postgres half of reporter::report() are now stubs with a comment for what real code goes there. Redis Streams XREADGROUP loop and the Redis pub/sub alert stay real. Verified with a clean cargo check --all-targets (0 warnings).
This commit is contained in:
@@ -1,18 +1,6 @@
|
|||||||
# Redis Streams
|
|
||||||
CONTINUUM_REDIS_URL=redis://localhost:6379
|
CONTINUUM_REDIS_URL=redis://localhost:6379
|
||||||
CONTINUUM_FRAME_STREAM=stream:camera_frames
|
CONTINUUM_FRAME_STREAM=stream:camera_frames
|
||||||
CONTINUUM_CONSUMER_GROUP=ai-worker
|
CONTINUUM_CONSUMER_GROUP=ai-worker
|
||||||
CONTINUUM_CONSUMER_NAME=ai-worker-1
|
CONTINUUM_CONSUMER_NAME=ai-worker-1
|
||||||
CONTINUUM_STREAM_BLOCK_MS=5000
|
|
||||||
CONTINUUM_STREAM_BATCH_SIZE=16
|
|
||||||
|
|
||||||
# Postgres (fast-path alert writes; same database as continuum-backend)
|
|
||||||
DATABASE_URL=postgres://continuum:continuum@localhost:5432/continuum
|
|
||||||
|
|
||||||
# Inference
|
|
||||||
CONTINUUM_MODEL_PATH=./models/failure-detector.onnx
|
|
||||||
CONTINUUM_MODEL_INPUT_SIZE=640
|
|
||||||
CONTINUUM_CONFIDENCE_THRESHOLD=0.75
|
|
||||||
CONTINUUM_INTRA_OP_THREADS=4
|
|
||||||
|
|
||||||
RUST_LOG=info,continuum_ai_worker=debug
|
RUST_LOG=info,continuum_ai_worker=debug
|
||||||
|
|||||||
+4
-12
@@ -2,7 +2,7 @@
|
|||||||
name = "continuum-ai-worker"
|
name = "continuum-ai-worker"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Continuum AI worker — consumes camera frame batches from Redis Streams and runs ONNX Runtime print-failure detection"
|
description = "Continuum AI worker — consumes camera frame batches from Redis Streams and runs print-failure detection"
|
||||||
license = "UNLICENSED"
|
license = "UNLICENSED"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -12,21 +12,13 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
redis = { version = "0.27", features = ["tokio-comp", "streams"] }
|
redis = { version = "0.27", features = ["tokio-comp", "streams"] }
|
||||||
ort = { version = "2.0.0-rc.9", features = ["ndarray", "download-binaries"] }
|
|
||||||
ndarray = "0.17"
|
|
||||||
image = "0.25"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "1"
|
|
||||||
base64 = "0.22"
|
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
|
||||||
[profile.release]
|
# Real inference (ONNX Runtime via the `ort` crate) and the Postgres audit
|
||||||
opt-level = 3
|
# write (via `sqlx`) go here once src/inference and src/reporter grow past
|
||||||
lto = true
|
# their stubs — see those modules' doc comments.
|
||||||
|
|||||||
@@ -2,9 +2,18 @@
|
|||||||
|
|
||||||
Computer-vision failure detection worker for the Continuum print farm
|
Computer-vision failure detection worker for the Continuum print farm
|
||||||
platform. Consumes camera frame batches pushed onto the Redis Stream
|
platform. Consumes camera frame batches pushed onto the Redis Stream
|
||||||
`stream:camera_frames` (by `continuum-backend`'s `src/queue/`), runs them
|
`stream:camera_frames` (by `continuum-backend`'s `src/queue/`) and reports
|
||||||
through an ONNX Runtime model, and fast-paths high-confidence failure alerts
|
failures.
|
||||||
back to Redis and Postgres.
|
|
||||||
|
## This is a learning-stage boilerplate
|
||||||
|
|
||||||
|
Real ONNX Runtime inference (the `ort` crate) and the Postgres audit write
|
||||||
|
(`sqlx`) are **not** implemented yet — `src/inference/mod.rs` always
|
||||||
|
"detects" one fixed example failure, and `src/reporter/mod.rs` only prints
|
||||||
|
where the Postgres write would go. The Redis Streams consumer-group loop in
|
||||||
|
`main.rs` and the Redis pub/sub alert in `reporter/` are real. This lets the
|
||||||
|
whole pipeline (read a frame, "detect", report, ack) run and be understood
|
||||||
|
before adding a real model or a SQL database on top.
|
||||||
|
|
||||||
## Pipeline
|
## Pipeline
|
||||||
|
|
||||||
@@ -13,24 +22,24 @@ continuum-proxy --(frames)--> continuum-backend --(XADD)--> stream:camera_frames
|
|||||||
|
|
|
|
||||||
XREADGROUP (this worker)
|
XREADGROUP (this worker)
|
||||||
v
|
v
|
||||||
src/inference (ort session)
|
inference::detect (stub for now)
|
||||||
v
|
v
|
||||||
src/reporter (Redis pub/sub + Postgres INSERT)
|
reporter::report (Redis pub/sub — real)
|
||||||
```
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
main.rs Consumer-group loop: XREADGROUP -> decode -> infer -> report -> XACK
|
|
||||||
inference/ Thread-safe ONNX Runtime session wrapper + pre/post-processing
|
|
||||||
reporter/ Alert fan-out: Redis pub/sub for realtime UI, Postgres for audit trail
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# place an ONNX failure-detection model at ./models/failure-detector.onnx
|
|
||||||
cargo run
|
cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.rs Consumer-group loop: XREADGROUP -> detect -> report -> XACK
|
||||||
|
config.rs Loads settings from environment variables
|
||||||
|
inference/ Detection stub — real ONNX Runtime inference goes here later
|
||||||
|
reporter/ Redis pub/sub alert (real) + a note for the Postgres write (later)
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,13 +6,6 @@ pub struct Config {
|
|||||||
pub frame_stream: String,
|
pub frame_stream: String,
|
||||||
pub consumer_group: String,
|
pub consumer_group: String,
|
||||||
pub consumer_name: String,
|
pub consumer_name: String,
|
||||||
pub stream_block_ms: usize,
|
|
||||||
pub stream_batch_size: usize,
|
|
||||||
pub database_url: String,
|
|
||||||
pub model_path: String,
|
|
||||||
pub model_input_size: u32,
|
|
||||||
pub confidence_threshold: f32,
|
|
||||||
pub intra_op_threads: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -22,21 +15,10 @@ impl Config {
|
|||||||
frame_stream: env_or("CONTINUUM_FRAME_STREAM", "stream:camera_frames"),
|
frame_stream: env_or("CONTINUUM_FRAME_STREAM", "stream:camera_frames"),
|
||||||
consumer_group: env_or("CONTINUUM_CONSUMER_GROUP", "ai-worker"),
|
consumer_group: env_or("CONTINUUM_CONSUMER_GROUP", "ai-worker"),
|
||||||
consumer_name: env_or("CONTINUUM_CONSUMER_NAME", "ai-worker-1"),
|
consumer_name: env_or("CONTINUUM_CONSUMER_NAME", "ai-worker-1"),
|
||||||
stream_block_ms: env_or("CONTINUUM_STREAM_BLOCK_MS", "5000").parse()?,
|
|
||||||
stream_batch_size: env_or("CONTINUUM_STREAM_BATCH_SIZE", "16").parse()?,
|
|
||||||
database_url: require("DATABASE_URL")?,
|
|
||||||
model_path: env_or("CONTINUUM_MODEL_PATH", "./models/failure-detector.onnx"),
|
|
||||||
model_input_size: env_or("CONTINUUM_MODEL_INPUT_SIZE", "640").parse()?,
|
|
||||||
confidence_threshold: env_or("CONTINUUM_CONFIDENCE_THRESHOLD", "0.75").parse()?,
|
|
||||||
intra_op_threads: env_or("CONTINUUM_INTRA_OP_THREADS", "4").parse()?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require(key: &str) -> anyhow::Result<String> {
|
|
||||||
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_or(key: &str, default: &str) -> String {
|
fn env_or(key: &str, default: &str) -> String {
|
||||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
env::var(key).unwrap_or_else(|_| default.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use image::DynamicImage;
|
|
||||||
use ort::session::builder::GraphOptimizationLevel;
|
|
||||||
use ort::session::Session;
|
|
||||||
use ort::value::Value;
|
|
||||||
use serde::Serialize;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use super::preprocess::to_input_tensor;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct Detection {
|
|
||||||
pub class_id: u32,
|
|
||||||
pub label: String,
|
|
||||||
pub confidence: f32,
|
|
||||||
/// Normalized [0, 1] bounding box in (x_min, y_min, x_max, y_max).
|
|
||||||
pub bbox: [f32; 4],
|
|
||||||
}
|
|
||||||
|
|
||||||
const CLASS_LABELS: &[&str] = &["spaghetti", "warping", "layer_shift", "bed_adhesion_failure"];
|
|
||||||
|
|
||||||
/// Thread-safe wrapper around an ONNX Runtime session. `ort::Session` is not
|
|
||||||
/// `Sync` on its own for concurrent `run()` calls, so callers share this
|
|
||||||
/// behind a `Mutex` and an `Arc` when fanning out across worker tasks.
|
|
||||||
pub struct FailureDetector {
|
|
||||||
session: Mutex<Session>,
|
|
||||||
input_size: u32,
|
|
||||||
confidence_threshold: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FailureDetector {
|
|
||||||
pub fn load(model_path: &str, input_size: u32, confidence_threshold: f32, intra_threads: usize) -> anyhow::Result<Arc<Self>> {
|
|
||||||
// ort's builder error types carry raw FFI pointers that aren't
|
|
||||||
// Send/Sync, so they can't flow through `?` into `anyhow::Result`
|
|
||||||
// directly (anyhow requires Send + Sync + 'static). Stringify at
|
|
||||||
// the boundary instead.
|
|
||||||
let session = Session::builder()
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to create ONNX Runtime session builder: {e}"))?
|
|
||||||
.with_optimization_level(GraphOptimizationLevel::Level3)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to set graph optimization level: {e}"))?
|
|
||||||
.with_intra_threads(intra_threads)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to set intra-op thread count: {e}"))?
|
|
||||||
.commit_from_file(model_path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("failed to load ONNX model at {model_path}: {e}"))?;
|
|
||||||
|
|
||||||
Ok(Arc::new(Self {
|
|
||||||
session: Mutex::new(session),
|
|
||||||
input_size,
|
|
||||||
confidence_threshold,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs inference on a single decoded frame and returns detections whose
|
|
||||||
/// confidence clears `confidence_threshold`.
|
|
||||||
pub async fn detect(&self, frame: &DynamicImage) -> anyhow::Result<Vec<Detection>> {
|
|
||||||
let tensor = to_input_tensor(frame, self.input_size);
|
|
||||||
let input = Value::from_array(tensor)?;
|
|
||||||
|
|
||||||
let mut session = self.session.lock().await;
|
|
||||||
let outputs = session.run(ort::inputs!["images" => input])?;
|
|
||||||
|
|
||||||
// Exported with NMS baked into the graph: output0 is [1, N, 6]
|
|
||||||
// rows of (x1, y1, x2, y2, confidence, class_id) in input-pixel space.
|
|
||||||
let (shape, data) = outputs["output0"].try_extract_tensor::<f32>()?;
|
|
||||||
let num_boxes = shape[1] as usize;
|
|
||||||
let size = self.input_size as f32;
|
|
||||||
|
|
||||||
let mut detections = Vec::new();
|
|
||||||
for i in 0..num_boxes {
|
|
||||||
let row = &data[i * 6..i * 6 + 6];
|
|
||||||
let confidence = row[4];
|
|
||||||
if confidence < self.confidence_threshold {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let class_id = row[5] as u32;
|
|
||||||
detections.push(Detection {
|
|
||||||
class_id,
|
|
||||||
label: CLASS_LABELS.get(class_id as usize).copied().unwrap_or("unknown").to_string(),
|
|
||||||
confidence,
|
|
||||||
bbox: [row[0] / size, row[1] / size, row[2] / size, row[3] / size],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(detections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-3
@@ -1,4 +1,19 @@
|
|||||||
mod detector;
|
use serde::Serialize;
|
||||||
mod preprocess;
|
|
||||||
|
|
||||||
pub use detector::{Detection, FailureDetector};
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Detection {
|
||||||
|
pub label: String,
|
||||||
|
pub confidence: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stand-in for real ONNX Runtime inference (the `ort` crate, using a model
|
||||||
|
/// trained on failure images). Always reports one fixed "failure" so the
|
||||||
|
/// rest of the pipeline — reporting, acking the stream entry — has
|
||||||
|
/// something to do and can be tested without a real model file.
|
||||||
|
///
|
||||||
|
/// Swap this out once you're comfortable with the pipeline around it:
|
||||||
|
/// decode `frame_bytes` with the `image` crate, run it through an `ort`
|
||||||
|
/// session, and turn the model's output into real `Detection`s.
|
||||||
|
pub fn detect(_frame_bytes: &[u8]) -> Vec<Detection> {
|
||||||
|
vec![Detection { label: "spaghetti".to_string(), confidence: 0.91 }]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
use image::DynamicImage;
|
|
||||||
use ndarray::Array4;
|
|
||||||
|
|
||||||
/// Resizes an RGB frame to a square `size x size` input tensor in CHW layout,
|
|
||||||
/// normalized to [0, 1] — the standard preprocessing for YOLO-family ONNX
|
|
||||||
/// export graphs.
|
|
||||||
pub fn to_input_tensor(image: &DynamicImage, size: u32) -> Array4<f32> {
|
|
||||||
let resized = image.resize_exact(size, size, image::imageops::FilterType::Triangle);
|
|
||||||
let rgb = resized.to_rgb8();
|
|
||||||
|
|
||||||
let mut tensor = Array4::<f32>::zeros((1, 3, size as usize, size as usize));
|
|
||||||
for (x, y, pixel) in rgb.enumerate_pixels() {
|
|
||||||
let [r, g, b] = pixel.0;
|
|
||||||
tensor[[0, 0, y as usize, x as usize]] = r as f32 / 255.0;
|
|
||||||
tensor[[0, 1, y as usize, x as usize]] = g as f32 / 255.0;
|
|
||||||
tensor[[0, 2, y as usize, x as usize]] = b as f32 / 255.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
tensor
|
|
||||||
}
|
|
||||||
+29
-108
@@ -2,138 +2,59 @@ mod config;
|
|||||||
mod inference;
|
mod inference;
|
||||||
mod reporter;
|
mod reporter;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use redis::streams::{StreamKey, StreamReadOptions, StreamReadReply};
|
use redis::streams::{StreamKey, StreamReadOptions, StreamReadReply};
|
||||||
use redis::AsyncCommands;
|
use redis::AsyncCommands;
|
||||||
use tracing::{error, info, warn};
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::inference::FailureDetector;
|
|
||||||
use crate::reporter::Reporter;
|
use crate::reporter::Reporter;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||||
.json()
|
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
dotenvy_load();
|
dotenvy::dotenv().ok();
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env()?;
|
||||||
|
|
||||||
info!(model = %config.model_path, "loading ONNX Runtime failure-detection model");
|
|
||||||
let detector = FailureDetector::load(
|
|
||||||
&config.model_path,
|
|
||||||
config.model_input_size,
|
|
||||||
config.confidence_threshold,
|
|
||||||
config.intra_op_threads,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut reporter = Reporter::connect(&config.redis_url, &config.database_url).await?;
|
|
||||||
|
|
||||||
let client = redis::Client::open(config.redis_url.clone())?;
|
let client = redis::Client::open(config.redis_url.clone())?;
|
||||||
let mut conn = client.get_multiplexed_tokio_connection().await?;
|
let mut conn = client.get_multiplexed_tokio_connection().await?;
|
||||||
|
let mut reporter = Reporter::connect(&config.redis_url).await?;
|
||||||
|
|
||||||
ensure_consumer_group(&mut conn, &config).await?;
|
// Consumer groups have to exist before you can read from them. This
|
||||||
|
// fails with "BUSYGROUP" if it already exists — that's fine, ignore it.
|
||||||
info!(
|
let _: redis::RedisResult<String> = conn
|
||||||
stream = %config.frame_stream,
|
|
||||||
group = %config.consumer_group,
|
|
||||||
consumer = %config.consumer_name,
|
|
||||||
"starting frame consumer loop"
|
|
||||||
);
|
|
||||||
|
|
||||||
let read_opts = StreamReadOptions::default()
|
|
||||||
.group(&config.consumer_group, &config.consumer_name)
|
|
||||||
.block(config.stream_block_ms)
|
|
||||||
.count(config.stream_batch_size);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let reply: StreamReadReply = match conn
|
|
||||||
.xread_options(&[&config.frame_stream], &[">"], &read_opts)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(reply) => reply,
|
|
||||||
Err(err) => {
|
|
||||||
error!(?err, "XREADGROUP failed, backing off");
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for StreamKey { key, ids } in reply.keys {
|
|
||||||
for entry in ids {
|
|
||||||
let fields = entry_fields(&entry.map);
|
|
||||||
match process_entry(&detector, &mut reporter, &fields).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(err) => warn!(?err, id = %entry.id, "failed to process frame entry"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let _: redis::RedisResult<i64> = conn
|
|
||||||
.xack(&key, &config.consumer_group, &[entry.id.as_str()])
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_consumer_group(
|
|
||||||
conn: &mut redis::aio::MultiplexedConnection,
|
|
||||||
config: &Config,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let result: redis::RedisResult<String> = conn
|
|
||||||
.xgroup_create_mkstream(&config.frame_stream, &config.consumer_group, "$")
|
.xgroup_create_mkstream(&config.frame_stream, &config.consumer_group, "$")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(err) = result {
|
tracing::info!(stream = %config.frame_stream, group = %config.consumer_group, "waiting for camera frames");
|
||||||
// BUSYGROUP means the group already exists — fine, everything else is real.
|
|
||||||
if !err.to_string().contains("BUSYGROUP") {
|
let read_opts = StreamReadOptions::default()
|
||||||
return Err(err.into());
|
.group(&config.consumer_group, &config.consumer_name)
|
||||||
|
.block(5000);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let reply: StreamReadReply = conn.xread_options(&[&config.frame_stream], &[">"], &read_opts).await?;
|
||||||
|
|
||||||
|
for StreamKey { key, ids } in reply.keys {
|
||||||
|
for entry in ids {
|
||||||
|
let printer_id = printer_id_field(&entry.map);
|
||||||
|
|
||||||
|
let detections = inference::detect(&[]);
|
||||||
|
if !detections.is_empty() {
|
||||||
|
reporter.report(&printer_id, &detections).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _: redis::RedisResult<i64> = conn.xack(&key, &config.consumer_group, &[entry.id.as_str()]).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_fields(map: &HashMap<String, redis::Value>) -> HashMap<String, String> {
|
fn printer_id_field(fields: &std::collections::HashMap<String, redis::Value>) -> String {
|
||||||
map.iter()
|
match fields.get("printer_id") {
|
||||||
.filter_map(|(k, v)| match v {
|
Some(redis::Value::BulkString(bytes)) => String::from_utf8_lossy(bytes).to_string(),
|
||||||
redis::Value::BulkString(bytes) => {
|
_ => "unknown".to_string(),
|
||||||
Some((k.clone(), String::from_utf8_lossy(bytes).to_string()))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn process_entry(
|
|
||||||
detector: &FailureDetector,
|
|
||||||
reporter: &mut Reporter,
|
|
||||||
fields: &HashMap<String, String>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let printer_id = fields
|
|
||||||
.get("printer_id")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("frame entry missing printer_id field"))?;
|
|
||||||
let job_id = fields.get("job_id").map(String::as_str);
|
|
||||||
let frame_b64 = fields
|
|
||||||
.get("frame")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("frame entry missing frame field"))?;
|
|
||||||
|
|
||||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, frame_b64)?;
|
|
||||||
let image = image::load_from_memory(&bytes)?;
|
|
||||||
|
|
||||||
let detections = detector.detect(&image).await?;
|
|
||||||
if !detections.is_empty() {
|
|
||||||
reporter.report_failure(printer_id, job_id, &detections).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads `.env` if present; a missing file is fine (env vars may already be
|
|
||||||
/// set by the process supervisor), so the error is deliberately ignored.
|
|
||||||
fn dotenvy_load() {
|
|
||||||
let _ = dotenvy::dotenv();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-28
@@ -1,42 +1,40 @@
|
|||||||
mod postgres;
|
use redis::AsyncCommands;
|
||||||
mod redis_pub;
|
|
||||||
|
|
||||||
use crate::inference::Detection;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::inference::Detection;
|
||||||
|
|
||||||
|
const ALERT_CHANNEL: &str = "channel:print_alerts";
|
||||||
|
|
||||||
|
/// Fast-paths a failure alert out to whatever's listening.
|
||||||
pub struct Reporter {
|
pub struct Reporter {
|
||||||
redis: redis::aio::MultiplexedConnection,
|
redis: redis::aio::MultiplexedConnection,
|
||||||
pg: PgPool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Reporter {
|
impl Reporter {
|
||||||
pub async fn connect(redis_url: &str, database_url: &str) -> anyhow::Result<Self> {
|
pub async fn connect(redis_url: &str) -> anyhow::Result<Self> {
|
||||||
let redis_client = redis::Client::open(redis_url)?;
|
let client = redis::Client::open(redis_url)?;
|
||||||
let redis = redis_client.get_multiplexed_tokio_connection().await?;
|
let redis = client.get_multiplexed_tokio_connection().await?;
|
||||||
let pg = PgPool::connect(database_url).await?;
|
Ok(Self { redis })
|
||||||
|
|
||||||
Ok(Self { redis, pg })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fast-paths a failure alert: publishes to Redis immediately for any
|
/// Publishes to Redis (real — this is what `continuum-backend`'s
|
||||||
/// live UI subscribers, then persists to Postgres for the audit trail
|
/// WebSocket fan-out subscribes to, for live operator-console alerts).
|
||||||
/// and downstream farm-manager notifications.
|
///
|
||||||
pub async fn report_failure(
|
/// A real version would also `INSERT` into Postgres here via `sqlx` for
|
||||||
&mut self,
|
/// the permanent audit trail — that's left as a `println!` for now;
|
||||||
printer_id: &str,
|
/// come back to it once async SQL feels comfortable.
|
||||||
job_id: Option<&str>,
|
pub async fn report(&mut self, printer_id: &str, detections: &[Detection]) -> anyhow::Result<()> {
|
||||||
detections: &[Detection],
|
let best = &detections[0];
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let best = detections
|
|
||||||
.iter()
|
|
||||||
.max_by(|a, b| a.confidence.total_cmp(&b.confidence))
|
|
||||||
.expect("report_failure called with no detections");
|
|
||||||
|
|
||||||
info!(printer_id, label = %best.label, confidence = best.confidence, "reporting print failure");
|
info!(printer_id, label = %best.label, confidence = best.confidence, "reporting print failure");
|
||||||
|
|
||||||
redis_pub::publish_alert(&mut self.redis, printer_id, best).await?;
|
let payload = serde_json::json!({
|
||||||
postgres::insert_alert(&self.pg, printer_id, job_id, detections).await?;
|
"printerId": printer_id,
|
||||||
|
"label": best.label,
|
||||||
|
"confidence": best.confidence,
|
||||||
|
});
|
||||||
|
self.redis.publish::<_, _, ()>(ALERT_CHANNEL, payload.to_string()).await?;
|
||||||
|
|
||||||
|
println!("(would also INSERT this alert into Postgres for the audit trail)");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::inference::Detection;
|
|
||||||
|
|
||||||
/// Persists the full detection set for a failure event to the shared
|
|
||||||
/// Postgres database (the same instance `continuum-backend` writes to via
|
|
||||||
/// Drizzle), for audit history and the farm-manager review queue.
|
|
||||||
pub async fn insert_alert(
|
|
||||||
pool: &PgPool,
|
|
||||||
printer_id: &str,
|
|
||||||
job_id: Option<&str>,
|
|
||||||
detections: &[Detection],
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let detections_json = serde_json::to_value(detections)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO print_failure_alerts (id, printer_id, print_job_id, detections, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, now())
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(Uuid::new_v4())
|
|
||||||
.bind(printer_id)
|
|
||||||
.bind(job_id)
|
|
||||||
.bind(detections_json)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
use redis::AsyncCommands;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::inference::Detection;
|
|
||||||
|
|
||||||
const ALERT_CHANNEL: &str = "channel:print_alerts";
|
|
||||||
|
|
||||||
/// Publishes a low-latency alert for anything subscribed to
|
|
||||||
/// `channel:print_alerts` — primarily `continuum-backend`'s WebSocket
|
|
||||||
/// fan-out to connected operator consoles.
|
|
||||||
pub async fn publish_alert(
|
|
||||||
conn: &mut redis::aio::MultiplexedConnection,
|
|
||||||
printer_id: &str,
|
|
||||||
detection: &Detection,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let payload = json!({
|
|
||||||
"type": "printer.failure_detected",
|
|
||||||
"printerId": printer_id,
|
|
||||||
"label": detection.label,
|
|
||||||
"confidence": detection.confidence,
|
|
||||||
"bbox": detection.bbox,
|
|
||||||
});
|
|
||||||
|
|
||||||
conn.publish::<_, _, ()>(ALERT_CHANNEL, payload.to_string()).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user