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