Skip to main content

a3s_box_runtime/scale/
manager.rs

1//! ScaleManager implementation.
2
3use std::collections::{HashMap, VecDeque};
4
5use a3s_box_core::scale::{
6    InstanceEvent, InstanceHealth, InstanceInfo, InstanceState, ScaleDirection, ScaleObservation,
7    ScaleOperationConflict, ScaleOperationRequest, ScaleOperationResponse, ScaleRequest,
8    ScaleResponse, SCALE_OPERATION_SCHEMA_VERSION,
9};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12
13use super::{ServiceHealth, ServiceInstances, TrackedInstance};
14
15/// Tracks all instances managed by this Box host.
16pub struct ScaleManager {
17    /// Maximum total instances across all services
18    max_instances: u32,
19    /// Per-service instance tracking
20    services: HashMap<String, ServiceInstances>,
21    /// Event log (bounded ring buffer)
22    events: Vec<InstanceEvent>,
23    /// Maximum events to retain
24    max_events: usize,
25    /// Monotonic desired-state revision per service.
26    revisions: HashMap<String, u64>,
27    /// Bounded replay receipts keyed by operation identity.
28    operation_receipts: HashMap<String, ScaleOperationReceipt>,
29    operation_order: VecDeque<String>,
30    max_operation_receipts: usize,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub(super) struct ScaleOperationReceipt {
35    request: ScaleOperationRequest,
36    response: ScaleOperationResponse,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub(super) struct ScaleAuthorityState {
41    pub(super) schema_version: u32,
42    pub(super) targets: HashMap<String, u32>,
43    pub(super) revisions: HashMap<String, u64>,
44    pub(super) receipts: Vec<ScaleOperationReceipt>,
45}
46
47impl ScaleManager {
48    /// Create a new scale manager with the given capacity.
49    pub fn new(max_instances: u32) -> Self {
50        Self {
51            max_instances,
52            services: HashMap::new(),
53            events: Vec::new(),
54            max_events: 1000,
55            revisions: HashMap::new(),
56            operation_receipts: HashMap::new(),
57            operation_order: VecDeque::new(),
58            max_operation_receipts: 10_000,
59        }
60    }
61
62    /// Return the authoritative desired replica count and CAS revision.
63    pub fn scale_observation(&self, service: &str) -> ScaleObservation {
64        ScaleObservation {
65            replicas: self
66                .services
67                .get(service)
68                .map_or(0, |state| state.target_replicas),
69            revision: Some(
70                self.revisions
71                    .get(service)
72                    .copied()
73                    .unwrap_or(0)
74                    .to_string(),
75            ),
76            ready_replicas: 0,
77            endpoints: Vec::new(),
78        }
79    }
80
81    /// Apply one versioned scale mutation exactly once.
82    pub fn apply_operation(
83        &mut self,
84        request: &ScaleOperationRequest,
85    ) -> Result<ScaleOperationResponse, ScaleOperationConflict> {
86        let observation = self.scale_observation(&request.service);
87
88        if let Some(receipt) = self.operation_receipts.get(&request.operation_id) {
89            return if receipt.request == *request {
90                Ok(receipt.response.clone())
91            } else {
92                Err(scale_conflict(
93                    "operation_conflict",
94                    "operation_id was already used with a different scale request",
95                    observation,
96                ))
97            };
98        }
99
100        if request.schema_version != SCALE_OPERATION_SCHEMA_VERSION {
101            return Err(scale_conflict(
102                "unsupported_schema",
103                format!(
104                    "scale schema {} is unsupported; expected {}",
105                    request.schema_version, SCALE_OPERATION_SCHEMA_VERSION
106                ),
107                observation,
108            ));
109        }
110        if request.operation_id.trim().is_empty() || request.service.trim().is_empty() {
111            return Err(scale_conflict(
112                "invalid_request",
113                "operation_id and service must be non-empty",
114                observation,
115            ));
116        }
117        if request.expected_revision.as_deref() != observation.revision.as_deref()
118            || request.current_replicas != observation.replicas
119        {
120            return Err(scale_conflict(
121                "stale_revision",
122                "scale request was not derived from the current desired state",
123                observation,
124            ));
125        }
126        let direction_matches = match request.direction {
127            ScaleDirection::Up => request.desired_replicas > request.current_replicas,
128            ScaleDirection::Down => request.desired_replicas < request.current_replicas,
129        };
130        if !direction_matches {
131            return Err(scale_conflict(
132                "invalid_direction",
133                "scale direction does not match the requested replica transition",
134                observation,
135            ));
136        }
137
138        let total_other: u32 = self
139            .services
140            .iter()
141            .filter(|(service, _)| service.as_str() != request.service)
142            .map(|(_, state)| state.target_replicas)
143            .sum();
144        let available = self.max_instances.saturating_sub(total_other);
145        if request.desired_replicas > available {
146            return Err(scale_conflict(
147                "capacity_exceeded",
148                format!(
149                    "requested {} replicas but only {} fit within host capacity",
150                    request.desired_replicas, available
151                ),
152                observation,
153            ));
154        }
155
156        let state = self
157            .services
158            .entry(request.service.clone())
159            .or_insert_with(|| ServiceInstances {
160                target_replicas: 0,
161                instances: Vec::new(),
162            });
163        state.target_replicas = request.desired_replicas;
164        let revision = self.revisions.entry(request.service.clone()).or_insert(0);
165        *revision = revision.saturating_add(1);
166        let response = ScaleOperationResponse {
167            accepted: true,
168            actual_replicas: request.desired_replicas,
169            revision: Some(revision.to_string()),
170            message: format!(
171                "Box accepted service '{}' at {} desired replicas",
172                request.service, request.desired_replicas
173            ),
174        };
175        self.remember_operation(request.clone(), response.clone());
176        Ok(response)
177    }
178
179    pub(super) fn finalize_operation_response(
180        &mut self,
181        request: &ScaleOperationRequest,
182        response: ScaleOperationResponse,
183    ) -> Result<(), String> {
184        let receipt = self
185            .operation_receipts
186            .get_mut(&request.operation_id)
187            .ok_or_else(|| {
188                format!(
189                    "scale operation {} has no durable acceptance receipt",
190                    request.operation_id
191                )
192            })?;
193        if receipt.request != *request {
194            return Err(format!(
195                "scale operation {} was accepted with different intent",
196                request.operation_id
197            ));
198        }
199        if response.revision != receipt.response.revision {
200            return Err(format!(
201                "scale operation {} completion changed its authority revision",
202                request.operation_id
203            ));
204        }
205        receipt.response = response;
206        Ok(())
207    }
208
209    fn remember_operation(
210        &mut self,
211        request: ScaleOperationRequest,
212        response: ScaleOperationResponse,
213    ) {
214        while self.operation_order.len() >= self.max_operation_receipts {
215            if let Some(expired) = self.operation_order.pop_front() {
216                self.operation_receipts.remove(&expired);
217            }
218        }
219        self.operation_order.push_back(request.operation_id.clone());
220        self.operation_receipts.insert(
221            request.operation_id.clone(),
222            ScaleOperationReceipt { request, response },
223        );
224    }
225
226    pub(super) fn authority_state(&self) -> ScaleAuthorityState {
227        let receipts = self
228            .operation_order
229            .iter()
230            .filter_map(|operation| self.operation_receipts.get(operation).cloned())
231            .collect();
232        ScaleAuthorityState {
233            schema_version: SCALE_OPERATION_SCHEMA_VERSION,
234            targets: self
235                .services
236                .iter()
237                .map(|(service, state)| (service.clone(), state.target_replicas))
238                .collect(),
239            revisions: self.revisions.clone(),
240            receipts,
241        }
242    }
243
244    pub(super) fn restore_authority_state(
245        &mut self,
246        state: ScaleAuthorityState,
247    ) -> Result<(), String> {
248        if state.schema_version != SCALE_OPERATION_SCHEMA_VERSION {
249            return Err(format!(
250                "unsupported scale authority schema {}",
251                state.schema_version
252            ));
253        }
254        if state
255            .targets
256            .keys()
257            .any(|service| service.trim().is_empty())
258            || state
259                .revisions
260                .keys()
261                .any(|service| service.trim().is_empty())
262        {
263            return Err("scale authority contains an empty service identity".to_string());
264        }
265        if state
266            .targets
267            .keys()
268            .any(|service| !state.revisions.contains_key(service))
269        {
270            return Err("scale authority target is missing its revision".to_string());
271        }
272
273        let mut receipts = HashMap::new();
274        let mut order = VecDeque::new();
275        for receipt in state.receipts {
276            let operation_id = receipt.request.operation_id.clone();
277            if operation_id.trim().is_empty() || receipts.contains_key(&operation_id) {
278                return Err("scale authority contains an invalid operation receipt".to_string());
279            }
280            order.push_back(operation_id.clone());
281            receipts.insert(operation_id, receipt);
282        }
283        while order.len() > self.max_operation_receipts {
284            if let Some(expired) = order.pop_front() {
285                receipts.remove(&expired);
286            }
287        }
288
289        self.services = state
290            .targets
291            .into_iter()
292            .map(|(service, target_replicas)| {
293                (
294                    service,
295                    ServiceInstances {
296                        target_replicas,
297                        instances: Vec::new(),
298                    },
299                )
300            })
301            .collect();
302        self.revisions = state.revisions;
303        self.operation_receipts = receipts;
304        self.operation_order = order;
305        Ok(())
306    }
307
308    /// Process a scale request and return the response.
309    ///
310    /// This determines how many instances to create or destroy
311    /// but does not actually start/stop VMs — the caller is responsible
312    /// for that based on the response.
313    pub fn process_request(&mut self, request: &ScaleRequest) -> ScaleResponse {
314        let service = &request.service;
315        let desired = request.replicas;
316
317        // Compute total instances in other services first (before mutable borrow)
318        let total_other: u32 = self
319            .services
320            .iter()
321            .filter(|(k, _)| k.as_str() != service)
322            .map(|(_, v)| v.instances.len() as u32)
323            .sum();
324
325        // Get or create service entry
326        let svc = self
327            .services
328            .entry(service.clone())
329            .or_insert_with(|| ServiceInstances {
330                target_replicas: 0,
331                instances: Vec::new(),
332            });
333
334        let current = svc.instances.len() as u32;
335
336        // Check capacity
337        let available = self.max_instances.saturating_sub(total_other);
338        let target = desired.min(available);
339
340        svc.target_replicas = target;
341
342        let accepted = target == desired;
343        let error = if !accepted {
344            Some(format!(
345                "Capped to {} instances (max {} total, {} used by other services)",
346                target, self.max_instances, total_other
347            ))
348        } else {
349            None
350        };
351
352        let instances: Vec<InstanceInfo> = svc
353            .instances
354            .iter()
355            .map(|inst| InstanceInfo {
356                id: inst.id.clone(),
357                state: inst.state,
358                service: service.clone(),
359                created_at: inst.created_at,
360                ready_at: inst.ready_at,
361                endpoint: inst.endpoint.clone(),
362                health: inst.health.clone(),
363            })
364            .collect();
365
366        ScaleResponse {
367            request_id: request.request_id.clone(),
368            accepted,
369            current_replicas: current,
370            target_replicas: target,
371            instances,
372            error,
373        }
374    }
375
376    /// Register a new instance for a service.
377    pub fn register_instance(&mut self, service: &str, instance_id: &str, endpoint: Option<&str>) {
378        let svc = self
379            .services
380            .entry(service.to_string())
381            .or_insert_with(|| ServiceInstances {
382                target_replicas: 0,
383                instances: Vec::new(),
384            });
385
386        // Don't register duplicates
387        if svc.instances.iter().any(|i| i.id == instance_id) {
388            return;
389        }
390
391        svc.instances.push(TrackedInstance {
392            id: instance_id.to_string(),
393            state: InstanceState::Creating,
394            created_at: Utc::now(),
395            ready_at: None,
396            endpoint: endpoint.map(|s| s.to_string()),
397            health: InstanceHealth::default(),
398        });
399    }
400
401    /// Update an instance's state and emit a transition event.
402    pub fn update_state(
403        &mut self,
404        service: &str,
405        instance_id: &str,
406        new_state: InstanceState,
407    ) -> Option<InstanceEvent> {
408        let svc = self.services.get_mut(service)?;
409        let inst = svc.instances.iter_mut().find(|i| i.id == instance_id)?;
410
411        let old_state = inst.state;
412        if old_state == new_state {
413            return None;
414        }
415
416        inst.state = new_state;
417        if new_state == InstanceState::Ready && inst.ready_at.is_none() {
418            inst.ready_at = Some(Utc::now());
419        }
420
421        let event = InstanceEvent::transition(instance_id, service, old_state, new_state);
422        self.push_event(event.clone());
423        Some(event)
424    }
425
426    /// Update an instance's health metrics.
427    pub fn update_health(&mut self, service: &str, instance_id: &str, health: InstanceHealth) {
428        if let Some(svc) = self.services.get_mut(service) {
429            if let Some(inst) = svc.instances.iter_mut().find(|i| i.id == instance_id) {
430                inst.health = health;
431            }
432        }
433    }
434
435    /// Update an instance's endpoint.
436    pub fn update_endpoint(&mut self, service: &str, instance_id: &str, endpoint: &str) {
437        if let Some(svc) = self.services.get_mut(service) {
438            if let Some(inst) = svc.instances.iter_mut().find(|i| i.id == instance_id) {
439                inst.endpoint = Some(endpoint.to_string());
440            }
441        }
442    }
443
444    /// Remove an instance from tracking.
445    pub fn deregister_instance(&mut self, service: &str, instance_id: &str) -> bool {
446        if let Some(svc) = self.services.get_mut(service) {
447            let before = svc.instances.len();
448            svc.instances.retain(|i| i.id != instance_id);
449            return svc.instances.len() < before;
450        }
451        false
452    }
453
454    /// Get instances that need to be created (target > current running).
455    pub fn instances_to_create(&self, service: &str) -> u32 {
456        if let Some(svc) = self.services.get(service) {
457            let active = svc
458                .instances
459                .iter()
460                .filter(|i| !matches!(i.state, InstanceState::Stopped | InstanceState::Failed))
461                .count() as u32;
462            svc.target_replicas.saturating_sub(active)
463        } else {
464            0
465        }
466    }
467
468    /// Get instances that should be stopped (current > target).
469    /// Returns instance IDs to stop, preferring non-busy instances.
470    pub fn instances_to_stop(&self, service: &str) -> Vec<String> {
471        if let Some(svc) = self.services.get(service) {
472            let active: Vec<&TrackedInstance> = svc
473                .instances
474                .iter()
475                .filter(|i| {
476                    !matches!(
477                        i.state,
478                        InstanceState::Stopped
479                            | InstanceState::Failed
480                            | InstanceState::Stopping
481                            | InstanceState::Draining
482                    )
483                })
484                .collect();
485
486            let excess = (active.len() as u32).saturating_sub(svc.target_replicas);
487            if excess == 0 {
488                return Vec::new();
489            }
490
491            // Prefer stopping idle (Ready) instances over Busy ones
492            let mut candidates: Vec<&TrackedInstance> = active;
493            candidates.sort_by_key(|i| match i.state {
494                InstanceState::Ready => 0,    // Stop idle first
495                InstanceState::Creating => 1, // Then creating
496                InstanceState::Booting => 2,  // Then booting
497                InstanceState::Busy => 3,     // Busy last
498                _ => 4,
499            });
500
501            candidates
502                .iter()
503                .take(excess as usize)
504                .map(|i| i.id.clone())
505                .collect()
506        } else {
507            Vec::new()
508        }
509    }
510
511    /// Get all ready instances for a service (for traffic routing).
512    pub fn ready_instances(&self, service: &str) -> Vec<InstanceInfo> {
513        if let Some(svc) = self.services.get(service) {
514            svc.instances
515                .iter()
516                .filter(|i| i.state == InstanceState::Ready)
517                .map(|i| InstanceInfo {
518                    id: i.id.clone(),
519                    state: i.state,
520                    service: service.to_string(),
521                    created_at: i.created_at,
522                    ready_at: i.ready_at,
523                    endpoint: i.endpoint.clone(),
524                    health: i.health.clone(),
525                })
526                .collect()
527        } else {
528            Vec::new()
529        }
530    }
531
532    /// Get the total number of instances across all services.
533    pub fn total_instances(&self) -> u32 {
534        self.services
535            .values()
536            .map(|s| s.instances.len() as u32)
537            .sum()
538    }
539
540    /// Get the number of instances for a specific service.
541    pub fn service_instance_count(&self, service: &str) -> u32 {
542        self.services
543            .get(service)
544            .map(|s| s.instances.len() as u32)
545            .unwrap_or(0)
546    }
547
548    /// List all tracked services.
549    pub fn services(&self) -> Vec<String> {
550        self.services.keys().cloned().collect()
551    }
552
553    /// Get recent events.
554    pub fn recent_events(&self, limit: usize) -> &[InstanceEvent] {
555        let start = self.events.len().saturating_sub(limit);
556        &self.events[start..]
557    }
558
559    fn push_event(&mut self, event: InstanceEvent) {
560        self.events.push(event);
561        if self.events.len() > self.max_events {
562            self.events.drain(..self.events.len() - self.max_events);
563        }
564    }
565
566    /// Aggregate health metrics for a service (for autoscaler decisions).
567    pub fn service_health(&self, service: &str) -> ServiceHealth {
568        let svc = match self.services.get(service) {
569            Some(s) => s,
570            None => return ServiceHealth::default(),
571        };
572
573        let active: Vec<&TrackedInstance> = svc
574            .instances
575            .iter()
576            .filter(|i| matches!(i.state, InstanceState::Ready | InstanceState::Busy))
577            .collect();
578
579        if active.is_empty() {
580            return ServiceHealth {
581                active_instances: 0,
582                ready_instances: 0,
583                busy_instances: 0,
584                ..Default::default()
585            };
586        }
587
588        let ready_count = active
589            .iter()
590            .filter(|i| i.state == InstanceState::Ready)
591            .count() as u32;
592        let busy_count = active
593            .iter()
594            .filter(|i| i.state == InstanceState::Busy)
595            .count() as u32;
596
597        let mut total_cpu = 0.0f64;
598        let mut total_mem = 0u64;
599        let mut total_inflight = 0u32;
600        let mut cpu_count = 0u32;
601        let mut unhealthy = 0u32;
602
603        for inst in &active {
604            if let Some(cpu) = inst.health.cpu_percent {
605                total_cpu += cpu as f64;
606                cpu_count += 1;
607            }
608            if let Some(mem) = inst.health.memory_bytes {
609                total_mem += mem;
610            }
611            total_inflight += inst.health.inflight_requests;
612            if !inst.health.healthy {
613                unhealthy += 1;
614            }
615        }
616
617        ServiceHealth {
618            active_instances: active.len() as u32,
619            ready_instances: ready_count,
620            busy_instances: busy_count,
621            avg_cpu_percent: if cpu_count > 0 {
622                Some((total_cpu / cpu_count as f64) as f32)
623            } else {
624                None
625            },
626            total_memory_bytes: total_mem,
627            total_inflight_requests: total_inflight,
628            unhealthy_instances: unhealthy,
629        }
630    }
631
632    /// Initiate graceful drain for an instance.
633    ///
634    /// Transitions the instance to `Draining` state. The caller should:
635    /// 1. Stop routing new requests to this instance
636    /// 2. Wait for in-flight requests to complete (or timeout)
637    /// 3. Call `complete_drain()` to transition to `Stopping`
638    pub fn start_drain(&mut self, service: &str, instance_id: &str) -> Option<InstanceEvent> {
639        let svc = self.services.get_mut(service)?;
640        let inst = svc.instances.iter_mut().find(|i| i.id == instance_id)?;
641
642        // Can only drain from Ready or Busy
643        if !matches!(inst.state, InstanceState::Ready | InstanceState::Busy) {
644            return None;
645        }
646
647        let old_state = inst.state;
648        inst.state = InstanceState::Draining;
649
650        let event =
651            InstanceEvent::transition(instance_id, service, old_state, InstanceState::Draining)
652                .with_message("Graceful drain initiated");
653        self.push_event(event.clone());
654        Some(event)
655    }
656
657    /// Complete a drain and transition to Stopping.
658    ///
659    /// Called after in-flight requests have completed or the drain timeout expired.
660    pub fn complete_drain(&mut self, service: &str, instance_id: &str) -> Option<InstanceEvent> {
661        let svc = self.services.get_mut(service)?;
662        let inst = svc.instances.iter_mut().find(|i| i.id == instance_id)?;
663
664        if inst.state != InstanceState::Draining {
665            return None;
666        }
667
668        inst.state = InstanceState::Stopping;
669
670        let event = InstanceEvent::transition(
671            instance_id,
672            service,
673            InstanceState::Draining,
674            InstanceState::Stopping,
675        )
676        .with_message("Drain complete, stopping instance");
677        self.push_event(event.clone());
678        Some(event)
679    }
680
681    /// Check if a draining instance has no in-flight requests.
682    pub fn is_drain_complete(&self, service: &str, instance_id: &str) -> bool {
683        if let Some(svc) = self.services.get(service) {
684            if let Some(inst) = svc.instances.iter().find(|i| i.id == instance_id) {
685                return inst.state == InstanceState::Draining && inst.health.inflight_requests == 0;
686            }
687        }
688        false
689    }
690
691    /// Get all instances currently draining.
692    pub fn draining_instances(&self, service: &str) -> Vec<String> {
693        if let Some(svc) = self.services.get(service) {
694            svc.instances
695                .iter()
696                .filter(|i| i.state == InstanceState::Draining)
697                .map(|i| i.id.clone())
698                .collect()
699        } else {
700            Vec::new()
701        }
702    }
703}
704
705fn scale_conflict(
706    code: impl Into<String>,
707    message: impl Into<String>,
708    observation: ScaleObservation,
709) -> ScaleOperationConflict {
710    ScaleOperationConflict {
711        code: code.into(),
712        message: message.into(),
713        observation,
714    }
715}