aimdb_core/session/pump.rs
1//! Data-plane pump helpers.
2//!
3//! Two free functions that own the boilerplate a data-plane connector used to
4//! hand-roll. The author writes only the pure I/O adapter — a
5//! [`Connector`](crate::transport::Connector) (outbound) and a [`Source`]
6//! (inbound) — and composes the helpers in `build()`
7//! (illustrative — `sink()`/`subscription()` are the author's own constructors):
8//!
9//! ```rust,ignore
10//! let mut f = pump_sink(db, "redis", self.sink().await?); // outbound
11//! f.extend(pump_source(db, "redis", self.subscription().await?)); // inbound
12//! Ok(f)
13//! ```
14//!
15//! Both are `no_std + alloc`-native (boxed futures, no `tokio`).
16
17use alloc::boxed::Box;
18use alloc::sync::Arc;
19use alloc::vec;
20use alloc::vec::Vec;
21
22use super::Source;
23use crate::builder::{AimDb, BoxFuture};
24use crate::router::RouterBuilder;
25use crate::transport::{Connector, ConnectorConfig};
26
27/// Outbound pump: one publisher future per outbound route on `scheme`.
28///
29/// Extracts the consume-and-publish loop a data-plane connector used to write by
30/// hand. For each route from [`collect_outbound_routes`](AimDb::collect_outbound_routes),
31/// the returned future subscribes to the route's fused
32/// [`SerializedSource`](crate::connector::SerializedSource) — whose readers
33/// yield destination + serialized payload directly (no `Box<dyn Any>` per
34/// message) — and publishes through `sink`. Per-route
35/// configuration (`qos`/`retain`/…) is built once from the route's URL query
36/// via [`ConnectorConfig::from_query`].
37///
38/// The publisher future terminates when its subscription yields an error (e.g. the
39/// record buffer closed), matching the legacy hand-rolled loop.
40pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc<dyn Connector>) -> Vec<BoxFuture> {
41 let routes = db.collect_outbound_routes(scheme);
42 let mut futures: Vec<BoxFuture> = Vec::with_capacity(routes.len());
43
44 for crate::OutboundRoute {
45 topic: default_topic,
46 source,
47 config,
48 } in routes
49 {
50 let sink = sink.clone();
51 let runtime_ctx = db.runtime_ctx();
52 let cfg = ConnectorConfig::from_query(&config);
53 let scratch_capacity = source.serializer_scratch_capacity().unwrap_or(0);
54
55 futures.push(Box::pin(async move {
56 // Subscribe inside the pump future (not at collect time), so the
57 // ring-buffer cursor starts when the publisher actually runs.
58 let mut reader = source.subscribe();
59 // One bounded allocation per route, reused for every successful
60 // into-slice serialization. Owned-only sources request zero bytes.
61 let mut scratch = vec![0_u8; scratch_capacity];
62
63 log_info!(
64 "pump_sink: publisher started for destination: {}",
65 default_topic
66 );
67
68 loop {
69 let msg = match reader
70 .recv_into(&runtime_ctx, scratch.as_mut_slice())
71 .await
72 {
73 Ok(m) => m,
74 // SPMC-ring overflow: messages were missed, but the reader
75 // recovers (cursor resets to the oldest live value). Skip the
76 // gap and keep pumping — a transient lag must not permanently
77 // kill the publisher.
78 Err(crate::DbError::BufferLagged { .. }) => {
79 log_warn!("pump_sink: consumer lagged for '{}'", default_topic);
80 continue;
81 }
82 // Buffer closed / fatal — the record is gone; end the publisher.
83 Err(_e) => {
84 log_info!(
85 "pump_sink: publisher stopping for '{}': {:?}",
86 default_topic,
87 _e
88 );
89 break;
90 }
91 };
92 let crate::connector::SerializedValueInto { dest, payload } = msg;
93 // Destination: dynamic (resolved by the source) or default (from URL).
94 let dest = dest.as_deref().unwrap_or(&default_topic);
95
96 let payload = match &payload {
97 crate::connector::SerializedPayload::Scratch { len } => {
98 let Some(payload) = scratch.get(..*len) else {
99 log_error!(
100 "pump_sink: serializer returned invalid length {} for {}-byte scratch buffer",
101 len,
102 scratch.len()
103 );
104 continue;
105 };
106 payload
107 }
108 crate::connector::SerializedPayload::Owned(payload) => payload.as_slice(),
109 };
110
111 // Publish through the connector's pure I/O adapter.
112 if let Err(_e) = sink.publish(dest, &cfg, payload).await {
113 log_error!("pump_sink: failed to publish to '{}': {:?}", dest, _e);
114 } else {
115 log_debug!("pump_sink: published to: {}", dest);
116 }
117 }
118
119 log_info!(
120 "pump_sink: publisher stopped for destination: {}",
121 default_topic
122 );
123 }));
124 }
125
126 futures
127}
128
129/// Inbound pump: a single multiplexed reader future for `scheme`.
130///
131/// Drives one [`Source`] (never one task per topic), fanning each
132/// `(topic, payload)` out to the matching producers via a [`Router`] built from
133/// [`collect_inbound_routes`](AimDb::collect_inbound_routes).
134///
135/// Backpressure: [`Router::route`] drops + logs on a full producer buffer rather
136/// than blocking, so one slow record never stalls the shared source. Route errors
137/// are non-fatal.
138///
139/// [`Router`]: crate::router::Router
140/// [`Router::route`]: crate::router::Router::route
141pub fn pump_source(db: &AimDb, scheme: &str, mut src: impl Source + 'static) -> Vec<BoxFuture> {
142 let routes = db.collect_inbound_routes(scheme);
143 let router = Arc::new(RouterBuilder::from_routes(routes).build());
144 let ctx = db.runtime_ctx();
145
146 vec![Box::pin(async move {
147 log_info!(
148 "pump_source: reader started ({} topics)",
149 router.resource_ids().len()
150 );
151
152 while let Some((topic, payload)) = src.next().await {
153 // `route` deserializes and fans out to producers (synchronously —
154 // the fused ingest path never awaits); it drops + logs on a full
155 // producer buffer and never returns a fatal error.
156 if let Err(_e) = router.route(&topic, &payload, &ctx) {
157 log_error!(
158 "pump_source: failed to route message on '{}': {}",
159 topic,
160 _e
161 );
162 }
163 }
164
165 log_info!("pump_source: reader stopped");
166 })]
167}