Skip to content

Commit 2d66d64

Browse files
committed
feat(ingest): add max_shards cap to IngestSettings and ScalingArbiter
Adds `max_shards: Option<NonZeroUsize>` to `IngestSettings` so operators can bound the number of shards the control plane will open for a source. The scaling arbiter now accepts a `RangeInclusive<NonZeroUsize>` and clamps scale-up decisions within [min_shards, max_shards]; the scale-down path is unblocked when shards exceed the cap (e.g. after a config change).
1 parent 710aa11 commit 2d66d64

4 files changed

Lines changed: 102 additions & 27 deletions

File tree

quickwit/quickwit-config/src/index_config/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ pub struct IngestSettings {
272272
#[schema(default = 1, value_type = usize)]
273273
#[serde(default = "IngestSettings::default_min_shards")]
274274
pub min_shards: NonZeroUsize,
275+
/// Configures the maximum number of shards the control plane may open for ingestion.
276+
/// When absent, the number of shards is unbounded.
277+
/// Must be >= `min_shards` when set.
278+
#[schema(value_type = Option<usize>)]
279+
#[serde(default, skip_serializing_if = "Option::is_none")]
280+
pub max_shards: Option<NonZeroUsize>,
275281
/// Whether to validate documents against the current doc mapping during ingestion.
276282
/// Defaults to true. When false, documents will be written directly to the WAL without
277283
/// validation, but might still be rejected during indexing when applying the doc mapping
@@ -294,6 +300,7 @@ impl Default for IngestSettings {
294300
fn default() -> Self {
295301
Self {
296302
min_shards: Self::default_min_shards(),
303+
max_shards: None,
297304
validate_docs: true,
298305
}
299306
}
@@ -594,6 +601,7 @@ impl crate::TestableForRegression for IndexConfig {
594601
};
595602
let ingest_settings = IngestSettings {
596603
min_shards: NonZeroUsize::new(12).unwrap(),
604+
max_shards: None,
597605
validate_docs: true,
598606
};
599607
let search_settings = SearchSettings {
@@ -1114,6 +1122,7 @@ mod tests {
11141122
fn test_ingest_settings_serde() {
11151123
let settings = IngestSettings {
11161124
min_shards: NonZeroUsize::MIN,
1125+
max_shards: None,
11171126
validate_docs: false,
11181127
};
11191128
let settings_yaml = serde_yaml::to_string(&settings).unwrap();
@@ -1124,6 +1133,7 @@ mod tests {
11241133

11251134
let settings = IngestSettings {
11261135
min_shards: NonZeroUsize::MIN,
1136+
max_shards: None,
11271137
validate_docs: true,
11281138
};
11291139
let settings_yaml = serde_yaml::to_string(&settings).unwrap();

quickwit/quickwit-config/src/index_config/serialize.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ impl IndexConfigForSerialization {
141141
&index_config.search_settings,
142142
&index_config.retention_policy_opt,
143143
)?;
144+
if let Some(max_shards) = index_config.ingest_settings.max_shards {
145+
ensure!(
146+
max_shards >= index_config.ingest_settings.min_shards,
147+
"ingest_settings.max_shards ({}) must be >= ingest_settings.min_shards ({})",
148+
max_shards.get(),
149+
index_config.ingest_settings.min_shards.get()
150+
);
151+
}
144152
Ok(index_config)
145153
}
146154
}

quickwit/quickwit-control-plane/src/ingest/ingest_controller.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -396,14 +396,19 @@ impl IngestController {
396396
&local_shards_update.source_uid,
397397
&local_shards_update.shard_infos,
398398
);
399-
let min_shards = model
399+
let ingest_settings = &model
400400
.index_metadata(&local_shards_update.source_uid.index_uid)
401401
.expect("index should exist")
402402
.index_config
403-
.ingest_settings
404-
.min_shards;
405-
406-
let Some(scaling_mode) = self.scaling_arbiter.should_scale(shard_stats, min_shards) else {
403+
.ingest_settings;
404+
let min_shards = ingest_settings.min_shards;
405+
let max_shards = ingest_settings.max_shards.unwrap_or(NonZeroUsize::MAX);
406+
let num_shards_range = min_shards..=max_shards;
407+
408+
let Some(scaling_mode) = self
409+
.scaling_arbiter
410+
.should_scale(shard_stats, num_shards_range)
411+
else {
407412
return Ok(());
408413
};
409414
match scaling_mode {

quickwit/quickwit-control-plane/src/ingest/scaling_arbiter.rs

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
use std::num::NonZeroUsize;
16+
use std::ops::RangeInclusive;
1617

1718
use crate::model::{ScalingMode, ShardStats};
1819

@@ -71,8 +72,10 @@ impl ScalingArbiter {
7172
pub(crate) fn should_scale(
7273
&self,
7374
shard_stats: ShardStats,
74-
min_shards: NonZeroUsize,
75+
num_shards_range: RangeInclusive<NonZeroUsize>,
7576
) -> Option<ScalingMode> {
77+
let min_shards = *num_shards_range.start();
78+
let max_shards = *num_shards_range.end();
7679
// If ingest is idle, there is nothing to do. Idle shards are automatically closed by
7780
// ingesters (see `quickwit_ingest::ingest_v2::idle::CloseIdleShardsTask`).
7881
if shard_stats.num_open_shards == 0 || shard_stats.avg_long_term_ingestion_rate == 0.0 {
@@ -85,15 +88,19 @@ impl ScalingArbiter {
8588
}
8689
// Scale up based on the short term metric value while making sure that
8790
// the long term value doesn't get near the scale down threshold.
91+
// We skip the scale-up block entirely when already at the cap so that
92+
// execution can fall through to the scale-down check below (relevant
93+
// when max_shards was lowered after shards were already opened).
8894
if shard_stats.avg_short_term_ingestion_rate
8995
>= self.scale_up_shards_short_term_threshold_mib_per_sec
96+
&& shard_stats.num_open_shards < max_shards.get()
9097
{
9198
let new_calculated_num_shards = usize::min(
9299
self.long_term_scale_up_threshold_max_shards(shard_stats),
93100
self.scale_up_factor_target_shards(shard_stats),
94101
);
95-
96-
let target_num_shards = usize::max(min_shards.get(), new_calculated_num_shards);
102+
let target_num_shards =
103+
new_calculated_num_shards.clamp(min_shards.get(), max_shards.get());
97104

98105
if target_num_shards > shard_stats.num_open_shards {
99106
let num_shards_to_open = target_num_shards - shard_stats.num_open_shards;
@@ -133,7 +140,7 @@ mod tests {
133140
avg_short_term_ingestion_rate: 0.0,
134141
avg_long_term_ingestion_rate: 0.0,
135142
},
136-
NonZeroUsize::MIN
143+
NonZeroUsize::MIN..=NonZeroUsize::MAX
137144
),
138145
None,
139146
);
@@ -145,7 +152,7 @@ mod tests {
145152
avg_short_term_ingestion_rate: 5.0,
146153
avg_long_term_ingestion_rate: 6.0,
147154
},
148-
NonZeroUsize::MIN
155+
NonZeroUsize::MIN..=NonZeroUsize::MAX
149156
),
150157
None
151158
);
@@ -157,7 +164,7 @@ mod tests {
157164
avg_short_term_ingestion_rate: 8.1,
158165
avg_long_term_ingestion_rate: 8.1,
159166
},
160-
NonZeroUsize::MIN
167+
NonZeroUsize::MIN..=NonZeroUsize::MAX
161168
),
162169
Some(ScalingMode::Up(1))
163170
);
@@ -169,7 +176,7 @@ mod tests {
169176
avg_short_term_ingestion_rate: 8.1,
170177
avg_long_term_ingestion_rate: 8.1,
171178
},
172-
NonZeroUsize::MIN
179+
NonZeroUsize::MIN..=NonZeroUsize::MAX
173180
),
174181
Some(ScalingMode::Up(1))
175182
);
@@ -181,7 +188,7 @@ mod tests {
181188
avg_short_term_ingestion_rate: 3.0,
182189
avg_long_term_ingestion_rate: 1.5,
183190
},
184-
NonZeroUsize::MIN
191+
NonZeroUsize::MIN..=NonZeroUsize::MAX
185192
),
186193
Some(ScalingMode::Down)
187194
);
@@ -193,7 +200,7 @@ mod tests {
193200
avg_short_term_ingestion_rate: 3.0,
194201
avg_long_term_ingestion_rate: 1.5,
195202
},
196-
NonZeroUsize::MIN
203+
NonZeroUsize::MIN..=NonZeroUsize::MAX
197204
),
198205
None,
199206
);
@@ -205,7 +212,7 @@ mod tests {
205212
avg_short_term_ingestion_rate: 8.0,
206213
avg_long_term_ingestion_rate: 3.0,
207214
},
208-
NonZeroUsize::MIN
215+
NonZeroUsize::MIN..=NonZeroUsize::MAX
209216
),
210217
None,
211218
);
@@ -224,7 +231,7 @@ mod tests {
224231
avg_short_term_ingestion_rate: 0.0,
225232
avg_long_term_ingestion_rate: 0.0,
226233
},
227-
NonZeroUsize::MIN
234+
NonZeroUsize::MIN..=NonZeroUsize::MAX
228235
),
229236
None,
230237
);
@@ -236,7 +243,7 @@ mod tests {
236243
avg_short_term_ingestion_rate: 5.0,
237244
avg_long_term_ingestion_rate: 6.0,
238245
},
239-
NonZeroUsize::MIN
246+
NonZeroUsize::MIN..=NonZeroUsize::MAX
240247
),
241248
None
242249
);
@@ -248,7 +255,7 @@ mod tests {
248255
avg_short_term_ingestion_rate: 8.1,
249256
avg_long_term_ingestion_rate: 8.1,
250257
},
251-
NonZeroUsize::MIN
258+
NonZeroUsize::MIN..=NonZeroUsize::MAX
252259
),
253260
Some(ScalingMode::Up(1))
254261
);
@@ -260,7 +267,7 @@ mod tests {
260267
avg_short_term_ingestion_rate: 8.1,
261268
avg_long_term_ingestion_rate: 8.1,
262269
},
263-
NonZeroUsize::MIN
270+
NonZeroUsize::MIN..=NonZeroUsize::MAX
264271
),
265272
Some(ScalingMode::Up(2))
266273
);
@@ -272,7 +279,7 @@ mod tests {
272279
avg_short_term_ingestion_rate: 3.0,
273280
avg_long_term_ingestion_rate: 1.5,
274281
},
275-
NonZeroUsize::MIN
282+
NonZeroUsize::MIN..=NonZeroUsize::MAX
276283
),
277284
Some(ScalingMode::Down)
278285
);
@@ -284,7 +291,7 @@ mod tests {
284291
avg_short_term_ingestion_rate: 3.0,
285292
avg_long_term_ingestion_rate: 1.5,
286293
},
287-
NonZeroUsize::MIN
294+
NonZeroUsize::MIN..=NonZeroUsize::MAX
288295
),
289296
None,
290297
);
@@ -296,7 +303,7 @@ mod tests {
296303
avg_short_term_ingestion_rate: 8.0,
297304
avg_long_term_ingestion_rate: 3.1,
298305
},
299-
NonZeroUsize::MIN
306+
NonZeroUsize::MIN..=NonZeroUsize::MAX
300307
),
301308
None,
302309
);
@@ -309,7 +316,7 @@ mod tests {
309316
avg_short_term_ingestion_rate: 8.1,
310317
avg_long_term_ingestion_rate: 5.,
311318
},
312-
NonZeroUsize::MIN
319+
NonZeroUsize::MIN..=NonZeroUsize::MAX
313320
),
314321
Some(ScalingMode::Up(1)),
315322
);
@@ -408,8 +415,8 @@ mod tests {
408415
avg_short_term_ingestion_rate: 0.0,
409416
avg_long_term_ingestion_rate: 0.0,
410417
};
411-
let min_shards = NonZeroUsize::MIN;
412-
let scaling_mode = scaling_arbiter.should_scale(shard_stats, min_shards);
418+
let scaling_mode =
419+
scaling_arbiter.should_scale(shard_stats, NonZeroUsize::MIN..=NonZeroUsize::MAX);
413420
assert!(scaling_mode.is_none());
414421

415422
let shard_stats = ShardStats {
@@ -419,7 +426,8 @@ mod tests {
419426
avg_long_term_ingestion_rate: 0.0,
420427
};
421428
let min_shards = NonZeroUsize::new(2).unwrap();
422-
let scaling_mode = scaling_arbiter.should_scale(shard_stats, min_shards);
429+
let scaling_mode =
430+
scaling_arbiter.should_scale(shard_stats, min_shards..=NonZeroUsize::MAX);
423431
assert!(scaling_mode.is_none());
424432
}
425433

@@ -436,8 +444,52 @@ mod tests {
436444
};
437445
let min_shards = NonZeroUsize::new(5).unwrap();
438446
let scaling_mode = scaling_arbiter
439-
.should_scale(shard_stats, min_shards)
447+
.should_scale(shard_stats, min_shards..=NonZeroUsize::MAX)
440448
.unwrap();
441449
assert_eq!(scaling_mode, ScalingMode::Up(4));
442450
}
451+
452+
#[test]
453+
fn test_scaling_arbiter_max_shards() {
454+
let scaling_arbiter =
455+
ScalingArbiter::with_max_shard_ingestion_throughput_mib_per_sec(10.0, 2.0);
456+
457+
// Already at max — scale-up should be suppressed even when load is high.
458+
let shard_stats = ShardStats {
459+
num_open_shards: 3,
460+
num_closed_shards: 0,
461+
avg_short_term_ingestion_rate: 9.0,
462+
avg_long_term_ingestion_rate: 9.0,
463+
};
464+
let max_shards = NonZeroUsize::new(3).unwrap();
465+
assert_eq!(
466+
scaling_arbiter.should_scale(shard_stats, NonZeroUsize::MIN..=max_shards),
467+
None,
468+
);
469+
470+
// Below max — scale-up is allowed but capped at max.
471+
let shard_stats = ShardStats {
472+
num_open_shards: 2,
473+
num_closed_shards: 0,
474+
avg_short_term_ingestion_rate: 9.0,
475+
avg_long_term_ingestion_rate: 9.0,
476+
};
477+
assert_eq!(
478+
scaling_arbiter.should_scale(shard_stats, NonZeroUsize::MIN..=max_shards),
479+
Some(ScalingMode::Up(1)),
480+
);
481+
482+
// Above max (e.g. max_shards was lowered after shards were opened) with low load —
483+
// scale-down should still trigger because execution falls through the scale-up block.
484+
let shard_stats = ShardStats {
485+
num_open_shards: 4,
486+
num_closed_shards: 0,
487+
avg_short_term_ingestion_rate: 9.0,
488+
avg_long_term_ingestion_rate: 1.0,
489+
};
490+
assert_eq!(
491+
scaling_arbiter.should_scale(shard_stats, NonZeroUsize::MIN..=max_shards),
492+
Some(ScalingMode::Down),
493+
);
494+
}
443495
}

0 commit comments

Comments
 (0)