1use mj_core::state::RecoveryObservation;
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use tokio::sync::{Notify, mpsc, watch};
7pub(crate) fn current_background_session(
10 observed: &mj_core::state::SessionRecord,
11) -> anyhow::Result<Option<mj_core::state::SessionRecord>> {
12 let current = crate::database::read_durable_session_record(&observed.id)?;
13 Ok(current.filter(|current| {
14 current.state == mj_core::state::SessionState::Running
15 && current.target == observed.target
16 && current.native_session_id == observed.native_session_id
17 && current.harness_kind == observed.harness_kind
18 && current.last_profile == observed.last_profile
19 }))
20}
21
22#[derive(Clone)]
32pub struct RecoveryObserver {
33 pub(crate) observations: ObservationSender<PendingRecoveryObservation>,
34 pub gate: Arc<RecoveryGate>,
35}
36
37#[derive(Clone)]
38pub(crate) struct PendingRecoveryObservation {
39 pub observation: RecoveryObservation,
40 pub observed_wait: bool,
43}
44
45pub struct RecoveryReservation {
48 session_id: String,
49 gate: Arc<RecoveryGate>,
50}
51
52impl Drop for RecoveryReservation {
53 fn drop(&mut self) {
54 self.gate.release(&self.session_id);
55 }
56}
57
58pub struct RecoveryGate {
64 state: Mutex<RecoveryGateState>,
65 closed: Notify,
66 busy: watch::Sender<BTreeSet<String>>,
69}
70
71impl Default for RecoveryGate {
72 fn default() -> Self {
73 Self {
74 state: Mutex::default(),
75 closed: Notify::new(),
76 busy: watch::channel(BTreeSet::new()).0,
77 }
78 }
79}
80
81#[derive(Default)]
82struct RecoveryGateState {
83 closed: bool,
84 busy: BTreeMap<String, Arc<AtomicBool>>,
87 reservations: BTreeMap<String, usize>,
88}
89
90impl RecoveryGate {
91 pub async fn run_background<T>(
95 self: &Arc<Self>,
96 session_id: &str,
97 work: impl std::future::Future<Output = T>,
98 ) -> Option<T> {
99 let admission = self.try_start(session_id)?;
100 let cancelled = admission.cancellation();
101 let cancellation = async {
102 while !cancelled.load(Ordering::Acquire) {
103 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
104 }
105 };
106 tokio::select! {
107 biased;
108 _ = cancellation => None,
109 result = work => Some(result),
110 }
111 }
112
113 pub fn reserve(self: &Arc<Self>, session_id: &str) -> RecoveryReservation {
114 let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
115 *state.reservations.entry(session_id.to_owned()).or_default() += 1;
116 RecoveryReservation {
117 session_id: session_id.to_owned(),
118 gate: self.clone(),
119 }
120 }
121
122 pub async fn reserve_when_idle(
135 self: &Arc<Self>,
136 session_id: &str,
137 deadline: tokio::time::Instant,
138 ) -> RecoveryReservation {
139 let mut busy = self.subscribe();
142 loop {
143 {
144 let mut state = self
145 .state
146 .lock()
147 .unwrap_or_else(std::sync::PoisonError::into_inner);
148 if !state.busy.contains_key(session_id) || tokio::time::Instant::now() >= deadline {
149 *state.reservations.entry(session_id.to_owned()).or_default() += 1;
150 return RecoveryReservation {
151 session_id: session_id.to_owned(),
152 gate: self.clone(),
153 };
154 }
155 }
156 if tokio::time::timeout_at(deadline, busy.changed())
157 .await
158 .is_ok_and(|changed| changed.is_err())
159 {
160 tokio::time::sleep_until(deadline).await;
163 }
164 }
165 }
166
167 fn release(&self, session_id: &str) {
168 let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
169 let Some(count) = state.reservations.get_mut(session_id) else {
170 return;
171 };
172 *count -= 1;
173 if *count == 0 {
174 state.reservations.remove(session_id);
175 }
176 }
177
178 pub fn try_start(self: &Arc<Self>, session_id: &str) -> Option<RecoveryAttempt> {
186 if crate::upgrade::is_draining() {
187 return None;
188 }
189 let mut state = self
190 .state
191 .lock()
192 .unwrap_or_else(std::sync::PoisonError::into_inner);
193 if state.closed
194 || state.busy.contains_key(session_id)
195 || state.reservations.contains_key(session_id)
196 {
197 return None;
198 }
199 let cancelled = Arc::new(AtomicBool::new(false));
200 state.busy.insert(session_id.to_owned(), cancelled.clone());
201 self.publish_busy(&state);
202 Some(RecoveryAttempt(Arc::new(RecoveryAdmission {
203 gate: self.clone(),
204 session_id: session_id.to_owned(),
205 cancelled,
206 })))
207 }
208
209 fn finish(&self, session_id: &str, identity: &Arc<AtomicBool>) {
210 let mut state = self
211 .state
212 .lock()
213 .unwrap_or_else(std::sync::PoisonError::into_inner);
214 if state
215 .busy
216 .get(session_id)
217 .is_some_and(|current| Arc::ptr_eq(current, identity))
218 {
219 state.busy.remove(session_id);
220 self.publish_busy(&state);
221 }
222 }
223
224 fn publish_busy(&self, state: &RecoveryGateState) {
225 self.busy.send_replace(state.busy.keys().cloned().collect());
226 }
227
228 pub async fn closed(&self) {
229 loop {
230 let notified = self.closed.notified();
231 tokio::pin!(notified);
232 notified.as_mut().enable();
233 if self
234 .state
235 .lock()
236 .unwrap_or_else(std::sync::PoisonError::into_inner)
237 .closed
238 {
239 return;
240 }
241 notified.await;
242 }
243 }
244
245 pub fn subscribe(&self) -> watch::Receiver<BTreeSet<String>> {
246 self.busy.subscribe()
247 }
248
249 pub fn is_busy(&self, session_id: &str) -> bool {
250 self.state
251 .lock()
252 .unwrap_or_else(|error| error.into_inner())
253 .busy
254 .contains_key(session_id)
255 }
256
257 pub fn cancel_busy(&self, session_id: &str) {
259 if let Some(cancelled) = self
260 .state
261 .lock()
262 .unwrap_or_else(|error| error.into_inner())
263 .busy
264 .get(session_id)
265 {
266 cancelled.store(true, Ordering::Release);
267 }
268 }
269
270 pub fn close(&self) {
273 let mut state = self
274 .state
275 .lock()
276 .unwrap_or_else(std::sync::PoisonError::into_inner);
277 state.closed = true;
278 for cancelled in state.busy.values() {
279 cancelled.store(true, Ordering::Release);
280 }
281 self.closed.notify_waiters();
282 }
283
284 pub fn busy_sessions(&self) -> BTreeSet<String> {
285 self.state
286 .lock()
287 .unwrap_or_else(|error| error.into_inner())
288 .busy
289 .keys()
290 .cloned()
291 .collect()
292 }
293}
294
295#[derive(Clone)]
298pub struct RecoveryAttempt(Arc<RecoveryAdmission>);
299
300struct RecoveryAdmission {
301 gate: Arc<RecoveryGate>,
302 session_id: String,
303 cancelled: Arc<AtomicBool>,
304}
305
306impl RecoveryAttempt {
307 pub fn cancellation(&self) -> Arc<AtomicBool> {
308 self.0.cancelled.clone()
309 }
310
311 pub(crate) async fn run_blocking<T: Send + 'static>(
315 self,
316 work: impl FnOnce(Arc<AtomicBool>) -> T + Send + 'static,
317 ) -> (Result<T, tokio::task::JoinError>, Self) {
318 let executing = self.clone();
319 let result = tokio::task::spawn_blocking(move || work(executing.cancellation())).await;
320 (result, self)
321 }
322}
323
324impl std::ops::Deref for RecoveryAttempt {
325 type Target = AtomicBool;
326 fn deref(&self) -> &AtomicBool {
327 &self.0.cancelled
328 }
329}
330
331impl Drop for RecoveryAdmission {
332 fn drop(&mut self) {
333 self.gate.finish(&self.session_id, &self.cancelled);
334 }
335}
336
337#[derive(Clone)]
340pub(crate) struct ObservationSender<T> {
341 pending: Arc<Mutex<PendingObservations<T>>>,
342 wake: mpsc::Sender<()>,
343}
344
345pub(crate) struct ObservationReceiver<T> {
346 pending: Arc<Mutex<PendingObservations<T>>>,
347 wake: mpsc::Receiver<()>,
348}
349
350struct PendingObservations<T> {
351 values: BTreeMap<String, T>,
352 ready: VecDeque<String>,
353}
354
355pub(crate) fn observation_channel<T>() -> (ObservationSender<T>, ObservationReceiver<T>) {
356 let pending = Arc::new(Mutex::new(PendingObservations {
357 values: BTreeMap::new(),
358 ready: VecDeque::new(),
359 }));
360 let (tx, rx) = mpsc::channel(1);
361 (
362 ObservationSender {
363 pending: pending.clone(),
364 wake: tx,
365 },
366 ObservationReceiver { pending, wake: rx },
367 )
368}
369
370impl<T> ObservationSender<T> {
371 pub(crate) fn send(
372 &self,
373 session_id: String,
374 mut observation: T,
375 merge: impl FnOnce(&T, &mut T),
376 ) {
377 if self.wake.is_closed() {
378 return;
379 }
380 let mut pending = self
381 .pending
382 .lock()
383 .unwrap_or_else(std::sync::PoisonError::into_inner);
384 if let Some(previous) = pending.values.get(&session_id) {
385 merge(previous, &mut observation);
386 } else {
387 pending.ready.push_back(session_id.clone());
388 }
389 pending.values.insert(session_id, observation);
390 let _ = self.wake.try_send(());
392 }
393}
394
395impl<T> ObservationReceiver<T> {
396 pub(crate) fn try_recv(&mut self) -> Option<T> {
397 let mut pending = self
398 .pending
399 .lock()
400 .unwrap_or_else(std::sync::PoisonError::into_inner);
401 let session = pending.ready.pop_front()?;
402 pending.values.remove(&session)
403 }
404
405 pub(crate) async fn recv(&mut self) -> Option<T> {
406 tokio::task::yield_now().await;
407 loop {
408 if let Some(observation) = self.try_recv() {
409 return Some(observation);
410 }
411 self.wake.recv().await?;
412 }
413 }
414}
415
416impl RecoveryObserver {
417 pub fn observe(&self, observation: RecoveryObservation) {
420 let pending = PendingRecoveryObservation {
421 observed_wait: observation.checkpoint_wait.is_some(),
422 observation,
423 };
424 self.observations.send(
425 pending.observation.session.id.clone(),
426 pending,
427 |previous, next| {
428 next.observed_wait |= previous.observed_wait;
429 next.observation.latest_completed_turn_ordinal = next
430 .observation
431 .latest_completed_turn_ordinal
432 .max(previous.observation.latest_completed_turn_ordinal);
433 },
434 );
435 }
436
437 pub fn is_busy(&self, session_id: &str) -> bool {
438 self.gate.is_busy(session_id)
439 }
440
441 pub fn reserve(&self, session_id: &str) -> RecoveryReservation {
447 self.gate.reserve(session_id)
448 }
449
450 pub fn cancel_busy(&self, session_id: &str) {
454 self.gate.cancel_busy(session_id);
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[tokio::test]
463 async fn lifecycle_reservation_preempts_background_work_before_releasing_admission() {
464 let gate = Arc::new(RecoveryGate::default());
465 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
466 let task = tokio::spawn({
467 let gate = gate.clone();
468 async move {
469 gate.run_background("child", async {
470 started_tx.send(()).unwrap();
471 std::future::pending::<()>().await;
472 })
473 .await
474 }
475 });
476 started_rx.await.unwrap();
477 let reservation = gate.reserve("child");
478 assert!(gate.is_busy("child"));
479 gate.cancel_busy("child");
480 assert!(
481 tokio::time::timeout(std::time::Duration::from_secs(1), task)
482 .await
483 .unwrap()
484 .unwrap()
485 .is_none()
486 );
487 assert!(!gate.is_busy("child"));
488 assert!(
489 gate.run_background("child", async { panic!("reserved worker accessed") })
490 .await
491 .is_none()
492 );
493 assert_eq!(gate.run_background("other", async { 7 }).await, Some(7));
494 drop(reservation);
495 assert_eq!(gate.run_background("child", async { 9 }).await, Some(9));
496 }
497
498 #[tokio::test]
499 async fn aborting_background_task_releases_worker_admission() {
500 let gate = Arc::new(RecoveryGate::default());
501 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
502 let task = tokio::spawn({
503 let gate = gate.clone();
504 async move {
505 gate.run_background("child", async {
506 started_tx.send(()).unwrap();
507 std::future::pending::<()>().await;
508 })
509 .await
510 }
511 });
512 started_rx.await.unwrap();
513 assert!(gate.run_background("child", async { 1 }).await.is_none());
514 task.abort();
515 assert!(task.await.unwrap_err().is_cancelled());
516 assert_eq!(gate.run_background("child", async { 2 }).await, Some(2));
517 }
518 #[test]
519 fn closing_gate_cancels_admitted_work_and_refuses_late_admission() {
520 for _ in 0..100 {
521 let gate = Arc::new(RecoveryGate::default());
522 let worker_gate = gate.clone();
523 let worker = std::thread::spawn(move || worker_gate.try_start("session"));
524 gate.close();
525 if let Some(attempt) = worker.join().unwrap() {
526 assert!(attempt.load(Ordering::Acquire));
527 assert!(gate.is_busy("session"));
528 drop(attempt);
529 }
530 assert!(gate.try_start("late").is_none());
531 assert!(gate.busy_sessions().is_empty());
532 }
533 }
534
535 #[test]
536 fn old_attempt_identity_cannot_release_a_replacement() {
537 let gate = Arc::new(RecoveryGate::default());
538 let old = gate.try_start("session").unwrap();
539 let identity = old.cancellation();
540 drop(old);
541 let replacement = gate.try_start("session").unwrap();
542 gate.finish("session", &identity);
543 assert!(gate.is_busy("session"));
544 drop(replacement);
545 assert!(!gate.is_busy("session"));
546 }
547
548 #[test]
549 fn busy_publication_tracks_concurrent_session_transitions() {
550 let gate = Arc::new(RecoveryGate::default());
551 let view = gate.subscribe();
552 std::thread::scope(|scope| {
553 for session in 0..8 {
554 let gate = gate.clone();
555 scope.spawn(move || {
556 for _ in 0..1000 {
557 drop(gate.try_start(&session.to_string()).unwrap());
558 }
559 });
560 }
561 });
562 assert_eq!(*view.borrow(), gate.busy_sessions());
563 assert!(view.borrow().is_empty());
564 }
565
566 #[tokio::test]
567 async fn aborting_a_blocking_waiter_keeps_admission_until_executor_settles() {
568 let gate = Arc::new(RecoveryGate::default());
569 let attempt = gate.try_start("session").unwrap();
570 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
571 let (finish_tx, finish_rx) = std::sync::mpsc::channel();
572 let waiter = tokio::spawn(attempt.run_blocking(move |_| {
573 started_tx.send(()).unwrap();
574 finish_rx.recv().unwrap();
575 }));
576 started_rx.await.unwrap();
577 waiter.abort();
578 assert!(matches!(waiter.await, Err(error) if error.is_cancelled()));
579 assert!(gate.is_busy("session"));
580 gate.close();
581 finish_tx.send(()).unwrap();
582 let mut busy = gate.subscribe();
583 tokio::time::timeout(
584 std::time::Duration::from_secs(5),
585 busy.wait_for(|sessions| sessions.is_empty()),
586 )
587 .await
588 .unwrap()
589 .unwrap();
590 }
591
592 #[tokio::test]
593 async fn panicking_executor_retains_admission_until_failure_is_settled() {
594 let gate = Arc::new(RecoveryGate::default());
595 let attempt = gate.try_start("session").unwrap();
596 let (result, settlement) = attempt
597 .run_blocking::<()>(|_| panic!("executor failed"))
598 .await;
599 assert!(result.unwrap_err().is_panic());
600 assert!(gate.try_start("session").is_none());
601 drop(settlement);
602 assert!(gate.try_start("session").is_some());
603 }
604
605 #[tokio::test]
606 async fn coalesced_observations_keep_independent_sessions_fair() {
607 let (sender, mut receiver) = observation_channel();
608 for value in 0..100_000 {
609 sender.send("a".into(), value, |_, _| {});
610 }
611 sender.send("b".into(), 7, |_, _| {});
612 assert_eq!(receiver.recv().await, Some(99_999));
613 sender.send("a".into(), 100_000, |_, _| {});
614 assert_eq!(receiver.recv().await, Some(7));
615 assert_eq!(receiver.recv().await, Some(100_000));
616 assert!(receiver.try_recv().is_none());
617 drop(sender);
618 assert_eq!(receiver.recv().await, None);
619 }
620
621 #[tokio::test]
624 async fn reserving_when_idle_waits_for_running_work_then_blocks_new_work() {
625 let gate = Arc::new(RecoveryGate::default());
626 let copy = gate.try_start("session-1").expect("the slot starts free");
627 let waiter = tokio::spawn({
628 let gate = gate.clone();
629 async move {
630 gate.reserve_when_idle(
631 "session-1",
632 tokio::time::Instant::now() + std::time::Duration::from_secs(30),
633 )
634 .await
635 }
636 });
637 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
638 assert!(!waiter.is_finished(), "the running copy is not preempted");
639 drop(copy);
640 let reservation = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
641 .await
642 .expect("the reservation follows the copy")
643 .unwrap();
644 assert!(
645 gate.try_start("session-1").is_none(),
646 "no copy or upgrade starts while the review holds the session"
647 );
648 assert!(
649 gate.try_start("session-2").is_some(),
650 "other sessions are free"
651 );
652 drop(reservation);
653 assert!(gate.try_start("session-1").is_some());
654 }
655
656 #[tokio::test]
658 async fn reserving_when_idle_stops_waiting_at_the_deadline() {
659 let gate = Arc::new(RecoveryGate::default());
660 let _copy = gate.try_start("session-1").unwrap();
661 let reservation = tokio::time::timeout(
662 std::time::Duration::from_secs(5),
663 gate.reserve_when_idle(
664 "session-1",
665 tokio::time::Instant::now() + std::time::Duration::from_millis(50),
666 ),
667 )
668 .await
669 .expect("the deadline ends the wait");
670 drop(_copy);
671 assert!(
672 gate.try_start("session-1").is_none(),
673 "the reservation holds"
674 );
675 drop(reservation);
676 assert!(gate.try_start("session-1").is_some());
677 }
678}