use super::{JobIdentity, shot_seed};
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, VecDeque},
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
time::{Duration, Instant},
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobCapabilities {
pub executor: String,
pub programs: Vec<String>,
pub compiler: String,
pub physical_model: String,
pub calibration: String,
pub calibration_expires_at: u64,
pub readouts: Vec<String>,
pub precision_bits: Vec<u8>,
pub max_attempts: u64,
pub max_parameters: usize,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SimulationRequest {
pub version: u32,
pub identity: JobIdentity,
pub compiler: String,
pub physical_model: String,
pub bindings: Vec<f64>,
pub readouts: Vec<String>,
pub precision_bits: u8,
pub error_budget: f64,
pub max_result_bytes: usize,
pub timeout_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawSimulationShot {
pub raw: Vec<u64>,
pub accepted: bool,
pub values: Vec<f64>,
pub photons: u64,
pub latency_ticks: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct JobId(pub u64);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum JobStatus {
Queued,
Running,
Completed,
Cancelled,
TimedOut,
Failed(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SimulationResult {
pub request: SimulationRequest,
pub execution_kind: String,
pub status: JobStatus,
pub shots: Vec<(u64, RawSimulationShot)>,
pub encoded_bytes: usize,
pub wall_seconds: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct JobServiceLimits {
pub workers: usize,
pub queued: usize,
pub retained: usize,
pub result_bytes: usize,
}
impl Default for JobServiceLimits {
fn default() -> Self {
Self {
workers: 2,
queued: 64,
retained: 256,
result_bytes: 16 << 20,
}
}
}
struct Entry {
request: SimulationRequest,
status: JobStatus,
shots: Vec<(u64, RawSimulationShot)>,
bytes: usize,
submitted: Instant,
calibration_lease: Duration,
cancel: Arc<AtomicBool>,
}
struct State {
entries: BTreeMap<JobId, Entry>,
queue: VecDeque<JobId>,
shutdown: bool,
}
struct Shared {
state: Mutex<State>,
wake: Condvar,
}
pub struct LocalSimulationJobs {
shared: Arc<Shared>,
threads: Vec<JoinHandle<()>>,
capabilities: JobCapabilities,
limits: JobServiceLimits,
}
impl LocalSimulationJobs {
pub fn new<F>(
capabilities: JobCapabilities,
limits: JobServiceLimits,
evaluate: F,
) -> Result<Self>
where
F: Fn(&SimulationRequest, u64, u64, &AtomicBool) -> Result<RawSimulationShot>
+ Send
+ Sync
+ 'static,
{
if capabilities.programs.is_empty()
|| capabilities.programs.iter().any(|p| p.is_empty())
|| capabilities.compiler.is_empty()
|| capabilities.physical_model.is_empty()
|| capabilities.executor.is_empty()
|| capabilities.calibration.is_empty()
|| capabilities.max_attempts == 0
|| capabilities.readouts.is_empty()
|| capabilities.precision_bits.is_empty()
|| capabilities
.precision_bits
.iter()
.any(|p| ![32, 64].contains(p))
|| limits.workers == 0
|| limits.workers > 256
|| limits.queued == 0
|| limits.retained == 0
|| limits.result_bytes == 0
{
return Err(Error::InvalidValue(
"invalid asynchronous job service limits".into(),
));
}
let shared = Arc::new(Shared {
state: Mutex::new(State {
entries: BTreeMap::new(),
queue: VecDeque::new(),
shutdown: false,
}),
wake: Condvar::new(),
});
let evaluate = Arc::new(evaluate);
let mut threads = Vec::with_capacity(limits.workers);
for _ in 0..limits.workers {
let shared = shared.clone();
let evaluate = evaluate.clone();
threads.push(std::thread::spawn(move || worker(shared, evaluate)));
}
Ok(Self {
shared,
threads,
capabilities,
limits,
})
}
pub fn capabilities(&self) -> &JobCapabilities {
&self.capabilities
}
pub fn submit(
&self,
id: JobId,
request: SimulationRequest,
now_unix_seconds: u64,
) -> Result<()> {
request.identity.validate()?;
let c = &self.capabilities;
if !c.programs.contains(&request.identity.program)
|| request.compiler != c.compiler
|| request.physical_model != c.physical_model
|| request.version != 1
|| request.identity.calibration != c.calibration
|| request.identity.executor != c.executor
|| now_unix_seconds >= c.calibration_expires_at
|| request.identity.attempts > c.max_attempts
|| request.bindings.len() > c.max_parameters
|| request.bindings.iter().any(|x| !x.is_finite())
|| request.readouts.is_empty()
|| request.readouts.iter().any(|r| !c.readouts.contains(r))
|| !c.precision_bits.contains(&request.precision_bits)
|| !request.error_budget.is_finite()
|| !(0.0..=1.).contains(&request.error_budget)
|| request.max_result_bytes == 0
|| request.max_result_bytes > self.limits.result_bytes
|| request.timeout_ms == 0
{
return Err(Error::InvalidValue(
"simulation request fails capability/calibration/budget contract".into(),
));
}
let mut state = self.shared.state.lock().map_err(poison)?;
if let Some(existing) = state.entries.get(&id) {
return if existing.request == request {
Ok(())
} else {
Err(Error::InvalidValue(
"job id reused with different immutable request".into(),
))
};
}
if state.entries.len() >= self.limits.retained || state.queue.len() >= self.limits.queued {
return Err(Error::ResourceLimit {
resource: "asynchronous job queue/retention",
required: state.entries.len() + 1,
limit: self.limits.retained,
});
}
state.entries.insert(
id,
Entry {
request,
status: JobStatus::Queued,
shots: vec![],
bytes: 0,
submitted: Instant::now(),
calibration_lease: Duration::from_secs(c.calibration_expires_at - now_unix_seconds),
cancel: Arc::new(AtomicBool::new(false)),
},
);
state.queue.push_back(id);
self.shared.wake.notify_one();
Ok(())
}
pub fn status(&self, id: JobId) -> Result<JobStatus> {
let s = self.shared.state.lock().map_err(poison)?;
Ok(s.entries.get(&id).ok_or_else(unknown)?.status.clone())
}
pub fn result(&self, id: JobId) -> Result<SimulationResult> {
let s = self.shared.state.lock().map_err(poison)?;
let e = s.entries.get(&id).ok_or_else(unknown)?;
Ok(SimulationResult {
request: e.request.clone(),
execution_kind: "local_simulator".into(),
status: e.status.clone(),
shots: e.shots.clone(),
encoded_bytes: e.bytes,
wall_seconds: e.submitted.elapsed().as_secs_f64(),
})
}
pub fn cancel(&self, id: JobId) -> Result<()> {
let mut s = self.shared.state.lock().map_err(poison)?;
let e = s.entries.get_mut(&id).ok_or_else(unknown)?;
e.cancel.store(true, Ordering::Release);
if e.status == JobStatus::Queued {
e.status = JobStatus::Cancelled;
s.queue.retain(|v| *v != id);
}
Ok(())
}
pub fn forget(&self, id: JobId) -> Result<()> {
let mut s = self.shared.state.lock().map_err(poison)?;
let e = s.entries.get(&id).ok_or_else(unknown)?;
if matches!(e.status, JobStatus::Queued | JobStatus::Running) {
return Err(Error::InvalidValue(
"cannot forget active simulation job".into(),
));
}
s.entries.remove(&id);
Ok(())
}
}
impl Drop for LocalSimulationJobs {
fn drop(&mut self) {
if let Ok(mut s) = self.shared.state.lock() {
s.shutdown = true;
for e in s.entries.values() {
e.cancel.store(true, Ordering::Release);
}
self.shared.wake.notify_all();
}
for t in self.threads.drain(..) {
let _ = t.join();
}
}
}
fn poison(e: impl std::fmt::Display) -> Error {
Error::InvalidValue(format!("job service lock: {e}"))
}
fn unknown() -> Error {
Error::InvalidValue("unknown simulation job".into())
}
fn worker<F>(shared: Arc<Shared>, evaluate: Arc<F>)
where
F: Fn(&SimulationRequest, u64, u64, &AtomicBool) -> Result<RawSimulationShot>
+ Send
+ Sync
+ 'static,
{
loop {
let (id, request, cancel, start, lease) = {
let Ok(mut s) = shared.state.lock() else {
return;
};
while s.queue.is_empty() && !s.shutdown {
s = match shared.wake.wait(s) {
Ok(v) => v,
Err(_) => return,
};
}
if s.shutdown {
return;
}
let id = s.queue.pop_front().unwrap();
let e = s.entries.get_mut(&id).unwrap();
e.status = JobStatus::Running;
(
id,
e.request.clone(),
e.cancel.clone(),
e.submitted,
e.calibration_lease,
)
};
let timeout = Duration::from_millis(request.timeout_ms);
let mut terminal = JobStatus::Completed;
for attempt in 0..request.identity.attempts {
if cancel.load(Ordering::Acquire) {
terminal = JobStatus::Cancelled;
break;
}
if start.elapsed() >= lease {
terminal =
JobStatus::Failed("calibration expired during queued/running job".into());
break;
}
if start.elapsed() >= timeout {
terminal = JobStatus::TimedOut;
break;
}
let answer = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
evaluate(
&request,
attempt,
shot_seed(request.identity.seed, attempt, 0),
&cancel,
)
}));
let answer = match answer {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
terminal = JobStatus::Failed(e.to_string());
break;
}
Err(_) => {
terminal = JobStatus::Failed("simulator evaluator panicked".into());
break;
}
};
if answer.values.len() != request.readouts.len()
|| answer.values.iter().any(|v| !v.is_finite())
{
terminal = JobStatus::Failed("invalid raw simulation readout".into());
break;
}
if answer.raw.len() > request.max_result_bytes / 8
|| answer.values.len() > request.max_result_bytes / 8
{
terminal = JobStatus::Failed("raw simulation record budget".into());
break;
}
let size = match serde_json::to_vec(&(attempt, &answer)) {
Ok(v) => v.len(),
Err(e) => {
terminal = JobStatus::Failed(e.to_string());
break;
}
};
let Ok(mut s) = shared.state.lock() else {
return;
};
let e = s.entries.get_mut(&id).unwrap();
if size > request.max_result_bytes.saturating_sub(e.bytes) {
terminal = JobStatus::Failed("retained simulation record budget".into());
break;
}
e.bytes += size;
e.shots.push((attempt, answer));
if cancel.load(Ordering::Acquire) {
terminal = JobStatus::Cancelled;
break;
}
if start.elapsed() >= lease {
terminal =
JobStatus::Failed("calibration expired during queued/running job".into());
break;
}
if start.elapsed() >= timeout {
terminal = JobStatus::TimedOut;
break;
}
}
if let Ok(mut s) = shared.state.lock() {
let entry = s.entries.get_mut(&id).unwrap();
entry.status =
if terminal == JobStatus::Completed && entry.cancel.load(Ordering::Acquire) {
JobStatus::Cancelled
} else {
terminal
};
} else {
return;
}
}
}