1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::cluster::{ClusterEpoch, ClusterNodeId, PartitionId};
6use crate::grid::elasticity::{
7 validate_move_preserves_zone_quorum, MovePhase, NodeTopology, PartitionMove, RegionId,
8 ReshardPlan, ReshardPlanError, UpgradeGuard, UpgradeGuardError, UpgradeStep,
9 ZoneAwareReplicaSet,
10};
11use crate::grid::ReplicationConfig;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ScaleRecommendation {
17 Hold,
19 ScaleOut {
21 suggested: usize,
23 },
24 ScaleIn {
26 drain: Vec<ClusterNodeId>,
28 },
29 Rebalance,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct CapacitySample {
36 pub region: RegionId,
38 pub memory_pressure: f32,
40 pub replication_lag: u64,
42 pub hot_partition_skew: f32,
44 pub repair_debt: u64,
46 pub seconds_since_last_scale: u64,
48 pub scale_in_candidates: Vec<ClusterNodeId>,
50}
51
52impl CapacitySample {
53 pub fn new(region: impl Into<RegionId>) -> Self {
55 Self {
56 region: region.into(),
57 memory_pressure: 0.0,
58 replication_lag: 0,
59 hot_partition_skew: 0.0,
60 repair_debt: 0,
61 seconds_since_last_scale: u64::MAX,
62 scale_in_candidates: Vec::new(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
69pub struct CapacityThresholds {
70 pub scale_out_memory_pressure: f32,
72 pub scale_in_memory_pressure: f32,
74 pub replication_lag_limit: u64,
76 pub hot_partition_skew_limit: f32,
78 pub repair_debt_limit: u64,
80 pub minimum_dwell_secs: u64,
82 pub scale_out_suggested: usize,
84}
85
86impl Default for CapacityThresholds {
87 fn default() -> Self {
88 Self {
89 scale_out_memory_pressure: 0.85,
90 scale_in_memory_pressure: 0.25,
91 replication_lag_limit: 1_000,
92 hot_partition_skew_limit: 2.0,
93 repair_debt_limit: 100,
94 minimum_dwell_secs: 300,
95 scale_out_suggested: 1,
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct CapacitySignal {
103 pub region: RegionId,
105 pub memory_pressure: f32,
107 pub replication_lag: u64,
109 pub hot_partition_skew: f32,
111 pub repair_debt: u64,
113 pub recommendation: ScaleRecommendation,
115}
116
117pub fn evaluate_capacity(sample: CapacitySample, thresholds: CapacityThresholds) -> CapacitySignal {
119 let in_dwell_window = sample.seconds_since_last_scale < thresholds.minimum_dwell_secs;
120 let recommendation = if in_dwell_window {
121 ScaleRecommendation::Hold
122 } else if sample.memory_pressure >= thresholds.scale_out_memory_pressure
123 || sample.replication_lag > thresholds.replication_lag_limit
124 {
125 ScaleRecommendation::ScaleOut {
126 suggested: thresholds.scale_out_suggested.max(1),
127 }
128 } else if sample.hot_partition_skew >= thresholds.hot_partition_skew_limit
129 || sample.repair_debt > thresholds.repair_debt_limit
130 {
131 ScaleRecommendation::Rebalance
132 } else if sample.memory_pressure <= thresholds.scale_in_memory_pressure
133 && !sample.scale_in_candidates.is_empty()
134 {
135 ScaleRecommendation::ScaleIn {
136 drain: sample.scale_in_candidates.clone(),
137 }
138 } else {
139 ScaleRecommendation::Hold
140 };
141
142 CapacitySignal {
143 region: sample.region,
144 memory_pressure: sample.memory_pressure,
145 replication_lag: sample.replication_lag,
146 hot_partition_skew: sample.hot_partition_skew,
147 repair_debt: sample.repair_debt,
148 recommendation,
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum AutoscalerIntent {
156 ScaleOut {
158 node: ClusterNodeId,
160 topology: NodeTopology,
162 candidate: ZoneAwareReplicaSet,
164 backfill_sources: Vec<(PartitionId, ClusterNodeId, u64)>,
166 compat: UpgradeStep,
168 },
169 ScaleIn {
171 drain: ClusterNodeId,
173 remaining_voters: usize,
175 drain_targets: Vec<(PartitionId, ClusterNodeId, u64)>,
177 compat: UpgradeStep,
179 },
180 Rebalance {
182 plan: ReshardPlan,
184 compat: UpgradeStep,
186 },
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191pub struct AutoscalerAdmissionPolicy {
192 pub epoch: ClusterEpoch,
194 pub replication: ReplicationConfig,
196 pub upgrade_guard: UpgradeGuard,
198 pub max_concurrent_moves: usize,
200}
201
202impl AutoscalerAdmissionPolicy {
203 pub fn new(
205 epoch: ClusterEpoch,
206 replication: ReplicationConfig,
207 upgrade_guard: UpgradeGuard,
208 max_concurrent_moves: usize,
209 ) -> Self {
210 Self {
211 epoch,
212 replication,
213 upgrade_guard,
214 max_concurrent_moves: max_concurrent_moves.max(1),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum ScaleAction {
223 ScaleOut,
225 ScaleIn,
227 Rebalance,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct AutoscalerAdmission {
234 pub action: ScaleAction,
236 pub plan: ReshardPlan,
238 pub quorum_eligible: bool,
240 pub removal_allowed: bool,
242}
243
244pub fn admit_autoscaler_intent(
246 intent: AutoscalerIntent,
247 policy: AutoscalerAdmissionPolicy,
248) -> Result<AutoscalerAdmission, AutoscalerIntentError> {
249 policy
250 .replication
251 .validate()
252 .map_err(|error| AutoscalerIntentError::new(error.to_string()))?;
253
254 match intent {
255 AutoscalerIntent::ScaleOut {
256 node,
257 topology: _,
258 candidate,
259 backfill_sources,
260 compat,
261 } => {
262 policy
263 .upgrade_guard
264 .check(compat)
265 .map_err(AutoscalerIntentError::from)?;
266 validate_move_preserves_zone_quorum(&candidate, policy.replication.write_quorum)
267 .map_err(AutoscalerIntentError::from)?;
268 let moves = backfill_sources
269 .into_iter()
270 .map(|(partition, from, bytes)| {
271 PartitionMove::new(partition, from, node.clone(), bytes)
272 })
273 .collect();
274 Ok(AutoscalerAdmission {
275 action: ScaleAction::ScaleOut,
276 plan: ReshardPlan::new(policy.epoch, moves, policy.max_concurrent_moves),
277 quorum_eligible: false,
278 removal_allowed: false,
279 })
280 }
281 AutoscalerIntent::ScaleIn {
282 drain,
283 remaining_voters,
284 drain_targets,
285 compat,
286 } => {
287 policy
288 .upgrade_guard
289 .check(compat)
290 .map_err(AutoscalerIntentError::from)?;
291 if remaining_voters < policy.replication.write_quorum {
292 return Err(AutoscalerIntentError::new(
293 "autoscaler intent would break write quorum",
294 ));
295 }
296 Ok(AutoscalerAdmission {
297 action: ScaleAction::ScaleIn,
298 plan: ReshardPlan::drain_node(
299 policy.epoch,
300 drain,
301 drain_targets,
302 policy.max_concurrent_moves,
303 ),
304 quorum_eligible: true,
305 removal_allowed: false,
306 })
307 }
308 AutoscalerIntent::Rebalance { plan, compat } => {
309 policy
310 .upgrade_guard
311 .check(compat)
312 .map_err(AutoscalerIntentError::from)?;
313 Ok(AutoscalerAdmission {
314 action: ScaleAction::Rebalance,
315 plan,
316 quorum_eligible: true,
317 removal_allowed: true,
318 })
319 }
320 }
321}
322
323pub fn scale_out_counts_toward_quorum(plan: &ReshardPlan) -> bool {
325 !plan.moves.is_empty()
326 && plan
327 .moves
328 .iter()
329 .all(|movement| movement.phase >= MovePhase::Commit)
330}
331
332pub fn scale_in_removal_allowed(plan: &ReshardPlan) -> bool {
334 !plan.moves.is_empty()
335 && plan
336 .moves
337 .iter()
338 .all(|movement| movement.phase == MovePhase::Cleanup)
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct AutoscalerIntentError {
344 message: String,
345}
346
347impl AutoscalerIntentError {
348 fn new(message: impl Into<String>) -> Self {
349 Self {
350 message: message.into(),
351 }
352 }
353}
354
355impl From<UpgradeGuardError> for AutoscalerIntentError {
356 fn from(error: UpgradeGuardError) -> Self {
357 Self::new(error.to_string())
358 }
359}
360
361impl From<ReshardPlanError> for AutoscalerIntentError {
362 fn from(error: ReshardPlanError) -> Self {
363 Self::new(error.to_string())
364 }
365}
366
367impl fmt::Display for AutoscalerIntentError {
368 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369 formatter.write_str(&self.message)
370 }
371}
372
373impl std::error::Error for AutoscalerIntentError {}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
377pub struct CapacityAutoscalerMetrics {
378 pub capacity_recommendation: ScaleRecommendation,
380 pub scale_actions_total: u64,
382}