Skip to main content

proxima_primitives/pipe/
delay.rs

1//! Per-call artificial-latency [`SendPipe`]/[`Pipe`] combinator — waits a
2//! sampled [`Dist`]ribution before dispatching each call to the inner pipe.
3//!
4//! # Composed primitives
5//!
6//! - [`Clock`] — the injectable sleep seam
7//!   `Delay` schedules its wait against, the same seam
8//!   [`Retry`](crate::pipe::retry::Retry) and
9//!   [`RateLimit`](crate::pipe::rate_limit::RateLimit) are generic over.
10//!   `Delay` only calls `Clock::delay`; it never reads `Clock::now_nanos`
11//!   (it has no deadline arithmetic to do).
12//! - [`TimeClock`] — the production `Clock`, and the default `Clk` type
13//!   parameter, so every existing caller (`Delay::new`, `DelayConfig`,
14//!   `DelayFactory`) is unaffected.
15//!
16//! # Why a wrapper exists vs. composing the primitive directly
17//!
18//! `Clock::delay` alone is just a future to await; `Delay` adds the policy
19//! around it: per-call-index sampling (`Dist::Const` / `Dist::Range`, seeded
20//! and deterministic), an optional [`When`] gate that skips the wait on a
21//! miss, and the serde/`PipeFactory` surface (`DelayConfig`, `DelayFactory`)
22//! that lets a pipeline graph configure it declaratively.
23
24use alloc::sync::Arc;
25use bytes::Bytes;
26use core::future::Future;
27use core::time::Duration;
28use portable_atomic::{AtomicU64, Ordering};
29
30use crate::pipe::SendPipe;
31use crate::pipe::capabilities::Clock;
32use crate::pipe::clock::TimeClock;
33use crate::pipe::primitives::Pipe;
34use crate::pipe::when::When;
35use serde::{Deserialize, Serialize};
36
37use crate::pipe::handler::{PipeHandle, ThreadLocalPipeHandle};
38use crate::pipe::request::{Request, Response};
39use proxima_core::ProximaError;
40
41#[cfg(feature = "std")]
42use crate::pipe::handler::into_handle;
43#[cfg(feature = "std")]
44use crate::pipe::pipe_factory::PipeFactory;
45#[cfg(feature = "std")]
46use serde_json::Value;
47#[cfg(feature = "std")]
48use std::pin::Pin;
49
50/// How a [`Delay`] picks the duration to wait before each call.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum Dist {
54    Const { ms: u64 },
55    Range { min_ms: u64, max_ms: u64, seed: u64 },
56}
57
58impl Dist {
59    #[must_use]
60    pub fn sample(&self, call_index: u64) -> Duration {
61        match self {
62            Dist::Const { ms } => Duration::from_millis(*ms),
63            Dist::Range {
64                min_ms,
65                max_ms,
66                seed,
67            } => {
68                let (low, high) = if min_ms <= max_ms {
69                    (*min_ms, *max_ms)
70                } else {
71                    (*max_ms, *min_ms)
72                };
73                let mut rng = fastrand::Rng::with_seed(seed.wrapping_add(call_index));
74                Duration::from_millis(rng.u64(low..=high))
75            }
76        }
77    }
78}
79
80impl Default for Dist {
81    fn default() -> Self {
82        Dist::Const { ms: 0 }
83    }
84}
85
86// ── main struct ──────────────────────────────────────────────────────────────
87
88pub struct Delay<Inner = PipeHandle, Clk = TimeClock> {
89    pub inner: Inner,
90    dist: Dist,
91    clock: Clk,
92    when: Option<When>,
93    calls: Arc<AtomicU64>,
94}
95
96impl<Inner> Delay<Inner, TimeClock> {
97    #[must_use]
98    pub fn new(inner: Inner, dist: Dist) -> Self {
99        Self::with_clock(inner, dist, TimeClock)
100    }
101}
102
103impl<Inner, Clk> Delay<Inner, Clk> {
104    /// Materialise with an explicit [`Clock`] — the seam a deterministic test
105    /// or example injects a fake clock through; production code goes via
106    /// [`Delay::new`], which defaults `Clk` to [`TimeClock`].
107    #[must_use]
108    pub fn with_clock(inner: Inner, dist: Dist, clock: Clk) -> Self {
109        Self {
110            inner,
111            dist,
112            clock,
113            when: None,
114            calls: Arc::new(AtomicU64::new(0)),
115        }
116    }
117
118    #[must_use]
119    pub fn with_when(mut self, when: When) -> Self {
120        self.when = Some(when);
121        self
122    }
123
124    #[must_use]
125    pub fn dist(&self) -> Dist {
126        self.dist
127    }
128
129    #[must_use]
130    pub fn when(&self) -> Option<When> {
131        self.when
132    }
133
134    fn wait_for(&self, call_index: u64) -> Duration {
135        match self.when {
136            Some(gate) if !gate.fires(call_index) => Duration::ZERO,
137            _ => self.dist.sample(call_index),
138        }
139    }
140}
141
142impl<Inner, Clk> SendPipe for Delay<Inner, Clk>
143where
144    Inner: SendPipe + Clone + Send + Sync + 'static,
145    Inner::In: Send,
146    Clk: Clock + Clone + Send + Sync + 'static,
147    Clk::Delay: Send,
148{
149    type In = Inner::In;
150    type Out = Inner::Out;
151    type Err = Inner::Err;
152
153    fn call(
154        &self,
155        input: Inner::In,
156    ) -> impl Future<Output = Result<Inner::Out, Inner::Err>> + Send {
157        let call_index = self.calls.fetch_add(1, Ordering::Relaxed);
158        let wait = self.wait_for(call_index);
159        let sleep = self.clock.delay(wait);
160        let inner = self.inner.clone();
161        async move {
162            sleep.await;
163            SendPipe::call(&inner, input).await
164        }
165    }
166}
167
168impl<Clk> Pipe for Delay<ThreadLocalPipeHandle, Clk>
169where
170    ThreadLocalPipeHandle: Pipe<In = Request<Bytes>, Out = Response<Bytes>, Err = ProximaError>,
171    Clk: Clock + Clone + 'static,
172{
173    type In = Request<Bytes>;
174    type Out = Response<Bytes>;
175    type Err = ProximaError;
176
177    fn call(
178        &self,
179        input: Request<Bytes>,
180    ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
181        let call_index = self.calls.fetch_add(1, Ordering::Relaxed);
182        let wait = self.wait_for(call_index);
183        let sleep = self.clock.delay(wait);
184        let inner = self.inner.clone();
185        async move {
186            sleep.await;
187            Pipe::call(&inner, input).await
188        }
189    }
190}
191
192// ── serde config + factory ───────────────────────────────────────────────────
193
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
195pub struct DelayConfig {
196    pub dist: Dist,
197}
198
199impl DelayConfig {
200    #[must_use]
201    pub fn into_delay(self, inner: PipeHandle) -> Delay<PipeHandle, TimeClock> {
202        Delay::new(inner, self.dist)
203    }
204}
205
206impl Delay<PipeHandle, TimeClock> {
207    #[must_use]
208    pub fn to_config(&self) -> DelayConfig {
209        DelayConfig { dist: self.dist }
210    }
211
212    #[cfg(feature = "std")]
213    pub fn from_spec(inner: PipeHandle, value: &Value) -> Result<Self, ProximaError> {
214        let config: DelayConfig = serde_json::from_value(value.clone())
215            .map_err(|err| ProximaError::Config(format!("delay config: {err}")))?;
216        Ok(config.into_delay(inner))
217    }
218}
219
220#[cfg(feature = "std")]
221pub struct DelayFactory;
222
223#[cfg(feature = "std")]
224impl PipeFactory for DelayFactory {
225    fn name(&self) -> &str {
226        "delay"
227    }
228
229    fn build(
230        &self,
231        spec: &Value,
232        inner: Option<PipeHandle>,
233    ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
234        let spec = spec.clone();
235        Box::pin(async move {
236            let inner =
237                inner.ok_or_else(|| ProximaError::Config("delay requires an inner pipe".into()))?;
238            let delay = Delay::from_spec(inner, &spec)?;
239            Ok(into_handle(delay))
240        })
241    }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used, clippy::expect_used)]
246mod tests {
247    use std::future::Future;
248    use std::sync::atomic::AtomicBool;
249    use std::task::{Context, Poll};
250
251    use futures::task::noop_waker;
252
253    use super::*;
254    use crate::pipe::clock::testing::MockClock;
255    use crate::pipe::handler::into_handle;
256
257    #[derive(Clone)]
258    struct RanFlag {
259        ran: Arc<AtomicBool>,
260    }
261
262    impl SendPipe for RanFlag {
263        type In = Request<Bytes>;
264        type Out = Response<Bytes>;
265        type Err = ProximaError;
266
267        fn call(
268            &self,
269            _request: Request<Bytes>,
270        ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
271            let ran = self.ran.clone();
272            async move {
273                ran.store(true, Ordering::SeqCst);
274                Ok(Response::ok("ok"))
275            }
276        }
277    }
278
279    fn build_request() -> Request<Bytes> {
280        Request::builder()
281            .method("GET")
282            .path("/v1/x")
283            .body("payload")
284            .build()
285            .expect("builder")
286    }
287
288    #[test]
289    fn delay_waits_for_the_configured_duration_before_running_inner() {
290        let clock = MockClock::new();
291        let ran = Arc::new(AtomicBool::new(false));
292        let inner = RanFlag { ran: ran.clone() };
293        let delay = Delay::with_clock(inner, Dist::Const { ms: 100 }, clock.clone());
294
295        let waker = noop_waker();
296        let mut context = Context::from_waker(&waker);
297        let mut call = Box::pin(SendPipe::call(&delay, build_request()));
298
299        assert!(
300            matches!(call.as_mut().poll(&mut context), Poll::Pending),
301            "delay parks before its deadline"
302        );
303        assert!(
304            !ran.load(Ordering::SeqCst),
305            "inner pipe must not run while the delay is pending"
306        );
307
308        clock.advance(Duration::from_millis(99));
309        assert!(
310            matches!(call.as_mut().poll(&mut context), Poll::Pending),
311            "still parked one ms short of the deadline"
312        );
313        assert!(!ran.load(Ordering::SeqCst), "inner still has not run");
314
315        clock.advance(Duration::from_millis(1));
316        match call.as_mut().poll(&mut context) {
317            Poll::Ready(result) => {
318                let response = result.expect("call ok");
319                assert_eq!(response.status, 200);
320            }
321            Poll::Pending => panic!("delay must complete once the mock clock crosses the deadline"),
322        }
323        assert!(
324            ran.load(Ordering::SeqCst),
325            "inner pipe ran after the delay elapsed"
326        );
327    }
328
329    #[test]
330    fn zero_delay_runs_inner_on_first_poll() {
331        let clock = MockClock::new();
332        let ran = Arc::new(AtomicBool::new(false));
333        let inner = RanFlag { ran: ran.clone() };
334        let delay = Delay::with_clock(inner, Dist::Const { ms: 0 }, clock);
335
336        let waker = noop_waker();
337        let mut context = Context::from_waker(&waker);
338        let mut call = Box::pin(SendPipe::call(&delay, build_request()));
339
340        match call.as_mut().poll(&mut context) {
341            Poll::Ready(result) => assert_eq!(result.expect("call ok").status, 200),
342            Poll::Pending => panic!("zero delay must not park"),
343        }
344        assert!(ran.load(Ordering::SeqCst), "inner ran immediately");
345    }
346
347    #[test]
348    fn range_sample_is_deterministic_and_within_bounds() {
349        let dist = Dist::Range {
350            min_ms: 10,
351            max_ms: 50,
352            seed: 0xC0FFEE,
353        };
354        let first: Vec<Duration> = (0..256).map(|index| dist.sample(index)).collect();
355        let second: Vec<Duration> = (0..256).map(|index| dist.sample(index)).collect();
356        assert_eq!(
357            first, second,
358            "same (seed, bounds) yields an identical sample sequence"
359        );
360        for sample in first {
361            assert!(
362                sample >= Duration::from_millis(10) && sample <= Duration::from_millis(50),
363                "sample {sample:?} stays within [10ms, 50ms]"
364            );
365        }
366    }
367
368    #[test]
369    fn const_sample_ignores_the_call_index() {
370        let dist = Dist::Const { ms: 42 };
371        for index in 0..64 {
372            assert_eq!(
373                dist.sample(index),
374                Duration::from_millis(42),
375                "const delay is index-independent"
376            );
377        }
378    }
379
380    #[test]
381    fn config_builder_round_trip_parity() {
382        fn echo_pipe() -> PipeHandle {
383            struct EchoPipe;
384            impl SendPipe for EchoPipe {
385                type In = Request<Bytes>;
386                type Out = Response<Bytes>;
387                type Err = ProximaError;
388                fn call(
389                    &self,
390                    _request: Request<Bytes>,
391                ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send
392                {
393                    async move { Ok(Response::ok("ok")) }
394                }
395            }
396            into_handle(EchoPipe)
397        }
398
399        let config = DelayConfig {
400            dist: Dist::Range {
401                min_ms: 5,
402                max_ms: 25,
403                seed: 7,
404            },
405        };
406
407        let delay = config.into_delay(echo_pipe());
408        let back = delay.to_config();
409        let json = serde_json::to_value(config).expect("serialize");
410        let parsed: DelayConfig = serde_json::from_value(json.clone()).expect("deserialize");
411
412        assert_eq!(
413            back, config,
414            "builder projects back to the originating config"
415        );
416        assert_eq!(parsed, config, "serde round-trip is lossless");
417        assert_eq!(
418            json,
419            serde_json::json!({ "dist": { "kind": "range", "min_ms": 5, "max_ms": 25, "seed": 7 } }),
420            "Dist serializes as a tagged object"
421        );
422    }
423
424    #[test]
425    fn from_spec_parses_const_dist() {
426        fn echo_pipe() -> PipeHandle {
427            struct EchoPipe;
428            impl SendPipe for EchoPipe {
429                type In = Request<Bytes>;
430                type Out = Response<Bytes>;
431                type Err = ProximaError;
432                fn call(
433                    &self,
434                    _request: Request<Bytes>,
435                ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send
436                {
437                    async move { Ok(Response::ok("ok")) }
438                }
439            }
440            into_handle(EchoPipe)
441        }
442
443        let value = serde_json::json!({ "dist": { "kind": "const", "ms": 250 } });
444        let delay = Delay::from_spec(echo_pipe(), &value).expect("from spec");
445        assert_eq!(delay.dist(), Dist::Const { ms: 250 });
446    }
447
448    #[derive(Clone, PartialEq, Debug)]
449    struct CountEvent(u64);
450
451    #[derive(Clone)]
452    struct EventSink;
453
454    impl SendPipe for EventSink {
455        type In = CountEvent;
456        type Out = CountEvent;
457        type Err = ProximaError;
458
459        fn call(
460            &self,
461            input: CountEvent,
462        ) -> impl Future<Output = Result<CountEvent, ProximaError>> + Send {
463            async move { Ok(CountEvent(input.0 + 1)) }
464        }
465    }
466
467    #[test]
468    fn delay_is_generic_over_a_non_http_payload() {
469        let clock = MockClock::new();
470        let delay = Delay::with_clock(EventSink, Dist::Const { ms: 30 }, clock.clone());
471
472        let waker = noop_waker();
473        let mut context = Context::from_waker(&waker);
474        let mut call = Box::pin(SendPipe::call(&delay, CountEvent(41)));
475
476        assert!(
477            matches!(call.as_mut().poll(&mut context), Poll::Pending),
478            "event call parks before the deadline"
479        );
480        clock.advance(Duration::from_millis(30));
481        match call.as_mut().poll(&mut context) {
482            Poll::Ready(result) => assert_eq!(result.expect("event call"), CountEvent(42)),
483            Poll::Pending => panic!("event call must complete once the deadline passes"),
484        }
485    }
486
487    #[test]
488    fn gated_delay_skips_sleep_on_a_miss_and_parks_on_a_fire() {
489        let gate = When::prob(1.0).seed(1);
490        let clock = MockClock::new();
491        let ran = Arc::new(AtomicBool::new(false));
492        let delay = Delay::with_clock(
493            RanFlag { ran: ran.clone() },
494            Dist::Const { ms: 50 },
495            clock.clone(),
496        )
497        .with_when(gate);
498
499        let waker = noop_waker();
500        let mut context = Context::from_waker(&waker);
501        let mut call = Box::pin(SendPipe::call(&delay, build_request()));
502
503        assert!(
504            matches!(call.as_mut().poll(&mut context), Poll::Pending),
505            "fired gate parks the delay"
506        );
507        assert!(
508            !ran.load(Ordering::SeqCst),
509            "inner has not run while parked"
510        );
511        clock.advance(Duration::from_millis(50));
512        assert!(
513            matches!(call.as_mut().poll(&mut context), Poll::Ready(_)),
514            "delay completes after the deadline"
515        );
516        assert!(
517            ran.load(Ordering::SeqCst),
518            "inner ran after the gated delay elapsed"
519        );
520    }
521
522    #[test]
523    fn gated_delay_with_a_never_firing_gate_runs_inner_immediately() {
524        let gate = When::prob(0.0).seed(1);
525        let clock = MockClock::new();
526        let ran = Arc::new(AtomicBool::new(false));
527        let delay = Delay::with_clock(
528            RanFlag { ran: ran.clone() },
529            Dist::Const { ms: 9_999 },
530            clock,
531        )
532        .with_when(gate);
533
534        let waker = noop_waker();
535        let mut context = Context::from_waker(&waker);
536        let mut call = Box::pin(SendPipe::call(&delay, build_request()));
537
538        match call.as_mut().poll(&mut context) {
539            Poll::Ready(result) => assert_eq!(result.expect("call ok").status, 200),
540            Poll::Pending => panic!("a never-firing gate must skip the sleep"),
541        }
542        assert!(ran.load(Ordering::SeqCst), "inner ran without any wait");
543    }
544}