use std::collections::BTreeMap;
use std::error::Error;
use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::{
Barrier,
atomic::{AtomicUsize, Ordering},
};
use std::thread;
use super::{Running, Slots, StartError};
type TestResult = Result<(), Box<dyn Error>>;
#[test]
fn full_admission_refuses_and_dropping_each_guard_returns_exactly_one_slot() -> TestResult {
let slots = Slots::new(NonZeroUsize::MIN.saturating_add(1));
let first = slots.try_acquire()?;
let second = slots.try_acquire()?;
let failure = slots.try_acquire().err().ok_or("third slot accepted")?;
assert_eq!(failure.code, "query_busy");
drop(first);
let replacement = slots.try_acquire()?;
assert!(slots.try_acquire().is_err());
drop(second);
drop(replacement);
let first = slots.try_acquire()?;
let second = slots.try_acquire()?;
drop((first, second));
Ok(())
}
#[test]
fn simultaneous_admission_never_exceeds_the_limit() -> TestResult {
let slots = Slots::new(NonZeroUsize::MIN);
let barrier = Barrier::new(2);
let accepted = AtomicUsize::new(0);
thread::scope(|scope| -> TestResult {
let mut threads = Vec::new();
for _ in 0..2 {
threads.push(scope.spawn(|| {
barrier.wait();
let slot = slots.try_acquire();
if slot.is_ok() {
accepted.fetch_add(1, Ordering::Relaxed);
}
barrier.wait();
slot.map(drop)
}));
}
for thread in threads {
if let Err(failure) = thread.join().map_err(|_| "admission thread panicked")? {
assert_eq!(failure.code, "query_busy");
}
}
assert_eq!(accepted.load(Ordering::Relaxed), 1);
Ok(())
})?;
drop(slots.try_acquire()?);
Ok(())
}
#[test]
fn absent_settings_refuse_without_acquiring_a_slot_or_starting_a_worker() -> TestResult {
let slots = Slots::new(NonZeroUsize::MIN);
let failure = Running::start(
&slots,
None,
NonZeroU32::MIN,
".".to_owned(),
None,
BTreeMap::new(),
)
.err()
.ok_or("absent settings accepted")?;
match failure {
StartError::Admission(failure) => assert_eq!(failure.code, "query_engine_unavailable"),
StartError::Worker(error) => return Err(error),
}
drop(slots.try_acquire()?);
Ok(())
}
struct StandIn {
script: std::path::PathBuf,
frames: std::path::PathBuf,
}
impl StandIn {
fn new(name: &str) -> Result<Self, Box<dyn Error>> {
use haematite_query::{Ready, Tag, frame};
use std::os::unix::fs::PermissionsExt;
let base =
std::env::temp_dir().join(format!("haematite-standin-{}-{name}", std::process::id()));
let (frames, script) = (base.with_extension("frames"), base.with_extension("sh"));
let ready = Ready {
protocol: haematite_query::PROTOCOL,
worker: env!("CARGO_PKG_VERSION").to_owned(),
core_limit: 0,
descriptors_closed: 0,
environment: 0,
engine: "stand-in".to_owned(),
jaq_core: "0".to_owned(),
jaq_std: "0".to_owned(),
jaq_json: "0".to_owned(),
reading: Some(haematite_query::Reading::Candidate),
};
let mut bytes = Vec::new();
frame::write(
&mut bytes,
Tag::Ready as u8,
&serde_json::to_vec(&ready)?,
NonZeroU32::MAX,
)?;
frame::write(&mut bytes, Tag::Output as u8, b"1", NonZeroU32::MAX)?;
frame::write(
&mut bytes,
Tag::Done as u8,
br#"{"outputs":1}"#,
NonZeroU32::MAX,
)?;
std::fs::write(&frames, bytes)?;
std::fs::write(
&script,
format!(
"#!/bin/sh\n/bin/cat '{}'\nexec /bin/cat >/dev/null\n",
frames.display()
),
)?;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?;
let stand_in = Self { script, frames };
let first = std::process::Command::new(&stand_in.script)
.env_clear()
.current_dir("/")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|error| format!("query stand-in prelaunch failed: {error}"))?;
if !first.status.success() {
return Err(format!(
"query stand-in prelaunch ended with {}; stderr: {}",
first.status,
String::from_utf8_lossy(&first.stderr)
)
.into());
}
Ok(stand_in)
}
fn settings(&self) -> super::Settings {
super::Settings {
time_ms: std::num::NonZeroU64::MIN.saturating_add(59_999),
memory_bytes: std::num::NonZeroU64::MIN.saturating_add(999_999),
outputs_per_record: std::num::NonZeroU64::MIN.saturating_add(6),
scan_records: std::num::NonZeroU64::MIN.saturating_add(99),
max_running: NonZeroUsize::MIN,
worker: self.script.clone(),
}
}
}
impl Drop for StandIn {
fn drop(&mut self) {
let _script: std::io::Result<()> = std::fs::remove_file(&self.script);
let _frames: std::io::Result<()> = std::fs::remove_file(&self.frames);
}
}
struct Source {
records: std::collections::VecDeque<haematite_query::page::Record>,
fail: bool,
}
impl haematite_query::page::RecordSource for Source {
fn next_record(
&mut self,
) -> Result<Option<haematite_query::page::Record>, haematite_query::handle::Error> {
if self.fail {
return Err(haematite_query::handle::Error::new(
haematite_query::Refusal::QueryRuntime,
"the source failed",
));
}
Ok(self.records.pop_front())
}
}
fn source(ids: &[&str], fail: bool) -> Result<Source, serde_json::Error> {
let raw = |json: &str| serde_json::value::RawValue::from_string(json.to_owned());
let records = ids
.iter()
.map(|id| {
Ok(haematite_query::page::Record {
value: raw("1")?,
origin: haematite_query::page::Origin {
collection: "patients".to_owned(),
id: (*id).to_owned(),
version: raw(r#"{"generation":7,"sequence":11}"#)?,
score: None,
},
})
})
.collect::<Result<_, serde_json::Error>>()?;
Ok(Source { records, fail })
}
fn alive(pid: u32) -> Result<bool, std::io::Error> {
std::process::Command::new("/bin/kill")
.args(["-0", &pid.to_string()])
.status()
.map(|status| status.success())
}
fn one_row() -> haematite_query::page::Allowance {
haematite_query::page::Allowance {
rows: std::num::NonZeroU64::MIN,
bytes: std::num::NonZeroU64::MIN.saturating_add(9_999),
scan_records: 10,
}
}
#[test]
fn a_page_cut_through_the_guard_reaps_the_worker_before_the_slot_is_free() -> TestResult {
let stand_in = StandIn::new("cut")?;
let settings = stand_in.settings();
let slots = Slots::new(NonZeroUsize::MIN);
let frame = NonZeroU32::MIN.saturating_add(8191);
let mut running = Running::start(
&slots,
Some(&settings),
frame,
".".to_owned(),
None,
BTreeMap::new(),
)?;
let worker = running.worker()?;
let pid = worker.pid();
let mut records = source(&["a", "b"], false)?;
let page = haematite_query::page::read_page(worker, &mut records, &one_row(), None)?;
assert!(page.next.is_some(), "control: the page was not cut");
assert!(!alive(pid)?, "the cut left the worker alive");
assert!(
slots.try_acquire().is_err(),
"the slot was freed while the guard lives"
);
drop(running);
drop(slots.try_acquire()?);
Ok(())
}
#[test]
fn an_error_through_the_guard_reaps_the_worker_before_the_slot_is_free() -> TestResult {
let stand_in = StandIn::new("error")?;
let settings = stand_in.settings();
let slots = Slots::new(NonZeroUsize::MIN);
let frame = NonZeroU32::MIN.saturating_add(8191);
let mut running = Running::start(
&slots,
Some(&settings),
frame,
".".to_owned(),
None,
BTreeMap::new(),
)?;
let worker = running.worker()?;
let pid = worker.pid();
let mut records = source(&["a"], true)?;
let error = haematite_query::page::read_page(worker, &mut records, &one_row(), None)
.err()
.ok_or("a failing source gave a page")?;
assert_eq!(error.refusal.code, haematite_query::Refusal::QueryRuntime);
assert!(
slots.try_acquire().is_err(),
"the slot was freed while the guard lives"
);
drop(running);
assert!(!alive(pid)?, "dropping the guard left the worker alive");
drop(slots.try_acquire()?);
Ok(())
}