Skip to main content

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, ClusterControllerCtx, ClusterState, CreateReason, Decision, ObservedReplica,
48    ReconfigurationAudit, ReconfigurationRecord, ReconfigurationStatus, ReconfigurationWrite,
49    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: a field set by exactly one strategy is taken as-is, a field no
244    /// strategy sets is left unchanged, and a field set to the same value by
245    /// 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 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        merged
328    }
329
330    /// Fetch the live signals the strategies declared they need for `states`.
331    ///
332    /// Each strategy names its needs as a pure function of the durable state
333    /// and the tick's config signals ([`Strategy::signal_request`]), so the
334    /// kernel stays ignorant of when a strategy engages. Signals are fetched
335    /// only where requested: a steady cluster is never probed, keeping the ctx
336    /// seam pay-for-what-you-use. Refresh-window inputs are fetched as one batch
337    /// so every scheduled cluster shares one oracle read per phase. The returned
338    /// map omits a state when one of its required inputs was unavailable, which
339    /// causes the reconciliation phase to skip that cluster.
340    async fn fetch_signals(
341        &self,
342        ctx: &mut dyn ClusterControllerCtx,
343        states: &[ClusterState],
344        config: &ConfigSignals,
345    ) -> BTreeMap<ClusterId, LiveSignals> {
346        let mut signals = BTreeMap::new();
347        let mut refresh_window_clusters = Vec::new();
348        for state in states {
349            let request = self
350                .strategies
351                .iter()
352                .fold(SignalRequest::default(), |acc, strategy| {
353                    acc.union(strategy.signal_request(state, config))
354                });
355            let mut live = LiveSignals::default();
356            if request.hydratable_objects {
357                live.has_hydratable_objects = ctx.has_hydratable_objects(state.cluster_id).await;
358            }
359            if request.hydration || request.readiness.is_some() {
360                let replica_ids: Vec<_> = state
361                    .replicas
362                    .iter()
363                    .filter(|r| r.owned_shape().is_some())
364                    .map(|r| r.replica_id)
365                    .collect();
366                if !replica_ids.is_empty() {
367                    // Two probes only when a cluster has both an in-flight
368                    // reconfiguration and an armed burst policy. Each is an
369                    // in-memory pass over the instance's collections, and the
370                    // two answer different questions, so neither subsumes the
371                    // other cheaply enough to be worth deriving one from the
372                    // other here.
373                    if request.hydration {
374                        live.hydrated_replicas =
375                            ctx.hydrated_replicas(state.cluster_id, &replica_ids).await;
376                    }
377                    if let Some(reference) = &request.readiness {
378                        live.ready_replicas = ctx
379                            .ready_replicas(state.cluster_id, &replica_ids, reference)
380                            .await;
381                    }
382                }
383            }
384            if request.refresh_window {
385                refresh_window_clusters.push(state.cluster_id);
386            }
387            signals.insert(state.cluster_id, live);
388        }
389        if !refresh_window_clusters.is_empty() {
390            match ctx.refresh_window_inputs(&refresh_window_clusters).await {
391                Some(batch) => {
392                    let read_ts = batch.read_ts;
393                    let mut cluster_inputs = batch.cluster_inputs;
394                    for cluster_id in refresh_window_clusters {
395                        let Some(inputs) = cluster_inputs.remove(&cluster_id) else {
396                            signals.remove(&cluster_id);
397                            continue;
398                        };
399                        let live = signals
400                            .get_mut(&cluster_id)
401                            .expect("signal entry inserted for requested cluster");
402                        live.refresh_window = Some(RefreshWindowInputs {
403                            read_ts,
404                            compaction_estimate: inputs.compaction_estimate,
405                            refresh_mvs: inputs.refresh_mvs,
406                        });
407                    }
408                }
409                None => {
410                    for cluster_id in refresh_window_clusters {
411                        signals.remove(&cluster_id);
412                    }
413                }
414            }
415        }
416        signals
417    }
418
419    /// Diff the unioned desired set against the actual replicas of one cluster
420    /// and emit the create/drop decisions that close the gap.
421    fn collect_replica_decisions(
422        &self,
423        state: &ClusterState,
424        signals: &LiveSignals,
425        config: &ConfigSignals,
426        now: mz_repr::Timestamp,
427    ) -> Vec<Decision> {
428        let contributions: Vec<Vec<DesiredReplica>> = self
429            .strategies
430            .iter()
431            .map(|strategy| strategy.desired_replicas(state, signals, config, now))
432            .collect();
433
434        reconcile_replicas(state, &contributions)
435    }
436}
437
438/// Join one `StateWrite` field across the strategies that set it: `None` if
439/// none did, the common value if one or more set it to the same value, and
440/// `None` with `field` pushed onto `conflicts` if two set it to different
441/// values. The result and the conflict signal depend only on the set of values,
442/// not the order they arrive in.
443fn join<T: PartialEq>(
444    field: &'static str,
445    values: impl IntoIterator<Item = Option<T>>,
446    conflicts: &mut Vec<&'static str>,
447) -> Option<T> {
448    let mut merged: Option<T> = None;
449    for value in values.into_iter().flatten() {
450        match &merged {
451            None => merged = Some(value),
452            Some(existing) if *existing == value => {}
453            // Two strategies disagree on this field. Record it and leave the
454            // field unchanged; merge_state_writes raises the alarm.
455            Some(_) => {
456                conflicts.push(field);
457                return None;
458            }
459        }
460    }
461    merged
462}
463
464/// The pure multiset union/diff kernel for one cluster: given each strategy's
465/// desired replica slots and the actual replicas, match slots to replicas by
466/// shape and emit the creates and drops that close the gap.
467///
468/// Semantics:
469/// - The desired set is the multiset **union** of every strategy's slots: a
470///   given shape is desired `max` over strategies (not the sum), since a replica
471///   of that shape satisfies every strategy that wants one. This is what makes a
472///   replica survive iff *some* strategy desires its shape.
473/// - For each shape, if actual count < desired count we create the difference;
474///   if actual count > desired count we drop the difference, picking specific
475///   excess replicas. A replica of a shape no strategy desires is dropped.
476/// - Creates carry the winning [`CreateReason`] among the slots that
477///   desired the shape (see [`CreateReason::outranks`]). Drops carry no
478///   attribution. A drop happens exactly when no strategy desires the replica.
479fn reconcile_replicas(
480    state: &ClusterState,
481    contributions: &[Vec<DesiredReplica>],
482) -> Vec<Decision> {
483    // Desired count per shape = max over strategies of how many that strategy
484    // wants of the shape, carrying the highest-ranking reason among the
485    // slots.
486    let mut desired: Vec<DesiredShape> = Vec::new();
487    for slots in contributions {
488        // How many of each shape this strategy wants, and the winning reason
489        // among the shape's slots.
490        let mut per_shape: Vec<(ReplicaShape, usize, CreateReason)> = Vec::new();
491        for slot in slots {
492            match per_shape
493                .iter_mut()
494                .find(|(s, _, _)| s.matches(&slot.shape))
495            {
496                Some((_, count, reason)) => {
497                    *count += 1;
498                    if slot.reason.outranks(reason) {
499                        *reason = slot.reason.clone();
500                    }
501                }
502                None => per_shape.push((slot.shape.clone(), 1, slot.reason.clone())),
503            }
504        }
505        for (shape, count, reason) in per_shape {
506            match desired.iter_mut().find(|d| d.shape.matches(&shape)) {
507                Some(existing) => {
508                    existing.count = existing.count.max(count);
509                    if reason.outranks(&existing.reason) {
510                        existing.reason = reason;
511                    }
512                }
513                None => desired.push(DesiredShape {
514                    shape,
515                    count,
516                    reason,
517                }),
518            }
519        }
520    }
521
522    // Bucket the controller-owned replicas by shape. Replicas the controller
523    // does not own (see `ObservedReplica::owned_shape`) are invisible to the
524    // desired/actual diff: neither counted toward a shape nor dropped.
525    let mut actual_by_shape: Vec<(ReplicaShape, Vec<&ObservedReplica>)> = Vec::new();
526    for replica in &state.replicas {
527        let Some(shape) = replica.owned_shape() else {
528            continue;
529        };
530        match actual_by_shape.iter_mut().find(|(s, _)| s.matches(shape)) {
531            Some((_, replicas)) => replicas.push(replica),
532            None => actual_by_shape.push((shape.clone(), vec![replica])),
533        }
534    }
535
536    let mut decisions = Vec::new();
537
538    // Every observed replica occupies a name, owned or not, so a generated
539    // name never collides with a replica already on the cluster.
540    let used_names: Vec<&str> = state.replicas.iter().map(|r| r.name.as_str()).collect();
541    let mut name_gen = ReplicaNameGen::new(&used_names);
542
543    // The compare-and-append witness for every create/drop this tick emits for
544    // the cluster: the apply path rejects the batch if the cluster's durable
545    // state has diverged from what we diffed against (e.g. a concurrent `ALTER`),
546    // so a stale create/drop can never reshape the replica set against the new
547    // config.
548    let expected = state.expected();
549
550    // Creates: for each desired shape, fill the gap below its desired count.
551    for d in &desired {
552        let actual_count = actual_by_shape
553            .iter()
554            .find(|(s, _)| s.matches(&d.shape))
555            .map(|(_, replicas)| replicas.len())
556            .unwrap_or(0);
557        for _ in actual_count..d.count {
558            decisions.push(Decision::CreateReplica {
559                cluster_id: state.cluster_id,
560                name: name_gen.next_name(),
561                shape: d.shape.clone(),
562                // Multiple creates of one shape in a tick share the merged
563                // reason.
564                reason: d.reason.clone(),
565                expected: expected.clone(),
566            });
567        }
568    }
569
570    // Drops: any actual replica beyond the desired count for its shape, plus
571    // every replica of a shape no strategy desires.
572    for (shape, replicas) in &actual_by_shape {
573        let desired_count = desired
574            .iter()
575            .find(|d| d.shape.matches(shape))
576            .map(|d| d.count)
577            .unwrap_or(0);
578        for replica in replicas.iter().skip(desired_count) {
579            decisions.push(Decision::DropReplica {
580                cluster_id: state.cluster_id,
581                replica_id: replica.replica_id,
582                expected: expected.clone(),
583            });
584        }
585    }
586
587    decisions
588}
589
590/// A shape the union desires, how many, and the highest-ranking reason of
591/// the strategies that wanted it.
592struct DesiredShape {
593    shape: ReplicaShape,
594    count: usize,
595    reason: CreateReason,
596}
597
598/// Generates deterministic fresh replica names that avoid a set of in-use names.
599///
600/// The controller derives names from the observed actual set rather than
601/// renaming existing replicas, which keeps re-emission harmless. The concrete
602/// naming convention (the `rNN` managed-replica scheme) is the environment's; the
603/// kernel only needs distinct, stable-per-tick names, so it uses a simple
604/// monotonic scheme starting past the highest observed `rNN` index, and never
605/// below `r1` since managed-replica names are 1-based.
606struct ReplicaNameGen {
607    next: u32,
608    used: BTreeSet<String>,
609}
610
611impl ReplicaNameGen {
612    fn new(used: &[&str]) -> Self {
613        let mut highest = 1;
614        for name in used {
615            if let Some(idx) = name.strip_prefix('r').and_then(|n| n.parse::<u32>().ok()) {
616                highest = highest.max(idx + 1);
617            }
618        }
619        Self {
620            next: highest,
621            used: used.iter().map(|n| n.to_string()).collect(),
622        }
623    }
624
625    fn next_name(&mut self) -> String {
626        loop {
627            let name = format!("r{}", self.next);
628            self.next += 1;
629            if !self.used.contains(&name) {
630                self.used.insert(name.clone());
631                return name;
632            }
633        }
634    }
635}
636
637#[cfg(test)]
638mod tests;