mz_cluster_controller/lib.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! The cluster controller: the single decision-maker for the replica set of
11//! every managed cluster.
12//!
13//! It is a **reconciler**. Each tick it reads desired cluster state and live
14//! signals through the [`ClusterControllerCtx`] boundary, runs a set of pure
15//! [`Strategy`]s, unions their desired contributions, diffs that against the
16//! actual replica set, and emits the create/drop and durable-state-write
17//! [`Decision`]s that close the gap. It holds no in-memory state: the source of
18//! truth is always the catalog plus live signals, pulled fresh each tick.
19//!
20//! The crate is **pure**. It depends only on primitive id/shape types and the
21//! [`ClusterControllerCtx`] trait, never on the adapter or catalog. That
22//! boundary is what makes the controller testable against a fake
23//! implementation and extractable later without touching controller code.
24//!
25//! A tick runs two phases per cluster, `update_state` then `desired_replicas`
26//! (see [`ClusterController::reconcile`]). Every [`Decision`] carries the
27//! durable state it was derived from, and the apply path transacts it only if
28//! that state still holds (compare-and-append). So a create or drop derived
29//! from a pre-`ALTER` snapshot can never reshape the replica set against the
30//! config the `ALTER` has since established. Applies are per cluster, so one
31//! cluster's rejection does not block the others, and commands name explicit
32//! replicas, so re-emitting one across a lagging view or a restart is a no-op.
33//!
34//! [`ClusterControllerCtx`]: crate::ctx::ClusterControllerCtx
35
36pub mod ctx;
37pub mod strategy;
38
39use std::collections::{BTreeMap, BTreeSet};
40
41use mz_adapter_types::dyncfgs::{DEFAULT_HYDRATION_BURST_LINGER, ENABLE_HYDRATION_BURST};
42use mz_controller_types::ClusterId;
43use mz_dyncfg::ConfigSet;
44use mz_ore::soft_panic_or_log;
45
46use crate::ctx::{
47 ApplyOutcome, BurstAudit, BurstFinishCause, BurstWrite, ClusterControllerCtx, ClusterState,
48 CreateReason, Decision, ObservedReplica, ReconfigurationAudit, ReconfigurationRecord,
49 ReconfigurationStatus, ReconfigurationWrite, RefreshWindowInputs, ReplicaShape, StateWrite,
50};
51use crate::strategy::{
52 BaselineStrategy, ConfigSignals, DesiredReplica, GracefulReconfigurationStrategy,
53 HydrationBurstStrategy, LiveSignals, OnRefreshStrategy, SignalRequest, Strategy,
54};
55
56/// The cluster controller. Holds the (stateless) set of strategies and drives a
57/// reconcile tick against a [`ClusterControllerCtx`].
58pub struct ClusterController {
59 strategies: Vec<Box<dyn Strategy>>,
60 /// The dyncfgs the config signals are latched from each tick. A shared
61 /// handle, so a flipped flag takes effect on the next tick.
62 dyncfgs: ConfigSet,
63}
64
65impl ClusterController {
66 /// A controller with the full set of strategies. Each strategy's rustdoc
67 /// describes when it engages.
68 pub fn new(dyncfgs: ConfigSet) -> Self {
69 Self {
70 strategies: vec![
71 Box::new(BaselineStrategy),
72 Box::new(GracefulReconfigurationStrategy),
73 Box::new(OnRefreshStrategy),
74 Box::new(HydrationBurstStrategy),
75 ],
76 dyncfgs,
77 }
78 }
79
80 /// The tick's config signals, latched from the dyncfgs so every strategy
81 /// decides against one consistent config per tick.
82 fn config_signals(&self) -> ConfigSignals {
83 ConfigSignals {
84 burst_enabled: ENABLE_HYDRATION_BURST.get(&self.dyncfgs),
85 default_burst_linger: DEFAULT_HYDRATION_BURST_LINGER.get(&self.dyncfgs),
86 }
87 }
88
89 /// Run one reconcile tick over every managed cluster the ctx reports.
90 ///
91 /// See the module docs for the two-phase structure. Both phases apply per
92 /// cluster, so a compare-and-append rejection on one cluster never blocks
93 /// progress on the others.
94 pub async fn reconcile(&self, ctx: &mut dyn ClusterControllerCtx) {
95 let cluster_ids = ctx.managed_cluster_ids().await;
96 if cluster_ids.is_empty() {
97 return;
98 }
99
100 // Phase 1: update_state. We merge every strategy's write for a cluster
101 // into one compare-and-append, applied per cluster and independently of
102 // other clusters. Two separate decisions live here.
103 //
104 // Per cluster, not one batch per tick: a write rejected because a
105 // concurrent `ALTER` moved the cluster off its `expected` rejects only
106 // that cluster and leaves the rest free to progress. One batched apply
107 // would let a single mid-`ALTER` cluster sink the whole tick, the failure
108 // mode at large cluster counts where some cluster is almost always
109 // mid-`ALTER`.
110 //
111 // Merged across strategies, not one apply per strategy: every strategy
112 // for a cluster shares the same start-of-tick `expected`, so applying
113 // them one at a time would let the first write move the cluster off that
114 // `expected` and reject all the rest, serializing a cluster's disjoint
115 // writes one-per-tick. Merging lands them together under one guard. We
116 // still rely on the compare-and-append, not the merge, for `ALTER`
117 // safety, which is why the merged write carries the cluster's `expected`.
118 // See `merge_state_writes` for the join and its conflict handling.
119 let states = ctx.cluster_states(&cluster_ids).await;
120 let config = self.config_signals();
121 let signals = self.fetch_signals(ctx, &states, &config).await;
122 let now = ctx.now();
123 // Set when we issue any phase-1 apply, applied or rejected. Either way
124 // the durable state may have moved (our write, or the concurrent `ALTER`
125 // that rejected it), so phase 2 re-reads.
126 let mut phase_1_wrote = false;
127 // Clusters whose phase-1 write was rejected. We skip their phase 2 this
128 // tick. Proceeding would be safe (we re-read below and every create/drop
129 // is guard-checked), but a cluster that just lost a race is likely still
130 // settling, so we let it recompute next tick instead of emitting work
131 // that is probably about to go stale.
132 let mut rejected = BTreeSet::new();
133 for state in &states {
134 let Some(signals) = signals.get(&state.cluster_id) else {
135 continue;
136 };
137 let write = self.merge_state_writes(state, signals, &config, now);
138 if write.is_empty() {
139 continue;
140 }
141 phase_1_wrote = true;
142 let decision = Decision::UpdateClusterState {
143 cluster_id: state.cluster_id,
144 expected: state.expected(),
145 write,
146 };
147 // A phase-1 batch carries no creates, so it cannot exhaust the
148 // resource budget. Treat any non-applied outcome as a rejection.
149 if ctx.apply(vec![decision]).await != ApplyOutcome::Applied {
150 rejected.insert(state.cluster_id);
151 }
152 }
153
154 // Phase 2: desired_replicas. The barrier exists so that a cut-over a
155 // phase-1 write performed is visible before we diff the replica set
156 // against the realized config. We re-read (and re-enrich) only if phase 1
157 // wrote. The first read is otherwise still current. A stale diff is
158 // harmless: every create/drop carries its `expected` and is guard-rejected
159 // if the durable state has since diverged.
160 let (states, signals) = if phase_1_wrote {
161 let states = ctx.cluster_states(&cluster_ids).await;
162 let signals = self.fetch_signals(ctx, &states, &config).await;
163 (states, signals)
164 } else {
165 (states, signals)
166 };
167 let now = ctx.now();
168 for state in &states {
169 if rejected.contains(&state.cluster_id) {
170 continue;
171 }
172 let Some(signals) = signals.get(&state.cluster_id) else {
173 continue;
174 };
175 let decisions = self.collect_replica_decisions(state, signals, &config, now);
176 if decisions.is_empty() {
177 continue;
178 }
179 // Per-cluster apply: a guard failure here is isolated to this cluster,
180 // and benign anyway since every command names an explicit replica and
181 // is reconciled away next tick. We do not retry within the tick.
182 match ctx.apply(decisions).await {
183 ApplyOutcome::Applied | ApplyOutcome::Rejected => {}
184 ApplyOutcome::ResourceExhausted => {
185 // The batch exceeded the resource budget. Retrying cannot make
186 // the transient peak smaller, so shed the cluster's most
187 // expendable transient strategy and recompute next tick.
188 //
189 // The failed apply rolled back without changing durable state,
190 // so this tick's `expected` witness is still current, unless a
191 // concurrent user `ALTER` re-targeted the record, in which case
192 // the guard rejects the shed and that new reconfiguration is
193 // left to converge instead of being clobbered.
194 if let Some(shed) = Self::shed_decision(state) {
195 let _ = ctx.apply(vec![shed]).await;
196 }
197 }
198 }
199 }
200 }
201
202 /// The decision that sheds this cluster's most expendable transient strategy
203 /// after a resource-exhausted apply, or `None` if nothing sheddable is
204 /// active.
205 ///
206 /// The strategy to shed is chosen by presence, ranked by expendability, not
207 /// by which create failed: validation is aggregate, and the strategy worth
208 /// giving up may be one whose replicas already materialized rather than one
209 /// in the failed batch. A graceful reconfiguration is a discretionary user
210 /// change that fails cleanly. The failure is audited, the wait-shim reports
211 /// insufficient resources, and the cluster keeps running at its realized
212 /// shape. The baseline is never shed because it is the committed floor. A
213 /// hydration burst remains armed because no durable state records that the
214 /// unchanged policy should suppress it.
215 ///
216 /// Without an active graceful reconfiguration there is nothing to shed. The
217 /// next tick retries the desired replica set.
218 fn shed_decision(state: &ClusterState) -> Option<Decision> {
219 let record = state.reconfiguration.as_ref()?;
220 if !record.is_in_progress() {
221 return None;
222 }
223 Some(Decision::UpdateClusterState {
224 cluster_id: state.cluster_id,
225 expected: state.expected(),
226 write: StateWrite {
227 reconfiguration: Some(ReconfigurationWrite {
228 record: Some(ReconfigurationRecord {
229 status: ReconfigurationStatus::ResourceExhausted,
230 ..record.clone()
231 }),
232 audit: Some(ReconfigurationAudit::ResourceExhausted),
233 }),
234 ..Default::default()
235 },
236 })
237 }
238
239 /// Merge every strategy's [`Strategy::update_state`] for one cluster into the
240 /// single [`StateWrite`] the tick applies under one compare-and-append.
241 ///
242 /// The merge is a per-field join, independent of the order strategies run
243 /// in, before the cutover adjustment below: a field set by exactly one
244 /// strategy is taken as-is, a field no strategy sets is left unchanged, and
245 /// a field set to the same value by several is that value.
246 ///
247 /// Two strategies setting one field to *different* values is a conflict.
248 /// The strategies keep every field single-writer at any given moment:
249 /// most fields are owned by exactly one strategy outright, and
250 /// `new_replication_factor`, which both the graceful cut-over and the
251 /// on-refresh normalization write, is time-shared (on-refresh skips its
252 /// normalization while a reconfiguration record is in progress). So by
253 /// design a conflict cannot happen and the merge is really a disjoint
254 /// union. We treat a conflict as an invariant violation rather than a
255 /// condition to resolve: there is no safety-meaningful winner to pick for
256 /// a contended `size` or record, so we trip [`soft_panic_or_log!`] (a
257 /// panic under test/CI soft assertions, a logged error in production) and
258 /// leave the field unchanged, the only outcome that cannot make things
259 /// worse. A persistent conflict then freezes that field and keeps tripping
260 /// the alarm, which is the point: surface the design bug loudly instead of
261 /// silently picking an arbitrary value.
262 fn merge_state_writes(
263 &self,
264 state: &ClusterState,
265 signals: &LiveSignals,
266 config: &ConfigSignals,
267 now: mz_repr::Timestamp,
268 ) -> StateWrite {
269 let writes: Vec<StateWrite> = self
270 .strategies
271 .iter()
272 .map(|strategy| strategy.update_state(state, signals, config, now))
273 .filter(|write| !write.is_empty())
274 .collect();
275
276 let mut conflicts: Vec<&'static str> = Vec::new();
277 // Exhaustive construction (every field named, no `..`): a field added to
278 // `StateWrite` is a compile error here until its join is spelled out.
279 let mut merged = StateWrite {
280 new_size: join(
281 "size",
282 writes.iter().map(|w| w.new_size.clone()),
283 &mut conflicts,
284 ),
285 new_replication_factor: join(
286 "replication_factor",
287 writes.iter().map(|w| w.new_replication_factor),
288 &mut conflicts,
289 ),
290 new_availability_zones: join(
291 "availability_zones",
292 writes.iter().map(|w| w.new_availability_zones.clone()),
293 &mut conflicts,
294 ),
295 new_logging: join(
296 "logging",
297 writes.iter().map(|w| w.new_logging.clone()),
298 &mut conflicts,
299 ),
300 new_arrangement_compression: join(
301 "arrangement_compression",
302 writes.iter().map(|w| w.new_arrangement_compression),
303 &mut conflicts,
304 ),
305 reconfiguration: join(
306 "reconfiguration",
307 writes.iter().map(|w| w.reconfiguration.clone()),
308 &mut conflicts,
309 ),
310 burst: join(
311 "burst",
312 writes.iter().map(|w| w.burst.clone()),
313 &mut conflicts,
314 ),
315 };
316
317 if !conflicts.is_empty() {
318 soft_panic_or_log!(
319 "cluster {:?}: strategies produced conflicting state writes for \
320 field(s) {}; leaving those fields unchanged. Strategies must own \
321 disjoint `StateWrite` fields.",
322 state.cluster_id,
323 conflicts.join(", "),
324 );
325 }
326
327 // A graceful cutover replaces the steady set used to establish burst
328 // readiness. Lag allowances are not transitive, so the new steady set
329 // must establish its own readiness directly against the burst helpers.
330 if merged.new_size.is_some() {
331 if let Some(record) = &state.burst {
332 let explicit_cleanup = merged.burst.as_ref().is_some_and(|write| {
333 write.audit
334 == Some(BurstAudit::Finished {
335 cause: BurstFinishCause::NoLongerWarranted,
336 })
337 });
338 if !explicit_cleanup {
339 merged.burst = Some(BurstWrite {
340 record: Some(crate::ctx::BurstRecord {
341 steady_ready_at: None,
342 ..record.clone()
343 }),
344 audit: None,
345 });
346 }
347 }
348 }
349
350 merged
351 }
352
353 /// Fetch the live signals the strategies declared they need for `states`.
354 ///
355 /// Each strategy names its needs as a pure function of the durable state
356 /// and the tick's config signals ([`Strategy::signal_request`]), so the
357 /// kernel stays ignorant of when a strategy engages. Signals are fetched
358 /// only where requested: a steady cluster is never probed, keeping the ctx
359 /// seam pay-for-what-you-use. Refresh-window inputs are fetched as one batch
360 /// so every scheduled cluster shares one oracle read per phase. The returned
361 /// map omits a state when one of its required inputs was unavailable, which
362 /// causes the reconciliation phase to skip that cluster.
363 async fn fetch_signals(
364 &self,
365 ctx: &mut dyn ClusterControllerCtx,
366 states: &[ClusterState],
367 config: &ConfigSignals,
368 ) -> BTreeMap<ClusterId, LiveSignals> {
369 let mut signals = BTreeMap::new();
370 let mut refresh_window_clusters = Vec::new();
371 for state in states {
372 let request = self
373 .strategies
374 .iter()
375 .fold(SignalRequest::default(), |acc, strategy| {
376 acc.union(strategy.signal_request(state, config))
377 });
378 let mut live = LiveSignals::default();
379 if request.hydratable_objects {
380 live.has_hydratable_objects = ctx.has_hydratable_objects(state.cluster_id).await;
381 }
382 if request.hydration || !request.readiness.is_empty() {
383 let replica_ids: Vec<_> = state
384 .replicas
385 .iter()
386 .filter(|r| r.owned_shape().is_some())
387 .map(|r| r.replica_id)
388 .collect();
389 if !replica_ids.is_empty() {
390 if request.hydration {
391 live.hydrated_replicas =
392 ctx.hydrated_replicas(state.cluster_id, &replica_ids).await;
393 }
394 for reference in request.readiness {
395 let ready = ctx
396 .ready_replicas(state.cluster_id, &replica_ids, &reference)
397 .await;
398 live.ready_replicas.insert(reference, ready);
399 }
400 }
401 }
402 if request.refresh_window {
403 refresh_window_clusters.push(state.cluster_id);
404 }
405 signals.insert(state.cluster_id, live);
406 }
407 if !refresh_window_clusters.is_empty() {
408 match ctx.refresh_window_inputs(&refresh_window_clusters).await {
409 Some(batch) => {
410 let read_ts = batch.read_ts;
411 let mut cluster_inputs = batch.cluster_inputs;
412 for cluster_id in refresh_window_clusters {
413 let Some(inputs) = cluster_inputs.remove(&cluster_id) else {
414 signals.remove(&cluster_id);
415 continue;
416 };
417 let live = signals
418 .get_mut(&cluster_id)
419 .expect("signal entry inserted for requested cluster");
420 live.refresh_window = Some(RefreshWindowInputs {
421 read_ts,
422 compaction_estimate: inputs.compaction_estimate,
423 refresh_mvs: inputs.refresh_mvs,
424 });
425 }
426 }
427 None => {
428 for cluster_id in refresh_window_clusters {
429 signals.remove(&cluster_id);
430 }
431 }
432 }
433 }
434 signals
435 }
436
437 /// Diff the unioned desired set against the actual replicas of one cluster
438 /// and emit the create/drop decisions that close the gap.
439 fn collect_replica_decisions(
440 &self,
441 state: &ClusterState,
442 signals: &LiveSignals,
443 config: &ConfigSignals,
444 now: mz_repr::Timestamp,
445 ) -> Vec<Decision> {
446 let contributions: Vec<Vec<DesiredReplica>> = self
447 .strategies
448 .iter()
449 .map(|strategy| strategy.desired_replicas(state, signals, config, now))
450 .collect();
451
452 reconcile_replicas(state, &contributions)
453 }
454}
455
456/// Join one `StateWrite` field across the strategies that set it: `None` if
457/// none did, the common value if one or more set it to the same value, and
458/// `None` with `field` pushed onto `conflicts` if two set it to different
459/// values. The result and the conflict signal depend only on the set of values,
460/// not the order they arrive in.
461fn join<T: PartialEq>(
462 field: &'static str,
463 values: impl IntoIterator<Item = Option<T>>,
464 conflicts: &mut Vec<&'static str>,
465) -> Option<T> {
466 let mut merged: Option<T> = None;
467 for value in values.into_iter().flatten() {
468 match &merged {
469 None => merged = Some(value),
470 Some(existing) if *existing == value => {}
471 // Two strategies disagree on this field. Record it and leave the
472 // field unchanged; merge_state_writes raises the alarm.
473 Some(_) => {
474 conflicts.push(field);
475 return None;
476 }
477 }
478 }
479 merged
480}
481
482/// The pure multiset union/diff kernel for one cluster: given each strategy's
483/// desired replica slots and the actual replicas, match slots to replicas by
484/// shape and emit the creates and drops that close the gap.
485///
486/// Semantics:
487/// - The desired set is the multiset **union** of every strategy's slots: a
488/// given shape is desired `max` over strategies (not the sum), since a replica
489/// of that shape satisfies every strategy that wants one. This is what makes a
490/// replica survive iff *some* strategy desires its shape.
491/// - For each shape, if actual count < desired count we create the difference;
492/// if actual count > desired count we drop the difference, picking specific
493/// excess replicas. A replica of a shape no strategy desires is dropped.
494/// - Creates carry the winning [`CreateReason`] among the slots that
495/// desired the shape (see [`CreateReason::outranks`]). Drops carry no
496/// attribution. A drop happens exactly when no strategy desires the replica.
497fn reconcile_replicas(
498 state: &ClusterState,
499 contributions: &[Vec<DesiredReplica>],
500) -> Vec<Decision> {
501 // Desired count per shape = max over strategies of how many that strategy
502 // wants of the shape, carrying the highest-ranking reason among the
503 // slots.
504 let mut desired: Vec<DesiredShape> = Vec::new();
505 for slots in contributions {
506 // How many of each shape this strategy wants, and the winning reason
507 // among the shape's slots.
508 let mut per_shape: Vec<(ReplicaShape, usize, CreateReason)> = Vec::new();
509 for slot in slots {
510 match per_shape
511 .iter_mut()
512 .find(|(s, _, _)| s.matches(&slot.shape))
513 {
514 Some((_, count, reason)) => {
515 *count += 1;
516 if slot.reason.outranks(reason) {
517 *reason = slot.reason.clone();
518 }
519 }
520 None => per_shape.push((slot.shape.clone(), 1, slot.reason.clone())),
521 }
522 }
523 for (shape, count, reason) in per_shape {
524 match desired.iter_mut().find(|d| d.shape.matches(&shape)) {
525 Some(existing) => {
526 existing.count = existing.count.max(count);
527 if reason.outranks(&existing.reason) {
528 existing.reason = reason;
529 }
530 }
531 None => desired.push(DesiredShape {
532 shape,
533 count,
534 reason,
535 }),
536 }
537 }
538 }
539
540 // Bucket the controller-owned replicas by shape. Replicas the controller
541 // does not own (see `ObservedReplica::owned_shape`) are invisible to the
542 // desired/actual diff: neither counted toward a shape nor dropped.
543 let mut actual_by_shape: Vec<(ReplicaShape, Vec<&ObservedReplica>)> = Vec::new();
544 for replica in &state.replicas {
545 let Some(shape) = replica.owned_shape() else {
546 continue;
547 };
548 match actual_by_shape.iter_mut().find(|(s, _)| s.matches(shape)) {
549 Some((_, replicas)) => replicas.push(replica),
550 None => actual_by_shape.push((shape.clone(), vec![replica])),
551 }
552 }
553
554 let mut decisions = Vec::new();
555
556 // Every observed replica occupies a name, owned or not, so a generated
557 // name never collides with a replica already on the cluster.
558 let used_names: Vec<&str> = state.replicas.iter().map(|r| r.name.as_str()).collect();
559 let mut name_gen = ReplicaNameGen::new(&used_names);
560
561 // The compare-and-append witness for every create/drop this tick emits for
562 // the cluster: the apply path rejects the batch if the cluster's durable
563 // state has diverged from what we diffed against (e.g. a concurrent `ALTER`),
564 // so a stale create/drop can never reshape the replica set against the new
565 // config.
566 let expected = state.expected();
567
568 // Creates: for each desired shape, fill the gap below its desired count.
569 for d in &desired {
570 let actual_count = actual_by_shape
571 .iter()
572 .find(|(s, _)| s.matches(&d.shape))
573 .map(|(_, replicas)| replicas.len())
574 .unwrap_or(0);
575 for _ in actual_count..d.count {
576 decisions.push(Decision::CreateReplica {
577 cluster_id: state.cluster_id,
578 name: name_gen.next_name(),
579 shape: d.shape.clone(),
580 // Multiple creates of one shape in a tick share the merged
581 // reason.
582 reason: d.reason.clone(),
583 expected: expected.clone(),
584 });
585 }
586 }
587
588 // Drops: any actual replica beyond the desired count for its shape, plus
589 // every replica of a shape no strategy desires.
590 for (shape, replicas) in &actual_by_shape {
591 let desired_count = desired
592 .iter()
593 .find(|d| d.shape.matches(shape))
594 .map(|d| d.count)
595 .unwrap_or(0);
596 for replica in replicas.iter().skip(desired_count) {
597 decisions.push(Decision::DropReplica {
598 cluster_id: state.cluster_id,
599 replica_id: replica.replica_id,
600 expected: expected.clone(),
601 });
602 }
603 }
604
605 decisions
606}
607
608/// A shape the union desires, how many, and the highest-ranking reason of
609/// the strategies that wanted it.
610struct DesiredShape {
611 shape: ReplicaShape,
612 count: usize,
613 reason: CreateReason,
614}
615
616/// Generates deterministic fresh replica names that avoid a set of in-use names.
617///
618/// The controller derives names from the observed actual set rather than
619/// renaming existing replicas, which keeps re-emission harmless. The concrete
620/// naming convention (the `rNN` managed-replica scheme) is the environment's; the
621/// kernel only needs distinct, stable-per-tick names, so it uses a simple
622/// monotonic scheme starting past the highest observed `rNN` index, and never
623/// below `r1` since managed-replica names are 1-based.
624struct ReplicaNameGen {
625 next: u32,
626 used: BTreeSet<String>,
627}
628
629impl ReplicaNameGen {
630 fn new(used: &[&str]) -> Self {
631 let mut highest = 1;
632 for name in used {
633 if let Some(idx) = name.strip_prefix('r').and_then(|n| n.parse::<u32>().ok()) {
634 highest = highest.max(idx + 1);
635 }
636 }
637 Self {
638 next: highest,
639 used: used.iter().map(|n| n.to_string()).collect(),
640 }
641 }
642
643 fn next_name(&mut self) -> String {
644 loop {
645 let name = format!("r{}", self.next);
646 self.next += 1;
647 if !self.used.contains(&name) {
648 self.used.insert(name.clone());
649 return name;
650 }
651 }
652 }
653}
654
655#[cfg(test)]
656mod tests;