Skip to main content

solverforge_solver/manager/solver_manager/
runtime.rs

1use std::any::Any;
2use std::fmt::{self, Debug};
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use solverforge_core::domain::PlanningSolution;
6
7use super::slot::{JobSlot, SLOT_CANCELLED, SLOT_COMPLETED, SLOT_FAILED, SLOT_SOLVING};
8use super::types::{SolverEvent, SolverLifecycleState, SolverTerminalReason};
9use crate::stats::SolverTelemetry;
10
11mod pause;
12
13/// Preserves a foreign-runtime error object while giving retained solving a
14/// stable, displayable failure message.
15pub struct SolverPanicPayload {
16    message: String,
17    payload: Box<dyn Any + Send>,
18}
19
20impl SolverPanicPayload {
21    pub fn new(message: impl Into<String>, payload: impl Any + Send) -> Self {
22        Self {
23            message: message.into(),
24            payload: Box::new(payload),
25        }
26    }
27
28    pub fn message(&self) -> &str {
29        &self.message
30    }
31
32    pub fn into_parts(self) -> (String, Box<dyn Any + Send>) {
33        (self.message, self.payload)
34    }
35}
36
37impl Debug for SolverPanicPayload {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.debug_struct("SolverPanicPayload")
40            .field("message", &self.message)
41            .finish_non_exhaustive()
42    }
43}
44
45/// Runtime context for a retained solve job.
46///
47/// This is passed into `Solvable::solve()` so the runtime path can publish
48/// lifecycle events, settle exact pauses, and observe cancellation.
49pub struct SolverRuntime<S: PlanningSolution> {
50    job_id: usize,
51    pub(super) slot: &'static JobSlot<S>,
52}
53
54impl<S: PlanningSolution> Clone for SolverRuntime<S> {
55    fn clone(&self) -> Self {
56        *self
57    }
58}
59
60impl<S: PlanningSolution> Copy for SolverRuntime<S> {}
61
62impl<S: PlanningSolution> Debug for SolverRuntime<S> {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.debug_struct("SolverRuntime")
65            .field("job_id", &self.job_id)
66            .finish()
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum EventKind {
72    Progress,
73    Resumed,
74    Cancelled,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum CompletionDisposition {
79    Published,
80    Pause,
81    Cancel,
82    AlreadyTerminal,
83}
84
85impl<S: PlanningSolution> SolverRuntime<S> {
86    pub(super) fn new(job_id: usize, slot: &'static JobSlot<S>) -> Self {
87        Self { job_id, slot }
88    }
89
90    /// Creates a runtime handle for synchronous solves that are not retained by
91    /// a [`SolverManager`](super::SolverManager).
92    ///
93    /// Detached runtimes publish lifecycle state into an internal slot without
94    /// an event receiver. Retained solves should continue to use
95    /// `SolverManager`, which owns reusable slots and event delivery.
96    pub fn detached() -> Self {
97        let slot = Box::leak(Box::new(JobSlot::new()));
98        slot.state.store(SLOT_SOLVING, Ordering::Release);
99        slot.worker_running.store(true, Ordering::Release);
100        Self {
101            job_id: usize::MAX,
102            slot,
103        }
104    }
105
106    pub fn job_id(&self) -> usize {
107        self.job_id
108    }
109
110    pub fn is_cancel_requested(&self) -> bool {
111        self.slot.terminate.load(Ordering::Acquire)
112    }
113
114    pub(crate) fn is_pause_requested(&self) -> bool {
115        self.slot.pause_requested.load(Ordering::Acquire)
116    }
117
118    pub(crate) fn cancel_flag(&self) -> &'static AtomicBool {
119        &self.slot.terminate
120    }
121
122    pub fn emit_progress(
123        &self,
124        current_score: Option<S::Score>,
125        best_score: Option<S::Score>,
126        telemetry: SolverTelemetry,
127    ) {
128        self.emit_non_snapshot_event(current_score, best_score, telemetry, EventKind::Progress);
129    }
130
131    pub fn emit_best_solution(
132        &self,
133        solution: S,
134        current_score: Option<S::Score>,
135        best_score: S::Score,
136        telemetry: SolverTelemetry,
137    ) {
138        self.slot.with_publication(|sender, record| {
139            let state = self.current_state();
140            let terminal_reason = record.terminal_reason;
141            record.current_score = current_score;
142            record.best_score = Some(best_score);
143            record.publish_telemetry(telemetry);
144
145            let snapshot_revision = record.push_snapshot(super::types::SolverSnapshot {
146                job_id: self.job_id,
147                snapshot_revision: 0,
148                lifecycle_state: state,
149                terminal_reason,
150                current_score,
151                best_score: Some(best_score),
152                telemetry: record.telemetry.clone(),
153                solution: solution.clone(),
154            });
155
156            let metadata = record.next_metadata(self.job_id, state, Some(snapshot_revision));
157            if let Some(sender) = sender {
158                let _ = sender.send(SolverEvent::BestSolution { metadata, solution });
159            }
160        });
161    }
162
163    pub fn emit_completed(
164        &self,
165        solution: S,
166        current_score: Option<S::Score>,
167        best_score: S::Score,
168        telemetry: SolverTelemetry,
169        terminal_reason: SolverTerminalReason,
170    ) {
171        loop {
172            let disposition = self.slot.with_publication(|sender, record| {
173                if self.current_state().is_terminal() {
174                    return CompletionDisposition::AlreadyTerminal;
175                }
176                if self.is_cancel_requested() {
177                    return CompletionDisposition::Cancel;
178                }
179                if self.is_pause_requested()
180                    || matches!(
181                        self.current_state(),
182                        SolverLifecycleState::PauseRequested | SolverLifecycleState::Paused
183                    )
184                {
185                    return CompletionDisposition::Pause;
186                }
187
188                self.slot.state.store(SLOT_COMPLETED, Ordering::SeqCst);
189                record.terminal_reason = Some(terminal_reason);
190                record.checkpoint_available = false;
191                record.current_score = current_score;
192                record.best_score = Some(best_score);
193                record.publish_telemetry(telemetry.clone());
194
195                let snapshot_revision = record.push_snapshot(super::types::SolverSnapshot {
196                    job_id: self.job_id,
197                    snapshot_revision: 0,
198                    lifecycle_state: SolverLifecycleState::Completed,
199                    terminal_reason: Some(terminal_reason),
200                    current_score,
201                    best_score: Some(best_score),
202                    telemetry: record.telemetry.clone(),
203                    solution: solution.clone(),
204                });
205
206                let metadata = record.next_metadata(
207                    self.job_id,
208                    SolverLifecycleState::Completed,
209                    Some(snapshot_revision),
210                );
211                if let Some(sender) = sender {
212                    let _ = sender.send(SolverEvent::Completed {
213                        metadata,
214                        solution: solution.clone(),
215                    });
216                }
217                CompletionDisposition::Published
218            });
219
220            match disposition {
221                CompletionDisposition::Published | CompletionDisposition::AlreadyTerminal => {
222                    return;
223                }
224                CompletionDisposition::Cancel => {
225                    self.emit_cancelled(current_score, Some(best_score), telemetry);
226                    return;
227                }
228                CompletionDisposition::Pause => {
229                    if self.pause_with_snapshot(
230                        solution.clone(),
231                        current_score,
232                        Some(best_score),
233                        telemetry.clone(),
234                    ) {
235                        continue;
236                    }
237                    if self.is_cancel_requested() {
238                        self.emit_cancelled(current_score, Some(best_score), telemetry);
239                        return;
240                    }
241                }
242            }
243        }
244    }
245
246    pub fn emit_cancelled(
247        &self,
248        current_score: Option<S::Score>,
249        best_score: Option<S::Score>,
250        telemetry: SolverTelemetry,
251    ) {
252        self.emit_non_snapshot_terminal_event(
253            SolverLifecycleState::Cancelled,
254            SolverTerminalReason::Cancelled,
255            current_score,
256            best_score,
257            telemetry,
258            EventKind::Cancelled,
259        );
260    }
261
262    pub fn emit_failed(&self, error: String) {
263        self.slot.with_publication(|sender, record| {
264            if matches!(
265                self.current_state(),
266                SolverLifecycleState::Completed
267                    | SolverLifecycleState::Cancelled
268                    | SolverLifecycleState::Failed
269            ) {
270                return;
271            }
272            self.slot.state.store(SLOT_FAILED, Ordering::SeqCst);
273            record.terminal_reason = Some(SolverTerminalReason::Failed);
274            record.checkpoint_available = false;
275            record.failure_message = Some(error.clone());
276            record.clear_candidate_trace_detail();
277            let metadata = record.next_metadata(self.job_id, SolverLifecycleState::Failed, None);
278            if let Some(sender) = sender {
279                let _ = sender.send(SolverEvent::Failed { metadata, error });
280            }
281        });
282    }
283
284    pub(crate) fn is_terminal(&self) -> bool {
285        self.current_state().is_terminal()
286    }
287
288    fn current_state(&self) -> SolverLifecycleState {
289        self.slot
290            .raw_state()
291            .expect("runtime accessed a freed job slot")
292    }
293
294    fn emit_non_snapshot_event(
295        &self,
296        current_score: Option<S::Score>,
297        best_score: Option<S::Score>,
298        telemetry: SolverTelemetry,
299        kind: EventKind,
300    ) {
301        self.slot.with_publication(|sender, record| {
302            if kind == EventKind::Resumed && self.is_cancel_requested() {
303                return;
304            }
305            let lifecycle_state = match kind {
306                EventKind::Progress => self.current_state(),
307                EventKind::Resumed => {
308                    self.slot.state.store(SLOT_SOLVING, Ordering::SeqCst);
309                    SolverLifecycleState::Solving
310                }
311                EventKind::Cancelled => unreachable!(),
312            };
313            record.current_score = current_score;
314            record.best_score = best_score;
315            record.publish_telemetry(telemetry);
316            if lifecycle_state != SolverLifecycleState::Paused {
317                record.checkpoint_available = false;
318            }
319            let metadata = record.next_metadata(self.job_id, lifecycle_state, None);
320            let event = match kind {
321                EventKind::Progress => SolverEvent::Progress { metadata },
322                EventKind::Resumed => SolverEvent::Resumed { metadata },
323                EventKind::Cancelled => unreachable!(),
324            };
325            if let Some(sender) = sender {
326                let _ = sender.send(event);
327            }
328        });
329    }
330
331    fn emit_non_snapshot_terminal_event(
332        &self,
333        lifecycle_state: SolverLifecycleState,
334        terminal_reason: SolverTerminalReason,
335        current_score: Option<S::Score>,
336        best_score: Option<S::Score>,
337        telemetry: SolverTelemetry,
338        kind: EventKind,
339    ) {
340        self.slot.with_publication(|sender, record| {
341            if self.current_state().is_terminal() {
342                return;
343            }
344            match lifecycle_state {
345                SolverLifecycleState::Cancelled => {
346                    self.slot.state.store(SLOT_CANCELLED, Ordering::SeqCst);
347                }
348                SolverLifecycleState::Failed => {
349                    self.slot.state.store(SLOT_FAILED, Ordering::SeqCst);
350                }
351                _ => {}
352            }
353            record.terminal_reason = Some(terminal_reason);
354            record.checkpoint_available = false;
355            record.current_score = current_score;
356            record.best_score = best_score;
357            record.publish_telemetry(telemetry);
358            let metadata = record.next_metadata(self.job_id, lifecycle_state, None);
359            let event = match kind {
360                EventKind::Cancelled => SolverEvent::Cancelled { metadata },
361                EventKind::Progress | EventKind::Resumed => unreachable!(),
362            };
363            if let Some(sender) = sender {
364                let _ = sender.send(event);
365            }
366        });
367    }
368}
369
370pub(super) fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
371    if let Some(message) = payload.downcast_ref::<&'static str>() {
372        (*message).to_string()
373    } else if let Some(message) = payload.downcast_ref::<String>() {
374        message.clone()
375    } else if let Some(payload) = payload.downcast_ref::<SolverPanicPayload>() {
376        payload.message().to_string()
377    } else {
378        "solver panicked with a non-string payload".to_string()
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::{panic_payload_to_string, SolverPanicPayload};
385
386    #[test]
387    fn retained_failure_uses_foreign_runtime_message() {
388        let payload = SolverPanicPayload::new("foreign traceback", 41_u8);
389        assert_eq!(
390            panic_payload_to_string(Box::new(payload)),
391            "foreign traceback"
392        );
393    }
394
395    #[test]
396    fn foreign_runtime_payload_remains_owned() {
397        let payload = SolverPanicPayload::new("foreign traceback", 41_u8);
398        let (message, payload) = payload.into_parts();
399        assert_eq!(message, "foreign traceback");
400        assert_eq!(*payload.downcast::<u8>().expect("u8 payload"), 41);
401    }
402}