1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::cluster::{ClusterEpoch, ClusterNodeId, PartitionId};
use crate::grid::elasticity::{
validate_move_preserves_zone_quorum, MovePhase, NodeTopology, PartitionMove, RegionId,
ReshardPlan, ReshardPlanError, UpgradeGuard, UpgradeGuardError, UpgradeStep,
ZoneAwareReplicaSet,
};
use crate::grid::ReplicationConfig;
/// Recommendation emitted for an external autoscaler or operator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScaleRecommendation {
/// No scaling action should be taken.
Hold,
/// Add nodes and backfill before quorum admission.
ScaleOut {
/// Suggested number of nodes to add.
suggested: usize,
},
/// Drain listed nodes before removal.
ScaleIn {
/// Nodes that can be drained by an external autoscaler.
drain: Vec<ClusterNodeId>,
},
/// Rebalance hot partitions without changing membership.
Rebalance,
}
/// One capacity sample consumed by the recommendation engine.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapacitySample {
/// Region that owns this sample.
pub region: RegionId,
/// Memory pressure, normalized to `0.0..=1.0+`.
pub memory_pressure: f32,
/// Cross-region replication lag.
pub replication_lag: u64,
/// Hot-partition skew ratio.
pub hot_partition_skew: f32,
/// Repair debt that can block safe scaling.
pub repair_debt: u64,
/// Seconds since the last accepted scale action.
pub seconds_since_last_scale: u64,
/// Nodes eligible for scale-in drain.
pub scale_in_candidates: Vec<ClusterNodeId>,
}
impl CapacitySample {
/// Create a capacity sample with no scale-in candidates.
pub fn new(region: impl Into<RegionId>) -> Self {
Self {
region: region.into(),
memory_pressure: 0.0,
replication_lag: 0,
hot_partition_skew: 0.0,
repair_debt: 0,
seconds_since_last_scale: u64::MAX,
scale_in_candidates: Vec::new(),
}
}
}
/// Tunables for capacity recommendations.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct CapacityThresholds {
/// Memory pressure at which scale-out is recommended.
pub scale_out_memory_pressure: f32,
/// Memory pressure below which scale-in can be recommended.
pub scale_in_memory_pressure: f32,
/// Replication lag at which scale-out is recommended.
pub replication_lag_limit: u64,
/// Hot-partition skew at which rebalance is recommended.
pub hot_partition_skew_limit: f32,
/// Repair debt at which rebalance is recommended.
pub repair_debt_limit: u64,
/// Minimum seconds between accepted scale actions.
pub minimum_dwell_secs: u64,
/// Suggested node count for scale-out recommendations.
pub scale_out_suggested: usize,
}
impl Default for CapacityThresholds {
fn default() -> Self {
Self {
scale_out_memory_pressure: 0.85,
scale_in_memory_pressure: 0.25,
replication_lag_limit: 1_000,
hot_partition_skew_limit: 2.0,
repair_debt_limit: 100,
minimum_dwell_secs: 300,
scale_out_suggested: 1,
}
}
}
/// Structured capacity signal exported by status/metrics.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapacitySignal {
/// Region this signal describes.
pub region: RegionId,
/// Memory pressure, normalized to `0.0..=1.0+`.
pub memory_pressure: f32,
/// Cross-region replication lag.
pub replication_lag: u64,
/// Hot-partition skew ratio.
pub hot_partition_skew: f32,
/// Repair debt that can block safe scaling.
pub repair_debt: u64,
/// Autoscaler recommendation.
pub recommendation: ScaleRecommendation,
}
/// Compute one capacity recommendation with dwell-time hysteresis.
pub fn evaluate_capacity(sample: CapacitySample, thresholds: CapacityThresholds) -> CapacitySignal {
let in_dwell_window = sample.seconds_since_last_scale < thresholds.minimum_dwell_secs;
let recommendation = if in_dwell_window {
ScaleRecommendation::Hold
} else if sample.memory_pressure >= thresholds.scale_out_memory_pressure
|| sample.replication_lag > thresholds.replication_lag_limit
{
ScaleRecommendation::ScaleOut {
suggested: thresholds.scale_out_suggested.max(1),
}
} else if sample.hot_partition_skew >= thresholds.hot_partition_skew_limit
|| sample.repair_debt > thresholds.repair_debt_limit
{
ScaleRecommendation::Rebalance
} else if sample.memory_pressure <= thresholds.scale_in_memory_pressure
&& !sample.scale_in_candidates.is_empty()
{
ScaleRecommendation::ScaleIn {
drain: sample.scale_in_candidates.clone(),
}
} else {
ScaleRecommendation::Hold
};
CapacitySignal {
region: sample.region,
memory_pressure: sample.memory_pressure,
replication_lag: sample.replication_lag,
hot_partition_skew: sample.hot_partition_skew,
repair_debt: sample.repair_debt,
recommendation,
}
}
/// Autoscaler membership intent accepted through the guarded admission surface.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AutoscalerIntent {
/// Add a node, backfill partitions, then count it toward quorum.
ScaleOut {
/// Joining node.
node: ClusterNodeId,
/// Authoritative topology for the joining node.
topology: NodeTopology,
/// Candidate replica set after the join.
candidate: ZoneAwareReplicaSet,
/// Backfill work from existing owners into the joining node.
backfill_sources: Vec<(PartitionId, ClusterNodeId, u64)>,
/// Compatibility step advertised by the joining binary.
compat: UpgradeStep,
},
/// Drain a node before removing it.
ScaleIn {
/// Node being drained.
drain: ClusterNodeId,
/// Remaining voters after removal.
remaining_voters: usize,
/// Drain movements to surviving owners.
drain_targets: Vec<(PartitionId, ClusterNodeId, u64)>,
/// Compatibility step advertised by the operator/controller.
compat: UpgradeStep,
},
/// Rebalance partitions without a membership change.
Rebalance {
/// Candidate reshard plan.
plan: ReshardPlan,
/// Compatibility step advertised by the operator/controller.
compat: UpgradeStep,
},
}
/// Guardrails used to admit an autoscaler intent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoscalerAdmissionPolicy {
/// Current control-plane epoch.
pub epoch: ClusterEpoch,
/// Replication/quorum configuration.
pub replication: ReplicationConfig,
/// Compatibility guard.
pub upgrade_guard: UpgradeGuard,
/// Maximum concurrent reshard moves.
pub max_concurrent_moves: usize,
}
impl AutoscalerAdmissionPolicy {
/// Create a policy with normalized concurrency.
pub fn new(
epoch: ClusterEpoch,
replication: ReplicationConfig,
upgrade_guard: UpgradeGuard,
max_concurrent_moves: usize,
) -> Self {
Self {
epoch,
replication,
upgrade_guard,
max_concurrent_moves: max_concurrent_moves.max(1),
}
}
}
/// Accepted scale action.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScaleAction {
/// Add a node.
ScaleOut,
/// Drain/remove a node.
ScaleIn,
/// Rebalance partitions.
Rebalance,
}
/// Accepted autoscaler admission result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoscalerAdmission {
/// Action accepted by the guard.
pub action: ScaleAction,
/// Reshard/drain/backfill work to execute before final membership effect.
pub plan: ReshardPlan,
/// Whether a joining node can count toward quorum immediately.
pub quorum_eligible: bool,
/// Whether a draining node may be removed immediately.
pub removal_allowed: bool,
}
/// Validate an autoscaler intent against zone, quorum, and COMPAT invariants.
pub fn admit_autoscaler_intent(
intent: AutoscalerIntent,
policy: AutoscalerAdmissionPolicy,
) -> Result<AutoscalerAdmission, AutoscalerIntentError> {
policy
.replication
.validate()
.map_err(|error| AutoscalerIntentError::new(error.to_string()))?;
match intent {
AutoscalerIntent::ScaleOut {
node,
topology: _,
candidate,
backfill_sources,
compat,
} => {
policy
.upgrade_guard
.check(compat)
.map_err(AutoscalerIntentError::from)?;
validate_move_preserves_zone_quorum(&candidate, policy.replication.write_quorum)
.map_err(AutoscalerIntentError::from)?;
let moves = backfill_sources
.into_iter()
.map(|(partition, from, bytes)| {
PartitionMove::new(partition, from, node.clone(), bytes)
})
.collect();
Ok(AutoscalerAdmission {
action: ScaleAction::ScaleOut,
plan: ReshardPlan::new(policy.epoch, moves, policy.max_concurrent_moves),
quorum_eligible: false,
removal_allowed: false,
})
}
AutoscalerIntent::ScaleIn {
drain,
remaining_voters,
drain_targets,
compat,
} => {
policy
.upgrade_guard
.check(compat)
.map_err(AutoscalerIntentError::from)?;
if remaining_voters < policy.replication.write_quorum {
return Err(AutoscalerIntentError::new(
"autoscaler intent would break write quorum",
));
}
Ok(AutoscalerAdmission {
action: ScaleAction::ScaleIn,
plan: ReshardPlan::drain_node(
policy.epoch,
drain,
drain_targets,
policy.max_concurrent_moves,
),
quorum_eligible: true,
removal_allowed: false,
})
}
AutoscalerIntent::Rebalance { plan, compat } => {
policy
.upgrade_guard
.check(compat)
.map_err(AutoscalerIntentError::from)?;
Ok(AutoscalerAdmission {
action: ScaleAction::Rebalance,
plan,
quorum_eligible: true,
removal_allowed: true,
})
}
}
}
/// Return whether every move has reached the committed owner.
pub fn scale_out_counts_toward_quorum(plan: &ReshardPlan) -> bool {
!plan.moves.is_empty()
&& plan
.moves
.iter()
.all(|movement| movement.phase >= MovePhase::Commit)
}
/// Return whether a scale-in drain completed and the node can be removed.
pub fn scale_in_removal_allowed(plan: &ReshardPlan) -> bool {
!plan.moves.is_empty()
&& plan
.moves
.iter()
.all(|movement| movement.phase == MovePhase::Cleanup)
}
/// Error returned by autoscaler intent admission.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoscalerIntentError {
message: String,
}
impl AutoscalerIntentError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl From<UpgradeGuardError> for AutoscalerIntentError {
fn from(error: UpgradeGuardError) -> Self {
Self::new(error.to_string())
}
}
impl From<ReshardPlanError> for AutoscalerIntentError {
fn from(error: ReshardPlanError) -> Self {
Self::new(error.to_string())
}
}
impl fmt::Display for AutoscalerIntentError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for AutoscalerIntentError {}
/// Bounded metric snapshot for capacity/autoscaler surfaces.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapacityAutoscalerMetrics {
/// Last bounded recommendation label.
pub capacity_recommendation: ScaleRecommendation,
/// Total accepted scale actions.
pub scale_actions_total: u64,
}