use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use dataflow_rs::datavalue::OwnedDataTensor;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::mpsc;
use super::artifact::{ArtifactRef, ArtifactStore};
use super::manifest::Manifest;
use super::onnx;
use super::runtimes::{LoadedModel, ModelRuntimes};
use crate::config::ModelsConfig;
use crate::connector::StorageConnectorConfig;
#[derive(Debug, Clone, PartialEq)]
pub struct AdmissionJob {
pub model_id: String,
pub version: i64,
pub artifact: ArtifactRef,
pub signature: Option<String>,
pub manifest: Manifest,
}
pub struct AdmissionDeps<'a> {
pub store: &'a ArtifactStore,
pub storage: &'a StorageConnectorConfig,
pub client: &'a reqwest::Client,
pub config: &'a ModelsConfig,
pub node: &'a str,
pub runtimes: &'a ModelRuntimes,
}
pub const PROBE_RUNS: usize = 5;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Stats {
pub parameters: u64,
pub nodes: u64,
pub artifact_bytes: u64,
pub probe_ms: f64,
pub ir_version: i64,
pub opset: i64,
pub runtime: String,
pub device: String,
}
impl Stats {
pub fn offline(graph: &onnx::GraphStats, artifact_bytes: u64) -> Self {
Self {
parameters: graph.parameters,
nodes: graph.nodes,
artifact_bytes,
probe_ms: 0.0,
ir_version: graph.ir_version,
opset: graph.opset,
runtime: String::new(),
device: String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AdmissionState {
Passed {
stats: Stats,
},
Failed {
stage: &'static str,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct AdmissionOutcome {
pub model_id: String,
pub version: i64,
pub state: AdmissionState,
pub artifact_path: Option<PathBuf>,
pub elapsed: Duration,
}
impl AdmissionOutcome {
pub fn passed(&self) -> bool {
matches!(self.state, AdmissionState::Passed { .. })
}
}
pub fn admission_json(outcome: &AdmissionOutcome, node: &str, now: DateTime<Utc>) -> Value {
let (state, stage, reason) = match &outcome.state {
AdmissionState::Passed { .. } => ("passed", None, None),
AdmissionState::Failed { stage, reason } => ("failed", Some(*stage), Some(reason.as_str())),
};
json!({
"state": state,
"at": now.naive_utc(),
"node": node,
"stage": stage,
"reason": reason,
})
}
pub async fn admit(deps: &AdmissionDeps<'_>, job: &AdmissionJob) -> AdmissionOutcome {
let started = Instant::now();
let stage = Mutex::new("signature");
let budget = Duration::from_secs(deps.config.admission_timeout_secs);
let state = match tokio::time::timeout(budget, sequence(deps, job, &stage)).await {
Ok(Ok((stats, path))) => (AdmissionState::Passed { stats }, Some(path)),
Ok(Err((stage, reason))) => (AdmissionState::Failed { stage, reason }, None),
Err(_elapsed) => {
let stage = *stage.lock().unwrap_or_else(|e| e.into_inner());
(
AdmissionState::Failed {
stage,
reason: format!(
"admission exceeded models.admission_timeout_secs ({}) during {stage}",
deps.config.admission_timeout_secs
),
},
None,
)
}
};
let elapsed = started.elapsed();
match &state.0 {
AdmissionState::Passed { .. } => {
crate::metrics::record_model_admission("passed", None, elapsed.as_secs_f64());
}
AdmissionState::Failed { stage, .. } => {
crate::metrics::record_model_admission("failed", Some(stage), elapsed.as_secs_f64());
}
}
AdmissionOutcome {
model_id: job.model_id.clone(),
version: job.version,
state: state.0,
artifact_path: state.1,
elapsed,
}
}
async fn sequence(
deps: &AdmissionDeps<'_>,
job: &AdmissionJob,
stage: &Mutex<&'static str>,
) -> Result<(Stats, PathBuf), (&'static str, String)> {
let at = |s: &'static str| *stage.lock().unwrap_or_else(|e| e.into_inner()) = s;
at("signature");
crate::crypto::ed25519::verify(
&deps.config.trust.public_keys,
&job.artifact.digest,
job.signature.as_deref(),
)
.map_err(|reason| ("signature", format!("{reason} (models.trust.public_keys)")))?;
at("head");
let info = deps
.store
.head(deps.storage, deps.client, &job.artifact.key)
.await
.map_err(|e| (e.stage(), with_connector(&job.artifact, e.to_string())))?;
at("size");
if let Some(size) = info.size
&& size > deps.config.max_artifact_bytes as u64
{
return Err((
"size",
format!(
"the object is {size} bytes, over models.max_artifact_bytes ({})",
deps.config.max_artifact_bytes
),
));
}
at("fetch");
let fetch_started = Instant::now();
let fetched = deps
.store
.fetch(
deps.storage,
deps.client,
&job.artifact,
deps.config.max_artifact_bytes,
Duration::from_secs(deps.config.fetch_timeout_secs),
)
.await;
let fetch_secs = fetch_started.elapsed().as_secs_f64();
let path = match fetched {
Ok(path) => path,
Err(e) => {
crate::metrics::record_model_fetch(&job.model_id, "error", 0, fetch_secs);
return Err((e.stage(), with_connector(&job.artifact, e.to_string())));
}
};
let artifact_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
crate::metrics::record_model_fetch(&job.model_id, "ok", artifact_bytes, fetch_secs);
at("parse");
let bytes = Bytes::from(tokio::fs::read(&path).await.map_err(|e| {
(
"parse",
format!("the cached artifact could not be read: {e}"),
)
})?);
let graph = {
let bytes = bytes.clone();
tokio::task::spawn_blocking(move || onnx::read_stats(&bytes))
.await
.map_err(|e| ("parse", format!("the parse did not complete: {e}")))?
.map_err(|reason| ("parse", reason))?
};
check_boundary(&job.manifest, &graph).map_err(|reason| ("parse", reason))?;
if deps.config.max_parameters != 0 && graph.parameters > deps.config.max_parameters {
return Err((
"parse",
format!(
"the graph has {} parameters, over models.max_parameters ({})",
graph.parameters, deps.config.max_parameters
),
));
}
at("probe");
let (runtime, device) = deps
.runtimes
.default_for(deps.config, &job.manifest.format)
.map_err(|selection| ("probe", selection.to_string()))?;
let inputs = job
.manifest
.zero_inputs()
.map_err(|reason| ("probe", reason))?;
let load_started = Instant::now();
let loaded = {
let runtime = runtime.clone();
let bytes = bytes.clone();
let manifest = job.manifest.clone();
let device = device.to_string();
tokio::task::spawn_blocking(move || runtime.load(&bytes, &manifest, &device)).await
};
let load_secs = load_started.elapsed().as_secs_f64();
let outcome = match &loaded {
Ok(Ok(_)) => "ok",
_ => "error",
};
crate::metrics::record_model_load(
&job.model_id,
runtime.name(),
outcome,
"admission",
load_secs,
);
let model = match loaded {
Ok(Ok(model)) => model,
Ok(Err(e)) => {
return Err((
"probe",
format!(
"the {} runtime could not load the graph on '{device}': {e}",
runtime.name(),
),
));
}
Err(e) => return Err(("probe", format!("the load did not complete: {e}"))),
};
let (probe_ms, outputs) = tokio::task::spawn_blocking(move || probe_runs(&*model, inputs))
.await
.map_err(|e| ("probe", format!("the probe did not complete: {e}")))?
.map_err(|reason| ("probe", reason))?;
if probe_ms > deps.config.max_probe_ms as f64 {
return Err((
"probe",
format!(
"the probe inference took {probe_ms:.3} ms (median of {PROBE_RUNS}), over \
models.max_probe_ms ({})",
deps.config.max_probe_ms
),
));
}
check_outputs(&job.manifest, &outputs).map_err(|reason| ("probe", reason))?;
Ok((
Stats {
parameters: graph.parameters,
nodes: graph.nodes,
artifact_bytes,
probe_ms,
ir_version: graph.ir_version,
opset: graph.opset,
runtime: runtime.name().to_string(),
device: device.to_string(),
},
path,
))
}
pub fn check_boundary(manifest: &Manifest, graph: &onnx::GraphStats) -> Result<(), String> {
check_names("input", manifest.input_names(), &graph.input_names)?;
check_names("output", manifest.output_names(), &graph.output_names)
}
fn check_names<'a>(
kind: &str,
declared: impl Iterator<Item = &'a str>,
graph: &[String],
) -> Result<(), String> {
for name in declared {
if !graph.iter().any(|g| g == name) {
return Err(format!(
"the manifest declares {kind} '{name}', which the graph does not have; the \
graph's {kind}s are: {}",
graph
.iter()
.map(|n| format!("'{n}'"))
.collect::<Vec<_>>()
.join(", ")
));
}
}
Ok(())
}
fn probe_runs(
model: &dyn LoadedModel,
inputs: Vec<OwnedDataTensor>,
) -> Result<(f64, Vec<OwnedDataTensor>), String> {
let mut times = Vec::with_capacity(PROBE_RUNS);
let mut outputs = Vec::new();
for _ in 0..PROBE_RUNS {
let started = Instant::now();
outputs = model
.run(inputs.clone())
.map_err(|e| format!("the probe inference failed: {e}"))?;
times.push(started.elapsed().as_secs_f64() * 1000.0);
}
times.sort_by(f64::total_cmp);
Ok((times[PROBE_RUNS / 2], outputs))
}
fn check_outputs(manifest: &Manifest, outputs: &[OwnedDataTensor]) -> Result<(), String> {
if outputs.len() != manifest.outputs.len() {
return Err(format!(
"the probe produced {} outputs, the manifest declares {}",
outputs.len(),
manifest.outputs.len()
));
}
for (decl, tensor) in manifest.outputs.iter().zip(outputs) {
if tensor.dtype().name() != decl.dtype || tensor.shape() != decl.shape.as_slice() {
return Err(format!(
"output '{}' is {}{:?} from the graph, but the manifest declares {}{:?}",
decl.name,
tensor.dtype().name(),
tensor.shape(),
decl.dtype,
decl.shape
));
}
}
Ok(())
}
fn with_connector(artifact: &ArtifactRef, message: String) -> String {
format!(
"connector '{}', key '{}': {message}",
artifact.connector, artifact.key
)
}
pub const QUEUE_CAPACITY: usize = 1024;
#[derive(Clone)]
pub struct AdmissionQueue {
tx: mpsc::Sender<AdmissionJob>,
}
#[derive(Debug)]
pub struct QueueFull(pub Box<AdmissionJob>);
impl std::fmt::Display for QueueFull {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"the admission queue is full ({QUEUE_CAPACITY} jobs waiting); retry once the worker \
has caught up"
)
}
}
impl AdmissionQueue {
pub fn new() -> (Self, mpsc::Receiver<AdmissionJob>) {
Self::with_capacity(QUEUE_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> (Self, mpsc::Receiver<AdmissionJob>) {
let (tx, rx) = mpsc::channel(capacity);
(Self { tx }, rx)
}
pub fn enqueue(&self, job: AdmissionJob) -> Result<(), QueueFull> {
self.tx.try_send(job).map_err(|e| match e {
mpsc::error::TrySendError::Full(job) | mpsc::error::TrySendError::Closed(job) => {
QueueFull(Box::new(job))
}
})
}
}
pub async fn run_worker<F, Fut>(mut receiver: mpsc::Receiver<AdmissionJob>, mut handle: F)
where
F: FnMut(AdmissionJob) -> Fut,
Fut: Future<Output = ()>,
{
while let Some(job) = receiver.recv().await {
handle(job).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::artifact::tests::{
artifact, spawn_bucket, spawn_bucket_with_delay, storage_config, temp_cache_dir,
};
use crate::model::fixture;
fn config() -> ModelsConfig {
ModelsConfig {
enabled: true,
cache_dir: "unused: the store is built directly".to_string(),
..ModelsConfig::default()
}
}
fn job(body: &[u8]) -> AdmissionJob {
job_with(body, fixture::manifest())
}
fn job_with(body: &[u8], manifest: Manifest) -> AdmissionJob {
AdmissionJob {
model_id: "ada.c4-tiny".to_string(),
version: 1,
artifact: artifact(body),
signature: None,
manifest,
}
}
struct Rig {
storage: StorageConnectorConfig,
client: reqwest::Client,
dir: PathBuf,
store: ArtifactStore,
config: ModelsConfig,
runtimes: ModelRuntimes,
}
impl Rig {
fn new(addr: std::net::SocketAddr, config: ModelsConfig) -> Self {
let dir = temp_cache_dir();
Self {
storage: storage_config(addr),
client: reqwest::Client::new(),
store: ArtifactStore::new(&dir, 1 << 20),
dir,
runtimes: ModelRuntimes::builtin(&config),
config,
}
}
fn deps(&self) -> AdmissionDeps<'_> {
AdmissionDeps {
store: &self.store,
storage: &self.storage,
client: &self.client,
config: &self.config,
node: "node-a",
runtimes: &self.runtimes,
}
}
fn reset_store(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
self.store = ArtifactStore::new(&self.dir, 1 << 20);
}
}
impl Drop for Rig {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn failure(outcome: &AdmissionOutcome) -> (&'static str, &str) {
match &outcome.state {
AdmissionState::Failed { stage, reason } => (stage, reason.as_str()),
AdmissionState::Passed { .. } => unreachable!("expected a failure: {outcome:?}"),
}
}
#[tokio::test]
async fn a_verified_artifact_passes_with_its_stats() {
let body = fixture::ONNX.to_vec();
let bucket = spawn_bucket(body.clone(), None).await;
let rig = Rig::new(bucket.addr, config());
let outcome = admit(&rig.deps(), &job(&body)).await;
assert!(outcome.passed(), "{outcome:?}");
assert_eq!(outcome.model_id, "ada.c4-tiny");
assert_eq!(outcome.version, 1);
assert_eq!(
outcome.artifact_path.as_deref(),
Some(rig.store.path_for(&job(&body).artifact.digest).as_path())
);
let AdmissionState::Passed { stats } = &outcome.state else {
unreachable!("passed")
};
assert_eq!(stats.parameters, 1479);
assert_eq!(stats.nodes, 4);
assert_eq!(stats.ir_version, 9);
assert_eq!(stats.opset, 17);
assert_eq!(stats.artifact_bytes, body.len() as u64);
assert_eq!(stats.runtime, "tract");
assert_eq!(stats.device, "cpu");
assert!(stats.probe_ms > 0.0, "{stats:?}");
assert!(
stats.probe_ms <= rig.config.max_probe_ms as f64,
"{stats:?}"
);
let stats_json = serde_json::to_value(stats).expect("serialises");
let mut keys: Vec<&str> = stats_json
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"artifact_bytes",
"device",
"ir_version",
"nodes",
"opset",
"parameters",
"probe_ms",
"runtime"
]
);
let now = Utc::now();
assert_eq!(
admission_json(&outcome, "node-a", now),
json!({
"state": "passed",
"at": now.naive_utc(),
"node": "node-a",
"stage": null,
"reason": null,
})
);
}
#[tokio::test]
async fn each_stage_fails_by_name() {
let body = fixture::ONNX.to_vec();
let bucket = spawn_bucket(body.clone(), None).await;
let signer = crate::crypto::ed25519::SigningKey::generate();
let mut config = config();
config.trust.public_keys = vec![signer.public_key_base64()];
let mut rig = Rig::new(bucket.addr, config);
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "signature");
assert!(reason.contains("models.trust.public_keys"), "{reason}");
assert!(outcome.artifact_path.is_none());
assert_eq!(bucket.gets.load(std::sync::atomic::Ordering::SeqCst), 0);
let mut signed = job(&body);
signed.signature = Some(signer.sign(&signed.artifact.digest));
assert!(
admit(&rig.deps(), &signed).await.passed(),
"a good signature passes"
);
rig.reset_store();
rig.config.trust.public_keys.clear();
rig.config.max_artifact_bytes = 4;
let gets_before = bucket.gets.load(std::sync::atomic::Ordering::SeqCst);
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "size");
assert!(reason.contains("models.max_artifact_bytes (4)"), "{reason}");
assert_eq!(
bucket.gets.load(std::sync::atomic::Ordering::SeqCst),
gets_before
);
rig.config.max_artifact_bytes = 1 << 20;
let outcome = admit(&rig.deps(), &job(b"what the row expected")).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "digest");
assert!(reason.contains("connector 'bucket'"), "{reason}");
assert!(reason.contains("nothing was kept"), "{reason}");
rig.storage.operations.presign_get = false;
let outcome = admit(&rig.deps(), &job(&body)).await;
assert_eq!(failure(&outcome).0, "gate");
rig.storage.operations.presign_get = true;
let missing = spawn_bucket(body.clone(), Some(404)).await;
rig.storage = storage_config(missing.addr);
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "head");
assert!(reason.contains("404"), "{reason}");
let now = Utc::now();
let recorded = admission_json(&outcome, "n", now);
assert_eq!(recorded["state"], "failed");
assert_eq!(recorded["stage"], "head");
assert_eq!(recorded["node"], "n");
assert_eq!(recorded["at"], json!(now.naive_utc()));
assert!(
serde_json::from_value::<orion_api::dto::ModelAdmission>(recorded.clone())
.is_ok_and(|a| a.at.is_some()),
"{recorded}"
);
assert!(
recorded["reason"]
.as_str()
.is_some_and(|r| r.contains("404"))
);
}
#[tokio::test]
async fn the_parse_stage_reads_the_graph_against_the_manifest() {
let junk = b"\x00\x01this is not a model".to_vec();
let bucket = spawn_bucket(junk.clone(), None).await;
let rig = Rig::new(bucket.addr, config());
let outcome = admit(&rig.deps(), &job(&junk)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "parse");
assert!(reason.contains("not an ONNX model"), "{reason}");
assert!(rig.store.path_for(&job(&junk).artifact.digest).is_file());
let body = fixture::ONNX.to_vec();
let bucket = spawn_bucket(body.clone(), None).await;
let mut rig = Rig::new(bucket.addr, config());
let mut manifest = fixture::manifest();
manifest.inputs[0].name = "boards".to_string();
let outcome = admit(&rig.deps(), &job_with(&body, manifest)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "parse");
assert!(reason.contains("input 'boards'"), "{reason}");
assert!(reason.contains("'board'"), "{reason}");
let mut manifest = fixture::manifest();
manifest.outputs[0].name = "logits".to_string();
let outcome = admit(&rig.deps(), &job_with(&body, manifest)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "parse");
assert!(reason.contains("output 'logits'"), "{reason}");
assert!(reason.contains("'policy'"), "{reason}");
rig.config.max_parameters = 1000;
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "parse");
assert!(reason.contains("1479"), "{reason}");
assert!(reason.contains("models.max_parameters (1000)"), "{reason}");
rig.config.max_parameters = 1479;
assert!(
admit(&rig.deps(), &job(&body)).await.passed(),
"at the ceiling is within it"
);
}
#[tokio::test]
async fn the_probe_stage_runs_the_graph_on_the_default_runtime() {
let body = fixture::ONNX.to_vec();
let bucket = spawn_bucket(body.clone(), None).await;
let mut rig = Rig::new(bucket.addr, config());
rig.runtimes = ModelRuntimes::empty();
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert!(
reason.contains("runtime 'tract' for format 'onnx' is not enabled on this node"),
"{reason}"
);
rig.runtimes = ModelRuntimes::builtin(&rig.config);
let mut manifest = fixture::manifest();
manifest.outputs[0].shape = vec![1, 8];
let outcome = admit(&rig.deps(), &job_with(&body, manifest)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert!(reason.contains("output 'policy'"), "{reason}");
assert!(reason.contains("f32[1, 7]"), "{reason}");
assert!(reason.contains("f32[1, 8]"), "{reason}");
let mut manifest = fixture::manifest();
manifest.outputs[0].dtype = "f64".to_string();
let outcome = admit(&rig.deps(), &job_with(&body, manifest)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert!(reason.contains("f64[1, 7]"), "{reason}");
rig.config.max_probe_ms = 0;
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert!(reason.contains("models.max_probe_ms (0)"), "{reason}");
assert!(reason.contains("median of 5"), "{reason}");
}
#[tokio::test]
async fn the_probe_fails_with_the_selection_message_when_no_runtime_serves_the_format() {
let body = fixture::ONNX.to_vec();
let bucket = spawn_bucket(body.clone(), None).await;
let mut disabled = config();
disabled.runtimes.get_mut("tract").expect("entry").enabled = false;
let rig = Rig::new(bucket.addr, disabled);
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert_eq!(
reason,
"runtime 'tract' for format 'onnx' is not enabled on this node \
(models.runtimes.tract.enabled)"
);
assert!(rig.store.path_for(&job(&body).artifact.digest).is_file());
let mut unknown = config();
unknown
.default_runtime
.insert("onnx".to_string(), "ort".to_string());
let rig = Rig::new(bucket.addr, unknown);
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert!(
reason.contains("runtime 'ort' for format 'onnx' is unknown"),
"{reason}"
);
let rig = Rig::new(bucket.addr, config());
let mut manifest = fixture::manifest();
manifest.format = "nnef".to_string();
let outcome = admit(&rig.deps(), &job_with(&body, manifest)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "probe");
assert_eq!(
reason,
"no default runtime for format 'nnef' (models.default_runtime.nnef is not set)"
);
}
#[tokio::test]
async fn an_admission_over_its_budget_fails_in_the_stage_it_was_in() {
let body = fixture::ONNX.to_vec();
let bucket = spawn_bucket_with_delay(body.clone(), None, Duration::from_secs(5)).await;
let mut config = config();
config.admission_timeout_secs = 1;
let rig = Rig::new(bucket.addr, config);
let outcome = admit(&rig.deps(), &job(&body)).await;
let (stage, reason) = failure(&outcome);
assert_eq!(stage, "fetch");
assert!(reason.contains("admission_timeout_secs (1)"), "{reason}");
assert!(reason.contains("during fetch"), "{reason}");
assert!(outcome.elapsed < Duration::from_secs(4));
}
#[tokio::test]
async fn the_queue_is_bounded_and_the_worker_drains_it() {
let (queue, rx) = AdmissionQueue::with_capacity(2);
let body = b"x";
queue.enqueue(job(body)).expect("one");
queue.enqueue(job(body)).expect("two");
let err = queue
.enqueue(job(body))
.expect_err("three is over capacity");
assert_eq!(*err.0, job(body), "the job comes back");
assert!(err.to_string().contains("full"), "{err}");
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = seen.clone();
let worker = tokio::spawn(run_worker(rx, move |_job| {
let counted = counted.clone();
async move {
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}));
drop(queue);
worker
.await
.expect("the worker ends when every sender is gone");
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
}