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