1use 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
15pub struct ScaleManager {
17 max_instances: u32,
19 services: HashMap<String, ServiceInstances>,
21 events: Vec<InstanceEvent>,
23 max_events: usize,
25 revisions: HashMap<String, u64>,
27 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 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 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 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 pub fn process_request(&mut self, request: &ScaleRequest) -> ScaleResponse {
314 let service = &request.service;
315 let desired = request.replicas;
316
317 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 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 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 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 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 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 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 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 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 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 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 let mut candidates: Vec<&TrackedInstance> = active;
493 candidates.sort_by_key(|i| match i.state {
494 InstanceState::Ready => 0, InstanceState::Creating => 1, InstanceState::Booting => 2, InstanceState::Busy => 3, _ => 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 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 pub fn total_instances(&self) -> u32 {
534 self.services
535 .values()
536 .map(|s| s.instances.len() as u32)
537 .sum()
538 }
539
540 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 pub fn services(&self) -> Vec<String> {
550 self.services.keys().cloned().collect()
551 }
552
553 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 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 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 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 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 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 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}