1#[cfg(test)]
15use std::sync::mpsc;
16use std::time::{Duration, Instant};
17
18use crate::channel::ChannelMode;
19use crate::conversation::handoff::{AttemptDecision, ConsumptionReceipt};
20use crate::conversation::{ConversationConfig, ConversationSupervisor, CrashPolicy};
21use crate::error::LiminalError;
22use crate::routing::group::{ConsumerGroup, ConsumerRegistration};
23use crate::routing::{
24 ConsumerId, ConsumerStateView, FieldValue, FunctionError, RoutingDecision, RoutingMessage,
25 SupervisedExecutor,
26};
27
28#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct DispatchOutcome {
32 delivered_to: ConsumerId,
33 rerouted_from: Vec<ConsumerId>,
34 reroute_timings: Vec<RerouteTiming>,
35}
36
37impl DispatchOutcome {
38 #[must_use]
40 pub const fn delivered_to(&self) -> &ConsumerId {
41 &self.delivered_to
42 }
43
44 #[must_use]
46 pub fn rerouted_from(&self) -> &[ConsumerId] {
47 &self.rerouted_from
48 }
49
50 #[must_use]
55 pub fn reroute_timings(&self) -> &[RerouteTiming] {
56 &self.reroute_timings
57 }
58
59 #[must_use]
61 pub fn delivered_first_try(&self) -> bool {
62 self.rerouted_from.is_empty()
63 }
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
68pub enum DispatchError {
69 #[error("routing function selected no consumer from the available group set")]
71 NoConsumerAvailable,
72 #[error("routing function selected unknown consumer '{0}'")]
74 UnknownConsumerSelected(String),
75 #[error("routing function evaluation failed: {0}")]
77 Evaluation(#[from] FunctionError),
78 #[error("dispatch conversation failed: {0}")]
80 Conversation(String),
81}
82
83impl From<LiminalError> for DispatchError {
84 fn from(error: LiminalError) -> Self {
85 Self::Conversation(error.to_string())
86 }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct RerouteTiming {
101 crash_observed: Instant,
102 reroute_initiated: Instant,
103}
104
105impl RerouteTiming {
106 #[must_use]
108 pub fn detection_to_reroute(&self) -> Duration {
109 self.reroute_initiated
110 .saturating_duration_since(self.crash_observed)
111 }
112
113 #[must_use]
115 pub const fn crash_observed(&self) -> Instant {
116 self.crash_observed
117 }
118}
119
120#[derive(Clone, Debug)]
127pub struct DispatchConversation {
128 group: ConsumerGroup,
129 executor: SupervisedExecutor,
130 supervisor: ConversationSupervisor,
131 #[cfg(test)]
132 probe: Option<std::sync::Arc<acceptance_tests::AttemptProbe>>,
133}
134
135impl DispatchConversation {
136 pub fn new(group: ConsumerGroup) -> Result<Self, DispatchError> {
143 let supervisor = ConversationSupervisor::new()?;
144 let executor = SupervisedExecutor::with_default_timeout(supervisor.scheduler());
145 Ok(Self {
146 group,
147 executor,
148 supervisor,
149 #[cfg(test)]
150 probe: None,
151 })
152 }
153
154 #[must_use]
157 pub const fn with_supervisor(
158 group: ConsumerGroup,
159 executor: SupervisedExecutor,
160 supervisor: ConversationSupervisor,
161 ) -> Self {
162 Self {
163 group,
164 executor,
165 supervisor,
166 #[cfg(test)]
167 probe: None,
168 }
169 }
170
171 #[must_use]
173 pub const fn group(&self) -> &ConsumerGroup {
174 &self.group
175 }
176
177 #[must_use]
179 pub const fn supervisor(&self) -> &ConversationSupervisor {
180 &self.supervisor
181 }
182
183 pub fn dispatch(&self, message: &RoutingMessage) -> Result<DispatchOutcome, DispatchError> {
198 let mut excluded: Vec<ConsumerId> = Vec::new();
199 let mut reroute_timings: Vec<RerouteTiming> = Vec::new();
200 loop {
201 let selected = self.select_consumer(message, &excluded)?;
202 match self.run_attempt(message, &selected)? {
203 AttemptResult::Delivered => {
204 return Ok(DispatchOutcome {
205 delivered_to: selected.consumer().clone(),
206 rerouted_from: excluded,
207 reroute_timings,
208 });
209 }
210 AttemptResult::Crashed(mut timing) => {
211 timing.reroute_initiated = Instant::now();
212 #[cfg(test)]
213 if let Some(probe) = &self.probe {
214 probe
215 .reroutes
216 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
217 }
218 debug_assert!(
225 timing.detection_to_reroute() < Duration::from_millis(1),
226 "crash-to-reroute exceeded one millisecond"
227 );
228 reroute_timings.push(timing);
229 excluded.push(selected.consumer().clone());
230 }
231 }
232 }
233 }
234
235 fn select_consumer(
238 &self,
239 message: &RoutingMessage,
240 excluded: &[ConsumerId],
241 ) -> Result<ConsumerRegistration, DispatchError> {
242 let snapshot = self.group.snapshot();
243 let available: Vec<&ConsumerRegistration> = snapshot
244 .consumers()
245 .iter()
246 .filter(|registration| !excluded.contains(registration.consumer()))
247 .collect();
248 if available.is_empty() {
249 return Err(DispatchError::NoConsumerAvailable);
250 }
251
252 let state_views: Vec<ConsumerStateView> = available
253 .iter()
254 .map(|registration| registration.state().clone())
255 .collect();
256 let decision: RoutingDecision =
257 self.executor
258 .execute(snapshot.routing_function(), message.clone(), state_views)?;
259
260 let Some(selected_id) = decision.selected() else {
261 return Err(DispatchError::NoConsumerAvailable);
262 };
263 available
264 .into_iter()
265 .find(|registration| registration.consumer() == selected_id)
266 .cloned()
267 .ok_or_else(|| DispatchError::UnknownConsumerSelected(selected_id.as_str().to_owned()))
268 }
269
270 fn run_attempt(
274 &self,
275 message: &RoutingMessage,
276 selected: &ConsumerRegistration,
277 ) -> Result<AttemptResult, DispatchError> {
278 #[cfg(test)]
279 if let Some(probe) = &self.probe {
280 probe
281 .attempts
282 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
283 }
284 let consumer_pid = selected.participant();
285 let actor = AttemptActor(self.supervisor.spawn(ConversationConfig::new(
286 vec![consumer_pid],
287 None,
288 ChannelMode::Ephemeral,
289 CrashPolicy::RouteToNext,
290 ))?);
291 actor.0.pid()?;
292 #[cfg(test)]
293 if let Some(probe) = &self.probe {
294 let capture = probe
295 .observe_permission
296 .lock()
297 .map_err(|error| {
298 DispatchError::Conversation(format!(
299 "observe permission lock poisoned: {error}"
300 ))
301 })?
302 .is_some();
303 if capture {
304 let (captured, receiver) = mpsc::sync_channel(1);
305 actor.0.notify_on_participant_exit(consumer_pid, captured)?;
306 *probe.captured_exit.lock().map_err(|error| {
307 DispatchError::Conversation(format!("EXIT capture lock poisoned: {error}"))
308 })? = Some(receiver);
309 }
310 }
311 let receipt = ConsumptionReceipt::new(consumer_pid)?;
312 #[cfg(test)]
313 if let Some(probe) = &self.probe {
314 receipt.instrument(std::sync::Arc::clone(&probe.receipts))?;
315 let probe = std::sync::Arc::clone(probe);
316 receipt.observe(std::sync::Arc::new(move |event| {
317 probe.record_event(event);
318 }))?;
319 }
320 let mut registration = actor
321 .0
322 .register_dispatch_receipt(std::sync::Arc::clone(&receipt))?;
323 if receipt.is_pending()? {
324 if let Err(error) = actor
325 .0
326 .send_dispatch(dispatch_envelope(message)?, std::sync::Arc::clone(&receipt))
327 {
328 if receipt.is_pending()? {
329 return Err(error.into());
330 }
331 }
332 }
333 #[cfg(test)]
334 if let Some(probe) = &self.probe {
335 probe.arm_waiter(consumer_pid)?;
336 }
337 let result = observe_attempt(&receipt);
338 #[cfg(test)]
339 if let Some(probe) = &self.probe {
340 probe
341 .waiter_resumes
342 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
343 }
344 registration.release()?;
345 result
346 }
347}
348
349struct AttemptActor(crate::conversation::ConversationActor);
350
351impl Drop for AttemptActor {
352 fn drop(&mut self) {
353 self.0.finalize();
354 }
355}
356
357fn observe_attempt(receipt: &ConsumptionReceipt) -> Result<AttemptResult, DispatchError> {
358 match receipt.wait_decision()? {
359 AttemptDecision::Accepted(acceptance)
360 if acceptance.attempt == receipt.attempt()
361 && acceptance.participant == receipt.participant() =>
362 {
363 Ok(AttemptResult::Delivered)
364 }
365 AttemptDecision::Exited(crash_observed) => Ok(AttemptResult::Crashed(RerouteTiming {
366 crash_observed,
367 reroute_initiated: Instant::now(),
368 })),
369 AttemptDecision::Closed => Err(DispatchError::Conversation(
370 "dispatch closed by conversation finalization or supervisor shutdown".to_owned(),
371 )),
372 AttemptDecision::Abandoned => Err(DispatchError::Conversation(
373 "dispatch attempt explicitly abandoned before acceptance".to_owned(),
374 )),
375 AttemptDecision::Pending | AttemptDecision::Accepted(_) => {
376 Err(DispatchError::Conversation(
377 "dispatch receipt returned an invalid or unrelated terminal decision".to_owned(),
378 ))
379 }
380 }
381}
382
383#[derive(Debug)]
385enum AttemptResult {
386 Delivered,
388 Crashed(RerouteTiming),
391}
392
393fn dispatch_envelope(message: &RoutingMessage) -> Result<crate::envelope::Envelope, DispatchError> {
396 let payload = encode_message(message)?;
397 Ok(crate::envelope::Envelope::new(
398 payload,
399 None,
400 crate::channel::SchemaId::new(),
401 crate::envelope::PublisherId::default(),
402 ))
403}
404
405fn encode_message(message: &RoutingMessage) -> Result<Vec<u8>, DispatchError> {
408 let map: serde_json::Map<String, serde_json::Value> = message
409 .fields()
410 .map(|(name, value)| (name.to_owned(), field_to_json(value)))
411 .collect();
412 serde_json::to_vec(&serde_json::Value::Object(map)).map_err(|error| {
413 DispatchError::Conversation(format!("failed to encode dispatched message: {error}"))
414 })
415}
416
417fn field_to_json(value: &FieldValue) -> serde_json::Value {
421 match value {
422 FieldValue::Text(text) => serde_json::Value::String(text.clone()),
423 FieldValue::Integer(integer) => serde_json::Value::from(*integer),
424 FieldValue::Float(float) => serde_json::Number::from_f64(*float)
425 .map_or(serde_json::Value::Null, serde_json::Value::Number),
426 FieldValue::Boolean(boolean) => serde_json::Value::Bool(*boolean),
427 FieldValue::Null => serde_json::Value::Null,
428 }
429}
430
431#[cfg(test)]
434fn select_first() -> crate::routing::RoutingFunction {
435 use crate::routing::function::loader::{ModuleLoader, RoutingModule};
436 ModuleLoader::new().load(RoutingModule::new(
437 b"dispatch-first",
438 |_message, consumers| {
439 consumers
440 .first()
441 .map_or_else(RoutingDecision::none, |state| {
442 RoutingDecision::select(state.consumer.clone())
443 })
444 },
445 ))
446}
447
448#[cfg(test)]
449mod tests;
450
451#[cfg(test)]
452mod acceptance_tests;