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};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use tokio::sync::{mpsc, watch};
7/// Serialize target recovery with the durable transition into destruction.
8/// Callers hold the lock only on background execution paths; a recovery that
9/// has already started must finish before cleanup can stop its target.
10pub(crate) fn worker_target_mutex(session_id: &str) -> Arc<Mutex<()>> {
11    static LOCKS: std::sync::OnceLock<Mutex<BTreeMap<String, std::sync::Weak<Mutex<()>>>>> =
12        std::sync::OnceLock::new();
13    let mut locks = LOCKS
14        .get_or_init(Mutex::default)
15        .lock()
16        .unwrap_or_else(std::sync::PoisonError::into_inner);
17    locks.retain(|_, lock| lock.strong_count() > 0);
18    let slot = locks.entry(session_id.to_owned()).or_default();
19    if let Some(lock) = slot.upgrade() {
20        return lock;
21    }
22    let lock = Arc::new(Mutex::new(()));
23    *slot = Arc::downgrade(&lock);
24    lock
25}
26
27/// Reports session activity to the recovery coordinator.
28///
29/// Reporting is a queued hand-off, never a round trip: the caller is often a
30/// UI event loop, and a copy decision must never hold that loop up. The queue
31/// is unbounded so an observation is never dropped, which matters because the
32/// idle observation that ends a turn is the one that makes a copy due. Queue
33/// depth stays small in practice: the coordinator only folds an observation
34/// into per-session policy state and hands the copy itself to another task.
35/// It does pause while it records a failed copy, and the queue is what absorbs
36/// that pause instead of the caller.
37///
38/// A caller that must know no copy can start uses [`RecoveryObserver::reserve`]
39/// rather than the queue: the reservation blocks a copy from starting whether
40/// or not queued observations have been read yet.
41#[derive(Clone)]
42pub struct RecoveryObserver {
43    pub observations: mpsc::UnboundedSender<RecoveryObservation>,
44    pub gate: Arc<RecoveryGate>,
45}
46
47/// A per-session reservation held by a foreground lifecycle operation. The
48/// coordinator cannot start another recovery copy until this value is dropped.
49pub struct RecoveryReservation {
50    session_id: String,
51    gate: Arc<RecoveryGate>,
52}
53
54impl Drop for RecoveryReservation {
55    fn drop(&mut self) {
56        self.gate.release(&self.session_id);
57    }
58}
59
60/// The one slot per session that background work has to hold.
61///
62/// It is shared rather than per-coordinator: a recovery copy and a worker
63/// upgrade both act on a session's live worker, so only one of them may run at
64/// a time, and a foreground lifecycle operation preempts whichever it is.
65pub struct RecoveryGate {
66    state: Mutex<RecoveryGateState>,
67    /// Which sessions are busy, for waiters. Published from inside the gate so
68    /// every holder updates it, whatever started the work.
69    busy: watch::Sender<BTreeSet<String>>,
70}
71
72impl Default for RecoveryGate {
73    fn default() -> Self {
74        Self {
75            state: Mutex::default(),
76            busy: watch::channel(BTreeSet::new()).0,
77        }
78    }
79}
80
81#[derive(Default)]
82struct RecoveryGateState {
83    /// In-flight copies, each with the cancel flag its executor watches, so a
84    /// foreground lifecycle operation can preempt one instead of waiting.
85    busy: BTreeMap<String, Arc<AtomicBool>>,
86    reservations: BTreeMap<String, usize>,
87}
88
89impl RecoveryGate {
90    pub fn reserve(self: &Arc<Self>, session_id: &str) -> RecoveryReservation {
91        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
92        *state.reservations.entry(session_id.to_owned()).or_default() += 1;
93        RecoveryReservation {
94            session_id: session_id.to_owned(),
95            gate: self.clone(),
96        }
97    }
98
99    fn release(&self, session_id: &str) {
100        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
101        let Some(count) = state.reservations.get_mut(session_id) else {
102            return;
103        };
104        *count -= 1;
105        if *count == 0 {
106            state.reservations.remove(session_id);
107        }
108    }
109
110    /// Claims the session for background work and returns the cancel flag that
111    /// work must watch, or `None` when other work or a reservation already
112    /// holds it.
113    pub fn try_start(&self, session_id: &str) -> Option<Arc<AtomicBool>> {
114        let cancelled = {
115            let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
116            if state.busy.contains_key(session_id) || state.reservations.contains_key(session_id) {
117                return None;
118            }
119            let cancelled = Arc::new(AtomicBool::new(false));
120            state.busy.insert(session_id.to_owned(), cancelled.clone());
121            cancelled
122        };
123        self.publish_busy();
124        Some(cancelled)
125    }
126
127    pub fn finish(&self, session_id: &str) {
128        self.state
129            .lock()
130            .unwrap_or_else(|error| error.into_inner())
131            .busy
132            .remove(session_id);
133        self.publish_busy();
134    }
135
136    fn publish_busy(&self) {
137        let busy = self.busy_sessions();
138        self.busy.send_replace(busy);
139    }
140
141    pub fn subscribe(&self) -> watch::Receiver<BTreeSet<String>> {
142        self.busy.subscribe()
143    }
144
145    pub fn is_busy(&self, session_id: &str) -> bool {
146        self.state
147            .lock()
148            .unwrap_or_else(|error| error.into_inner())
149            .busy
150            .contains_key(session_id)
151    }
152
153    /// Asks the in-flight copy for this session, if any, to stop.
154    pub fn cancel_busy(&self, session_id: &str) {
155        if let Some(cancelled) = self
156            .state
157            .lock()
158            .unwrap_or_else(|error| error.into_inner())
159            .busy
160            .get(session_id)
161        {
162            cancelled.store(true, Ordering::Release);
163        }
164    }
165
166    /// Asks every in-flight copy to stop, used when a coordinator shuts down.
167    pub fn cancel_all(&self) {
168        for cancelled in self
169            .state
170            .lock()
171            .unwrap_or_else(|error| error.into_inner())
172            .busy
173            .values()
174        {
175            cancelled.store(true, Ordering::Release);
176        }
177    }
178
179    pub fn busy_sessions(&self) -> BTreeSet<String> {
180        self.state
181            .lock()
182            .unwrap_or_else(|error| error.into_inner())
183            .busy
184            .keys()
185            .cloned()
186            .collect()
187    }
188}
189
190impl RecoveryObserver {
191    /// Queues one observation for the coordinator. Returns as soon as the
192    /// observation is queued; a stopped coordinator makes this a no-op.
193    pub fn observe(&self, observation: RecoveryObservation) {
194        let session_id = observation.session.id.clone();
195        if let Err(error) = self.observations.send(observation) {
196            tracing::debug!(
197                %session_id,
198                %error,
199                "recovery observation dropped because the coordinator stopped"
200            );
201        }
202    }
203
204    pub fn is_busy(&self, session_id: &str) -> bool {
205        self.gate.is_busy(session_id)
206    }
207
208    /// Holds off any recovery copy for this session until the returned
209    /// reservation is dropped. This, not the observation queue, is what a
210    /// lifecycle operation relies on: queued observations may still be
211    /// unread, and the coordinator refuses to start a copy for a reserved
212    /// session whenever it reads them.
213    pub fn reserve(&self, session_id: &str) -> RecoveryReservation {
214        self.gate.reserve(session_id)
215    }
216
217    /// Asks an in-flight recovery copy for this session to stop. A foreground
218    /// lifecycle operation calls this after reserving so it preempts the copy
219    /// instead of waiting behind it.
220    pub fn cancel_busy(&self, session_id: &str) {
221        self.gate.cancel_busy(session_id);
222    }
223
224    pub async fn wait_idle(&self, session_id: &str) {
225        let mut busy = self.gate.subscribe();
226        while self.is_busy(session_id) {
227            if busy.changed().await.is_err() {
228                break;
229            }
230        }
231    }
232}