aster_forge_events/
redis_transport.rs1use async_trait::async_trait;
2use futures::StreamExt;
3use redis::AsyncCommands;
4use std::future::Future;
5use std::sync::Arc;
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8
9use crate::{
10 EventConnectionObservation, EventReconnectPolicy, EventSubscriptionSource,
11 EventSubscriptionUpdate, supervise_event_subscription,
12};
13
14#[derive(Debug, thiserror::Error)]
16pub enum RedisEventBusError {
17 #[error("open Redis event URL: {0}")]
19 Open(String),
20 #[error("event topic must not be empty")]
22 EmptyTopic,
23 #[error("publish event payload: {0}")]
25 Publish(String),
26 #[error("subscribe to event topic: {0}")]
28 Subscribe(String),
29 #[error("event subscription stream ended")]
31 StreamEnded,
32}
33
34pub trait EventConnectionObserver: Send + Sync {
36 fn observe_event_connection(&self, observation: EventConnectionObservation);
38}
39
40impl<F> EventConnectionObserver for F
41where
42 F: Fn(EventConnectionObservation) + Send + Sync,
43{
44 fn observe_event_connection(&self, observation: EventConnectionObservation) {
45 self(observation);
46 }
47}
48
49pub type RedisEventReconnectPolicy = EventReconnectPolicy;
51
52#[derive(Clone)]
54pub struct RedisEventBus {
55 client: redis::Client,
56 topic: String,
57 reconnect_policy: EventReconnectPolicy,
58}
59
60pub struct RedisEventSubscription {
62 pubsub: redis::aio::PubSub,
63}
64
65impl RedisEventBus {
66 pub fn from_url(url: &str, topic: impl Into<String>) -> Result<Self, RedisEventBusError> {
72 let topic = topic.into();
73 if topic.trim().is_empty() {
74 return Err(RedisEventBusError::EmptyTopic);
75 }
76 let client = redis::Client::open(url)
77 .map_err(|error| RedisEventBusError::Open(error.to_string()))?;
78 Ok(Self {
79 client,
80 topic,
81 reconnect_policy: EventReconnectPolicy::default(),
82 })
83 }
84
85 pub fn from_credentials(
95 base_url: &str,
96 username: Option<&str>,
97 password: Option<&str>,
98 topic: impl Into<String>,
99 ) -> Result<Self, RedisEventBusError> {
100 let topic = topic.into();
101 if topic.trim().is_empty() {
102 return Err(RedisEventBusError::EmptyTopic);
103 }
104 let url = aster_forge_utils::url::url_with_credentials(
105 base_url,
106 username,
107 password,
108 "Redis event base URL",
109 )
110 .map_err(|error| RedisEventBusError::Open(error.to_string()))?;
111 let client = redis::Client::open(url).map_err(|_| {
112 RedisEventBusError::Open("invalid Redis event connection configuration".to_string())
113 })?;
114 Ok(Self {
115 client,
116 topic,
117 reconnect_policy: EventReconnectPolicy::default(),
118 })
119 }
120
121 pub fn from_client(client: redis::Client, topic: impl Into<String>) -> Self {
123 Self {
124 client,
125 topic: topic.into(),
126 reconnect_policy: EventReconnectPolicy::default(),
127 }
128 }
129
130 #[must_use]
132 pub fn with_reconnect_policy(mut self, policy: RedisEventReconnectPolicy) -> Self {
133 self.reconnect_policy = policy;
134 self
135 }
136
137 #[must_use]
139 pub fn topic(&self) -> &str {
140 &self.topic
141 }
142
143 pub async fn publish(&self, payload: impl Into<String>) -> Result<(), RedisEventBusError> {
149 let mut connection = self
150 .client
151 .get_multiplexed_async_connection()
152 .await
153 .map_err(|error| RedisEventBusError::Publish(error.to_string()))?;
154 let _: usize = connection
155 .publish(&self.topic, payload.into())
156 .await
157 .map_err(|error| RedisEventBusError::Publish(error.to_string()))?;
158 Ok(())
159 }
160
161 pub async fn subscribe(&self) -> Result<RedisEventSubscription, RedisEventBusError> {
168 let mut pubsub = self
169 .client
170 .get_async_pubsub()
171 .await
172 .map_err(|error| RedisEventBusError::Subscribe(error.to_string()))?;
173 pubsub
174 .subscribe(&self.topic)
175 .await
176 .map_err(|error| RedisEventBusError::Subscribe(error.to_string()))?;
177 Ok(RedisEventSubscription { pubsub })
178 }
179
180 pub async fn run_subscription<F, Fut>(
185 &self,
186 shutdown: CancellationToken,
187 observer: Option<&dyn EventConnectionObserver>,
188 mut on_payload: F,
189 ) where
190 F: FnMut(String) -> Fut,
191 Fut: Future<Output = ()>,
192 {
193 let (updates_tx, mut updates_rx) = mpsc::channel(1);
194 let supervisor = supervise_event_subscription(
195 Arc::new(self.clone()),
196 self.reconnect_policy,
197 shutdown.clone(),
198 updates_tx,
199 );
200 tokio::pin!(supervisor);
201
202 loop {
203 let update = tokio::select! {
204 () = shutdown.cancelled() => return,
205 () = &mut supervisor => return,
206 update = updates_rx.recv() => update,
207 };
208 match update {
209 Some(EventSubscriptionUpdate::Connection(observation)) => {
210 if let Some(observer) = observer {
211 observer.observe_event_connection(observation);
212 }
213 }
214 Some(EventSubscriptionUpdate::Item(payload)) => on_payload(payload).await,
215 None => return,
216 }
217 }
218 }
219}
220
221impl RedisEventSubscription {
222 pub async fn receive(&mut self) -> Result<String, RedisEventBusError> {
228 loop {
229 let mut stream = self.pubsub.on_message();
230 let Some(message) = stream.next().await else {
231 return Err(RedisEventBusError::StreamEnded);
232 };
233 match message.get_payload::<String>() {
234 Ok(payload) => return Ok(payload),
235 Err(error) => tracing::warn!(%error, "failed to decode Redis event payload"),
236 }
237 }
238 }
239}
240
241#[async_trait]
242impl EventSubscriptionSource for RedisEventBus {
243 type Item = String;
244 type Subscription = RedisEventSubscription;
245 type Error = RedisEventBusError;
246
247 async fn subscribe(&self) -> Result<Self::Subscription, Self::Error> {
248 RedisEventBus::subscribe(self).await
249 }
250
251 async fn receive(
252 &self,
253 subscription: &mut Self::Subscription,
254 ) -> Result<Self::Item, Self::Error> {
255 subscription.receive().await
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::{RedisEventBus, RedisEventBusError};
262
263 #[test]
264 fn rejects_empty_topics() {
265 assert!(matches!(
266 RedisEventBus::from_url("redis://127.0.0.1", " "),
267 Err(RedisEventBusError::EmptyTopic)
268 ));
269 }
270
271 #[test]
272 fn credentialed_bus_accepts_reserved_password_characters() {
273 let bus = RedisEventBus::from_credentials(
274 "redis://cache.example:6379/2?protocol=resp3",
275 None,
276 Some("#[]{}^+=*@:/?%\u{5bc6}\u{7801}"),
277 "aster.events",
278 )
279 .unwrap();
280
281 assert_eq!(bus.topic(), "aster.events");
282 }
283
284 #[test]
285 fn credentialed_bus_rejects_conflicting_userinfo_without_secret_leak() {
286 let raw_password = "raw#event-secret";
287 let result = RedisEventBus::from_credentials(
288 "redis://existing@cache.example:6379/0",
289 Some("replacement"),
290 Some(raw_password),
291 "aster.events",
292 );
293 let Err(error) = result else {
294 panic!("conflicting Redis credentials should be rejected");
295 };
296
297 assert!(matches!(error, RedisEventBusError::Open(_)));
298 assert!(
299 error
300 .to_string()
301 .contains("must not already include userinfo")
302 );
303 assert!(!error.to_string().contains(raw_password));
304 }
305}