Skip to main content

oximedia_workflow/
resource_pool.rs

1#![allow(dead_code)]
2//! Resource pool management for workflow execution.
3//!
4//! Provides a pooling system for shared resources (CPU cores, GPU devices,
5//! network bandwidth, disk I/O slots) that workflow tasks can reserve and
6//! release during execution. The pool enforces capacity limits and supports
7//! fair scheduling of resource requests.
8
9use std::collections::HashMap;
10
11/// Unique identifier for a resource type.
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct ResourceId(String);
14
15impl ResourceId {
16    /// Create a new resource identifier.
17    pub fn new(name: impl Into<String>) -> Self {
18        Self(name.into())
19    }
20
21    /// Return the string name of this resource.
22    #[must_use]
23    pub fn name(&self) -> &str {
24        &self.0
25    }
26}
27
28impl std::fmt::Display for ResourceId {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "{}", self.0)
31    }
32}
33
34/// Describes a resource with capacity and current allocation.
35#[derive(Debug, Clone)]
36pub struct ResourceDescriptor {
37    /// Unique identifier for this resource.
38    pub id: ResourceId,
39    /// Maximum capacity available.
40    pub capacity: u64,
41    /// Currently allocated amount.
42    pub allocated: u64,
43    /// Human-readable label.
44    pub label: String,
45    /// Unit of measurement (e.g., "cores", "MB", "Mbps").
46    pub unit: String,
47}
48
49impl ResourceDescriptor {
50    /// Create a new resource descriptor.
51    pub fn new(
52        id: ResourceId,
53        capacity: u64,
54        label: impl Into<String>,
55        unit: impl Into<String>,
56    ) -> Self {
57        Self {
58            id,
59            capacity,
60            allocated: 0,
61            label: label.into(),
62            unit: unit.into(),
63        }
64    }
65
66    /// Return the remaining available capacity.
67    #[must_use]
68    pub fn available(&self) -> u64 {
69        self.capacity.saturating_sub(self.allocated)
70    }
71
72    /// Return the utilisation ratio as a value between 0.0 and 1.0.
73    #[allow(clippy::cast_precision_loss)]
74    #[must_use]
75    pub fn utilisation(&self) -> f64 {
76        if self.capacity == 0 {
77            return 0.0;
78        }
79        self.allocated as f64 / self.capacity as f64
80    }
81
82    /// Check whether the requested amount can be satisfied.
83    #[must_use]
84    pub fn can_allocate(&self, amount: u64) -> bool {
85        self.available() >= amount
86    }
87}
88
89/// A token representing a successful allocation that must be released.
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
91pub struct AllocationToken {
92    /// Unique token identifier.
93    pub token_id: u64,
94    /// Resource this token belongs to.
95    pub resource_id: ResourceId,
96    /// Amount allocated.
97    pub amount: u64,
98}
99
100/// A request for resources from the pool.
101#[derive(Debug, Clone)]
102pub struct ResourceRequest {
103    /// Which resource is being requested.
104    pub resource_id: ResourceId,
105    /// How much is needed.
106    pub amount: u64,
107    /// Priority of this request (higher = more important).
108    pub priority: u32,
109    /// Optional requester tag for tracking.
110    pub requester: String,
111}
112
113impl ResourceRequest {
114    /// Create a new resource request.
115    #[must_use]
116    pub fn new(resource_id: ResourceId, amount: u64) -> Self {
117        Self {
118            resource_id,
119            amount,
120            priority: 0,
121            requester: String::new(),
122        }
123    }
124
125    /// Set the priority of this request.
126    #[must_use]
127    pub fn with_priority(mut self, priority: u32) -> Self {
128        self.priority = priority;
129        self
130    }
131
132    /// Set the requester tag.
133    pub fn with_requester(mut self, requester: impl Into<String>) -> Self {
134        self.requester = requester.into();
135        self
136    }
137}
138
139/// Errors that can occur during pool operations.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum PoolError {
142    /// The requested resource does not exist in the pool.
143    ResourceNotFound(String),
144    /// Insufficient capacity for the requested amount.
145    InsufficientCapacity {
146        /// Resource identifier.
147        resource: String,
148        /// Amount requested.
149        requested: u64,
150        /// Amount available.
151        available: u64,
152    },
153    /// The allocation token is invalid or already released.
154    InvalidToken(u64),
155}
156
157impl std::fmt::Display for PoolError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            Self::ResourceNotFound(id) => write!(f, "resource not found: {id}"),
161            Self::InsufficientCapacity {
162                resource,
163                requested,
164                available,
165            } => {
166                write!(f, "insufficient capacity for '{resource}': requested {requested}, available {available}")
167            }
168            Self::InvalidToken(id) => write!(f, "invalid allocation token: {id}"),
169        }
170    }
171}
172
173/// The main resource pool that tracks multiple resource types.
174#[derive(Debug)]
175pub struct ResourcePool {
176    /// All resources in this pool.
177    resources: HashMap<ResourceId, ResourceDescriptor>,
178    /// Outstanding allocations keyed by token id.
179    allocations: HashMap<u64, AllocationToken>,
180    /// Counter for generating unique token ids.
181    next_token_id: u64,
182}
183
184impl ResourcePool {
185    /// Create a new empty resource pool.
186    #[must_use]
187    pub fn new() -> Self {
188        Self {
189            resources: HashMap::new(),
190            allocations: HashMap::new(),
191            next_token_id: 1,
192        }
193    }
194
195    /// Register a resource with the pool.
196    pub fn register(&mut self, descriptor: ResourceDescriptor) {
197        self.resources.insert(descriptor.id.clone(), descriptor);
198    }
199
200    /// Attempt to allocate a resource, returning a token on success.
201    ///
202    /// # Errors
203    ///
204    /// Returns `PoolError` if the resource is not found or has insufficient capacity.
205    pub fn allocate(&mut self, request: &ResourceRequest) -> Result<AllocationToken, PoolError> {
206        let resource = self
207            .resources
208            .get_mut(&request.resource_id)
209            .ok_or_else(|| PoolError::ResourceNotFound(request.resource_id.name().to_string()))?;
210
211        if !resource.can_allocate(request.amount) {
212            return Err(PoolError::InsufficientCapacity {
213                resource: resource.id.name().to_string(),
214                requested: request.amount,
215                available: resource.available(),
216            });
217        }
218
219        resource.allocated += request.amount;
220
221        let token = AllocationToken {
222            token_id: self.next_token_id,
223            resource_id: request.resource_id.clone(),
224            amount: request.amount,
225        };
226        self.next_token_id += 1;
227        self.allocations.insert(token.token_id, token.clone());
228        Ok(token)
229    }
230
231    /// Release a previous allocation using its token.
232    ///
233    /// # Errors
234    ///
235    /// Returns `PoolError::InvalidToken` if the token does not match any active allocation.
236    pub fn release(&mut self, token_id: u64) -> Result<(), PoolError> {
237        let token = self
238            .allocations
239            .remove(&token_id)
240            .ok_or(PoolError::InvalidToken(token_id))?;
241
242        if let Some(resource) = self.resources.get_mut(&token.resource_id) {
243            resource.allocated = resource.allocated.saturating_sub(token.amount);
244        }
245
246        Ok(())
247    }
248
249    /// Return a snapshot of all resources and their current state.
250    #[must_use]
251    pub fn snapshot(&self) -> Vec<ResourceDescriptor> {
252        self.resources.values().cloned().collect()
253    }
254
255    /// Return the descriptor for a specific resource.
256    #[must_use]
257    pub fn get_resource(&self, id: &ResourceId) -> Option<&ResourceDescriptor> {
258        self.resources.get(id)
259    }
260
261    /// Return the number of active allocations.
262    #[must_use]
263    pub fn active_allocations(&self) -> usize {
264        self.allocations.len()
265    }
266
267    /// Return the total number of registered resources.
268    #[must_use]
269    pub fn resource_count(&self) -> usize {
270        self.resources.len()
271    }
272
273    /// Check whether a request can be satisfied without actually allocating.
274    #[must_use]
275    pub fn can_satisfy(&self, request: &ResourceRequest) -> bool {
276        self.resources
277            .get(&request.resource_id)
278            .is_some_and(|r| r.can_allocate(request.amount))
279    }
280
281    /// Reset all allocations (useful for testing or emergency recovery).
282    pub fn reset_all(&mut self) {
283        self.allocations.clear();
284        for resource in self.resources.values_mut() {
285            resource.allocated = 0;
286        }
287    }
288}
289
290impl Default for ResourcePool {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296/// Summary statistics for the entire pool.
297#[derive(Debug, Clone)]
298pub struct PoolStats {
299    /// Total capacity across all resources.
300    pub total_capacity: u64,
301    /// Total allocated across all resources.
302    pub total_allocated: u64,
303    /// Number of resources.
304    pub resource_count: usize,
305    /// Number of active allocations.
306    pub active_allocations: usize,
307    /// Average utilisation across resources.
308    pub average_utilisation: f64,
309}
310
311impl ResourcePool {
312    /// Compute aggregate statistics for the pool.
313    #[allow(clippy::cast_precision_loss)]
314    #[must_use]
315    pub fn stats(&self) -> PoolStats {
316        let total_capacity: u64 = self.resources.values().map(|r| r.capacity).sum();
317        let total_allocated: u64 = self.resources.values().map(|r| r.allocated).sum();
318        let resource_count = self.resources.len();
319        let active_allocations = self.allocations.len();
320        let average_utilisation = if resource_count == 0 {
321            0.0
322        } else {
323            let sum: f64 = self
324                .resources
325                .values()
326                .map(ResourceDescriptor::utilisation)
327                .sum();
328            sum / resource_count as f64
329        };
330
331        PoolStats {
332            total_capacity,
333            total_allocated,
334            resource_count,
335            active_allocations,
336            average_utilisation,
337        }
338    }
339}
340
341// ---------------------------------------------------------------------------
342// Dynamic resource scaling
343// ---------------------------------------------------------------------------
344
345/// Scaling policy for a resource.
346#[derive(Debug, Clone)]
347pub enum ScalingPolicy {
348    /// Fixed capacity — no scaling.
349    Fixed,
350    /// Step scaling: increase by `step_size` when utilisation exceeds
351    /// `scale_up_threshold`, decrease when below `scale_down_threshold`.
352    Step {
353        /// Utilisation threshold to trigger scale-up.
354        scale_up_threshold: f64,
355        /// Utilisation threshold to trigger scale-down.
356        scale_down_threshold: f64,
357        /// Amount to add/remove per scaling event.
358        step_size: u64,
359        /// Minimum capacity (never scale below this).
360        min_capacity: u64,
361        /// Maximum capacity (never scale above this).
362        max_capacity: u64,
363    },
364    /// Target tracking: adjust capacity to maintain a target utilisation.
365    TargetTracking {
366        /// Target utilisation (0.0..1.0).
367        target_utilisation: f64,
368        /// Minimum capacity.
369        min_capacity: u64,
370        /// Maximum capacity.
371        max_capacity: u64,
372    },
373}
374
375/// The result of a scaling evaluation.
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub enum ScalingAction {
378    /// No change needed.
379    NoChange,
380    /// Scale up by this amount.
381    ScaleUp(u64),
382    /// Scale down by this amount.
383    ScaleDown(u64),
384}
385
386impl std::fmt::Display for ScalingAction {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        match self {
389            Self::NoChange => write!(f, "no change"),
390            Self::ScaleUp(n) => write!(f, "scale up by {n}"),
391            Self::ScaleDown(n) => write!(f, "scale down by {n}"),
392        }
393    }
394}
395
396/// Record of a scaling event.
397#[derive(Debug, Clone)]
398pub struct ScalingEvent {
399    /// Resource that was scaled.
400    pub resource_id: ResourceId,
401    /// Action taken.
402    pub action: ScalingAction,
403    /// Capacity before scaling.
404    pub old_capacity: u64,
405    /// Capacity after scaling.
406    pub new_capacity: u64,
407    /// Utilisation at the time of scaling.
408    pub utilisation: f64,
409    /// Timestamp (ms since epoch).
410    pub timestamp_ms: u64,
411}
412
413/// Manager for dynamic resource scaling.
414#[derive(Debug)]
415pub struct ResourceScaler {
416    /// Scaling policies keyed by resource ID.
417    policies: HashMap<ResourceId, ScalingPolicy>,
418    /// History of scaling events.
419    history: Vec<ScalingEvent>,
420    /// Maximum history entries.
421    max_history: usize,
422    /// Cooldown between scaling events per resource (ms).
423    cooldown_ms: u64,
424    /// Last scale time per resource.
425    last_scale_time: HashMap<ResourceId, u64>,
426}
427
428impl ResourceScaler {
429    /// Create a new resource scaler.
430    #[must_use]
431    pub fn new() -> Self {
432        Self {
433            policies: HashMap::new(),
434            history: Vec::new(),
435            max_history: 1000,
436            cooldown_ms: 60_000, // 1 minute default
437            last_scale_time: HashMap::new(),
438        }
439    }
440
441    /// Set the cooldown period between scaling events.
442    #[must_use]
443    pub fn with_cooldown_ms(mut self, ms: u64) -> Self {
444        self.cooldown_ms = ms;
445        self
446    }
447
448    /// Register a scaling policy for a resource.
449    pub fn set_policy(&mut self, resource_id: ResourceId, policy: ScalingPolicy) {
450        self.policies.insert(resource_id, policy);
451    }
452
453    /// Evaluate whether a resource should be scaled.
454    #[must_use]
455    #[allow(
456        clippy::cast_precision_loss,
457        clippy::cast_possible_truncation,
458        clippy::cast_sign_loss
459    )]
460    pub fn evaluate(&self, resource: &ResourceDescriptor) -> ScalingAction {
461        let policy = match self.policies.get(&resource.id) {
462            Some(p) => p,
463            None => return ScalingAction::NoChange,
464        };
465
466        let util = resource.utilisation();
467
468        match policy {
469            ScalingPolicy::Fixed => ScalingAction::NoChange,
470            ScalingPolicy::Step {
471                scale_up_threshold,
472                scale_down_threshold,
473                step_size,
474                min_capacity,
475                max_capacity,
476            } => {
477                if util >= *scale_up_threshold && resource.capacity < *max_capacity {
478                    let new_cap = (resource.capacity + step_size).min(*max_capacity);
479                    let delta = new_cap - resource.capacity;
480                    if delta > 0 {
481                        ScalingAction::ScaleUp(delta)
482                    } else {
483                        ScalingAction::NoChange
484                    }
485                } else if util <= *scale_down_threshold && resource.capacity > *min_capacity {
486                    let new_cap = resource
487                        .capacity
488                        .saturating_sub(*step_size)
489                        .max(*min_capacity);
490                    // Don't scale down below what's allocated
491                    let new_cap = new_cap.max(resource.allocated);
492                    let delta = resource.capacity - new_cap;
493                    if delta > 0 {
494                        ScalingAction::ScaleDown(delta)
495                    } else {
496                        ScalingAction::NoChange
497                    }
498                } else {
499                    ScalingAction::NoChange
500                }
501            }
502            ScalingPolicy::TargetTracking {
503                target_utilisation,
504                min_capacity,
505                max_capacity,
506            } => {
507                if resource.capacity == 0 || *target_utilisation <= 0.0 {
508                    return ScalingAction::NoChange;
509                }
510
511                let desired = (resource.allocated as f64 / target_utilisation).ceil() as u64;
512                let desired = desired.clamp(*min_capacity, *max_capacity);
513
514                if desired > resource.capacity {
515                    ScalingAction::ScaleUp(desired - resource.capacity)
516                } else if desired < resource.capacity {
517                    let delta = resource.capacity - desired;
518                    // Don't scale below allocated
519                    if desired >= resource.allocated {
520                        ScalingAction::ScaleDown(delta)
521                    } else {
522                        ScalingAction::NoChange
523                    }
524                } else {
525                    ScalingAction::NoChange
526                }
527            }
528        }
529    }
530
531    /// Apply a scaling action to a resource pool, respecting cooldown.
532    ///
533    /// Returns `true` if the action was applied, `false` if skipped (cooldown).
534    pub fn apply(
535        &mut self,
536        pool: &mut ResourcePool,
537        resource_id: &ResourceId,
538        now_ms: u64,
539    ) -> Option<ScalingEvent> {
540        // Check cooldown
541        if let Some(&last_time) = self.last_scale_time.get(resource_id) {
542            if now_ms.saturating_sub(last_time) < self.cooldown_ms {
543                return None;
544            }
545        }
546
547        let resource = pool.get_resource(resource_id)?.clone();
548        let action = self.evaluate(&resource);
549
550        if action == ScalingAction::NoChange {
551            return None;
552        }
553
554        let old_capacity = resource.capacity;
555        let new_capacity = match &action {
556            ScalingAction::ScaleUp(delta) => old_capacity + delta,
557            ScalingAction::ScaleDown(delta) => old_capacity.saturating_sub(*delta),
558            ScalingAction::NoChange => return None,
559        };
560
561        // Apply the scaling
562        if let Some(res) = pool.resources.get_mut(resource_id) {
563            res.capacity = new_capacity;
564        }
565
566        let event = ScalingEvent {
567            resource_id: resource_id.clone(),
568            action,
569            old_capacity,
570            new_capacity,
571            utilisation: resource.utilisation(),
572            timestamp_ms: now_ms,
573        };
574
575        self.last_scale_time.insert(resource_id.clone(), now_ms);
576        self.history.push(event.clone());
577        if self.history.len() > self.max_history {
578            self.history.remove(0);
579        }
580
581        Some(event)
582    }
583
584    /// Get scaling history.
585    #[must_use]
586    pub fn history(&self) -> &[ScalingEvent] {
587        &self.history
588    }
589
590    /// Get history for a specific resource.
591    #[must_use]
592    pub fn history_for(&self, resource_id: &ResourceId) -> Vec<&ScalingEvent> {
593        self.history
594            .iter()
595            .filter(|e| &e.resource_id == resource_id)
596            .collect()
597    }
598
599    /// Number of registered policies.
600    #[must_use]
601    pub fn policy_count(&self) -> usize {
602        self.policies.len()
603    }
604}
605
606impl Default for ResourceScaler {
607    fn default() -> Self {
608        Self::new()
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    fn cpu_resource() -> ResourceDescriptor {
617        ResourceDescriptor::new(ResourceId::new("cpu"), 8, "CPU Cores", "cores")
618    }
619
620    fn gpu_resource() -> ResourceDescriptor {
621        ResourceDescriptor::new(ResourceId::new("gpu"), 2, "GPU Devices", "devices")
622    }
623
624    #[test]
625    fn test_resource_descriptor_available() {
626        let mut r = cpu_resource();
627        assert_eq!(r.available(), 8);
628        r.allocated = 3;
629        assert_eq!(r.available(), 5);
630    }
631
632    #[test]
633    fn test_resource_descriptor_utilisation() {
634        let mut r = cpu_resource();
635        assert!((r.utilisation() - 0.0).abs() < f64::EPSILON);
636        r.allocated = 4;
637        assert!((r.utilisation() - 0.5).abs() < f64::EPSILON);
638    }
639
640    #[test]
641    fn test_resource_descriptor_zero_capacity() {
642        let r = ResourceDescriptor::new(ResourceId::new("empty"), 0, "Empty", "units");
643        assert!((r.utilisation() - 0.0).abs() < f64::EPSILON);
644        assert!(!r.can_allocate(1));
645    }
646
647    #[test]
648    fn test_pool_register_and_count() {
649        let mut pool = ResourcePool::new();
650        assert_eq!(pool.resource_count(), 0);
651        pool.register(cpu_resource());
652        assert_eq!(pool.resource_count(), 1);
653        pool.register(gpu_resource());
654        assert_eq!(pool.resource_count(), 2);
655    }
656
657    #[test]
658    fn test_pool_allocate_success() {
659        let mut pool = ResourcePool::new();
660        pool.register(cpu_resource());
661        let req = ResourceRequest::new(ResourceId::new("cpu"), 4);
662        let token = pool.allocate(&req).expect("should succeed in test");
663        assert_eq!(token.amount, 4);
664        assert_eq!(pool.active_allocations(), 1);
665        let r = pool
666            .get_resource(&ResourceId::new("cpu"))
667            .expect("should succeed in test");
668        assert_eq!(r.allocated, 4);
669    }
670
671    #[test]
672    fn test_pool_allocate_insufficient() {
673        let mut pool = ResourcePool::new();
674        pool.register(cpu_resource());
675        let req = ResourceRequest::new(ResourceId::new("cpu"), 100);
676        let err = pool.allocate(&req).unwrap_err();
677        assert!(matches!(err, PoolError::InsufficientCapacity { .. }));
678    }
679
680    #[test]
681    fn test_pool_allocate_unknown_resource() {
682        let mut pool = ResourcePool::new();
683        let req = ResourceRequest::new(ResourceId::new("missing"), 1);
684        let err = pool.allocate(&req).unwrap_err();
685        assert!(matches!(err, PoolError::ResourceNotFound(_)));
686    }
687
688    #[test]
689    fn test_pool_release_success() {
690        let mut pool = ResourcePool::new();
691        pool.register(cpu_resource());
692        let req = ResourceRequest::new(ResourceId::new("cpu"), 4);
693        let token = pool.allocate(&req).expect("should succeed in test");
694        pool.release(token.token_id)
695            .expect("should succeed in test");
696        assert_eq!(pool.active_allocations(), 0);
697        let r = pool
698            .get_resource(&ResourceId::new("cpu"))
699            .expect("should succeed in test");
700        assert_eq!(r.allocated, 0);
701    }
702
703    #[test]
704    fn test_pool_release_invalid_token() {
705        let mut pool = ResourcePool::new();
706        let err = pool.release(9999).unwrap_err();
707        assert!(matches!(err, PoolError::InvalidToken(9999)));
708    }
709
710    #[test]
711    fn test_pool_can_satisfy() {
712        let mut pool = ResourcePool::new();
713        pool.register(cpu_resource());
714        let req_ok = ResourceRequest::new(ResourceId::new("cpu"), 4);
715        assert!(pool.can_satisfy(&req_ok));
716        let req_too_much = ResourceRequest::new(ResourceId::new("cpu"), 100);
717        assert!(!pool.can_satisfy(&req_too_much));
718    }
719
720    #[test]
721    fn test_pool_reset_all() {
722        let mut pool = ResourcePool::new();
723        pool.register(cpu_resource());
724        pool.register(gpu_resource());
725        let req1 = ResourceRequest::new(ResourceId::new("cpu"), 4);
726        let req2 = ResourceRequest::new(ResourceId::new("gpu"), 1);
727        let _t1 = pool.allocate(&req1).expect("should succeed in test");
728        let _t2 = pool.allocate(&req2).expect("should succeed in test");
729        assert_eq!(pool.active_allocations(), 2);
730        pool.reset_all();
731        assert_eq!(pool.active_allocations(), 0);
732        for r in pool.snapshot() {
733            assert_eq!(r.allocated, 0);
734        }
735    }
736
737    #[test]
738    fn test_pool_stats() {
739        let mut pool = ResourcePool::new();
740        pool.register(cpu_resource());
741        pool.register(gpu_resource());
742        let req = ResourceRequest::new(ResourceId::new("cpu"), 4);
743        let _t = pool.allocate(&req).expect("should succeed in test");
744
745        let stats = pool.stats();
746        assert_eq!(stats.total_capacity, 10); // 8 + 2
747        assert_eq!(stats.total_allocated, 4);
748        assert_eq!(stats.resource_count, 2);
749        assert_eq!(stats.active_allocations, 1);
750        assert!(stats.average_utilisation > 0.0);
751    }
752
753    #[test]
754    fn test_resource_request_builder() {
755        let req = ResourceRequest::new(ResourceId::new("cpu"), 2)
756            .with_priority(10)
757            .with_requester("task-1");
758        assert_eq!(req.priority, 10);
759        assert_eq!(req.requester, "task-1");
760    }
761
762    #[test]
763    fn test_multiple_allocations_same_resource() {
764        let mut pool = ResourcePool::new();
765        pool.register(cpu_resource());
766        let req = ResourceRequest::new(ResourceId::new("cpu"), 3);
767        let t1 = pool.allocate(&req).expect("should succeed in test");
768        let t2 = pool.allocate(&req).expect("should succeed in test");
769        assert_eq!(pool.active_allocations(), 2);
770        let r = pool
771            .get_resource(&ResourceId::new("cpu"))
772            .expect("should succeed in test");
773        assert_eq!(r.allocated, 6);
774
775        // Third should fail (only 2 left)
776        let req_too_much = ResourceRequest::new(ResourceId::new("cpu"), 3);
777        assert!(pool.allocate(&req_too_much).is_err());
778
779        pool.release(t1.token_id).expect("should succeed in test");
780        // Now 5 available — 3 should work
781        let _t3 = pool
782            .allocate(&req_too_much)
783            .expect("should succeed in test");
784        assert_eq!(pool.active_allocations(), 2);
785        pool.release(t2.token_id).expect("should succeed in test");
786    }
787
788    // --- Dynamic resource scaling tests ---
789
790    #[test]
791    fn test_scaler_fixed_policy() {
792        let scaler = ResourceScaler::new();
793        let resource = cpu_resource();
794        assert_eq!(scaler.evaluate(&resource), ScalingAction::NoChange);
795    }
796
797    #[test]
798    fn test_scaler_step_scale_up() {
799        let mut scaler = ResourceScaler::new();
800        scaler.set_policy(
801            ResourceId::new("cpu"),
802            ScalingPolicy::Step {
803                scale_up_threshold: 0.8,
804                scale_down_threshold: 0.2,
805                step_size: 4,
806                min_capacity: 4,
807                max_capacity: 32,
808            },
809        );
810
811        let mut resource = cpu_resource(); // capacity = 8
812        resource.allocated = 7; // utilisation = 7/8 = 0.875 > 0.8
813
814        assert_eq!(scaler.evaluate(&resource), ScalingAction::ScaleUp(4));
815    }
816
817    #[test]
818    fn test_scaler_step_scale_down() {
819        let mut scaler = ResourceScaler::new();
820        scaler.set_policy(
821            ResourceId::new("cpu"),
822            ScalingPolicy::Step {
823                scale_up_threshold: 0.8,
824                scale_down_threshold: 0.2,
825                step_size: 4,
826                min_capacity: 4,
827                max_capacity: 32,
828            },
829        );
830
831        let mut resource = cpu_resource(); // capacity = 8
832        resource.allocated = 1; // utilisation = 1/8 = 0.125 < 0.2
833
834        assert_eq!(scaler.evaluate(&resource), ScalingAction::ScaleDown(4));
835    }
836
837    #[test]
838    fn test_scaler_step_capped_at_max() {
839        let mut scaler = ResourceScaler::new();
840        scaler.set_policy(
841            ResourceId::new("cpu"),
842            ScalingPolicy::Step {
843                scale_up_threshold: 0.8,
844                scale_down_threshold: 0.2,
845                step_size: 100,
846                min_capacity: 4,
847                max_capacity: 10,
848            },
849        );
850
851        let mut resource = cpu_resource(); // capacity = 8
852        resource.allocated = 7; // 87.5% > 80%
853
854        // Should scale up to max (10), so delta = 2
855        assert_eq!(scaler.evaluate(&resource), ScalingAction::ScaleUp(2));
856    }
857
858    #[test]
859    fn test_scaler_step_capped_at_min() {
860        let mut scaler = ResourceScaler::new();
861        scaler.set_policy(
862            ResourceId::new("cpu"),
863            ScalingPolicy::Step {
864                scale_up_threshold: 0.8,
865                scale_down_threshold: 0.2,
866                step_size: 100,
867                min_capacity: 6,
868                max_capacity: 32,
869            },
870        );
871
872        let mut resource = cpu_resource(); // capacity = 8
873        resource.allocated = 0; // 0% < 20%
874
875        // Should scale down to min (6), so delta = 2
876        assert_eq!(scaler.evaluate(&resource), ScalingAction::ScaleDown(2));
877    }
878
879    #[test]
880    fn test_scaler_target_tracking_scale_up() {
881        let mut scaler = ResourceScaler::new();
882        scaler.set_policy(
883            ResourceId::new("cpu"),
884            ScalingPolicy::TargetTracking {
885                target_utilisation: 0.5,
886                min_capacity: 4,
887                max_capacity: 32,
888            },
889        );
890
891        let mut resource = cpu_resource(); // capacity = 8
892        resource.allocated = 7; // desired = ceil(7/0.5) = 14
893
894        assert_eq!(scaler.evaluate(&resource), ScalingAction::ScaleUp(6));
895    }
896
897    #[test]
898    fn test_scaler_target_tracking_scale_down() {
899        let mut scaler = ResourceScaler::new();
900        scaler.set_policy(
901            ResourceId::new("cpu"),
902            ScalingPolicy::TargetTracking {
903                target_utilisation: 0.5,
904                min_capacity: 4,
905                max_capacity: 32,
906            },
907        );
908
909        let mut resource = ResourceDescriptor::new(ResourceId::new("cpu"), 16, "CPU", "cores");
910        resource.allocated = 2; // desired = ceil(2/0.5) = 4
911
912        assert_eq!(scaler.evaluate(&resource), ScalingAction::ScaleDown(12));
913    }
914
915    #[test]
916    fn test_scaler_target_tracking_at_target() {
917        let mut scaler = ResourceScaler::new();
918        scaler.set_policy(
919            ResourceId::new("cpu"),
920            ScalingPolicy::TargetTracking {
921                target_utilisation: 0.5,
922                min_capacity: 4,
923                max_capacity: 32,
924            },
925        );
926
927        let mut resource = cpu_resource(); // capacity = 8
928        resource.allocated = 4; // desired = ceil(4/0.5) = 8 == capacity
929
930        assert_eq!(scaler.evaluate(&resource), ScalingAction::NoChange);
931    }
932
933    #[test]
934    fn test_scaler_apply_to_pool() {
935        let mut pool = ResourcePool::new();
936        pool.register(cpu_resource()); // capacity = 8
937
938        // Allocate 7 of 8 to trigger scale-up
939        let req = ResourceRequest::new(ResourceId::new("cpu"), 7);
940        let _t = pool.allocate(&req).expect("allocate");
941
942        let mut scaler = ResourceScaler::new().with_cooldown_ms(0);
943        scaler.set_policy(
944            ResourceId::new("cpu"),
945            ScalingPolicy::Step {
946                scale_up_threshold: 0.8,
947                scale_down_threshold: 0.2,
948                step_size: 4,
949                min_capacity: 4,
950                max_capacity: 32,
951            },
952        );
953
954        let event = scaler.apply(&mut pool, &ResourceId::new("cpu"), 1000);
955        assert!(event.is_some());
956        let event = event.expect("event");
957        assert_eq!(event.old_capacity, 8);
958        assert_eq!(event.new_capacity, 12);
959
960        let resource = pool
961            .get_resource(&ResourceId::new("cpu"))
962            .expect("resource");
963        assert_eq!(resource.capacity, 12);
964    }
965
966    #[test]
967    fn test_scaler_cooldown() {
968        let mut pool = ResourcePool::new();
969        pool.register(cpu_resource());
970        let req = ResourceRequest::new(ResourceId::new("cpu"), 7);
971        let _t = pool.allocate(&req).expect("allocate");
972
973        let mut scaler = ResourceScaler::new().with_cooldown_ms(60_000);
974        scaler.set_policy(
975            ResourceId::new("cpu"),
976            ScalingPolicy::Step {
977                scale_up_threshold: 0.8,
978                scale_down_threshold: 0.2,
979                step_size: 4,
980                min_capacity: 4,
981                max_capacity: 32,
982            },
983        );
984
985        // First apply should work
986        let event = scaler.apply(&mut pool, &ResourceId::new("cpu"), 1000);
987        assert!(event.is_some());
988
989        // Second apply within cooldown should not
990        let event = scaler.apply(&mut pool, &ResourceId::new("cpu"), 2000);
991        assert!(event.is_none());
992
993        // After cooldown it should work again
994        let event = scaler.apply(&mut pool, &ResourceId::new("cpu"), 70_000);
995        // May or may not trigger since capacity changed; depends on new utilisation
996        // The point is the cooldown is respected
997        let _ = event;
998    }
999
1000    #[test]
1001    fn test_scaler_history() {
1002        let mut pool = ResourcePool::new();
1003        pool.register(cpu_resource());
1004        let req = ResourceRequest::new(ResourceId::new("cpu"), 7);
1005        let _t = pool.allocate(&req).expect("allocate");
1006
1007        let mut scaler = ResourceScaler::new().with_cooldown_ms(0);
1008        scaler.set_policy(
1009            ResourceId::new("cpu"),
1010            ScalingPolicy::Step {
1011                scale_up_threshold: 0.8,
1012                scale_down_threshold: 0.2,
1013                step_size: 4,
1014                min_capacity: 4,
1015                max_capacity: 32,
1016            },
1017        );
1018
1019        scaler.apply(&mut pool, &ResourceId::new("cpu"), 1000);
1020        assert_eq!(scaler.history().len(), 1);
1021
1022        let cpu_history = scaler.history_for(&ResourceId::new("cpu"));
1023        assert_eq!(cpu_history.len(), 1);
1024        assert_eq!(cpu_history[0].old_capacity, 8);
1025    }
1026
1027    #[test]
1028    fn test_scaling_action_display() {
1029        assert_eq!(ScalingAction::NoChange.to_string(), "no change");
1030        assert_eq!(ScalingAction::ScaleUp(4).to_string(), "scale up by 4");
1031        assert_eq!(ScalingAction::ScaleDown(2).to_string(), "scale down by 2");
1032    }
1033
1034    #[test]
1035    fn test_scaler_policy_count() {
1036        let mut scaler = ResourceScaler::new();
1037        assert_eq!(scaler.policy_count(), 0);
1038        scaler.set_policy(ResourceId::new("cpu"), ScalingPolicy::Fixed);
1039        assert_eq!(scaler.policy_count(), 1);
1040    }
1041
1042    #[test]
1043    fn test_scaler_unknown_resource() {
1044        let mut scaler = ResourceScaler::new();
1045        let mut pool = ResourcePool::new();
1046        pool.register(cpu_resource());
1047
1048        // No policy registered for gpu
1049        let event = scaler.apply(&mut pool, &ResourceId::new("gpu"), 1000);
1050        assert!(event.is_none());
1051    }
1052}