Skip to main content

optimization_job_api/
worker.rs

1//! Small blocking worker SDK. Solver state is private to each task; results are
2//! committed before a completed task is reported. No solver checkpoint is implied.
3use crate::{
4    JobSpec, PROTOCOL, Request, Result,
5    result::{self, Outcome, Record},
6    storage,
7};
8use serde_json::{Value, json};
9use std::{
10    collections::BTreeMap,
11    fs,
12    path::Path,
13    sync::{
14        Mutex,
15        atomic::{AtomicBool, AtomicUsize, Ordering},
16    },
17    thread,
18    time::{Duration, Instant},
19};
20
21pub struct TaskResult {
22    pub outcome: Outcome,
23    pub work: BTreeMap<String, u64>,
24}
25pub trait Prepared: Send + Sync {
26    fn tasks(&self) -> usize;
27    fn solve(&self, task: usize, remaining_seconds: f64) -> Result<TaskResult>;
28    /// A cooperative stop. Implementations without solver tokens may finish
29    /// their active task; the runner enforces the process deadline.
30    fn cancel(&self);
31}
32pub trait Worker {
33    fn describe(&self) -> Value;
34    /// Validate all model data, bounds, starts, budgets and thread support.
35    /// Resolve defaults into payload; no optimization is performed here.
36    fn prepare(&self, spec: &mut JobSpec, base: &Path) -> Result<Box<dyn Prepared>>;
37}
38pub fn description(
39    engine: &str,
40    version: &str,
41    profiles: Value,
42    problems: Value,
43    inner_max: usize,
44) -> Value {
45    json!({"protocol":PROTOCOL,"engine":engine,"build":{"engine_version":version,"job_api_version":env!("CARGO_PKG_VERSION")},"profiles":profiles,"problems":problems,"resources":{"inner_threads_max":inner_max,"cpu_slots":"outer_workers * inner_threads <= cpu_slots; no hidden nested parallelism"},"recovery":"journal_only_no_resume","execution":"native registered models; no deserialized callbacks"})
46}
47fn prepared(worker: &dyn Worker, path: &Path) -> Result<(JobSpec, Box<dyn Prepared>)> {
48    let mut s: JobSpec = serde_json::from_slice(&fs::read(path)?)?;
49    s.validate()?;
50    let expected = s.task_count;
51    let p = worker.prepare(&mut s, path.parent().ok_or("spec parent missing")?)?;
52    if expected.is_some_and(|n| n != p.tasks()) {
53        return Err("task_count disagrees with payload".into());
54    }
55    s.task_count = Some(p.tasks());
56    s.validate()?;
57    Ok((s, p))
58}
59pub fn execute(worker: &dyn Worker, dir: &Path) -> Result<()> {
60    let _owner = storage::lock(&dir.join("worker.lock"))?;
61    if dir.join("results.jsonl").exists() || dir.join("run.json").exists() {
62        return Err("existing attempt: journal recovery is not solver resume".into());
63    }
64    let start = Instant::now();
65    let (s, p) = prepared(worker, &dir.join("resolved.json"))?;
66    let persisted: JobSpec = serde_json::from_slice(&fs::read(dir.join("resolved.json"))?)?;
67    if s != persisted {
68        return Err("resolved job changed during worker validation".into());
69    }
70    let digest = s.digest()?;
71    let exe_digest = storage::digest(&fs::read(std::env::current_exe()?)?);
72    let journal = fs::OpenOptions::new()
73        .write(true)
74        .create_new(true)
75        .open(dir.join("results.jsonl"))?;
76    journal.sync_all()?;
77    fs::File::open(dir)?.sync_all()?;
78    let next = AtomicUsize::new(0);
79    let stop = AtomicBool::new(false);
80    let done = AtomicBool::new(false);
81    let output = Mutex::new(());
82    let error = Mutex::new(None::<String>);
83    thread::scope(|scope| {
84        let watcher = scope.spawn(|| {
85            while !done.load(Ordering::Acquire) {
86                if dir.join("cancel.request").exists()
87                    || start.elapsed().as_secs_f64() >= s.limits.wall_time_seconds
88                {
89                    stop.store(true, Ordering::Release);
90                    p.cancel();
91                }
92                thread::sleep(Duration::from_millis(5));
93            }
94        });
95        let mut handles = Vec::new();
96        for _ in 0..s.resources.outer_workers.min(p.tasks()) {
97            handles.push(scope.spawn(|| {
98                while !stop.load(Ordering::Acquire) {
99                    // Check locally too: a pre-existing cancel or tiny deadline
100                    // must never rely on watcher scheduling.
101                    if dir.join("cancel.request").exists()
102                        || start.elapsed().as_secs_f64() >= s.limits.wall_time_seconds
103                    {
104                        stop.store(true, Ordering::Release);
105                        p.cancel();
106                        break;
107                    }
108                    let task = next.fetch_add(1, Ordering::Relaxed);
109                    if task >= p.tasks() {
110                        break;
111                    }
112                    let began = Instant::now();
113                    let attempt = (|| -> Result<()> {
114                        let r = p.solve(
115                            task,
116                            (s.limits.wall_time_seconds - start.elapsed().as_secs_f64()).max(0.0),
117                        )?;
118                        // A record the runner would reject must never reach
119                        // the journal, where it would discard every result.
120                        r.outcome
121                            .check()
122                            .map_err(|e| format!("task {task}: invalid outcome: {e}"))?;
123                        let record = Record {
124                            task_id: task,
125                            spec_digest: digest.clone(),
126                            elapsed_seconds: began.elapsed().as_secs_f64(),
127                            outcome: r.outcome,
128                            work: r.work,
129                        };
130                        let _guard = output.lock().map_err(|_| "journal lock poisoned")?;
131                        storage::append_checked(&dir.join("results.jsonl"), 1, &record)?;
132                        Ok(())
133                    })();
134                    if let Err(e) = attempt {
135                        *error.lock().unwrap() = Some(e.to_string());
136                        stop.store(true, Ordering::Release);
137                        p.cancel();
138                        break;
139                    }
140                }
141            }));
142        }
143        for handle in handles {
144            if handle.join().is_err() {
145                *error.lock().unwrap() = Some("task panicked".into());
146                stop.store(true, Ordering::Release);
147                p.cancel();
148            }
149        }
150        done.store(true, Ordering::Release);
151        let _ = watcher.join();
152    });
153    if let Some(e) = error.into_inner().map_err(|_| "task error lock poisoned")? {
154        return Err(e.into());
155    }
156    let marker = fs::read(dir.join("cancel.request"))
157        .ok()
158        .and_then(|b| serde_json::from_slice::<Value>(&b).ok());
159    let timed_out = start.elapsed().as_secs_f64() >= s.limits.wall_time_seconds
160        || marker.as_ref().is_some_and(|m| m["reason"] == "timed_out");
161    let status = if timed_out {
162        "timed_out"
163    } else if stop.load(Ordering::Acquire) || dir.join("cancel.request").exists() {
164        "cancelled"
165    } else {
166        "completed"
167    };
168    let run = result::manifest(
169        dir,
170        &Request::Job(s),
171        status,
172        true,
173        start.elapsed().as_secs_f64(),
174        &exe_digest,
175    )?;
176    storage::write_atomic_json(&dir.join("run.json"), &run)?;
177    Ok(())
178}
179pub fn run(worker: impl Worker) {
180    let run = (|| -> Result<()> {
181        let args = std::env::args().collect::<Vec<_>>();
182        let value = match args.get(1).map(String::as_str) {
183            Some("describe") if args.len() == 2 => worker.describe(),
184            Some("validate") if args.len() == 3 => {
185                let (s, _) = prepared(&worker, Path::new(&args[2]))?;
186                json!({"resolved":s})
187            }
188            Some("execute") if args.len() == 3 => {
189                execute(&worker, Path::new(&args[2]))?;
190                json!({"executed":true})
191            }
192            _ => {
193                return Err(
194                    "usage: WORKER describe | validate SPEC | execute JOB_DIRECTORY".into(),
195                );
196            }
197        };
198        println!("{}", serde_json::to_string(&value)?);
199        Ok(())
200    })();
201    if let Err(e) = run {
202        eprintln!(
203            "{}",
204            json!({"error":{"code":"worker_error","message":e.to_string()}})
205        );
206        std::process::exit(1);
207    }
208}