Skip to main content

aion/runtime/
config.rs

1//! Builder-supplied scheduler configuration for the embedded runtime.
2
3use std::time::Duration;
4
5/// Configuration used when constructing the embedded BEAM runtime.
6#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
7pub struct RuntimeConfig {
8    /// Optional scheduler thread count supplied by the engine builder.
9    ///
10    /// `None` is passed through to beamr so the embedded runtime applies its own
11    /// runtime-aware default.
12    pub thread_count: Option<usize>,
13
14    /// Optional JIT compilation threshold supplied by the engine builder.
15    ///
16    /// `None` is passed through to beamr so the embedded runtime applies its own
17    /// default (`beamr::jit::profiler::DEFAULT_JIT_THRESHOLD`, 1000 recorded
18    /// calls per `(module, function, arity)` at the version this builds against).
19    /// Nothing here invents a value.
20    ///
21    /// # Why this is reachable at all
22    ///
23    /// beamr counts calls per MFA and compiles once the count reaches the
24    /// threshold. Until this field existed the engine held the scheduler and
25    /// never named the threshold, so the compile point was unreachable from
26    /// aion — there was no way to move the moment of compilation, and therefore
27    /// no way to test whether a fault tracks it.
28    ///
29    /// That is what this is for: a *proportionality* instrument. Halving the
30    /// threshold should halve the call count at which a compile-correlated fault
31    /// appears, which predicts a number rather than explaining one. A fault that
32    /// moves with the threshold is caused by compilation; a fault that does not
33    /// is not, however well the arithmetic happened to fit.
34    ///
35    /// # 🔴 What a large value does, and what it does NOT do
36    ///
37    /// This is **not** a JIT off-switch, and must not be documented as one.
38    /// `beamr::jit::profiler::JitProfiler::record_call` increments with
39    /// `saturating_add(1)` and then compiles unless `new_count < threshold`
40    /// (`jit/profiler.rs:253-262`). At `u32::MAX` the counter saturates at
41    /// `u32::MAX` and the comparison stops being true, so the MFA compiles —
42    /// after 4,294,967,295 recorded calls to that one MFA.
43    ///
44    /// Both halves of that sentence are load-bearing. It is a real horizon, not
45    /// a semantic quibble: at the ~5 calls per durable wait measured on this
46    /// engine that is ~859 million waits, so a large value genuinely defers
47    /// compilation past any workload this engine will run. And it is genuinely
48    /// not "off": the compile still happens on the other side of that horizon,
49    /// so anything relying on it never happening is relying on the workload, not
50    /// on the configuration. beamr's own scheduler-level disable is the answer
51    /// to "off", and it is a separate beamr release; this field cannot stand in
52    /// for it.
53    ///
54    /// The threshold is stored in an `AtomicU32` read by `current_threshold`
55    /// (`jit/profiler.rs:109`, `:189-191`). beamr can retune it at runtime via
56    /// `tune_threshold`, but only when an embedder drives that call, and aion
57    /// never does — so a value set here is the value that governs.
58    pub jit_threshold: Option<u32>,
59
60    /// Bounded readiness and retry policy for live signal mailbox delivery.
61    pub signal_delivery: SignalDeliveryConfig,
62
63    /// Unbounded backoff ladder for durable completion retries.
64    pub completion_retry: CompletionRetryConfig,
65
66    /// Whether the durable-outbox fan-out dispatch path is enabled.
67    pub outbox_enabled: bool,
68}
69
70/// Backoff ladder for the process-exit completion retry.
71///
72/// Separate from [`SignalDeliveryConfig`] because the two policies answer
73/// different questions. Signal delivery is a *bounded* enqueue ladder: it asks
74/// "has the process body materialized yet", gives up after
75/// `max_enqueue_attempts`, and its millisecond ceiling is sized for a wait
76/// measured in scheduler ticks. A completion retry is *unbounded* — there is no
77/// attempt count at which abandoning a finished run's terminal event becomes the
78/// right answer, so only the epoch close ends it — and it sleeps between
79/// **durable store round-trips against a store that is already failing**.
80///
81/// Borrowing the first for the second is how a value chosen for one job silently
82/// governs another. There is no attempt count here on purpose; what there is, is
83/// an interval, and it is now stated in one place where it can be changed.
84///
85/// # The fields are private, and that is the whole gate
86///
87/// 🔴 A value of this type is a ladder that CAN CLIMB. That is not a convention
88/// a caller is asked to honour, it is the only thing [`Self::try_new`] returns —
89/// and `try_new` plus [`Default`] are the only ways to obtain one. An earlier
90/// revision left the fields `pub` and put the check in `EngineBuilder::build`,
91/// which meant the invariant held for engines built through that builder and
92/// nowhere else: [`crate::RuntimeHandle::new`] is `pub` and reads
93/// `completion_retry` straight out of a [`RuntimeConfig`] a caller assembled by
94/// hand. There is now no second door to hold shut, because there is no way to
95/// name a degenerate ladder at all.
96///
97/// 🔴 If this type ever gains `serde::Deserialize` — and the server-config lane
98/// (`docs/design/aion-authoring/BRIEF-ENGINE-CONFIG-SERVER-SURFACE.md`) is the
99/// lane that will want it — a derived impl reintroduces exactly the hole this
100/// removed, because a derive writes the fields directly. Deserialization must go
101/// through [`Self::try_new`], not around it.
102///
103/// # The closure is held by these two examples, not by the paragraph above
104///
105/// Privacy is what makes the constructor the only door, and nothing in a unit
106/// test can observe privacy — a test that could name a degenerate ladder would
107/// not compile, so it would be a NON-RUN rather than a failure. These two
108/// doctests are the mechanism. They run from OUTSIDE the crate, which is the
109/// embedder's position, and they are identical but for the one line under test:
110/// if the fields are ever made `pub` again the second stops failing and
111/// `cargo test` says so.
112///
113/// The control — the constructor is reachable and the snippet around it is
114/// sound, so the refusal below cannot be an artefact of a broken example:
115///
116/// ```
117/// use aion::CompletionRetryConfig;
118/// use std::time::Duration;
119///
120/// let ladder = CompletionRetryConfig::try_new(
121///     Duration::from_millis(1),
122///     Duration::from_secs(30),
123/// )?;
124/// assert_eq!(ladder.initial_backoff(), Duration::from_millis(1));
125/// # Ok::<(), aion::InvalidCompletionRetryLadder>(())
126/// ```
127///
128/// The closure — the same snippet with the constructor call replaced by a struct
129/// literal naming a ladder that cannot climb:
130///
131/// ```compile_fail
132/// use aion::CompletionRetryConfig;
133/// use std::time::Duration;
134///
135/// let ladder = CompletionRetryConfig {
136///     initial_backoff: Duration::ZERO,
137///     max_backoff: Duration::ZERO,
138/// };
139/// assert_eq!(ladder.initial_backoff(), Duration::ZERO);
140/// ```
141///
142/// 🔴 The bound on that second example, measured rather than assumed. Making
143/// both fields `pub` again turns it red — the observed failure is rustdoc's
144/// "Test compiled successfully, but it's marked `compile_fail`" — so it does
145/// hold the closure. What it cannot do is verify WHY the compile failed:
146/// `compile_fail` passes when the snippet fails for any reason at all. An
147/// earlier revision of this paragraph claimed a `compile_fail,E0451` annotation
148/// pinned the reason; that was measured and is FALSE on this toolchain — a
149/// snippet edited to fail on an unresolved import still passed with the code
150/// attached, so the annotation was decoration reading as a gate and has been
151/// removed rather than left to mislead.
152///
153/// What stands in its place is the shared shape of the two examples. They differ
154/// in one expression, so a rename of the type, the constructor or the accessor
155/// breaks the CONTROL as well, and the control is an ordinary doctest that must
156/// compile and run. The residue is narrow and named: a hand-edit that breaks
157/// only the failing copy would go unnoticed.
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
159pub struct CompletionRetryConfig {
160    /// Sleep before the second attempt.
161    initial_backoff: Duration,
162
163    /// Upper bound the exponential ladder settles at.
164    max_backoff: Duration,
165}
166
167/// Why a proposed completion-retry ladder was refused.
168///
169/// Both arms describe the same outcome — an interval that cannot climb, driving
170/// a retry with no attempt budget into a hot loop against a store that is by
171/// definition already failing — reached through different fields. Refusing a
172/// value that cannot work is not the same as inventing one that does: nothing
173/// here supplies a floor, a cap or a budget.
174#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
175pub enum InvalidCompletionRetryLadder {
176    /// The floor is zero, so doubling can never lift it.
177    #[error(
178        "initial_backoff must be non-zero: the backoff ladder doubles from it, so a zero start \
179         can never climb and the unbounded retry becomes a hot loop against a failing store"
180    )]
181    ZeroInitialBackoff,
182
183    /// The ceiling sits below the floor, so the ladder ratchets down.
184    #[error(
185        "max_backoff ({max_backoff:?}) must be at least initial_backoff ({initial_backoff:?}): \
186         the ladder clamps to the ceiling as soon as doubling passes it, so a ceiling below the \
187         floor makes the interval ratchet DOWN — and a zero ceiling drives it to zero on the \
188         first advance, which is the same hot loop against a failing store that a zero \
189         initial_backoff would cause"
190    )]
191    CeilingBelowFloor {
192        /// The floor the caller proposed.
193        initial_backoff: Duration,
194
195        /// The ceiling the caller proposed, which is below it.
196        max_backoff: Duration,
197    },
198}
199
200impl Default for CompletionRetryConfig {
201    /// 1 ms initial, **30 s ceiling**, and **no attempt budget** — ruled, with
202    /// reasoning, on 2026-08-06.
203    ///
204    /// Full record and the argument behind every clause:
205    /// `docs/design/aion-authoring/RULING-COMPLETION-RETRY-BOUND-2026-08-06.md`.
206    /// The reasoning is reproduced here because the numbers without it read as
207    /// arbitrary defaults, and it was exactly that appearance — a ladder that
208    /// looked inherited and was actually introduced — that made a ruling
209    /// necessary.
210    ///
211    /// # Where the numbers came from, and what was NOT inherited with them
212    ///
213    /// 1 ms / 8 ms were [`SignalDeliveryConfig`]'s values, kept verbatim when
214    /// this knob was split out of that struct so no intermediate revision
215    /// altered behaviour. 🔴 That is not the same as "inherited":
216    /// **nothing at HEAD retries a completion at all**, so measured against
217    /// the tree this lands on the ladder is a value this change INTRODUCES.
218    ///
219    /// And the bound was not borrowed with the numbers.
220    /// `SignalDeliveryConfig` pairs its ladder with `max_enqueue_attempts: 8` —
221    /// it gives up. At an 8 ms ceiling with no budget this retry was roughly
222    /// **125 attempts per second, indefinitely**, against a store that is by
223    /// definition unwell, all on the single-worker engine-task executor. That is
224    /// a runaway which also starves everything else on that executor.
225    ///
226    /// The store cost of that runaway is **one to six history reads per
227    /// attempt, depending on how far the attempt gets before it dies**, and both
228    /// magnitudes below are derived from that range rather than from a single
229    /// number. 🔴 Two earlier revisions of this paragraph gave a single number
230    /// and then a one-to-three range; both were arrived at by reading the first
231    /// branch and stopping. The list below is the enumeration, and the rule it
232    /// exists to serve is that a cost is counted along every reachable path, not
233    /// along the one that comes to mind.
234    ///
235    /// - **One**, on the fail-fast path. `handle_process_exit_attempt`'s first
236    ///   fallible call past the epoch gate is the history read
237    ///   (`lifecycle/completion.rs:382`), so an attempt that fails *there* has
238    ///   issued one and only one.
239    /// - **Two or three**, on the recorded-then-bookkeeping-failed path. Past
240    ///   the append, `upsert_workflow_visibility` reads history
241    ///   (`lifecycle/visibility.rs:25`, skipped only for `TimedOut`) and
242    ///   `reconcile_terminal_registry` reads it again
243    ///   (`lifecycle/completion.rs:546`).
244    /// - **Four to six**, on the `ContinuedAsNew` branch — which is the ordinary
245    ///   continue-as-new completion, not an edge case, so under a sick store
246    ///   EVERY attempt of that run's loop pays it. Past the two above,
247    ///   `start_continuation_replacement` reads history to check for an existing
248    ///   replacement (`lifecycle/completion.rs:571`), then
249    ///   `start_workflow_with_options` reads again in `workflow_identity`
250    ///   (`lifecycle/start_admission.rs:122`, whenever the id is supplied — and
251    ///   here it always is) and once more for the new run's own visibility
252    ///   upsert (`lifecycle/start.rs:190` → `lifecycle/visibility.rs:25`).
253    ///
254    /// Every one of those can fail `StoreError::Backend`, which
255    /// `completion_retry`'s `store_error_is_transient` classifies **retryable**,
256    /// so the attempt spends its reads and the next one starts over from the
257    /// top. That the deepest of them is reachable is not inferred: the same
258    /// module's `TerminalWriterHeld` classification is justified by naming this
259    /// exact chain — `start_continuation_replacement` →
260    /// `start_workflow_with_options` → `registry.insert` — which sits *past* all
261    /// six.
262    ///
263    /// 🔴 THE O5 PROOF GUARDS THE FIRST NUMBER AND CANNOT SEE ANY OF THE
264    /// OTHERS. Its fixture makes the FIRST read fail, so the loop under it never
265    /// reaches the post-append reads at all; its attempt-count assertion is
266    /// invariant to how many reads a bookkeeping or continuation failure costs.
267    /// It is a real guard on the fail-fast cost and no guard whatsoever on the
268    /// rest, and an earlier revision of this paragraph offered it as a guard on
269    /// both. That is also the shape
270    /// `crate::store_faults::FlakyStore::fail_reads_after` exists to reach — a
271    /// budget that can skip past the append — so the deeper numbers are
272    /// testable; they are simply not tested by O5.
273    ///
274    /// So: 125 attempts per second is 125 reads per second on the fail-fast
275    /// path, and **up to 750** on the continue-as-new one.
276    ///
277    /// # The ceiling: 30 s
278    ///
279    /// At 30 s a stuck retry costs two attempts a minute, and therefore **two
280    /// to twelve** reads a minute by the range above, while the 1 ms start still
281    /// recovers instantly from a transient blip. 30 s is the value already ruled
282    /// for DR-001's R4, so it is precedent rather than a fresh invention.
283    ///
284    /// # No attempt budget, and that is DELIBERATE
285    ///
286    /// 🔴 Read this before "fixing" the missing budget. A completion is truth
287    /// trying to land in the record. `SignalDeliveryConfig`'s budget is sound
288    /// BECAUSE delivery is re-drivable — something upstream tries again. **A
289    /// completion has no re-driver.** If this loop gives up, the completion
290    /// does not land late, it never lands, and the workflow wedges silently —
291    /// converting a transient store outage into permanent silent data loss.
292    /// That is a worse failure than the runaway a budget would prevent, and the
293    /// runaway is already cured by the ceiling. The epoch close stays the only
294    /// terminator.
295    ///
296    /// 🔴 The condition on ever adding one: whoever wants a budget owes the
297    /// design **a place to PUT the abandoned completion** first. Until that
298    /// place exists, giving up is a silent fallback, and this repository does
299    /// not ship those.
300    ///
301    /// # The price of no-budget is loudness
302    ///
303    /// A retry loop ruled unbounded must never be invisible, so
304    /// `lifecycle::completion_retry` states itself at `warn` once the ladder
305    /// reaches this ceiling, carrying attempt count, elapsed time and the last
306    /// error classification. The rate is one line per ceiling-interval attempt
307    /// — which is a CONSEQUENCE of the ceiling, not a throttle. There is no
308    /// separate rate limiter and there must not be one; that would be an
309    /// invented cap.
310    ///
311    /// # Still not reachable from `aion server`
312    ///
313    /// Grepping `aion-server` and `aion-cli` for `completion_retry` /
314    /// `CompletionRetryConfig` returns nothing — nor for `signal_delivery` /
315    /// [`SignalDeliveryConfig`], which is the same shape. Only an embedder
316    /// calling [`crate::EngineBuilder`] can change either. That gap is a
317    /// separate ruled lane
318    /// (`docs/design/aion-authoring/BRIEF-ENGINE-CONFIG-SERVER-SURFACE.md`),
319    /// deliberately not a rider on this change. Until it lands, **this default
320    /// is the shipped answer and it cannot be answered by configuration.**
321    ///
322    /// # Why this does not go through [`CompletionRetryConfig::try_new`]
323    ///
324    /// `Default::default` cannot fail, and this module owns the private fields,
325    /// so the literal is written directly rather than unwrapping a `Result` —
326    /// this crate does not `unwrap` in library code, and a fallback on error
327    /// would be a silent one. That makes these two literals the only values in
328    /// the program that reach the ladder without passing the constructor, so
329    /// `the_default_ladder_is_one_the_constructor_would_accept` puts them back
330    /// through it. The gap is closed by that test, not by care.
331    fn default() -> Self {
332        Self {
333            initial_backoff: Duration::from_millis(1),
334            max_backoff: Duration::from_secs(30),
335        }
336    }
337}
338
339impl CompletionRetryConfig {
340    /// Create an explicit completion-retry backoff ladder, or refuse one that
341    /// cannot climb.
342    ///
343    /// This is the only public constructor, and it is fallible for the reason
344    /// stated on the type: the ladder governs a retry with no attempt budget, so
345    /// an interval that cannot climb is not a fast retry, it is an unterminated
346    /// hot loop against a store that is already unwell.
347    ///
348    /// Any non-zero interval the caller chooses is accepted as given. The only
349    /// pairs refused are the ones that contradict themselves.
350    ///
351    /// # Errors
352    ///
353    /// [`InvalidCompletionRetryLadder::ZeroInitialBackoff`] when the floor is
354    /// zero — `0 * 2 == 0`, so the ladder can never leave it.
355    ///
356    /// [`InvalidCompletionRetryLadder::CeilingBelowFloor`] when the ceiling is
357    /// below the floor. `sleep_backoff` assigns the ceiling whenever doubling
358    /// passes it, so such a ladder ratchets DOWN as the outage lengthens, which
359    /// is the opposite of what a backoff is for; a zero ceiling is the extreme
360    /// of that case and reaches zero on the first advance. A ceiling EQUAL to
361    /// the floor is legitimate — that is a fixed interval — and is accepted.
362    pub fn try_new(
363        initial_backoff: Duration,
364        max_backoff: Duration,
365    ) -> Result<Self, InvalidCompletionRetryLadder> {
366        if initial_backoff.is_zero() {
367            return Err(InvalidCompletionRetryLadder::ZeroInitialBackoff);
368        }
369        if max_backoff < initial_backoff {
370            return Err(InvalidCompletionRetryLadder::CeilingBelowFloor {
371                initial_backoff,
372                max_backoff,
373            });
374        }
375        Ok(Self {
376            initial_backoff,
377            max_backoff,
378        })
379    }
380
381    /// Sleep before the second attempt. Never zero.
382    #[must_use]
383    pub const fn initial_backoff(self) -> Duration {
384        self.initial_backoff
385    }
386
387    /// Upper bound the exponential ladder settles at. Never below
388    /// [`Self::initial_backoff`].
389    #[must_use]
390    pub const fn max_backoff(self) -> Duration {
391        self.max_backoff
392    }
393}
394
395/// Bounded signal delivery retry policy supplied by engine configuration.
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397pub struct SignalDeliveryConfig {
398    /// Maximum time to wait for a just-spawned process body to materialize.
399    pub ready_timeout: Duration,
400
401    /// Maximum number of mailbox enqueue attempts after the ready gate.
402    pub max_enqueue_attempts: u32,
403
404    /// Initial sleep between failed enqueue attempts.
405    pub initial_backoff: Duration,
406
407    /// Upper bound for exponential backoff between enqueue attempts.
408    pub max_backoff: Duration,
409}
410
411impl Default for SignalDeliveryConfig {
412    fn default() -> Self {
413        Self::new(
414            Duration::from_millis(50),
415            8,
416            Duration::from_millis(1),
417            Duration::from_millis(8),
418        )
419    }
420}
421
422impl SignalDeliveryConfig {
423    /// Create an explicit signal delivery policy.
424    #[must_use]
425    pub const fn new(
426        ready_timeout: Duration,
427        max_enqueue_attempts: u32,
428        initial_backoff: Duration,
429        max_backoff: Duration,
430    ) -> Self {
431        Self {
432            ready_timeout,
433            max_enqueue_attempts,
434            initial_backoff,
435            max_backoff,
436        }
437    }
438
439    /// Bound for draining the single cleanup worker after every registered
440    /// process has been force-unblocked.
441    ///
442    /// A zero process-readiness bound is valid for refusal tests, but is not a
443    /// useful thread-shutdown observation window. Use at least the standard
444    /// readiness window and scale it by every job the worker can own (one
445    /// running plus the bounded queue) rather than imposing an unrelated
446    /// timeout constant.
447    pub(crate) fn cleanup_shutdown_timeout(self) -> Duration {
448        self.ready_timeout
449            .max(Self::default().ready_timeout)
450            .saturating_mul(self.max_enqueue_attempts.max(1).saturating_add(1))
451    }
452}
453
454impl RuntimeConfig {
455    /// Create runtime configuration from the builder-supplied scheduler count.
456    #[must_use]
457    pub fn new(thread_count: Option<usize>) -> Self {
458        Self {
459            thread_count,
460            jit_threshold: None,
461            signal_delivery: SignalDeliveryConfig::default(),
462            completion_retry: CompletionRetryConfig::default(),
463            outbox_enabled: false,
464        }
465    }
466
467    /// Override the JIT compilation threshold passed to beamr's scheduler.
468    ///
469    /// Read [`Self::jit_threshold`] before using this. In particular a large
470    /// value defers compilation past any realistic workload but does not
471    /// disable the JIT, and must not be described as doing so.
472    #[must_use]
473    pub const fn with_jit_threshold(mut self, jit_threshold: Option<u32>) -> Self {
474        self.jit_threshold = jit_threshold;
475        self
476    }
477
478    /// Override the signal delivery retry policy.
479    #[must_use]
480    pub const fn with_signal_delivery(mut self, signal_delivery: SignalDeliveryConfig) -> Self {
481        self.signal_delivery = signal_delivery;
482        self
483    }
484
485    /// Override the durable completion-retry backoff ladder.
486    #[must_use]
487    pub const fn with_completion_retry(mut self, completion_retry: CompletionRetryConfig) -> Self {
488        self.completion_retry = completion_retry;
489        self
490    }
491
492    /// Override whether the durable-outbox fan-out dispatch path is enabled.
493    #[must_use]
494    pub const fn with_outbox_enabled(mut self, enabled: bool) -> Self {
495        self.outbox_enabled = enabled;
496        self
497    }
498}
499
500#[cfg(test)]
501mod completion_retry_ladder_tests {
502    use super::{CompletionRetryConfig, InvalidCompletionRetryLadder};
503    use std::time::Duration;
504
505    /// The two literals in `Default::default` are the only values in the
506    /// program that reach the ladder without passing the constructor. This puts
507    /// them back through it.
508    ///
509    /// It is not a restatement of the constructor's arithmetic — it never names
510    /// 1 ms or 30 s. It reads whatever the default holds and asks the gate
511    /// whether that pair is nameable, so a future change to the ruled numbers is
512    /// checked by this test rather than escaping it.
513    #[test]
514    fn the_default_ladder_is_one_the_constructor_would_accept() {
515        let shipped = CompletionRetryConfig::default();
516        let through_the_gate =
517            CompletionRetryConfig::try_new(shipped.initial_backoff(), shipped.max_backoff());
518        assert_eq!(
519            through_the_gate,
520            Ok(shipped),
521            "the shipped default must be a ladder the constructor would have accepted, or \
522             `Default` is a second door into the type with different rules"
523        );
524    }
525
526    /// A zero floor cannot be NAMED — not "is refused later by whoever happens
527    /// to check", but cannot be brought into existence.
528    ///
529    /// Zero is not a fast retry: `sleep_backoff` doubles from the current value
530    /// and `0 * 2 == 0`, and the completion retry has no attempt budget, so the
531    /// ladder can never climb and nothing else would ever stop it.
532    ///
533    /// The `Ok` half is the control. Both halves use the same ceiling, so the
534    /// only difference between them is the floor — without it a refusal here
535    /// would not be attributable to the zero at all.
536    #[test]
537    fn a_zero_initial_backoff_cannot_be_named() -> Result<(), Box<dyn std::error::Error>> {
538        assert_eq!(
539            CompletionRetryConfig::try_new(Duration::ZERO, Duration::from_millis(8)),
540            Err(InvalidCompletionRetryLadder::ZeroInitialBackoff),
541            "a zero initial backoff must be refused by the constructor"
542        );
543
544        let accepted =
545            CompletionRetryConfig::try_new(Duration::from_millis(1), Duration::from_millis(8))?;
546        assert_eq!(accepted.initial_backoff(), Duration::from_millis(1));
547        assert_eq!(accepted.max_backoff(), Duration::from_millis(8));
548        Ok(())
549    }
550
551    /// 🔴 The CEILING arm refuses too — the arm the floor's mutation never
552    /// drives.
553    ///
554    /// `try_new` has two conditions, and a mutation that deletes the second one
555    /// leaves the test above green, because it supplies a non-zero floor and so
556    /// never reaches the second condition: A MUTATION ONLY MEASURES THE BRANCH
557    /// THE TEST EXECUTES.
558    ///
559    /// Two shapes, because they fail differently and only one is obvious. A zero
560    /// ceiling drives the interval to zero on the first advance — the same hot
561    /// loop against a failing store that a zero floor causes. A ceiling merely
562    /// *below* the floor is subtler: the ladder clamps to the ceiling as soon as
563    /// doubling passes it, so the wait ratchets DOWN as the outage lengthens.
564    ///
565    /// The control is a ceiling EQUAL to the floor, which is a legitimate ladder
566    /// — a fixed interval — and must be accepted. Without it, a constructor that
567    /// refused everything would satisfy the two assertions above.
568    #[test]
569    fn a_ceiling_below_the_floor_cannot_be_named() {
570        let floor = Duration::from_millis(8);
571
572        assert_eq!(
573            CompletionRetryConfig::try_new(floor, Duration::ZERO),
574            Err(InvalidCompletionRetryLadder::CeilingBelowFloor {
575                initial_backoff: floor,
576                max_backoff: Duration::ZERO,
577            }),
578            "a zero max_backoff must be refused — it drives the interval to zero on the first \
579             advance"
580        );
581
582        assert_eq!(
583            CompletionRetryConfig::try_new(floor, Duration::from_millis(4)),
584            Err(InvalidCompletionRetryLadder::CeilingBelowFloor {
585                initial_backoff: floor,
586                max_backoff: Duration::from_millis(4),
587            }),
588            "a max_backoff below initial_backoff must be refused — the interval would ratchet \
589             DOWN as the outage lengthens"
590        );
591
592        assert!(
593            CompletionRetryConfig::try_new(floor, floor).is_ok(),
594            "a ceiling equal to the floor is a fixed interval and a legitimate ladder — if this \
595             is refused the two refusals above are not attributable to the ceiling being LOW"
596        );
597    }
598}