1use 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 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
108struct 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 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 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 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 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 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 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 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 pub fn abort_after_started_admission(&self, inference_id: &str) {
532 self.abort_owned_backend(inference_id);
533 }
534
535 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}