Skip to main content

mj_controller/
recovery_gate.rs

1//! Per-session coordination between foreground operations and background recovery.
2use 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};
7/// Validate a queued placement after admission. A lifecycle may have finished
8/// between the observation and this attempt, so the old target cannot be used.
9pub(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/// Reports session activity to the recovery coordinator.
23///
24/// Reporting retains only the newest observation per session. Completed-turn
25/// frontiers are merged monotonically, so coalescing does not lose the boundary
26/// that makes a recovery copy due.
27///
28/// A caller that must know no copy can start uses [`RecoveryObserver::reserve`]
29/// rather than the queue: the reservation blocks a copy from starting whether
30/// or not queued observations have been read yet.
31#[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    // A busy-to-idle edge releases a deferral even when both observations are
41    // folded into one pending entry before the policy consumes them.
42    pub observed_wait: bool,
43}
44
45/// A per-session reservation held by a foreground lifecycle operation. The
46/// coordinator cannot start another recovery copy until this value is dropped.
47pub 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
58/// The one slot per session that background work has to hold.
59///
60/// It is shared rather than per-coordinator: a recovery copy and a worker
61/// upgrade both act on a session's live worker, so only one of them may run at
62/// a time, and a foreground lifecycle operation preempts whichever it is.
63pub struct RecoveryGate {
64    state: Mutex<RecoveryGateState>,
65    closed: Notify,
66    /// Which sessions are busy, for waiters. Published from inside the gate so
67    /// every holder updates it, whatever started the work.
68    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    /// In-flight copies, each with the cancel flag its executor watches, so a
85    /// foreground lifecycle operation can preempt one instead of waiting.
86    busy: BTreeMap<String, Arc<AtomicBool>>,
87    reservations: BTreeMap<String, usize>,
88}
89
90impl RecoveryGate {
91    /// Run disposable background work under the same admission as recovery
92    /// and worker replacement. Lifecycle reservations preempt it before they
93    /// touch the worker. Dropping the future also releases admission.
94    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    /// Reserves the session for foreground work that needs its worker to
123    /// itself for a while, such as a turn review, once no background work
124    /// holds it.
125    ///
126    /// Unlike [`Self::reserve`], it does not preempt work already running:
127    /// a recovery copy protects the session's work and finishes in seconds,
128    /// so this waits for it, up to `deadline`. The check and the reservation
129    /// happen under one lock, so no copy or upgrade can start between them.
130    /// Work that is still running at `deadline` is left to finish; the
131    /// reservation is taken anyway, and the caller meets that work's own
132    /// refusal. Background work turned away meanwhile is offered again by the
133    /// daemon's periodic refresh once the reservation is dropped.
134    pub async fn reserve_when_idle(
135        self: &Arc<Self>,
136        session_id: &str,
137        deadline: tokio::time::Instant,
138    ) -> RecoveryReservation {
139        // Subscribing before the first check means a holder that finishes
140        // between the check and the wait still wakes this waiter.
141        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                // The gate owns the sender, so this only happens while the
161                // gate is being torn down; stop waiting.
162                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    /// Claims the session with an identity-bearing guard whose cancellation
179    /// flag the executor watches, or `None` when work or a reservation already
180    /// holds it, or a daemon upgrade is waiting.
181    ///
182    /// The work does not hold up a daemon upgrade: it is cancelled when the
183    /// daemon exits, and the next daemon starts it again. Starting it while a
184    /// handoff waits would only waste it.
185    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    /// Asks the in-flight copy for this session, if any, to stop.
258    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    /// Close admission and cancel every admitted attempt in one transition.
271    /// Existing guards retain ownership until their executors have settled.
272    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/// The cancel flag also identifies this particular admission. Only its owner
296/// can release the slot, including when an executor or coordinator unwinds.
297#[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    /// Both the waiter and blocking executor own admission. Aborting the waiter
312    /// cannot release a running executor; a panicking executor cannot release
313    /// its slot before the supervisor applies the failure outcome.
314    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/// Per-session latest-state mailbox shared by the two background policies.
338/// A capacity-one wake channel never queues an observation history.
339#[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        // Full means a wake is already pending. Closure discards disposable observations.
391        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    /// Queues one observation for the coordinator. Returns as soon as the
418    /// observation is queued; a stopped coordinator makes this a no-op.
419    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    /// Holds off any recovery copy for this session until the returned
442    /// reservation is dropped. This, not the observation queue, is what a
443    /// lifecycle operation relies on: queued observations may still be
444    /// unread, and the coordinator refuses to start a copy for a reserved
445    /// session whenever it reads them.
446    pub fn reserve(&self, session_id: &str) -> RecoveryReservation {
447        self.gate.reserve(session_id)
448    }
449
450    /// Asks an in-flight recovery copy for this session to stop. A foreground
451    /// lifecycle operation calls this after reserving so it preempts the copy
452    /// instead of waiting behind it.
453    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    /// A review waits for a recovery copy already running, then holds the
622    /// session so no copy or worker upgrade can start until it is done.
623    #[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    /// Work still running at the deadline is not waited for any longer.
657    #[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}