solverforge_solver/manager/solver_manager/
manager.rs1use std::marker::PhantomData;
2use std::panic::AssertUnwindSafe;
3use std::sync::atomic::Ordering;
4
5use solverforge_core::domain::PlanningSolution;
6use solverforge_core::score::Score;
7use tokio::sync::mpsc;
8
9use crate::stats::QualifiedCandidateTraceRunProvenance;
10
11use super::super::solution_manager::Analyzable;
12use super::runtime::{panic_payload_to_string, SolverRuntime};
13#[cfg(test)]
14use super::slot::SLOT_FREE;
15use super::slot::{JobSlot, SLOT_SOLVING};
16use super::types::{
17 SolverEvent, SolverLifecycleState, SolverManagerError, SolverSnapshot, SolverSnapshotAnalysis,
18 SolverStatus, SolverTelemetryDetail,
19};
20
21pub const MAX_JOBS: usize = 16;
23
24pub trait Solvable: PlanningSolution + Send + 'static {
26 fn solve(
27 self,
28 runtime: SolverRuntime<Self>,
29 qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
30 );
31}
32
33pub struct SolverManager<S: Solvable> {
35 slots: [JobSlot<S>; MAX_JOBS],
36 _phantom: PhantomData<fn() -> S>,
37}
38
39impl<S: Solvable> Default for SolverManager<S> {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl<S: Solvable> SolverManager<S>
46where
47 S::Score: Score,
48{
49 pub const fn new() -> Self {
50 Self {
51 slots: [
52 JobSlot::new(),
53 JobSlot::new(),
54 JobSlot::new(),
55 JobSlot::new(),
56 JobSlot::new(),
57 JobSlot::new(),
58 JobSlot::new(),
59 JobSlot::new(),
60 JobSlot::new(),
61 JobSlot::new(),
62 JobSlot::new(),
63 JobSlot::new(),
64 JobSlot::new(),
65 JobSlot::new(),
66 JobSlot::new(),
67 JobSlot::new(),
68 ],
69 _phantom: PhantomData,
70 }
71 }
72
73 pub fn solve(
74 &'static self,
75 solution: S,
76 ) -> Result<(usize, mpsc::UnboundedReceiver<SolverEvent<S>>), SolverManagerError> {
77 self.submit(solution, None)
78 }
79
80 pub fn solve_with_qualified_candidate_trace_provenance(
86 &'static self,
87 solution: S,
88 provenance: QualifiedCandidateTraceRunProvenance,
89 ) -> Result<(usize, mpsc::UnboundedReceiver<SolverEvent<S>>), SolverManagerError> {
90 self.submit(solution, Some(provenance))
91 }
92
93 fn submit(
94 &'static self,
95 solution: S,
96 qualified_candidate_trace_provenance: Option<QualifiedCandidateTraceRunProvenance>,
97 ) -> Result<(usize, mpsc::UnboundedReceiver<SolverEvent<S>>), SolverManagerError> {
98 let (sender, receiver) = mpsc::unbounded_channel();
99
100 let Some(slot_idx) = self
101 .slots
102 .iter()
103 .position(|slot| slot.try_initialize(sender.clone()))
104 else {
105 return Err(SolverManagerError::NoFreeJobSlots);
106 };
107
108 let slot = &self.slots[slot_idx];
109 let runtime = SolverRuntime::new(slot_idx, slot);
110
111 rayon::spawn(move || {
112 let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
113 solution.solve(runtime, qualified_candidate_trace_provenance)
114 }));
115
116 match result {
117 Ok(()) => {
118 if !runtime.is_terminal() {
119 if runtime.is_cancel_requested() {
120 let (current_score, best_score, telemetry) =
121 runtime.slot.with_publication(|_, record| {
122 (
123 record.current_score,
124 record.best_score,
125 record.telemetry.clone(),
126 )
127 });
128 runtime.emit_cancelled(current_score, best_score, telemetry);
129 } else {
130 runtime.emit_failed(
131 "solver returned without emitting a terminal lifecycle event"
132 .to_string(),
133 );
134 }
135 }
136 }
137 Err(payload) => {
138 runtime.emit_failed(panic_payload_to_string(payload));
139 }
140 }
141
142 runtime.slot.worker_exited();
143 });
144
145 Ok((slot_idx, receiver))
146 }
147
148 pub fn get_status(&self, job_id: usize) -> Result<SolverStatus<S::Score>, SolverManagerError> {
149 let slot = self.slot(job_id)?;
150 slot.with_publication(|_, record| {
151 let state = slot
152 .public_state()
153 .ok_or(SolverManagerError::JobNotFound { job_id })?;
154 Ok(record.status(job_id, state))
155 })
156 }
157
158 pub fn get_telemetry_detail(
168 &self,
169 job_id: usize,
170 ) -> Result<SolverTelemetryDetail<S::Score>, SolverManagerError> {
171 let slot = self.slot(job_id)?;
172 slot.with_publication(|_, record| {
173 let state = slot
174 .public_state()
175 .ok_or(SolverManagerError::JobNotFound { job_id })?;
176 Ok(SolverTelemetryDetail {
177 status: record.status(job_id, state),
178 candidate_trace: record.candidate_trace_detail.clone(),
179 })
180 })
181 }
182
183 pub fn pause(&self, job_id: usize) -> Result<(), SolverManagerError> {
184 let slot = self.slot(job_id)?;
185 let paused = slot.with_publication(|sender, record| {
186 match slot.state.compare_exchange(
187 SLOT_SOLVING,
188 super::slot::SLOT_PAUSE_REQUESTED,
189 Ordering::SeqCst,
190 Ordering::SeqCst,
191 ) {
192 Ok(_) => {
193 slot.pause_requested.store(true, Ordering::SeqCst);
194 record.clear_candidate_trace_detail();
195 let metadata =
196 record.next_metadata(job_id, SolverLifecycleState::PauseRequested, None);
197 if let Some(sender) = sender {
198 let _ = sender.send(SolverEvent::PauseRequested { metadata });
199 }
200 true
201 }
202 Err(_) => false,
203 }
204 });
205 if paused {
206 Ok(())
207 } else {
208 let state = slot
209 .public_state()
210 .ok_or(SolverManagerError::JobNotFound { job_id })?;
211 Err(SolverManagerError::InvalidStateTransition {
212 job_id,
213 action: "pause",
214 state,
215 })
216 }
217 }
218
219 pub fn resume(&self, job_id: usize) -> Result<(), SolverManagerError> {
220 let slot = self.slot(job_id)?;
221 slot.with_publication(|_, _record| {
222 let state = slot
223 .public_state()
224 .ok_or(SolverManagerError::JobNotFound { job_id })?;
225 if state != SolverLifecycleState::Paused {
226 return Err(SolverManagerError::InvalidStateTransition {
227 job_id,
228 action: "resume",
229 state,
230 });
231 }
232 slot.pause_requested.store(false, Ordering::SeqCst);
233 Ok(())
234 })?;
235 slot.pause_condvar.notify_one();
236 Ok(())
237 }
238
239 pub fn cancel(&self, job_id: usize) -> Result<(), SolverManagerError> {
240 let slot = self.slot(job_id)?;
241 slot.with_publication(|_, _record| {
242 let state = slot
243 .public_state()
244 .ok_or(SolverManagerError::JobNotFound { job_id })?;
245 if !matches!(
246 state,
247 SolverLifecycleState::Solving
248 | SolverLifecycleState::PauseRequested
249 | SolverLifecycleState::Paused
250 ) {
251 return Err(SolverManagerError::InvalidStateTransition {
252 job_id,
253 action: "cancel",
254 state,
255 });
256 }
257
258 slot.terminate.store(true, Ordering::SeqCst);
259 slot.pause_requested.store(false, Ordering::SeqCst);
260 Ok(())
261 })?;
262 slot.pause_condvar.notify_one();
263 Ok(())
264 }
265
266 pub fn delete(&self, job_id: usize) -> Result<(), SolverManagerError> {
267 let slot = self.slot(job_id)?;
268 match slot.delete_terminal() {
269 Ok(()) => {}
270 Err(None) => return Err(SolverManagerError::JobNotFound { job_id }),
271 Err(Some(state)) => {
272 return Err(SolverManagerError::InvalidStateTransition {
273 job_id,
274 action: "delete",
275 state,
276 });
277 }
278 }
279 slot.try_reset_deleted();
280 Ok(())
281 }
282
283 pub fn get_snapshot(
284 &self,
285 job_id: usize,
286 snapshot_revision: Option<u64>,
287 ) -> Result<SolverSnapshot<S>, SolverManagerError> {
288 let slot = self.slot(job_id)?;
289 slot.with_publication(|_, record| {
290 if slot.public_state().is_none() {
291 return Err(SolverManagerError::JobNotFound { job_id });
292 }
293
294 if record.snapshots.is_empty() {
295 return Err(SolverManagerError::NoSnapshotAvailable { job_id });
296 }
297
298 match snapshot_revision {
299 Some(revision) => record
300 .snapshots
301 .iter()
302 .find(|snapshot| snapshot.snapshot_revision == revision)
303 .cloned()
304 .ok_or(SolverManagerError::SnapshotNotFound {
305 job_id,
306 snapshot_revision: revision,
307 }),
308 None => Ok(record
309 .snapshots
310 .last()
311 .expect("checked non-empty snapshots")
312 .clone()),
313 }
314 })
315 }
316
317 pub fn analyze_snapshot(
318 &self,
319 job_id: usize,
320 snapshot_revision: Option<u64>,
321 ) -> Result<SolverSnapshotAnalysis<S::Score>, SolverManagerError>
322 where
323 S: Analyzable,
324 {
325 let snapshot = self.get_snapshot(job_id, snapshot_revision)?;
326 Ok(SolverSnapshotAnalysis {
327 job_id,
328 lifecycle_state: snapshot.lifecycle_state,
329 terminal_reason: snapshot.terminal_reason,
330 snapshot_revision: snapshot.snapshot_revision,
331 analysis: snapshot.solution.analyze(),
332 })
333 }
334
335 pub fn active_job_count(&self) -> usize {
336 self.slots
337 .iter()
338 .filter(|slot| slot.public_state().is_some())
339 .count()
340 }
341
342 #[cfg(test)]
343 pub(crate) fn slot_is_free_for_test(&self, job_id: usize) -> bool {
344 self.slots
345 .get(job_id)
346 .is_some_and(|slot| slot.state.load(Ordering::Acquire) == SLOT_FREE)
347 }
348
349 fn slot(&self, job_id: usize) -> Result<&JobSlot<S>, SolverManagerError> {
350 self.slots
351 .get(job_id)
352 .ok_or(SolverManagerError::JobNotFound { job_id })
353 }
354}