Skip to main content

aurum_core/runtime/
governor.rs

1//! Process/engine resource governor and overload policy (JOE-1596 / JOE-1831).
2//!
3//! Permit waiters use a [`Condvar`] so release wakes waiters promptly instead of
4//! busy-spinning on a short sleep. Cancel/deadline are re-checked on each wake
5//! (and at a short max wait slice so cancel without a release still progresses).
6
7use crate::error::{ProviderError, Result};
8use crate::runtime::op_context::OpContext;
9use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
10use std::sync::{Arc, Condvar, Mutex};
11use std::time::{Duration, Instant};
12
13/// Max time a waiter sleeps before re-checking cancel/deadline when no release
14/// has notified (cancel flags do not currently signal the condvar).
15const WAIT_SLICE: Duration = Duration::from_millis(50);
16
17/// Kind of concurrent work limited by the governor.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum PermitKind {
20    ModelLoad,
21    LocalStt,
22    LocalTts,
23    Remote,
24    Blocking,
25}
26
27/// Configuration for a [`ResourceGovernor`].
28#[derive(Debug, Clone)]
29pub struct GovernorConfig {
30    pub max_model_loads: usize,
31    pub max_local_stt: usize,
32    pub max_local_tts: usize,
33    pub max_remote: usize,
34    pub max_blocking: usize,
35    /// Total Whisper inference threads across concurrent STT jobs.
36    pub max_cpu_threads: usize,
37    /// Soft memory reservation budget (bytes).
38    pub max_memory_bytes: u64,
39    /// Max time to wait for a permit.
40    pub queue_timeout: Duration,
41    /// When true, fail immediately if a permit is not free (no wait).
42    pub fail_fast: bool,
43}
44
45impl Default for GovernorConfig {
46    fn default() -> Self {
47        let cpus = std::thread::available_parallelism()
48            .map(|n| n.get())
49            .unwrap_or(4)
50            .clamp(1, 16);
51        Self {
52            max_model_loads: 2,
53            max_local_stt: 2,
54            max_local_tts: 2,
55            max_remote: 4,
56            max_blocking: 4,
57            max_cpu_threads: cpus,
58            max_memory_bytes: 2 * 1024 * 1024 * 1024, // 2 GiB soft
59            queue_timeout: Duration::from_secs(30),
60            fail_fast: false,
61        }
62    }
63}
64
65impl GovernorConfig {
66    /// Conservative mobile/low-memory profile.
67    pub fn mobile() -> Self {
68        Self {
69            max_model_loads: 1,
70            max_local_stt: 1,
71            max_local_tts: 1,
72            max_remote: 2,
73            max_blocking: 2,
74            max_cpu_threads: 2,
75            max_memory_bytes: 512 * 1024 * 1024,
76            queue_timeout: Duration::from_secs(15),
77            fail_fast: false,
78        }
79    }
80
81    /// Higher-concurrency server profile.
82    pub fn server() -> Self {
83        let cpus = std::thread::available_parallelism()
84            .map(|n| n.get())
85            .unwrap_or(8);
86        Self {
87            max_model_loads: 4,
88            max_local_stt: cpus.max(4),
89            max_local_tts: 4,
90            max_remote: 16,
91            max_blocking: cpus.max(8),
92            max_cpu_threads: cpus,
93            max_memory_bytes: 8 * 1024 * 1024 * 1024,
94            queue_timeout: Duration::from_secs(60),
95            fail_fast: false,
96        }
97    }
98
99    /// Validate configuration (reject zero / inverted budgets).
100    pub fn validate(&self) -> Result<()> {
101        if self.max_model_loads == 0
102            || self.max_local_stt == 0
103            || self.max_local_tts == 0
104            || self.max_remote == 0
105            || self.max_blocking == 0
106            || self.max_cpu_threads == 0
107        {
108            return Err(crate::error::UserError::InvalidConfig {
109                reason: "governor permit and CPU budgets must be >= 1".into(),
110            }
111            .into());
112        }
113        Ok(())
114    }
115}
116
117struct CounterPool {
118    max: usize,
119    in_use: AtomicUsize,
120}
121
122impl CounterPool {
123    fn new(max: usize) -> Self {
124        Self {
125            max: max.max(1),
126            in_use: AtomicUsize::new(0),
127        }
128    }
129
130    fn try_acquire(&self) -> bool {
131        loop {
132            let cur = self.in_use.load(Ordering::SeqCst);
133            if cur >= self.max {
134                return false;
135            }
136            if self
137                .in_use
138                .compare_exchange(cur, cur + 1, Ordering::SeqCst, Ordering::SeqCst)
139                .is_ok()
140            {
141                return true;
142            }
143        }
144    }
145
146    fn release(&self) {
147        let prev = self.in_use.fetch_sub(1, Ordering::SeqCst);
148        debug_assert!(prev > 0, "permit released more times than acquired");
149    }
150
151    fn in_use(&self) -> usize {
152        self.in_use.load(Ordering::SeqCst)
153    }
154
155    fn max(&self) -> usize {
156        self.max
157    }
158}
159
160/// Process/engine resource governor.
161pub struct ResourceGovernor {
162    config: GovernorConfig,
163    model_loads: CounterPool,
164    local_stt: CounterPool,
165    local_tts: CounterPool,
166    remote: CounterPool,
167    blocking: CounterPool,
168    /// Threads currently allocated to STT jobs.
169    cpu_threads_in_use: AtomicUsize,
170    memory_reserved: AtomicU64,
171    /// Serialize multi-permit acquisition to avoid deadlock across kinds.
172    acquire_lock: Mutex<()>,
173    /// Mutex paired with [`Self::waiters`] for permit queue parking (JOE-1831).
174    wait_mutex: Mutex<()>,
175    /// Signalled on every permit/memory/CPU release so waiters stop spinning.
176    waiters: Condvar,
177}
178
179impl Default for ResourceGovernor {
180    fn default() -> Self {
181        Self::new(GovernorConfig::default())
182    }
183}
184
185impl ResourceGovernor {
186    pub fn new(config: GovernorConfig) -> Self {
187        Self {
188            model_loads: CounterPool::new(config.max_model_loads),
189            local_stt: CounterPool::new(config.max_local_stt),
190            local_tts: CounterPool::new(config.max_local_tts),
191            remote: CounterPool::new(config.max_remote),
192            blocking: CounterPool::new(config.max_blocking),
193            cpu_threads_in_use: AtomicUsize::new(0),
194            memory_reserved: AtomicU64::new(0),
195            acquire_lock: Mutex::new(()),
196            wait_mutex: Mutex::new(()),
197            waiters: Condvar::new(),
198            config,
199        }
200    }
201
202    /// Wake all permit waiters (called after any resource release).
203    fn notify_waiters(&self) {
204        self.waiters.notify_all();
205    }
206
207    /// Park until a release, cancel, or the wait budget expires (JOE-1831).
208    fn park_wait(&self, ctx: Option<&OpContext>, deadline: Instant) -> Result<()> {
209        if self.config.fail_fast {
210            return Ok(());
211        }
212        let now = Instant::now();
213        if now >= deadline {
214            return Ok(());
215        }
216        let rem = deadline.saturating_duration_since(now).min(WAIT_SLICE);
217        // Prefer OpContext remaining so an absolute deadline is honoured promptly.
218        let slice = ctx
219            .and_then(|c| c.remaining())
220            .map(|r| r.min(rem))
221            .unwrap_or(rem);
222        if slice.is_zero() {
223            return Ok(());
224        }
225        let guard = self.wait_mutex.lock().unwrap_or_else(|e| e.into_inner());
226        let (_g, _timeout) = self
227            .waiters
228            .wait_timeout(guard, slice)
229            .unwrap_or_else(|e| e.into_inner());
230        if let Some(c) = ctx {
231            c.check()?;
232        }
233        Ok(())
234    }
235
236    pub fn config(&self) -> &GovernorConfig {
237        &self.config
238    }
239
240    /// Process-wide default governor (desktop profile).
241    ///
242    /// Isolated engines can construct their own [`ResourceGovernor`] instead.
243    pub fn process_global() -> Arc<Self> {
244        use once_cell::sync::Lazy;
245        static G: Lazy<Arc<ResourceGovernor>> = Lazy::new(|| Arc::new(ResourceGovernor::default()));
246        Arc::clone(&G)
247    }
248
249    fn pool(&self, kind: PermitKind) -> &CounterPool {
250        match kind {
251            PermitKind::ModelLoad => &self.model_loads,
252            PermitKind::LocalStt => &self.local_stt,
253            PermitKind::LocalTts => &self.local_tts,
254            PermitKind::Remote => &self.remote,
255            PermitKind::Blocking => &self.blocking,
256        }
257    }
258
259    /// Acquire a single permit, optionally waiting with cancel/deadline.
260    pub fn acquire(
261        self: &Arc<Self>,
262        kind: PermitKind,
263        ctx: Option<&OpContext>,
264    ) -> Result<ResourcePermit> {
265        let timeout = self.wait_budget(ctx);
266        let deadline = Instant::now() + timeout;
267        loop {
268            if let Some(c) = ctx {
269                c.check()?;
270            }
271            if self.pool(kind).try_acquire() {
272                return Ok(ResourcePermit {
273                    governor: Arc::clone(self),
274                    kind,
275                    cpu_threads: 0,
276                    memory: 0,
277                    holds_blocking: false,
278                    released: false,
279                });
280            }
281            if self.config.fail_fast || Instant::now() >= deadline {
282                return Err(ProviderError::Overload {
283                    reason: format!(
284                        "{kind:?} permits exhausted ({}/{})",
285                        self.pool(kind).in_use(),
286                        self.pool(kind).max()
287                    ),
288                }
289                .into());
290            }
291            self.park_wait(ctx, deadline)?;
292        }
293    }
294
295    fn wait_budget(&self, ctx: Option<&OpContext>) -> Duration {
296        if self.config.fail_fast {
297            return Duration::ZERO;
298        }
299        ctx.and_then(|c| c.remaining())
300            .unwrap_or(self.config.queue_timeout)
301            .min(self.config.queue_timeout)
302    }
303
304    /// Acquire STT permit + blocking permit + CPU thread budget (+ optional memory).
305    ///
306    /// Acquisition order is fixed (memory → STT → blocking → CPU) under a brief
307    /// mutex so concurrent multi-resource acquires cannot deadlock. The mutex is
308    /// not held across sleep/wait.
309    pub fn acquire_stt(
310        self: &Arc<Self>,
311        cpu_threads: usize,
312        memory: u64,
313        ctx: Option<&OpContext>,
314    ) -> Result<ResourcePermit> {
315        let timeout = self.wait_budget(ctx);
316        let deadline = Instant::now() + timeout;
317        let want = cpu_threads.max(1);
318
319        loop {
320            if let Some(c) = ctx {
321                c.check()?;
322            }
323
324            {
325                let _order = self.acquire_lock.lock().unwrap_or_else(|e| e.into_inner());
326
327                // Try memory first.
328                if memory == 0 || self.try_reserve_memory(memory).is_ok() {
329                    if self.local_stt.try_acquire() {
330                        if self.blocking.try_acquire() {
331                            if self.try_reserve_cpu(want) {
332                                return Ok(ResourcePermit {
333                                    governor: Arc::clone(self),
334                                    kind: PermitKind::LocalStt,
335                                    cpu_threads: want,
336                                    memory,
337                                    holds_blocking: true,
338                                    released: false,
339                                });
340                            }
341                            self.blocking.release();
342                        }
343                        self.local_stt.release();
344                    }
345                    self.release_memory(memory);
346                }
347            }
348
349            if self.config.fail_fast || Instant::now() >= deadline {
350                return Err(ProviderError::Overload {
351                    reason: format!(
352                        "STT resources unavailable (cpu want {want}, budget {})",
353                        self.config.max_cpu_threads
354                    ),
355                }
356                .into());
357            }
358            self.park_wait(ctx, deadline)?;
359        }
360    }
361
362    /// Acquire local TTS + blocking permits (+ optional memory).
363    pub fn acquire_tts(
364        self: &Arc<Self>,
365        memory: u64,
366        ctx: Option<&OpContext>,
367    ) -> Result<ResourcePermit> {
368        let timeout = self.wait_budget(ctx);
369        let deadline = Instant::now() + timeout;
370
371        loop {
372            if let Some(c) = ctx {
373                c.check()?;
374            }
375
376            {
377                let _order = self.acquire_lock.lock().unwrap_or_else(|e| e.into_inner());
378
379                if memory == 0 || self.try_reserve_memory(memory).is_ok() {
380                    if self.local_tts.try_acquire() {
381                        if self.blocking.try_acquire() {
382                            return Ok(ResourcePermit {
383                                governor: Arc::clone(self),
384                                kind: PermitKind::LocalTts,
385                                cpu_threads: 0,
386                                memory,
387                                holds_blocking: true,
388                                released: false,
389                            });
390                        }
391                        self.local_tts.release();
392                    }
393                    self.release_memory(memory);
394                }
395            }
396
397            if self.config.fail_fast || Instant::now() >= deadline {
398                return Err(ProviderError::Overload {
399                    reason: format!(
400                        "LocalTts permits exhausted ({}/{})",
401                        self.local_tts.in_use(),
402                        self.local_tts.max()
403                    ),
404                }
405                .into());
406            }
407            self.park_wait(ctx, deadline)?;
408        }
409    }
410
411    fn try_reserve_memory(&self, bytes: u64) -> Result<()> {
412        if bytes == 0 {
413            return Ok(());
414        }
415        loop {
416            let cur = self.memory_reserved.load(Ordering::SeqCst);
417            let new = cur.saturating_add(bytes);
418            if new > self.config.max_memory_bytes {
419                return Err(ProviderError::Overload {
420                    reason: format!(
421                        "memory reservation {bytes} would exceed budget {} (in use {cur})",
422                        self.config.max_memory_bytes
423                    ),
424                }
425                .into());
426            }
427            if self
428                .memory_reserved
429                .compare_exchange(cur, new, Ordering::SeqCst, Ordering::SeqCst)
430                .is_ok()
431            {
432                return Ok(());
433            }
434        }
435    }
436
437    fn release_memory(&self, bytes: u64) {
438        if bytes > 0 {
439            self.memory_reserved.fetch_sub(bytes, Ordering::SeqCst);
440            self.notify_waiters();
441        }
442    }
443
444    fn try_reserve_cpu(&self, n: usize) -> bool {
445        loop {
446            let cur = self.cpu_threads_in_use.load(Ordering::SeqCst);
447            if cur + n > self.config.max_cpu_threads {
448                return false;
449            }
450            if self
451                .cpu_threads_in_use
452                .compare_exchange(cur, cur + n, Ordering::SeqCst, Ordering::SeqCst)
453                .is_ok()
454            {
455                return true;
456            }
457        }
458    }
459
460    fn release_cpu(&self, n: usize) {
461        if n > 0 {
462            self.cpu_threads_in_use.fetch_sub(n, Ordering::SeqCst);
463            self.notify_waiters();
464        }
465    }
466
467    /// How many Whisper threads a new STT job should use given remaining budget.
468    pub fn recommend_stt_threads(&self) -> usize {
469        let used = self.cpu_threads_in_use.load(Ordering::SeqCst);
470        let rem = self.config.max_cpu_threads.saturating_sub(used).max(1);
471        // Fair share: leave room for concurrent jobs when budget allows.
472        let fair = (self.config.max_cpu_threads / self.config.max_local_stt.max(1)).max(1);
473        fair.min(rem).clamp(1, 8)
474    }
475
476    pub fn stats(&self) -> GovernorStats {
477        GovernorStats {
478            model_loads: self.model_loads.in_use(),
479            local_stt: self.local_stt.in_use(),
480            local_tts: self.local_tts.in_use(),
481            remote: self.remote.in_use(),
482            blocking: self.blocking.in_use(),
483            cpu_threads: self.cpu_threads_in_use.load(Ordering::SeqCst),
484            memory_reserved: self.memory_reserved.load(Ordering::SeqCst),
485            max_cpu_threads: self.config.max_cpu_threads,
486            max_memory_bytes: self.config.max_memory_bytes,
487        }
488    }
489}
490
491/// Snapshot of governor occupancy.
492#[derive(Debug, Clone)]
493pub struct GovernorStats {
494    pub model_loads: usize,
495    pub local_stt: usize,
496    pub local_tts: usize,
497    pub remote: usize,
498    pub blocking: usize,
499    pub cpu_threads: usize,
500    pub memory_reserved: u64,
501    pub max_cpu_threads: usize,
502    pub max_memory_bytes: u64,
503}
504
505/// RAII permit — releases all held resources on drop.
506///
507/// Holds an [`Arc`] so permits can cross `.await` points.
508pub struct ResourcePermit {
509    governor: Arc<ResourceGovernor>,
510    kind: PermitKind,
511    cpu_threads: usize,
512    memory: u64,
513    holds_blocking: bool,
514    released: bool,
515}
516
517impl std::fmt::Debug for ResourcePermit {
518    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519        f.debug_struct("ResourcePermit")
520            .field("kind", &self.kind)
521            .field("cpu_threads", &self.cpu_threads)
522            .field("memory", &self.memory)
523            .field("holds_blocking", &self.holds_blocking)
524            .finish()
525    }
526}
527
528impl ResourcePermit {
529    pub fn kind(&self) -> PermitKind {
530        self.kind
531    }
532
533    pub fn cpu_threads(&self) -> usize {
534        self.cpu_threads
535    }
536
537    fn release_inner(&mut self) {
538        if self.released {
539            return;
540        }
541        self.released = true;
542        if self.cpu_threads > 0 {
543            self.governor.release_cpu(self.cpu_threads);
544            self.cpu_threads = 0;
545        }
546        if self.holds_blocking {
547            self.governor.blocking.release();
548            self.holds_blocking = false;
549        }
550        if self.memory > 0 {
551            self.governor.release_memory(self.memory);
552            self.memory = 0;
553        }
554        self.governor.pool(self.kind).release();
555        // Always wake waiters once per full permit release (CPU/memory already
556        // notified when non-zero; a second notify_all is cheap and covers the
557        // pure-permit case where only the kind counter changed).
558        self.governor.notify_waiters();
559    }
560}
561
562impl Drop for ResourcePermit {
563    fn drop(&mut self) {
564        self.release_inner();
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn permits_cap() {
574        let g = Arc::new(ResourceGovernor::new(GovernorConfig {
575            max_local_stt: 1,
576            fail_fast: true,
577            ..GovernorConfig::default()
578        }));
579        let a = g.acquire(PermitKind::LocalStt, None).unwrap();
580        let err = g.acquire(PermitKind::LocalStt, None).unwrap_err();
581        assert!(matches!(
582            err,
583            crate::error::TranscriptionError::Provider(ProviderError::Overload { .. })
584        ));
585        drop(a);
586        let _b = g.acquire(PermitKind::LocalStt, None).unwrap();
587    }
588
589    #[test]
590    fn memory_budget() {
591        let g = Arc::new(ResourceGovernor::new(GovernorConfig {
592            max_memory_bytes: 1000,
593            fail_fast: true,
594            ..GovernorConfig::default()
595        }));
596        assert!(g.try_reserve_memory(600).is_ok());
597        assert!(g.try_reserve_memory(600).is_err());
598        g.release_memory(600);
599        assert!(g.try_reserve_memory(600).is_ok());
600    }
601
602    #[test]
603    fn cpu_budget() {
604        let g = Arc::new(ResourceGovernor::new(GovernorConfig {
605            max_cpu_threads: 4,
606            max_local_stt: 4,
607            max_blocking: 4,
608            fail_fast: true,
609            ..GovernorConfig::default()
610        }));
611        let p = g.acquire_stt(3, 0, None).unwrap();
612        assert_eq!(p.cpu_threads(), 3);
613        let err = g.acquire_stt(3, 0, None).unwrap_err();
614        assert!(err.to_string().contains("CPU") || err.to_string().contains("overload"));
615        drop(p);
616        let _p2 = g.acquire_stt(2, 0, None).unwrap();
617    }
618
619    #[test]
620    fn tts_releases_blocking() {
621        let g = Arc::new(ResourceGovernor::new(GovernorConfig {
622            max_local_tts: 1,
623            max_blocking: 1,
624            fail_fast: true,
625            ..GovernorConfig::default()
626        }));
627        let p = g.acquire_tts(0, None).unwrap();
628        let err = g.acquire_tts(0, None).unwrap_err();
629        assert!(matches!(
630            err,
631            crate::error::TranscriptionError::Provider(ProviderError::Overload { .. })
632        ));
633        drop(p);
634        let _p2 = g.acquire_tts(0, None).unwrap();
635    }
636
637    #[test]
638    fn cancel_aborts_queue_wait() {
639        let g = Arc::new(ResourceGovernor::new(GovernorConfig {
640            max_local_stt: 1,
641            fail_fast: false,
642            queue_timeout: Duration::from_secs(5),
643            ..GovernorConfig::default()
644        }));
645        let _hold = g.acquire(PermitKind::LocalStt, None).unwrap();
646        let ctx = OpContext::new();
647        ctx.cancel.cancel();
648        let err = g.acquire(PermitKind::LocalStt, Some(&ctx)).unwrap_err();
649        assert!(matches!(
650            err,
651            crate::error::TranscriptionError::Provider(ProviderError::Cancelled)
652        ));
653    }
654
655    #[test]
656    fn config_validate_rejects_zero() {
657        let c = GovernorConfig {
658            max_cpu_threads: 0,
659            ..GovernorConfig::default()
660        };
661        assert!(c.validate().is_err());
662    }
663}