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        /* One shared slot per solution type per thread, cached as a leaked
98        `Box<dyn Any>`: the `&'static dyn Any` downcast yields a `&'static
99        JobSlot<S>`, so repeated detached solves reuse one allocation instead
100        of leaking a fresh slot per call. The slot is reset to its pristine
101        free state before every detached solve. */
102        thread_local! {
103            static DETACHED_SLOT: std::cell::RefCell<Option<&'static dyn std::any::Any>> =
104                const { std::cell::RefCell::new(None) };
105        }
106        let slot: &'static JobSlot<S> = DETACHED_SLOT.with(|cell| {
107            let mut storage = cell.borrow_mut();
108            let stored = storage.get_or_insert_with(|| {
109                let erased: Box<dyn std::any::Any> = Box::new(JobSlot::<S>::new());
110                Box::leak(erased)
111            });
112            match stored.downcast_ref::<JobSlot<S>>() {
113                Some(slot) => slot,
114                None => unreachable!("the detached slot registry stores exactly one type"),
115            }
116        });
117        slot.reset_for_detached_reuse();
118        slot.state.store(SLOT_SOLVING, Ordering::Release);
119        slot.worker_running.store(true, Ordering::Release);
120        Self {
121            job_id: usize::MAX,
122            slot,
123        }
124    }
125
126    pub fn job_id(&self) -> usize {
127        self.job_id
128    }
129
130    pub fn is_cancel_requested(&self) -> bool {
131        self.slot.terminate.load(Ordering::Acquire)
132    }
133
134    pub(crate) fn is_pause_requested(&self) -> bool {
135        self.slot.pause_requested.load(Ordering::Acquire)
136    }
137
138    pub(crate) fn cancel_flag(&self) -> &'static AtomicBool {
139        &self.slot.terminate
140    }
141
142    pub fn emit_progress(
143        &self,
144        current_score: Option<S::Score>,
145        best_score: Option<S::Score>,
146        telemetry: SolverTelemetry,
147    ) {
148        self.emit_non_snapshot_event(current_score, best_score, telemetry, EventKind::Progress);
149    }
150
151    pub fn emit_best_solution(
152        &self,
153        solution: S,
154        current_score: Option<S::Score>,
155        best_score: S::Score,
156        telemetry: SolverTelemetry,
157    ) {
158        self.slot.with_publication(|sender, record| {
159            let state = self.current_state();
160            let terminal_reason = record.terminal_reason;
161            record.current_score = current_score;
162            record.best_score = Some(best_score);
163            record.publish_telemetry(telemetry);
164
165            let snapshot_revision = record.push_snapshot(super::types::SolverSnapshot {
166                job_id: self.job_id,
167                snapshot_revision: 0,
168                lifecycle_state: state,
169                terminal_reason,
170                current_score,
171                best_score: Some(best_score),
172                telemetry: record.telemetry.clone(),
173                solution: solution.clone(),
174            });
175
176            let metadata = record.next_metadata(self.job_id, state, Some(snapshot_revision));
177            if let Some(sender) = sender {
178                let _ = sender.send(SolverEvent::BestSolution { metadata, solution });
179            }
180        });
181    }
182
183    pub fn emit_completed(
184        &self,
185        solution: S,
186        current_score: Option<S::Score>,
187        best_score: S::Score,
188        telemetry: SolverTelemetry,
189        terminal_reason: SolverTerminalReason,
190    ) {
191        loop {
192            let disposition = self.slot.with_publication(|sender, record| {
193                if self.current_state().is_terminal() {
194                    return CompletionDisposition::AlreadyTerminal;
195                }
196                if self.is_cancel_requested() {
197                    return CompletionDisposition::Cancel;
198                }
199                if self.is_pause_requested()
200                    || matches!(
201                        self.current_state(),
202                        SolverLifecycleState::PauseRequested | SolverLifecycleState::Paused
203                    )
204                {
205                    return CompletionDisposition::Pause;
206                }
207
208                self.slot.state.store(SLOT_COMPLETED, Ordering::SeqCst);
209                record.terminal_reason = Some(terminal_reason);
210                record.checkpoint_available = false;
211                record.current_score = current_score;
212                record.best_score = Some(best_score);
213                record.publish_telemetry(telemetry.clone());
214
215                let snapshot_revision = record.push_snapshot(super::types::SolverSnapshot {
216                    job_id: self.job_id,
217                    snapshot_revision: 0,
218                    lifecycle_state: SolverLifecycleState::Completed,
219                    terminal_reason: Some(terminal_reason),
220                    current_score,
221                    best_score: Some(best_score),
222                    telemetry: record.telemetry.clone(),
223                    solution: solution.clone(),
224                });
225
226                let metadata = record.next_metadata(
227                    self.job_id,
228                    SolverLifecycleState::Completed,
229                    Some(snapshot_revision),
230                );
231                if let Some(sender) = sender {
232                    let _ = sender.send(SolverEvent::Completed {
233                        metadata,
234                        solution: solution.clone(),
235                    });
236                }
237                CompletionDisposition::Published
238            });
239
240            match disposition {
241                CompletionDisposition::Published | CompletionDisposition::AlreadyTerminal => {
242                    return;
243                }
244                CompletionDisposition::Cancel => {
245                    self.emit_cancelled(current_score, Some(best_score), telemetry);
246                    return;
247                }
248                CompletionDisposition::Pause => {
249                    if self.pause_with_snapshot(
250                        solution.clone(),
251                        current_score,
252                        Some(best_score),
253                        telemetry.clone(),
254                    ) {
255                        continue;
256                    }
257                    if self.is_cancel_requested() {
258                        self.emit_cancelled(current_score, Some(best_score), telemetry);
259                        return;
260                    }
261                }
262            }
263        }
264    }
265
266    pub fn emit_cancelled(
267        &self,
268        current_score: Option<S::Score>,
269        best_score: Option<S::Score>,
270        telemetry: SolverTelemetry,
271    ) {
272        self.emit_non_snapshot_terminal_event(
273            SolverLifecycleState::Cancelled,
274            SolverTerminalReason::Cancelled,
275            current_score,
276            best_score,
277            telemetry,
278            EventKind::Cancelled,
279        );
280    }
281
282    pub fn emit_failed(&self, error: String) {
283        self.slot.with_publication(|sender, record| {
284            if matches!(
285                self.current_state(),
286                SolverLifecycleState::Completed
287                    | SolverLifecycleState::Cancelled
288                    | SolverLifecycleState::Failed
289            ) {
290                return;
291            }
292            self.slot.state.store(SLOT_FAILED, Ordering::SeqCst);
293            record.terminal_reason = Some(SolverTerminalReason::Failed);
294            record.checkpoint_available = false;
295            record.failure_message = Some(error.clone());
296            record.clear_candidate_trace_detail();
297            let metadata = record.next_metadata(self.job_id, SolverLifecycleState::Failed, None);
298            if let Some(sender) = sender {
299                let _ = sender.send(SolverEvent::Failed { metadata, error });
300            }
301        });
302    }
303
304    pub(crate) fn is_terminal(&self) -> bool {
305        self.current_state().is_terminal()
306    }
307
308    fn current_state(&self) -> SolverLifecycleState {
309        self.slot
310            .raw_state()
311            .expect("runtime accessed a freed job slot")
312    }
313
314    fn emit_non_snapshot_event(
315        &self,
316        current_score: Option<S::Score>,
317        best_score: Option<S::Score>,
318        telemetry: SolverTelemetry,
319        kind: EventKind,
320    ) {
321        self.slot.with_publication(|sender, record| {
322            if kind == EventKind::Resumed && self.is_cancel_requested() {
323                return;
324            }
325            let lifecycle_state = match kind {
326                EventKind::Progress => self.current_state(),
327                EventKind::Resumed => {
328                    self.slot.state.store(SLOT_SOLVING, Ordering::SeqCst);
329                    SolverLifecycleState::Solving
330                }
331                EventKind::Cancelled => unreachable!(),
332            };
333            record.current_score = current_score;
334            record.best_score = best_score;
335            record.publish_telemetry(telemetry);
336            if lifecycle_state != SolverLifecycleState::Paused {
337                record.checkpoint_available = false;
338            }
339            let metadata = record.next_metadata(self.job_id, lifecycle_state, None);
340            let event = match kind {
341                EventKind::Progress => SolverEvent::Progress { metadata },
342                EventKind::Resumed => SolverEvent::Resumed { metadata },
343                EventKind::Cancelled => unreachable!(),
344            };
345            if let Some(sender) = sender {
346                let _ = sender.send(event);
347            }
348        });
349    }
350
351    fn emit_non_snapshot_terminal_event(
352        &self,
353        lifecycle_state: SolverLifecycleState,
354        terminal_reason: SolverTerminalReason,
355        current_score: Option<S::Score>,
356        best_score: Option<S::Score>,
357        telemetry: SolverTelemetry,
358        kind: EventKind,
359    ) {
360        self.slot.with_publication(|sender, record| {
361            if self.current_state().is_terminal() {
362                return;
363            }
364            match lifecycle_state {
365                SolverLifecycleState::Cancelled => {
366                    self.slot.state.store(SLOT_CANCELLED, Ordering::SeqCst);
367                }
368                SolverLifecycleState::Failed => {
369                    self.slot.state.store(SLOT_FAILED, Ordering::SeqCst);
370                }
371                _ => {}
372            }
373            record.terminal_reason = Some(terminal_reason);
374            record.checkpoint_available = false;
375            record.current_score = current_score;
376            record.best_score = best_score;
377            record.publish_telemetry(telemetry);
378            let metadata = record.next_metadata(self.job_id, lifecycle_state, None);
379            let event = match kind {
380                EventKind::Cancelled => SolverEvent::Cancelled { metadata },
381                EventKind::Progress | EventKind::Resumed => unreachable!(),
382            };
383            if let Some(sender) = sender {
384                let _ = sender.send(event);
385            }
386        });
387    }
388}
389
390pub(super) fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
391    if let Some(message) = payload.downcast_ref::<&'static str>() {
392        (*message).to_string()
393    } else if let Some(message) = payload.downcast_ref::<String>() {
394        message.clone()
395    } else if let Some(payload) = payload.downcast_ref::<SolverPanicPayload>() {
396        payload.message().to_string()
397    } else {
398        "solver panicked with a non-string payload".to_string()
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::{panic_payload_to_string, SolverPanicPayload};
405
406    #[test]
407    fn retained_failure_uses_foreign_runtime_message() {
408        let payload = SolverPanicPayload::new("foreign traceback", 41_u8);
409        assert_eq!(
410            panic_payload_to_string(Box::new(payload)),
411            "foreign traceback"
412        );
413    }
414
415    #[test]
416    fn foreign_runtime_payload_remains_owned() {
417        let payload = SolverPanicPayload::new("foreign traceback", 41_u8);
418        let (message, payload) = payload.into_parts();
419        assert_eq!(message, "foreign traceback");
420        assert_eq!(*payload.downcast::<u8>().expect("u8 payload"), 41);
421    }
422}