Skip to main content

tatara_process/
requeue.rs

1//! Requeue-action primitives — the small typed layer over the
2//! `kube::runtime::controller::Action::requeue(std::time::Duration
3//! ::from_secs(<secs>))` two-link chain every phase-machine handler,
4//! error-policy sink, and Signals ingest arm in the workspace exits
5//! through.
6//!
7//! `kube_runtime` exposes only ONE requeue constructor —
8//! `Action::requeue(std::time::Duration)` — but every consumer in
9//! this workspace already carries its retry / heartbeat / short-
10//! retry budget as a whole-second `u64` (the module-level
11//! `TICK_RETRY` / `HEARTBEAT` / `SHORT_RETRY` constants, the
12//! per-call literals `1`, `5`, `10`, `15`, `30`, `60`, `120`, and
13//! the `ctx.config.heartbeat_seconds` slot). This module owns the
14//! one-line chain from that `u64` to the returned `Action`, so a
15//! future normalization (an injectable-jitter overlay, a bounded-
16//! rate limiter, a per-controller floor / ceiling clamp, a
17//! deterministic-clock test hook) lands at ONE substrate primitive
18//! and every downstream retry sink inherits the upgrade mechanically.
19
20use kube::runtime::controller::Action;
21use std::time::Duration;
22
23/// A `kube::runtime::controller::Action` that re-enqueues the
24/// current object `secs` seconds from now — the one-line
25/// `Action::requeue(Duration::from_secs(secs))` two-link chain
26/// lifted to ONE typed owner past the ★★ PRIME-DIRECTIVE ≥ 2
27/// duplication threshold.
28///
29/// Pre-lift the SAME chain was hand-authored at 35 workspace-wide
30/// consumer sites across 5 files in the two ACTIVE reconciler
31/// crates:
32///
33/// * `tatara-reconciler::phase_machine` — 22 sites feeding the
34///   module-level `TICK_RETRY` / `HEARTBEAT` / `SHORT_RETRY`
35///   constants at every FSM handler's tail (the `pending` /
36///   `forking` / `execing` / `running` / `attested` / `reconverging`
37///   / `releasing` / `exiting` / `failed` / `zombie` / `reaped`
38///   arms).
39/// * `tatara-reconciler::controller` — 5 sites feeding literal `1`
40///   (deletion-preemption + signal-arm re-poll), the
41///   `ctx.config.heartbeat_seconds` slot (suspend), and `30`
42///   (reconcile-error sink + `error_policy`).
43/// * `tatara-reconciler::table_controller` — 2 sites feeding `30`
44///   at the ProcessTable heartbeat + error policy.
45/// * `tatara-pool-reconciler::controller_pool` — 3 sites feeding
46///   the reconcile-interval slot + literal `15`.
47/// * `tatara-pool-reconciler::controller_allocation` — 3 sites
48///   feeding literal `5` (bind-retry), the reconcile-interval
49///   slot, and literal `15`.
50///
51/// All 35 sites walked the SAME two-link chain — take a whole-
52/// second `u64` (a literal, a named constant, or a config slot),
53/// wrap it in a `std::time::Duration` via `from_secs`, then hand
54/// the duration to `Action::requeue`. Differing only in the
55/// second-count operand. Post-lift each callsite reads
56/// `tatara_process::requeue::after_secs(N)` and the wrap +
57/// requeue chain lives at ONE substrate owner.
58///
59/// Return-form axis: `kube::runtime::controller::Action` — the
60/// exact type every kube-runtime `reconcile` fn returns as
61/// `Ok(...)` and every `error_policy` returns bare. The `u64`
62/// `secs` parameter matches `Duration::from_secs`'s own signature
63/// so the migration is byte-identical: every pre-lift site fed a
64/// `u64` (either a literal, a named `pub const N: u64 = ...;`, or
65/// a config field typed as `u64`) directly into `Duration::
66/// from_secs`, and the same feed continues to work at
67/// `after_secs`.
68///
69/// A future normalization — an injectable jitter overlay that
70/// randomizes ±10% of `secs` to avoid a thundering herd of
71/// synchronized reconcile ticks, a per-controller
72/// floor/ceiling clamp so a mis-configured heartbeat can't drive
73/// the API server, an injectable deterministic clock so
74/// integration tests can advance requeue budgets without waiting
75/// wall-clock time, a per-fleet rate limiter that spreads bursts
76/// across a sliding window, a `tracing`-annotated span carrying
77/// the requeue reason for post-hoc audit — lands at THIS ONE
78/// substrate primitive and every downstream retry sink across
79/// the two active reconciler crates inherits the upgrade
80/// mechanically. No per-site edit at any of the 35 listed
81/// callers or at future consumers (a new phase handler, a new
82/// controller crate, a per-Kind retry sink).
83///
84/// Sibling to the timed-decision primitives in [`crate::time`]
85/// on the "second-count → typed timed value" axis: `seconds_ago(N)
86/// -> DateTime<Utc>` seeds a wall-clock anchor `N` seconds in the
87/// past for `elapsed_since` consumers; `after_secs(N) -> Action`
88/// seeds a kube-runtime requeue `N` seconds in the future. Both
89/// carry the same "`u64` seconds is the workspace's canonical
90/// short-time unit" invariant so a switch to a finer-grained unit
91/// (a millisecond-precision retry budget for tight probes) would
92/// land at both primitives together rather than as scattered per-
93/// site conversions.
94#[must_use]
95pub fn after_secs(secs: u64) -> Action {
96    Action::requeue(Duration::from_secs(secs))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    // ─── after_secs substrate pins ─────────────────────────────────────
104    //
105    // Bind [`after_secs`] at fail-before-pass-after granularity so a
106    // regression that swapped the unit (`from_millis` instead of
107    // `from_secs`), flipped the requeue constructor (`Action::await_change`
108    // instead of `Action::requeue`), dropped the return through a stale
109    // hardcoded fallback, or reshaped the return form (an owned `Duration`
110    // instead of the `Action`) surfaces HERE rather than as silent
111    // operator-visible drift at the 35 downstream consumer sites across
112    // both active reconciler crates.
113
114    #[test]
115    fn after_secs_returns_action_carrying_the_second_count_duration() {
116        // Primary shape asserted end-to-end: the returned Action carries
117        // the exact `Duration::from_secs(secs)` value on its requeue slot.
118        // `Action` doesn't expose its inner Duration as a public getter,
119        // so verify the shape via `Debug` — `Action::requeue(Duration::
120        // from_secs(30))` formats with `30s` somewhere in the debug output;
121        // a regression that unit-swapped to `from_millis` would show `30ms`.
122        let debug = format!("{:?}", after_secs(30));
123        assert!(
124            debug.contains("30s") || debug.contains("30 s") || debug.contains("30.0s"),
125            "after_secs(30) debug {debug:?} must mention 30s — a regression that swapped the unit would drift here"
126        );
127    }
128
129    #[test]
130    fn after_secs_zero_returns_action_that_requeues_immediately() {
131        // Boundary corner: `secs = 0` yields `Action::requeue(Duration::
132        // ZERO)`, the "requeue this reconciliation immediately" shape
133        // kube-runtime treats as a tight re-tick. A regression that
134        // clamped the zero arm to a minimum (a "no requeue faster than
135        // 1s" clamp) would silently delay every tight-loop consumer that
136        // legitimately wants an immediate re-tick — the signal-consumed
137        // arm in `controller.rs`, a pending-phase check that just
138        // observed a wire update.
139        let debug = format!("{:?}", after_secs(0));
140        assert!(
141            debug.contains("0s") || debug.contains("0 s") || debug.contains("0ns"),
142            "after_secs(0) debug {debug:?} must reflect a zero requeue duration — a regression that clamped to a minimum would drift here"
143        );
144    }
145
146    #[test]
147    fn after_secs_matches_hand_authored_pre_lift_chain_shape() {
148        // Byte-identical parity with the pre-lift `Action::requeue(
149        // Duration::from_secs(N))` block that all 35 hand-authored
150        // callsites restated verbatim, swept across the representative
151        // second-counts every pre-lift consumer used:
152        //
153        //   1  → deletion-preempt + signal re-poll (`controller.rs`)
154        //   5  → bind-retry (`controller_allocation.rs`)
155        //   15 → cluster fallback (`controller_pool` / `controller_allocation`)
156        //   30 → reconcile-error + table-controller (`controller.rs`,
157        //        `table_controller.rs`)
158        //   60 → `TICK_RETRY` / `HEARTBEAT` scale (`phase_machine.rs`)
159        //   3600 → hour-scale sanity check
160        //
161        // `Action` doesn't derive `PartialEq`, so parity is asserted via
162        // `Debug` output — both blocks build the exact same requeue
163        // Action, so their debug reps must string-equal.
164        for secs in [1_u64, 5, 15, 30, 60, 3_600] {
165            let composed = format!("{:?}", after_secs(secs));
166            let hand_authored = format!("{:?}", Action::requeue(Duration::from_secs(secs)));
167            assert_eq!(
168                composed, hand_authored,
169                "after_secs({secs}) debug {composed:?} must match hand-authored Action::requeue(Duration::from_secs({secs})) debug {hand_authored:?} — a regression that reshaped either link would drift here"
170            );
171        }
172    }
173
174    #[test]
175    fn after_secs_composes_at_reconcile_return_position() {
176        // The canonical consumer shape end-to-end: a `reconcile` fn
177        // returns `Ok(after_secs(N))` where the caller expects a
178        // `Result<Action, _>` back. A regression that returned a
179        // different type (an owned `Duration`, a `Result` wrapper,
180        // an `Option<Action>`) would fail to type-check at every
181        // consumer. Pin the return shape by explicitly annotating
182        // the `Ok` arm so a regression that widened the return
183        // surfaces HERE rather than as a workspace-wide type error.
184        let out: Result<Action, ()> = Ok(after_secs(5));
185        assert!(out.is_ok());
186    }
187
188    #[test]
189    fn after_secs_composes_at_error_policy_return_position() {
190        // Peer to the reconcile-return shape: `error_policy` returns
191        // bare `Action` (no `Result` wrapper). Pin that shape too so a
192        // regression that changed the return to `Result<Action, _>`
193        // would surface HERE rather than as a workspace-wide type
194        // error at every `Controller::run(...).error_policy(...)`
195        // callsite.
196        let _out: Action = after_secs(30);
197    }
198
199    #[test]
200    fn after_secs_accepts_config_slot_typed_as_u64() {
201        // The `ctx.config.heartbeat_seconds` slot and every
202        // `TICK_RETRY` / `HEARTBEAT` / `SHORT_RETRY` module-level
203        // `pub const N: u64` binding is typed as `u64`; the primitive
204        // must accept those without a cast. A regression that
205        // narrowed the parameter to `u32` or widened it to `i64`
206        // would break either the `phase_machine.rs` const-fed
207        // callsites or the `controller.rs` config-fed slot at the
208        // migration boundary.
209        let heartbeat_seconds: u64 = 60;
210        let _out: Action = after_secs(heartbeat_seconds);
211    }
212}