1use alloc::boxed::Box;
39use alloc::format;
40use alloc::string::String;
41use alloc::sync::Arc;
42use core::time::Duration;
43
44use bytes::Bytes;
45use futures::FutureExt;
46use proxima_core::factory::Named;
47use proxima_core::signal::Signal;
48
49use crate::pipe::SendPipe;
50use crate::pipe::handler::PipeHandle;
51use crate::pipe::header_list::HeaderList;
52use crate::pipe::request::{Request, RequestContext};
53use crate::pipe::source::{SourceFactory, SourceHandle, into_source_handle};
54use proxima_core::ProximaError;
55
56pub type RequestFactory = Arc<dyn Fn() -> Request<Bytes> + Send + Sync + 'static>;
62
63pub const DEFAULT_TICK_METHOD: &[u8] = b"TICK";
66
67pub const DEFAULT_TICK_PATH: &[u8] = b"/tick";
70
71pub struct IntervalPipe {
81 period: Duration,
82 inner: PipeHandle,
83 request_factory: RequestFactory,
84 name: String,
85}
86
87impl IntervalPipe {
88 #[must_use]
90 pub fn new(
91 period: Duration,
92 inner: PipeHandle,
93 request_factory: RequestFactory,
94 name: impl Into<String>,
95 ) -> Self {
96 Self {
97 period,
98 inner,
99 request_factory,
100 name: name.into(),
101 }
102 }
103
104 #[must_use]
106 pub fn builder() -> IntervalPipeBuilder {
107 IntervalPipeBuilder::default()
108 }
109
110 #[must_use]
114 pub fn name(&self) -> &str {
115 &self.name
116 }
117
118 #[must_use]
123 pub fn empty_request_factory() -> RequestFactory {
124 Arc::new(|| Request {
125 method: crate::pipe::method::Method::from_bytes(DEFAULT_TICK_METHOD),
126 path: Bytes::from_static(DEFAULT_TICK_PATH),
127 query: HeaderList::new(),
128 metadata: HeaderList::new(),
129 payload: Bytes::new(),
130 stream: None,
131 context: RequestContext::default(),
132 })
133 }
134}
135
136impl SendPipe for IntervalPipe {
137 type In = Signal;
138 type Out = ();
139 type Err = ProximaError;
140
141 fn call(
142 &self,
143 cancel: Signal,
144 ) -> impl core::future::Future<Output = Result<(), ProximaError>> + Send {
145 let inner = self.inner.clone();
146 let period = self.period;
147 let factory = self.request_factory.clone();
148 async move {
149 run_interval_loop(period, inner, factory, cancel).await;
150 Ok(())
151 }
152 }
153}
154
155pub struct IntervalSourceFactory {
160 name: String,
161 inner: PipeHandle,
162}
163
164impl IntervalSourceFactory {
165 #[must_use]
168 pub fn new(name: impl Into<String>, inner: PipeHandle) -> Self {
169 Self {
170 name: name.into(),
171 inner,
172 }
173 }
174}
175
176impl Named for IntervalSourceFactory {
177 fn name(&self) -> &str {
178 &self.name
179 }
180}
181
182impl SourceFactory for IntervalSourceFactory {
183 fn build(&self, spec: &serde_json::Value) -> Result<SourceHandle, ProximaError> {
184 let period_ms = spec
185 .get("period_ms")
186 .and_then(serde_json::Value::as_u64)
187 .ok_or_else(|| ProximaError::Config("interval source requires period_ms".into()))?;
188 let pipe = IntervalPipe::builder()
189 .period(Duration::from_millis(period_ms))
190 .inner(self.inner.clone())
191 .name(self.name.clone())
192 .build()
193 .map_err(|err| ProximaError::Config(format!("interval source: {err}")))?;
194 Ok(into_source_handle(pipe))
195 }
196}
197
198async fn run_interval_loop(
199 period: Duration,
200 inner: PipeHandle,
201 request_factory: RequestFactory,
202 cancel: Signal,
203) {
204 let mut interval = proxima_core::time::interval(period);
205 interval.set_missed_tick_behavior(proxima_core::time::MissedTickBehavior::Skip);
206 interval.tick().await;
211 loop {
212 let cancel_fut = Box::pin(cancel.fired()).fuse();
216 let tick_fut = Box::pin(interval.tick()).fuse();
217 futures::pin_mut!(cancel_fut);
218 futures::pin_mut!(tick_fut);
219 let should_fire = futures::select_biased! {
220 _ = cancel_fut => {
221 tracing::debug!(target = "interval_pipe", "interval cancelled");
222 return;
223 }
224 _ = tick_fut => true,
225 };
226 if should_fire {
227 let request = request_factory();
228 if let Err(err) = inner.call(request).await {
229 tracing::error!(?err, target = "interval_pipe", "inner call failed");
230 }
231 }
232 }
233}
234
235#[derive(Default)]
237pub struct IntervalPipeBuilder {
238 period: Option<Duration>,
239 inner: Option<PipeHandle>,
240 request_factory: Option<RequestFactory>,
241 method: Option<Bytes>,
242 path: Option<Bytes>,
243 name: Option<String>,
244}
245
246impl IntervalPipeBuilder {
247 #[must_use]
249 pub fn period(mut self, period: Duration) -> Self {
250 self.period = Some(period);
251 self
252 }
253
254 #[must_use]
256 pub fn inner(mut self, inner: PipeHandle) -> Self {
257 self.inner = Some(inner);
258 self
259 }
260
261 #[must_use]
268 pub fn request_factory<F>(mut self, factory: F) -> Self
269 where
270 F: Fn() -> Request<Bytes> + Send + Sync + 'static,
271 {
272 self.request_factory = Some(Arc::new(factory));
273 self
274 }
275
276 #[must_use]
281 pub fn method(mut self, method: impl Into<Bytes>) -> Self {
282 self.method = Some(method.into());
283 self
284 }
285
286 #[must_use]
291 pub fn path(mut self, path: impl Into<Bytes>) -> Self {
292 self.path = Some(path.into());
293 self
294 }
295
296 #[must_use]
299 pub fn name(mut self, name: impl Into<String>) -> Self {
300 self.name = Some(name.into());
301 self
302 }
303
304 pub fn build(self) -> Result<IntervalPipe, IntervalBuildError> {
310 let period = self.period.ok_or(IntervalBuildError::MissingPeriod)?;
311 let inner = self.inner.ok_or(IntervalBuildError::MissingInner)?;
312 let factory = match self.request_factory {
313 Some(factory) => factory,
314 None => {
315 let method = crate::pipe::method::Method::from_wire(
316 self.method
317 .unwrap_or_else(|| Bytes::from_static(DEFAULT_TICK_METHOD)),
318 );
319 let path = self
320 .path
321 .unwrap_or_else(|| Bytes::from_static(DEFAULT_TICK_PATH));
322 Arc::new(move || Request {
323 method: method.clone(),
324 path: path.clone(),
325 query: HeaderList::new(),
326 metadata: HeaderList::new(),
327 payload: Bytes::new(),
328 stream: None,
329 context: RequestContext::default(),
330 })
331 }
332 };
333 let name = self.name.unwrap_or_else(|| "interval".into());
334 Ok(IntervalPipe::new(period, inner, factory, name))
335 }
336}
337
338#[derive(Debug, thiserror::Error)]
340#[non_exhaustive]
341pub enum IntervalBuildError {
342 #[error("IntervalPipe builder missing period")]
344 MissingPeriod,
345 #[error("IntervalPipe builder missing inner")]
347 MissingInner,
348}
349
350#[cfg(all(test, not(loom)))]
355#[allow(clippy::unwrap_used, clippy::expect_used)]
356mod tests {
357 use alloc::sync::Arc;
358 use std::sync::Mutex;
359
360 use crate::pipe::SendPipe;
361 use crate::pipe::handler::PipeHandle;
362 use crate::pipe::request::Response;
363
364 use super::*;
365
366 struct CountingPipe {
367 count: Mutex<usize>,
368 last_method: Mutex<crate::pipe::method::Method>,
369 }
370
371 impl CountingPipe {
372 fn new() -> Arc<Self> {
373 Arc::new(Self {
374 count: Mutex::new(0),
375 last_method: Mutex::new(crate::pipe::method::Method::default()),
376 })
377 }
378
379 fn count(&self) -> usize {
380 *self.count.lock().unwrap()
381 }
382
383 fn last_method(&self) -> crate::pipe::method::Method {
384 self.last_method.lock().unwrap().clone()
385 }
386 }
387
388 impl SendPipe for CountingPipe {
389 type In = Request<Bytes>;
390 type Out = Response<Bytes>;
391 type Err = ProximaError;
392
393 fn call(
394 &self,
395 request: Request<Bytes>,
396 ) -> impl core::future::Future<Output = Result<Response<Bytes>, ProximaError>> + Send
397 {
398 let method = request.method.clone();
399 *self.count.lock().unwrap() += 1;
400 *self.last_method.lock().unwrap() = method;
401 async { Ok(Response::ok("")) }
402 }
403 }
404
405 fn into_handle(pipe: Arc<CountingPipe>) -> PipeHandle {
406 pipe
407 }
408
409 #[proxima::test]
410 async fn builder_returns_error_on_missing_period() {
411 let inner = into_handle(CountingPipe::new());
412 let result = IntervalPipe::builder().inner(inner).build();
413 assert!(matches!(result, Err(IntervalBuildError::MissingPeriod)));
414 }
415
416 #[proxima::test]
417 async fn builder_returns_error_on_missing_inner() {
418 let result = IntervalPipe::builder()
419 .period(Duration::from_secs(1))
420 .build();
421 assert!(matches!(result, Err(IntervalBuildError::MissingInner)));
422 }
423
424 async fn drive_until_ticks(pipe: &IntervalPipe, capture: &Arc<CountingPipe>, ticks: usize) {
429 let cancel = Signal::new();
430 let loop_future = SendPipe::call(pipe, cancel.clone()).fuse();
431 let capture_for_watch = capture.clone();
432 let watcher = async move {
433 while capture_for_watch.count() < ticks {
434 proxima_core::time::sleep(Duration::from_millis(1)).await;
435 }
436 }
437 .fuse();
438 futures::pin_mut!(loop_future, watcher);
439 let raced = async {
440 futures::select_biased! {
441 () = watcher => (),
442 _ = loop_future => panic!("interval loop exited without being fired"),
443 }
444 };
445 proxima_core::time::timeout(Duration::from_secs(10), raced)
446 .await
447 .expect("interval_pipe should reach the tick count before the timeout");
448 cancel.fire();
449 }
450
451 #[proxima::test]
452 async fn interval_pipe_fires_three_ticks_with_default_method() {
453 let capture = CountingPipe::new();
454 let inner = into_handle(capture.clone());
455 let pipe = IntervalPipe::builder()
456 .period(Duration::from_millis(5))
457 .inner(inner)
458 .name("heartbeat")
459 .build()
460 .expect("builder");
461 drive_until_ticks(&pipe, &capture, 3).await;
462 assert!(capture.count() >= 3);
463 assert_eq!(
464 capture.last_method(),
465 crate::pipe::method::Method::from_bytes(DEFAULT_TICK_METHOD)
466 );
467 }
468
469 #[proxima::test]
470 async fn interval_pipe_respects_custom_request_factory() {
471 let capture = CountingPipe::new();
472 let inner = into_handle(capture.clone());
473 let pipe = IntervalPipe::builder()
474 .period(Duration::from_millis(5))
475 .inner(inner)
476 .request_factory(|| Request {
477 method: crate::pipe::method::Method::from_bytes(b"CUSTOM"),
478 path: Bytes::from_static(b"/custom"),
479 query: HeaderList::new(),
480 metadata: HeaderList::new(),
481 payload: Bytes::new(),
482 stream: None,
483 context: RequestContext::default(),
484 })
485 .build()
486 .expect("builder");
487 drive_until_ticks(&pipe, &capture, 1).await;
488 assert!(capture.count() >= 1);
489 assert_eq!(
490 capture.last_method(),
491 crate::pipe::method::Method::from_bytes(b"CUSTOM")
492 );
493 }
494
495 #[proxima::test]
496 async fn interval_source_factory_builds_from_period_ms_spec() {
497 let capture = CountingPipe::new();
498 let factory = IntervalSourceFactory::new("heartbeat", into_handle(capture.clone()));
499 let source = factory
500 .build(&serde_json::json!({ "period_ms": 5 }))
501 .expect("factory build");
502
503 let cancel = Signal::new();
504 let call_future = SendPipe::call(&source, cancel.clone()).fuse();
505 let capture_for_watch = capture.clone();
506 let watcher = async move {
507 while capture_for_watch.count() < 1 {
508 proxima_core::time::sleep(Duration::from_millis(1)).await;
509 }
510 }
511 .fuse();
512 futures::pin_mut!(call_future, watcher);
513 futures::select_biased! {
514 () = watcher => (),
515 _ = call_future => panic!("interval source exited without ticking"),
516 }
517 cancel.fire();
518 }
519
520 #[test]
521 fn interval_source_factory_rejects_a_spec_missing_period_ms() {
522 let factory = IntervalSourceFactory::new("heartbeat", into_handle(CountingPipe::new()));
523 let outcome = factory.build(&serde_json::json!({}));
524 assert!(matches!(outcome, Err(ProximaError::Config(_))));
525 }
526}