Skip to main content

canic_core/domain/policy/placement/
scaling.rs

1//! This module is PURE policy:
2//! - reads config
3//! - evaluates observed state
4//! - computes decisions
5//!
6//! No IC calls. No async. No side effects.
7
8use crate::{
9    InternalError,
10    cdk::types::BoundedString64,
11    config::schema::{ScalePool, ScalingConfig},
12    domain::policy::PolicyError,
13};
14use thiserror::Error as ThisError;
15
16pub use crate::view::placement::scaling::ScalingWorkerPlanEntry;
17
18///
19/// ScalingPolicyError
20/// Errors raised during scaling policy evaluation
21///
22
23#[derive(Debug, ThisError)]
24pub enum ScalingPolicyError {
25    #[error("scaling capability disabled for this canister")]
26    ScalingDisabled,
27
28    #[error("scaling pool '{0}' not found")]
29    PoolNotFound(String),
30}
31
32impl From<ScalingPolicyError> for InternalError {
33    fn from(err: ScalingPolicyError) -> Self {
34        PolicyError::from(err).into()
35    }
36}
37
38// ScalingWorkerPlanEntry lives in view/placement/scaling.
39
40///
41/// ScalingPlan
42///
43
44#[derive(Clone, Debug)]
45pub struct ScalingPlan {
46    pub should_spawn: bool,
47    pub reason: String,
48    pub worker_entry: Option<ScalingWorkerPlanEntry>,
49}
50
51///
52/// ScalingPolicy
53///
54
55pub struct ScalingPolicy;
56
57impl ScalingPolicy {
58    pub(crate) fn plan_create_worker(
59        pool: &str,
60        worker_count: u32,
61        scaling: Option<ScalingConfig>,
62    ) -> Result<ScalingPlan, InternalError> {
63        let pool_cfg = Self::get_scaling_pool_cfg(pool, scaling)?;
64        let policy = pool_cfg.policy;
65
66        // Max bound check
67        if policy.max_workers > 0 && worker_count >= policy.max_workers {
68            return Ok(ScalingPlan {
69                should_spawn: false,
70                reason: format!(
71                    "pool '{pool}' at max_workers ({}/{})",
72                    worker_count, policy.max_workers
73                ),
74                worker_entry: None,
75            });
76        }
77
78        // Min bound check
79        if worker_count < policy.min_workers {
80            let entry = ScalingWorkerPlanEntry {
81                pool: BoundedString64::new(pool),
82                canister_role: pool_cfg.canister_role,
83            };
84
85            return Ok(ScalingPlan {
86                should_spawn: true,
87                reason: format!(
88                    "pool '{pool}' below min_workers (current {worker_count}, min {})",
89                    policy.min_workers
90                ),
91                worker_entry: Some(entry),
92            });
93        }
94
95        Ok(ScalingPlan {
96            should_spawn: false,
97            reason: format!(
98                "pool '{pool}' within policy bounds (current {worker_count}, min {}, max {})",
99                policy.min_workers, policy.max_workers
100            ),
101            worker_entry: None,
102        })
103    }
104
105    fn get_scaling_pool_cfg(
106        pool: &str,
107        scaling: Option<ScalingConfig>,
108    ) -> Result<ScalePool, InternalError> {
109        let Some(scaling) = scaling else {
110            return Err(ScalingPolicyError::ScalingDisabled.into());
111        };
112
113        let Some(pool_cfg) = scaling.pools.get(pool) else {
114            return Err(ScalingPolicyError::PoolNotFound(pool.to_string()).into());
115        };
116
117        Ok(pool_cfg.clone())
118    }
119}