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}