Skip to main content

car_server_core/
inference_control.rs

1//! Per-WebSocket inference lifecycle control.
2//!
3//! The registry is session-scoped: an ID minted for one socket is
4//! indistinguishable from a never-issued ID on every other socket. Active,
5//! pending-control, orphan, and terminal records are bounded. A backend task is
6//! owned until it has actually returned, so an unconfirmed response cannot
7//! detach work or release its admission charge.
8
9use car_proto::InferenceControlStatus;
10use std::collections::{HashMap, VecDeque};
11use std::future::Future;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore};
15
16pub const DEFAULT_MAX_ACTIVE: usize = 64;
17pub const DEFAULT_MAX_TOMBSTONES: usize = 256;
18pub const DEFAULT_MAX_PENDING_CONTROLS: usize = 64;
19pub const DEFAULT_MAX_ORPHANS: usize = 64;
20pub const DEFAULT_TOMBSTONE_TTL: Duration = Duration::from_secs(300);
21pub const DEFAULT_TERMINATION_ACK_TIMEOUT: Duration = Duration::from_secs(5);
22pub const MAX_DEADLINE_TIMEOUT_MS: u64 = 600_000;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ControlCause {
26    Cancel,
27    Deadline,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum RegistryError {
32    ActiveLimitReached,
33    OrphanLimitReached,
34    PendingControlLimitReached,
35    DuplicateInferenceId,
36    InferenceNotActive,
37    DeadlineAlreadyScheduled,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub struct RegistryConfig {
42    pub max_active: usize,
43    pub max_tombstones: usize,
44    pub max_pending_controls: usize,
45    pub max_orphans: usize,
46    pub tombstone_ttl: Duration,
47    pub termination_ack_timeout: Duration,
48}
49
50impl Default for RegistryConfig {
51    fn default() -> Self {
52        Self {
53            max_active: DEFAULT_MAX_ACTIVE,
54            max_tombstones: DEFAULT_MAX_TOMBSTONES,
55            max_pending_controls: DEFAULT_MAX_PENDING_CONTROLS,
56            max_orphans: DEFAULT_MAX_ORPHANS,
57            tombstone_ttl: DEFAULT_TOMBSTONE_TTL,
58            termination_ack_timeout: DEFAULT_TERMINATION_ACK_TIMEOUT,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum TerminalOutcome {
65    Completed,
66    Controlled(InferenceControlStatus),
67}
68
69struct ActiveEntry {
70    run_id: Option<String>,
71    request_id: Option<String>,
72    phase: ActivePhase,
73    terminal: watch::Sender<Option<TerminalOutcome>>,
74    deadline_token: Option<u64>,
75    backend_task: Option<tokio::task::JoinHandle<()>>,
76    backend_terminated: bool,
77}
78
79#[derive(Clone)]
80enum ActivePhase {
81    Running,
82    Terminating(u64),
83}
84
85struct Tombstone {
86    at: Instant,
87    outcome: TerminalOutcome,
88    /// Retained until the backend wrapper observes the provider/runner future
89    /// return. Keeping the JoinHandle makes the task abort-on-session-drop.
90    backend_task: Option<tokio::task::JoinHandle<()>>,
91}
92
93#[derive(Default)]
94struct RegistryState {
95    active: HashMap<String, ActiveEntry>,
96    tombstones: HashMap<String, Tombstone>,
97    tombstone_order: VecDeque<String>,
98    next_token: u64,
99    orphan_count: usize,
100}
101
102enum ControlStart {
103    New(u64),
104    Wait(watch::Receiver<Option<TerminalOutcome>>),
105    Return(InferenceControlStatus),
106}
107
108/// Owns a claimed `Running -> Terminating(token)` transition until it reaches
109/// one terminal outcome. Cancellation and panic drop the control future, so a
110/// trailing `finish` call is not sufficient: without this guard the entry
111/// remains `Terminating` and every later control waits forever on its watch
112/// channel. Drop publishes the cause-specific unconfirmed outcome under the
113/// exact token; a stale guard can never overwrite another terminal.
114struct ControlClaimGuard<'a> {
115    registry: &'a InferenceRegistry,
116    inference_id: &'a str,
117    token: u64,
118    abandoned_status: InferenceControlStatus,
119    armed: bool,
120}
121
122impl<'a> ControlClaimGuard<'a> {
123    fn new(
124        registry: &'a InferenceRegistry,
125        inference_id: &'a str,
126        token: u64,
127        cause: ControlCause,
128    ) -> Self {
129        let abandoned_status = match cause {
130            ControlCause::Cancel => InferenceControlStatus::TerminationUnconfirmed,
131            ControlCause::Deadline => InferenceControlStatus::DeadlineExceededUnconfirmed,
132        };
133        Self {
134            registry,
135            inference_id,
136            token,
137            abandoned_status,
138            armed: true,
139        }
140    }
141
142    fn finish(mut self, status: InferenceControlStatus) -> InferenceControlStatus {
143        let won = self.registry.finish(
144            self.inference_id,
145            Some(self.token),
146            TerminalOutcome::Controlled(status),
147        );
148        self.armed = false;
149        if won {
150            status
151        } else {
152            InferenceControlStatus::AlreadyTerminal
153        }
154    }
155}
156
157impl Drop for ControlClaimGuard<'_> {
158    fn drop(&mut self) {
159        if self.armed {
160            let _ = self.registry.finish(
161                self.inference_id,
162                Some(self.token),
163                TerminalOutcome::Controlled(self.abandoned_status),
164            );
165        }
166    }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum DeadlineReservationError {
171    Terminal(InferenceControlStatus),
172    Registry(RegistryError),
173}
174
175pub struct DeadlineReservation {
176    registry: Arc<InferenceRegistry>,
177    inference_id: String,
178    token: u64,
179    terminal: watch::Receiver<Option<TerminalOutcome>>,
180    armed: bool,
181}
182
183impl DeadlineReservation {
184    /// Wait relative to receipt of the deadline RPC. Completion or another
185    /// control wakes the timer early. The acknowledgement closure is not
186    /// constructed or polled unless this exact reservation wins the race.
187    pub async fn wait_and_control<F, Fut>(
188        mut self,
189        timeout: Duration,
190        acknowledgement: F,
191    ) -> Result<InferenceControlStatus, RegistryError>
192    where
193        F: FnOnce() -> Fut,
194        Fut: Future<Output = bool>,
195    {
196        if self.terminal.borrow().is_none() {
197            tokio::select! {
198                _ = tokio::time::sleep(timeout) => {}
199                _ = self.terminal.changed() => {
200                    return Ok(InferenceControlStatus::AlreadyTerminal);
201                }
202            }
203        } else {
204            return Ok(InferenceControlStatus::AlreadyTerminal);
205        }
206
207        // A deadline reservation bounds one timer per active inference, but a
208        // sleeping timer is not a termination operation. Admit it against the
209        // shared control cap only once it actually expires, leaving capacity
210        // for an urgent cancel while many long deadlines are armed.
211        let _pending = self.registry.try_acquire_control()?;
212        let start = self
213            .registry
214            .claim_reserved_deadline(&self.inference_id, self.token);
215        self.armed = false;
216        Ok(self
217            .registry
218            .drive_control_start(
219                &self.inference_id,
220                ControlCause::Deadline,
221                start,
222                acknowledgement,
223            )
224            .await)
225    }
226}
227
228impl Drop for DeadlineReservation {
229    fn drop(&mut self) {
230        if self.armed {
231            self.registry
232                .release_deadline(&self.inference_id, self.token);
233        }
234    }
235}
236
237pub struct InferenceRegistry {
238    config: RegistryConfig,
239    pending_controls: Arc<Semaphore>,
240    state: Mutex<RegistryState>,
241}
242
243impl Default for InferenceRegistry {
244    fn default() -> Self {
245        Self::with_config(RegistryConfig::default())
246    }
247}
248
249impl InferenceRegistry {
250    pub fn with_config(config: RegistryConfig) -> Self {
251        assert!(
252            config.max_active > 0,
253            "active registry bound must be positive"
254        );
255        assert!(
256            config.max_tombstones > 0,
257            "tombstone bound must be positive"
258        );
259        assert!(
260            config.max_pending_controls > 0,
261            "pending-control bound must be positive"
262        );
263        assert!(config.max_orphans > 0, "orphan bound must be positive");
264        assert!(
265            config.max_orphans >= config.max_active,
266            "orphan bound must cover every already-active inference"
267        );
268        assert!(
269            config.max_orphans <= config.max_tombstones,
270            "orphan bound must fit inside tombstone bound"
271        );
272        Self {
273            pending_controls: Arc::new(Semaphore::new(config.max_pending_controls)),
274            config,
275            state: Mutex::new(RegistryState::default()),
276        }
277    }
278
279    pub fn try_acquire_control(self: &Arc<Self>) -> Result<OwnedSemaphorePermit, RegistryError> {
280        self.pending_controls
281            .clone()
282            .try_acquire_owned()
283            .map_err(|_| RegistryError::PendingControlLimitReached)
284    }
285
286    pub fn begin(
287        &self,
288    ) -> Result<(String, watch::Receiver<Option<TerminalOutcome>>), RegistryError> {
289        self.begin_for_run(None, None)
290    }
291
292    pub fn begin_for_run(
293        &self,
294        run_id: Option<String>,
295        request_id: Option<String>,
296    ) -> Result<(String, watch::Receiver<Option<TerminalOutcome>>), RegistryError> {
297        for _ in 0..8 {
298            let id = format!("inf_{}", uuid::Uuid::new_v4().simple());
299            match self.begin_with_id_for_run(&id, run_id.clone(), request_id.clone()) {
300                Ok(receiver) => return Ok((id, receiver)),
301                Err(RegistryError::DuplicateInferenceId) => continue,
302                Err(error) => return Err(error),
303            }
304        }
305        Err(RegistryError::DuplicateInferenceId)
306    }
307
308    pub fn begin_with_id(
309        &self,
310        inference_id: &str,
311    ) -> Result<watch::Receiver<Option<TerminalOutcome>>, RegistryError> {
312        self.begin_with_id_for_run(inference_id, None, None)
313    }
314
315    fn begin_with_id_for_run(
316        &self,
317        inference_id: &str,
318        run_id: Option<String>,
319        request_id: Option<String>,
320    ) -> Result<watch::Receiver<Option<TerminalOutcome>>, RegistryError> {
321        let mut state = self.state.lock().expect("inference registry poisoned");
322        self.prune_locked(&mut state, Instant::now());
323        if state.active.contains_key(inference_id) || state.tombstones.contains_key(inference_id) {
324            return Err(RegistryError::DuplicateInferenceId);
325        }
326        if state.active.len() >= self.config.max_active {
327            return Err(RegistryError::ActiveLimitReached);
328        }
329        // Every admitted active inference may terminalize before its backend
330        // wrapper returns, converting its owned JoinHandle into an orphaned,
331        // non-evictable tombstone. Treat the active entry itself as that
332        // worst-case capacity reservation. `finish` removes one active before
333        // adding at most one orphan, so this sum cannot grow after admission.
334        if state.active.len().saturating_add(state.orphan_count) >= self.config.max_orphans {
335            return Err(RegistryError::OrphanLimitReached);
336        }
337        let (terminal, receiver) = watch::channel(None);
338        state.active.insert(
339            inference_id.to_string(),
340            ActiveEntry {
341                run_id,
342                request_id,
343                phase: ActivePhase::Running,
344                terminal,
345                deadline_token: None,
346                backend_task: None,
347                backend_terminated: false,
348            },
349        );
350        Ok(receiver)
351    }
352
353    /// Active inference identities attributed to one run, sorted for stable
354    /// receipt selection.
355    pub fn active_for_run(&self, run_id: &str) -> Vec<(String, Option<String>)> {
356        let mut state = self.state.lock().expect("inference registry poisoned");
357        self.prune_locked(&mut state, Instant::now());
358        let mut active: Vec<_> = state
359            .active
360            .iter()
361            .filter(|(_, entry)| entry.run_id.as_deref() == Some(run_id))
362            .map(|(inference_id, entry)| (inference_id.clone(), entry.request_id.clone()))
363            .collect();
364        active.sort();
365        active
366    }
367
368    pub fn attach_backend(
369        &self,
370        inference_id: &str,
371        task: tokio::task::JoinHandle<()>,
372    ) -> Result<(), RegistryError> {
373        let mut state = self.state.lock().expect("inference registry poisoned");
374        let Some(entry) = state.active.get_mut(inference_id) else {
375            task.abort();
376            return Err(RegistryError::InferenceNotActive);
377        };
378        if entry.backend_terminated {
379            drop(task);
380        } else {
381            entry.backend_task = Some(task);
382        }
383        Ok(())
384    }
385
386    /// Called only after the actual backend future has returned.
387    pub fn backend_terminated(&self, inference_id: &str) {
388        let mut state = self.state.lock().expect("inference registry poisoned");
389        if let Some(entry) = state.active.get_mut(inference_id) {
390            entry.backend_terminated = true;
391            entry.backend_task.take();
392            return;
393        }
394        if let Some(tombstone) = state.tombstones.get_mut(inference_id) {
395            if tombstone.backend_task.take().is_some() {
396                state.orphan_count = state.orphan_count.saturating_sub(1);
397            }
398        }
399        self.prune_locked(&mut state, Instant::now());
400    }
401
402    pub fn is_active(&self, inference_id: &str) -> bool {
403        let mut state = self.state.lock().expect("inference registry poisoned");
404        self.prune_locked(&mut state, Instant::now());
405        state.active.contains_key(inference_id)
406    }
407
408    pub fn complete(&self, inference_id: &str) -> bool {
409        self.finish(inference_id, None, TerminalOutcome::Completed)
410    }
411
412    pub fn terminal_outcome(&self, inference_id: &str) -> Option<TerminalOutcome> {
413        let mut state = self.state.lock().expect("inference registry poisoned");
414        self.prune_locked(&mut state, Instant::now());
415        state.tombstones.get(inference_id).map(|t| t.outcome)
416    }
417
418    pub fn reserve_deadline(
419        self: &Arc<Self>,
420        inference_id: &str,
421    ) -> Result<DeadlineReservation, DeadlineReservationError> {
422        let (token, terminal) = {
423            let mut state = self.state.lock().expect("inference registry poisoned");
424            self.prune_locked(&mut state, Instant::now());
425            if state.tombstones.contains_key(inference_id) {
426                return Err(DeadlineReservationError::Terminal(
427                    InferenceControlStatus::AlreadyTerminal,
428                ));
429            }
430            let Some(entry) = state.active.get(inference_id) else {
431                return Err(DeadlineReservationError::Terminal(
432                    InferenceControlStatus::Unknown,
433                ));
434            };
435            if entry.deadline_token.is_some() {
436                return Err(DeadlineReservationError::Registry(
437                    RegistryError::DeadlineAlreadyScheduled,
438                ));
439            }
440            state.next_token = state.next_token.wrapping_add(1);
441            let token = state.next_token;
442            let entry = state
443                .active
444                .get_mut(inference_id)
445                .expect("entry inspected above");
446            entry.deadline_token = Some(token);
447            (token, entry.terminal.subscribe())
448        };
449        Ok(DeadlineReservation {
450            registry: self.clone(),
451            inference_id: inference_id.to_string(),
452            token,
453            terminal,
454            armed: true,
455        })
456    }
457
458    pub async fn schedule_deadline<F, Fut>(
459        self: &Arc<Self>,
460        inference_id: &str,
461        timeout: Duration,
462        acknowledgement: F,
463    ) -> Result<InferenceControlStatus, DeadlineReservationError>
464    where
465        F: FnOnce() -> Fut,
466        Fut: Future<Output = bool>,
467    {
468        self.reserve_deadline(inference_id)?
469            .wait_and_control(timeout, acknowledgement)
470            .await
471            .map_err(DeadlineReservationError::Registry)
472    }
473
474    pub async fn control<F>(
475        &self,
476        inference_id: &str,
477        cause: ControlCause,
478        acknowledgement: F,
479    ) -> InferenceControlStatus
480    where
481        F: Future<Output = bool>,
482    {
483        let start = self.begin_control(inference_id);
484        self.drive_control_start(inference_id, cause, start, || acknowledgement)
485            .await
486    }
487
488    pub fn counts(&self) -> (usize, usize) {
489        let mut state = self.state.lock().expect("inference registry poisoned");
490        self.prune_locked(&mut state, Instant::now());
491        (state.active.len(), state.tombstones.len())
492    }
493
494    pub fn orphan_count(&self) -> usize {
495        self.state
496            .lock()
497            .expect("inference registry poisoned")
498            .orphan_count
499    }
500
501    /// Disconnect teardown: every retained active/orphan handle is aborted.
502    pub fn abort_all(&self) {
503        let mut state = self.state.lock().expect("inference registry poisoned");
504        for (_, mut entry) in state.active.drain() {
505            if let Some(task) = entry.backend_task.take() {
506                task.abort();
507            }
508        }
509        for (_, mut tombstone) in state.tombstones.drain() {
510            if let Some(task) = tombstone.backend_task.take() {
511                task.abort();
512            }
513        }
514        state.tombstone_order.clear();
515        state.orphan_count = 0;
516    }
517
518    /// Roll back a start whose `infer.started` frame could not be delivered.
519    pub fn abandon(&self, inference_id: &str) {
520        let mut state = self.state.lock().expect("inference registry poisoned");
521        if let Some(mut entry) = state.active.remove(inference_id) {
522            if let Some(task) = entry.backend_task.take() {
523                task.abort();
524            }
525        }
526    }
527
528    /// Abort a backend after `infer.started` may already have entered the
529    /// socket sink, retaining a terminal tombstone so a later flush can never
530    /// expose an ID that control RPCs report as unknown.
531    pub fn abort_after_started_admission(&self, inference_id: &str) {
532        self.abort_owned_backend(inference_id);
533    }
534
535    /// Abort this handler's owned backend while preserving an existing
536    /// terminal outcome. If no control has terminalized the entry yet, publish
537    /// a completed tombstone first so any frame already admitted to the socket
538    /// can never expose an unknown ID. If control already moved the JoinHandle
539    /// into a tombstone, detach and abort it there and release its orphan charge.
540    pub fn abort_owned_backend(&self, inference_id: &str) {
541        let mut state = self.state.lock().expect("inference registry poisoned");
542        self.prune_locked(&mut state, Instant::now());
543        if let Some(mut entry) = state.active.remove(inference_id) {
544            let outcome = TerminalOutcome::Completed;
545            let _ = entry.terminal.send(Some(outcome));
546            if let Some(task) = entry.backend_task.take() {
547                task.abort();
548            }
549            self.insert_tombstone_locked(&mut state, inference_id.to_string(), outcome, None);
550            return;
551        }
552        let Some(tombstone) = state.tombstones.get_mut(inference_id) else {
553            return;
554        };
555        if let Some(task) = tombstone.backend_task.take() {
556            task.abort();
557            state.orphan_count = state.orphan_count.saturating_sub(1);
558        }
559    }
560
561    fn begin_control(&self, inference_id: &str) -> ControlStart {
562        let mut state = self.state.lock().expect("inference registry poisoned");
563        self.prune_locked(&mut state, Instant::now());
564        if state.tombstones.contains_key(inference_id) {
565            return ControlStart::Return(InferenceControlStatus::AlreadyTerminal);
566        }
567        let Some(phase) = state
568            .active
569            .get(inference_id)
570            .map(|entry| entry.phase.clone())
571        else {
572            return ControlStart::Return(InferenceControlStatus::Unknown);
573        };
574        match phase {
575            ActivePhase::Running => {
576                state.next_token = state.next_token.wrapping_add(1);
577                let token = state.next_token;
578                state
579                    .active
580                    .get_mut(inference_id)
581                    .expect("entry inspected above")
582                    .phase = ActivePhase::Terminating(token);
583                ControlStart::New(token)
584            }
585            ActivePhase::Terminating(_) => ControlStart::Wait(
586                state
587                    .active
588                    .get(inference_id)
589                    .expect("entry inspected above")
590                    .terminal
591                    .subscribe(),
592            ),
593        }
594    }
595
596    fn claim_reserved_deadline(&self, inference_id: &str, deadline_token: u64) -> ControlStart {
597        let mut state = self.state.lock().expect("inference registry poisoned");
598        self.prune_locked(&mut state, Instant::now());
599        if state.tombstones.contains_key(inference_id) {
600            return ControlStart::Return(InferenceControlStatus::AlreadyTerminal);
601        }
602        let Some(entry) = state.active.get_mut(inference_id) else {
603            return ControlStart::Return(InferenceControlStatus::Unknown);
604        };
605        if entry.deadline_token != Some(deadline_token) {
606            return ControlStart::Return(InferenceControlStatus::AlreadyTerminal);
607        }
608        entry.deadline_token = None;
609        match entry.phase.clone() {
610            ActivePhase::Running => {
611                state.next_token = state.next_token.wrapping_add(1);
612                let token = state.next_token;
613                state
614                    .active
615                    .get_mut(inference_id)
616                    .expect("entry inspected above")
617                    .phase = ActivePhase::Terminating(token);
618                ControlStart::New(token)
619            }
620            ActivePhase::Terminating(_) => ControlStart::Wait(entry.terminal.subscribe()),
621        }
622    }
623
624    async fn drive_control_start<F, Fut>(
625        &self,
626        inference_id: &str,
627        cause: ControlCause,
628        start: ControlStart,
629        acknowledgement: F,
630    ) -> InferenceControlStatus
631    where
632        F: FnOnce() -> Fut,
633        Fut: Future<Output = bool>,
634    {
635        match start {
636            ControlStart::Return(status) => status,
637            ControlStart::Wait(mut receiver) => {
638                if receiver.borrow().is_none() {
639                    let _ = receiver.changed().await;
640                }
641                InferenceControlStatus::AlreadyTerminal
642            }
643            ControlStart::New(token) => {
644                let claim = ControlClaimGuard::new(self, inference_id, token, cause);
645                let confirmed =
646                    tokio::time::timeout(self.config.termination_ack_timeout, acknowledgement())
647                        .await
648                        .unwrap_or(false);
649                let status = match (cause, confirmed) {
650                    (ControlCause::Cancel, true) => InferenceControlStatus::CancelledConfirmed,
651                    (ControlCause::Cancel, false) => InferenceControlStatus::TerminationUnconfirmed,
652                    (ControlCause::Deadline, true) => {
653                        InferenceControlStatus::DeadlineExceededConfirmed
654                    }
655                    (ControlCause::Deadline, false) => {
656                        InferenceControlStatus::DeadlineExceededUnconfirmed
657                    }
658                };
659                claim.finish(status)
660            }
661        }
662    }
663
664    fn release_deadline(&self, inference_id: &str, token: u64) {
665        let mut state = self.state.lock().expect("inference registry poisoned");
666        if let Some(entry) = state.active.get_mut(inference_id) {
667            if entry.deadline_token == Some(token) {
668                entry.deadline_token = None;
669            }
670        }
671    }
672
673    fn finish(
674        &self,
675        inference_id: &str,
676        expected_token: Option<u64>,
677        outcome: TerminalOutcome,
678    ) -> bool {
679        let mut state = self.state.lock().expect("inference registry poisoned");
680        self.prune_locked(&mut state, Instant::now());
681        let Some(entry) = state.active.get(inference_id) else {
682            return false;
683        };
684        if let Some(expected) = expected_token {
685            if !matches!(entry.phase, ActivePhase::Terminating(actual) if actual == expected) {
686                return false;
687            }
688        } else if !matches!(entry.phase, ActivePhase::Running) {
689            return false;
690        }
691        let mut entry = state
692            .active
693            .remove(inference_id)
694            .expect("entry inspected above");
695        let _ = entry.terminal.send(Some(outcome));
696        let backend_task = if entry.backend_terminated {
697            None
698        } else {
699            entry.backend_task.take()
700        };
701        if backend_task.is_some() {
702            state.orphan_count += 1;
703        }
704        self.insert_tombstone_locked(&mut state, inference_id.to_string(), outcome, backend_task);
705        true
706    }
707
708    fn insert_tombstone_locked(
709        &self,
710        state: &mut RegistryState,
711        inference_id: String,
712        outcome: TerminalOutcome,
713        backend_task: Option<tokio::task::JoinHandle<()>>,
714    ) {
715        state.tombstones.insert(
716            inference_id.clone(),
717            Tombstone {
718                at: Instant::now(),
719                outcome,
720                backend_task,
721            },
722        );
723        state.tombstone_order.push_back(inference_id);
724        self.enforce_tombstone_bound_locked(state);
725    }
726
727    fn enforce_tombstone_bound_locked(&self, state: &mut RegistryState) {
728        while state.tombstones.len() > self.config.max_tombstones {
729            let Some(index) = state.tombstone_order.iter().position(|id| {
730                state
731                    .tombstones
732                    .get(id)
733                    .is_none_or(|entry| entry.backend_task.is_none())
734            }) else {
735                break;
736            };
737            let id = state
738                .tombstone_order
739                .remove(index)
740                .expect("index inspected above");
741            state.tombstones.remove(&id);
742        }
743    }
744
745    fn prune_locked(&self, state: &mut RegistryState, now: Instant) {
746        let mut retained = VecDeque::with_capacity(state.tombstone_order.len());
747        while let Some(id) = state.tombstone_order.pop_front() {
748            let remove = state.tombstones.get(&id).is_none_or(|entry| {
749                entry.backend_task.is_none()
750                    && now.duration_since(entry.at) >= self.config.tombstone_ttl
751            });
752            if remove {
753                state.tombstones.remove(&id);
754            } else {
755                retained.push_back(id);
756            }
757        }
758        state.tombstone_order = retained;
759        self.enforce_tombstone_bound_locked(state);
760    }
761}