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
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct PublishAck {
27 pub topic: String,
29 pub partition: u32,
31 pub offset: u64,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct StoredEvent {
39 pub partition: u32,
41 pub offset: u64,
43 pub key: String,
45 pub value: serde_json::Value,
47 pub stored_at: u64,
49}
50
51pub trait EventBus: Send + Sync {
55 fn set_control_key(&self, _key: [u8; 32]) -> Result<(), BusError> {
57 Ok(())
58 }
59
60 fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError>;
62
63 fn publish_idempotent(
67 &self,
68 topic: &str,
69 key: &str,
70 value: &serde_json::Value,
71 _event_uid: &str,
72 ) -> Result<PublishAck, BusError> {
73 self.publish(topic, key, value)
74 }
75
76 fn find_event_by_uid(
78 &self,
79 topic: &str,
80 key: &str,
81 event_uid: &str,
82 ) -> Result<Option<PublishAck>, BusError> {
83 let partition = partition_for(key, self.partitions(topic)?);
84 let mut offset = 0u64;
85 loop {
86 let events = self.fetch(topic, partition, offset, 1_024)?;
87 if events.is_empty() {
88 return Ok(None);
89 }
90 for event in &events {
91 if event
92 .value
93 .get("metadata")
94 .and_then(|metadata| metadata.get("uid"))
95 .and_then(serde_json::Value::as_str)
96 == Some(event_uid)
97 {
98 return Ok(Some(PublishAck {
99 topic: topic.to_owned(),
100 partition,
101 offset: event.offset,
102 }));
103 }
104 }
105 let next = events
106 .last()
107 .and_then(|event| event.offset.checked_add(1))
108 .ok_or_else(|| BusError::Backend("event lookup offset overflow".to_owned()))?;
109 if next <= offset {
110 return Err(BusError::Backend(
111 "event lookup made no offset progress".to_owned(),
112 ));
113 }
114 offset = next;
115 }
116 }
117
118 fn fetch(
121 &self,
122 topic: &str,
123 partition: u32,
124 offset: u64,
125 max: usize,
126 ) -> Result<Vec<StoredEvent>, BusError>;
127
128 fn partitions(&self, topic: &str) -> Result<u32, BusError>;
130
131 fn topics(&self) -> Vec<String>;
133
134 fn maintenance(&self, _now_ms: u64) -> Result<u64, BusError> {
137 Ok(0)
138 }
139}
140
141pub fn partition_for(key: &str, partitions: u32) -> u32 {
146 let h = av_core::hash::fnv1a(key.as_bytes());
147 #[allow(clippy::cast_possible_truncation)]
148 ((h % u64::from(partitions.max(1))) as u32)
149}
150
151#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
152type ConnectorTask = Box<dyn FnOnce(tokio::runtime::Handle) + Send + 'static>;
153
154#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
155enum ConnectorCommand {
156 Run(ConnectorTask),
157 Shutdown,
158}
159
160#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
162pub(crate) struct ConnectorExecutor {
163 sender: std::sync::mpsc::SyncSender<ConnectorCommand>,
164 thread: parking_lot::Mutex<Option<std::thread::JoinHandle<()>>>,
165}
166
167#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
168impl ConnectorExecutor {
169 pub(crate) fn new(name: &str) -> Result<Self, BusError> {
170 let (sender, receiver) = std::sync::mpsc::sync_channel::<ConnectorCommand>(1_024);
171 let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1);
172 let thread = std::thread::Builder::new()
173 .name(name.to_owned())
174 .spawn(move || {
175 let runtime = tokio::runtime::Builder::new_multi_thread()
176 .worker_threads(1)
177 .enable_all()
178 .build()
179 .map_err(|error| error.to_string());
180 match runtime {
181 Ok(runtime) => {
182 let _ = ready_sender.send(Ok(()));
183 while let Ok(command) = receiver.recv() {
184 match command {
185 ConnectorCommand::Run(task) => task(runtime.handle().clone()),
186 ConnectorCommand::Shutdown => break,
187 }
188 }
189 }
190 Err(error) => {
191 let _ = ready_sender.send(Err(error));
192 }
193 }
194 })
195 .map_err(BusError::Io)?;
196 ready_receiver
197 .recv()
198 .map_err(|_| BusError::Backend("connector runtime failed to start".to_owned()))?
199 .map_err(BusError::Backend)?;
200 Ok(Self {
201 sender,
202 thread: parking_lot::Mutex::new(Some(thread)),
203 })
204 }
205
206 pub(crate) fn run<F, Fut, T>(&self, operation: F) -> Result<T, BusError>
207 where
208 F: FnOnce() -> Fut + Send + 'static,
209 Fut: std::future::Future<Output = T> + Send + 'static,
210 T: Send + 'static,
211 {
212 let (result_sender, result_receiver) = std::sync::mpsc::sync_channel(1);
213 self.sender
214 .send(ConnectorCommand::Run(Box::new(move |runtime| {
215 runtime.spawn(async move {
216 let result = tokio::time::timeout(std::time::Duration::from_secs(10), operation()).await;
217 let _ = result_sender.send(result);
218 });
219 })))
220 .map_err(|_| BusError::Backend("connector runtime is closed".to_owned()))?;
221 result_receiver
222 .recv()
223 .map_err(|_| BusError::Backend("connector operation was interrupted".to_owned()))
224 .and_then(|result| {
225 result.map_err(|_| BusError::Backend("connector operation timed out".to_owned()))
226 })
227 }
228}
229
230#[cfg(any(feature = "nats", feature = "kafka", feature = "cold-store"))]
231impl Drop for ConnectorExecutor {
232 fn drop(&mut self) {
233 let _ = self.sender.send(ConnectorCommand::Shutdown);
234 if let Some(thread) = self.thread.get_mut().take() {
235 let _ = thread.join();
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
243
244 use super::*;
245 #[cfg(any(feature = "nats", feature = "kafka"))]
246 use std::sync::Arc;
247
248 #[test]
249 fn partition_assignment_is_stable() {
250 assert_eq!(partition_for("agent-inst-1", 8), partition_for("agent-inst-1", 8));
253 let spread: std::collections::HashSet<u32> =
254 (0..100).map(|i| partition_for(&format!("inst-{i}"), 8)).collect();
255 assert!(spread.len() >= 6, "poor partition spread: {spread:?}");
256 }
257
258 #[test]
259 fn zero_partitions_clamped() {
260 assert_eq!(partition_for("x", 0), 0);
261 }
262
263 #[cfg(any(feature = "nats", feature = "kafka"))]
264 #[test]
265 fn persistent_connector_runtime_is_safe_inside_tokio() {
266 let outer = tokio::runtime::Runtime::new().unwrap();
267 outer.block_on(async {
268 let connector = ConnectorExecutor::new("test-connector").unwrap();
269 assert_eq!(connector.run(|| async { 41u64 }).unwrap(), 41);
270 assert_eq!(connector.run(|| async { 42u64 }).unwrap(), 42);
271 drop(connector);
272 });
273 }
274
275 #[cfg(any(feature = "nats", feature = "kafka"))]
276 #[test]
277 fn connector_operations_can_overlap() {
278 let connector = Arc::new(ConnectorExecutor::new("parallel-connector").unwrap());
279 let barrier = Arc::new(tokio::sync::Barrier::new(2));
280 std::thread::scope(|scope| {
281 let first_connector = Arc::clone(&connector);
282 let first_barrier = Arc::clone(&barrier);
283 let first = scope.spawn(move || {
284 first_connector
285 .run(move || async move {
286 first_barrier.wait().await;
287 1u64
288 })
289 .unwrap()
290 });
291 let second_connector = Arc::clone(&connector);
292 let second_barrier = Arc::clone(&barrier);
293 let second = scope.spawn(move || {
294 second_connector
295 .run(move || async move {
296 second_barrier.wait().await;
297 2u64
298 })
299 .unwrap()
300 });
301 assert_eq!(first.join().unwrap() + second.join().unwrap(), 3);
302 });
303 }
304}