1use serde::{Deserialize, Serialize};
4
5#[derive(Debug, thiserror::Error)]
7#[non_exhaustive]
8pub enum BusError {
9 #[error("unknown topic {0:?} (provision it via the manifest first)")]
12 UnknownTopic(String),
13 #[error("bus io: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("bus serde: {0}")]
18 Serde(#[from] serde_json::Error),
19 #[error("bus backend: {0}")]
21 Backend(String),
22}
23
24impl BusError {
25 #[must_use]
42 pub fn is_permanent(&self) -> bool {
43 matches!(self, Self::UnknownTopic(_) | Self::Serde(_))
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct PublishAck {
50 pub topic: String,
52 pub partition: u32,
54 pub offset: u64,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct StoredEvent {
62 pub partition: u32,
64 pub offset: u64,
66 pub key: String,
68 pub value: serde_json::Value,
70 pub stored_at: u64,
72}
73
74pub trait EventBus: Send + Sync {
78 fn set_control_key(&self, _key: [u8; 32]) -> Result<(), BusError> {
80 Ok(())
81 }
82
83 fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError>;
85
86 fn publish_idempotent(
90 &self,
91 topic: &str,
92 key: &str,
93 value: &serde_json::Value,
94 _event_uid: &str,
95 ) -> Result<PublishAck, BusError> {
96 self.publish(topic, key, value)
97 }
98
99 fn find_event_by_uid(
101 &self,
102 topic: &str,
103 key: &str,
104 event_uid: &str,
105 ) -> Result<Option<PublishAck>, BusError> {
106 let partition = partition_for(key, self.partitions(topic)?);
107 let mut offset = 0u64;
108 loop {
109 let events = self.fetch(topic, partition, offset, 1_024)?;
110 if events.is_empty() {
111 return Ok(None);
112 }
113 for event in &events {
114 if event
115 .value
116 .get("metadata")
117 .and_then(|metadata| metadata.get("uid"))
118 .and_then(serde_json::Value::as_str)
119 == Some(event_uid)
120 {
121 return Ok(Some(PublishAck {
122 topic: topic.to_owned(),
123 partition,
124 offset: event.offset,
125 }));
126 }
127 }
128 let next = events
129 .last()
130 .and_then(|event| event.offset.checked_add(1))
131 .ok_or_else(|| BusError::Backend("event lookup offset overflow".to_owned()))?;
132 if next <= offset {
133 return Err(BusError::Backend(
134 "event lookup made no offset progress".to_owned(),
135 ));
136 }
137 offset = next;
138 }
139 }
140
141 fn fetch(
144 &self,
145 topic: &str,
146 partition: u32,
147 offset: u64,
148 max: usize,
149 ) -> Result<Vec<StoredEvent>, BusError>;
150
151 fn partitions(&self, topic: &str) -> Result<u32, BusError>;
153
154 fn topics(&self) -> Vec<String>;
156
157 fn maintenance(&self, _now_ms: u64) -> Result<u64, BusError> {
160 Ok(0)
161 }
162}
163
164pub fn partition_for(key: &str, partitions: u32) -> u32 {
169 let h = av_core::hash::fnv1a(key.as_bytes());
170 #[allow(clippy::cast_possible_truncation)]
171 ((h % u64::from(partitions.max(1))) as u32)
172}
173
174#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
175type ConnectorTask = Box<dyn FnOnce(tokio::runtime::Handle) + Send + 'static>;
176
177#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
178enum ConnectorCommand {
179 Run(ConnectorTask),
180 Shutdown,
181}
182
183#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
185pub(crate) struct ConnectorExecutor {
186 sender: std::sync::mpsc::SyncSender<ConnectorCommand>,
187 thread: parking_lot::Mutex<Option<std::thread::JoinHandle<()>>>,
188}
189
190#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
191impl ConnectorExecutor {
192 pub(crate) fn new(name: &str) -> Result<Self, BusError> {
193 let (sender, receiver) = std::sync::mpsc::sync_channel::<ConnectorCommand>(1_024);
194 let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1);
195 let thread = std::thread::Builder::new()
196 .name(name.to_owned())
197 .spawn(move || {
198 let runtime = tokio::runtime::Builder::new_multi_thread()
199 .worker_threads(1)
200 .enable_all()
201 .build()
202 .map_err(|error| error.to_string());
203 match runtime {
204 Ok(runtime) => {
205 let _ = ready_sender.send(Ok(()));
206 while let Ok(command) = receiver.recv() {
207 match command {
208 ConnectorCommand::Run(task) => task(runtime.handle().clone()),
209 ConnectorCommand::Shutdown => break,
210 }
211 }
212 }
213 Err(error) => {
214 let _ = ready_sender.send(Err(error));
215 }
216 }
217 })
218 .map_err(BusError::Io)?;
219 ready_receiver
220 .recv()
221 .map_err(|_| BusError::Backend("connector runtime failed to start".to_owned()))?
222 .map_err(BusError::Backend)?;
223 Ok(Self {
224 sender,
225 thread: parking_lot::Mutex::new(Some(thread)),
226 })
227 }
228
229 pub(crate) fn run<F, Fut, T>(&self, operation: F) -> Result<T, BusError>
230 where
231 F: FnOnce() -> Fut + Send + 'static,
232 Fut: std::future::Future<Output = T> + Send + 'static,
233 T: Send + 'static,
234 {
235 let (result_sender, result_receiver) = std::sync::mpsc::sync_channel(1);
236 self.sender
237 .send(ConnectorCommand::Run(Box::new(move |runtime| {
238 runtime.spawn(async move {
239 let result = tokio::time::timeout(std::time::Duration::from_secs(10), operation()).await;
240 let _ = result_sender.send(result);
241 });
242 })))
243 .map_err(|_| BusError::Backend("connector runtime is closed".to_owned()))?;
244 result_receiver
245 .recv()
246 .map_err(|_| BusError::Backend("connector operation was interrupted".to_owned()))
247 .and_then(|result| {
248 result.map_err(|_| BusError::Backend("connector operation timed out".to_owned()))
249 })
250 }
251}
252
253#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
254impl Drop for ConnectorExecutor {
255 fn drop(&mut self) {
256 let _ = self.sender.send(ConnectorCommand::Shutdown);
257 if let Some(thread) = self.thread.get_mut().take() {
258 let _ = thread.join();
259 }
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
266
267 use super::*;
268 #[cfg(any(feature = "nats", feature = "kafka"))]
269 use std::sync::Arc;
270
271 struct DefaultLookupBus {
280 events: Vec<StoredEvent>,
281 }
282
283 impl EventBus for DefaultLookupBus {
284 fn publish(
285 &self,
286 _topic: &str,
287 _key: &str,
288 _value: &serde_json::Value,
289 ) -> Result<PublishAck, BusError> {
290 Err(BusError::Backend("publish unused in this test".to_owned()))
291 }
292 fn fetch(
293 &self,
294 _topic: &str,
295 _partition: u32,
296 offset: u64,
297 max: usize,
298 ) -> Result<Vec<StoredEvent>, BusError> {
299 Ok(self
300 .events
301 .iter()
302 .filter(|event| event.offset >= offset)
303 .take(max)
304 .cloned()
305 .collect())
306 }
307 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
308 Ok(1)
309 }
310 fn topics(&self) -> Vec<String> {
311 vec!["t".to_owned()]
312 }
313 }
314
315 fn stored(offset: u64, uid: &str) -> StoredEvent {
316 StoredEvent {
317 partition: 0,
318 offset,
319 key: "k".to_owned(),
320 value: serde_json::json!({"metadata": {"uid": uid}, "n": offset}),
321 stored_at: 0,
322 }
323 }
324
325 #[test]
326 fn default_find_event_by_uid_matches_exact_uid_only() {
327 let bus = DefaultLookupBus {
328 events: vec![stored(0, "uid-a"), stored(1, "uid-b"), stored(2, "uid-c")],
329 };
330 let hit = bus.find_event_by_uid("t", "k", "uid-b").unwrap();
331 assert_eq!(
332 hit.map(|ack| ack.offset),
333 Some(1),
334 "default lookup must find the exact uid at its offset"
335 );
336 assert!(
337 bus.find_event_by_uid("t", "k", "uid-missing").unwrap().is_none(),
338 "default lookup must return None for an absent uid, not a false ack"
339 );
340 let empty = DefaultLookupBus { events: vec![] };
342 assert!(empty.find_event_by_uid("t", "k", "uid-a").unwrap().is_none());
343 assert_eq!(
345 bus.maintenance(0).unwrap(),
346 0,
347 "default maintenance must report zero work"
348 );
349 }
350
351 #[test]
352 fn partition_assignment_is_stable() {
353 assert_eq!(partition_for("agent-inst-1", 8), 3);
358 assert_eq!(partition_for("agent-inst-2", 8), 6);
359 assert_eq!(partition_for("session-abc", 8), 2);
360 assert_eq!(partition_for("", 8), 5);
361 let spread: std::collections::HashSet<u32> =
362 (0..100).map(|i| partition_for(&format!("inst-{i}"), 8)).collect();
363 assert!(spread.len() >= 6, "poor partition spread: {spread:?}");
364 }
365
366 #[test]
367 fn zero_partitions_clamped() {
368 assert_eq!(partition_for("x", 0), 0);
369 }
370
371 #[test]
377 fn bus_error_is_permanent_split() {
378 assert!(
379 BusError::UnknownTopic("orders".to_owned()).is_permanent(),
380 "unknown topic is a misconfiguration; retry cannot succeed"
381 );
382 assert!(
383 !BusError::Backend("network reset".to_owned()).is_permanent(),
384 "backend outages are transient by default"
385 );
386 assert!(
387 !BusError::Io(std::io::Error::new(std::io::ErrorKind::TimedOut, "slow disk")).is_permanent(),
388 "I/O timeouts are transient"
389 );
390 let serde_err = serde_json::from_slice::<serde_json::Value>(b"not json").unwrap_err();
393 assert!(
394 BusError::Serde(serde_err).is_permanent(),
395 "serde errors are permanent for that payload"
396 );
397 }
398
399 #[cfg(any(feature = "nats", feature = "kafka"))]
400 #[test]
401 fn persistent_connector_runtime_is_safe_inside_tokio() {
402 let outer = tokio::runtime::Runtime::new().unwrap();
403 outer.block_on(async {
404 let connector = ConnectorExecutor::new("test-connector").unwrap();
405 assert_eq!(connector.run(|| async { 41u64 }).unwrap(), 41);
406 assert_eq!(connector.run(|| async { 42u64 }).unwrap(), 42);
407 drop(connector);
408 });
409 }
410
411 #[cfg(any(feature = "nats", feature = "kafka"))]
412 #[test]
413 fn connector_operations_can_overlap() {
414 let connector = Arc::new(ConnectorExecutor::new("parallel-connector").unwrap());
415 let barrier = Arc::new(tokio::sync::Barrier::new(2));
416 std::thread::scope(|scope| {
417 let first_connector = Arc::clone(&connector);
418 let first_barrier = Arc::clone(&barrier);
419 let first = scope.spawn(move || {
420 first_connector
421 .run(move || async move {
422 first_barrier.wait().await;
423 1u64
424 })
425 .unwrap()
426 });
427 let second_connector = Arc::clone(&connector);
428 let second_barrier = Arc::clone(&barrier);
429 let second = scope.spawn(move || {
430 second_connector
431 .run(move || async move {
432 second_barrier.wait().await;
433 2u64
434 })
435 .unwrap()
436 });
437 assert_eq!(first.join().unwrap() + second.join().unwrap(), 3);
438 });
439 }
440}