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 #[cfg(test)]
219 if let Some(probe) = &self.probe
220 && let Some((pending, unrelated)) = probe.cell
221 {
222 eprintln!(
223 "idle pending={pending} unrelated={unrelated} detection_to_reroute_us={}",
224 timing.detection_to_reroute().as_micros()
225 );
226 }
227 reroute_timings.push(timing);
231 excluded.push(selected.consumer().clone());
232 }
233 }
234 }
235 }
236
237 fn select_consumer(
240 &self,
241 message: &RoutingMessage,
242 excluded: &[ConsumerId],
243 ) -> Result<ConsumerRegistration, DispatchError> {
244 let snapshot = self.group.snapshot();
245 let available: Vec<&ConsumerRegistration> = snapshot
246 .consumers()
247 .iter()
248 .filter(|registration| !excluded.contains(registration.consumer()))
249 .collect();
250 if available.is_empty() {
251 return Err(DispatchError::NoConsumerAvailable);
252 }
253
254 let state_views: Vec<ConsumerStateView> = available
255 .iter()
256 .map(|registration| registration.state().clone())
257 .collect();
258 let decision: RoutingDecision =
259 self.executor
260 .execute(snapshot.routing_function(), message.clone(), state_views)?;
261
262 let Some(selected_id) = decision.selected() else {
263 return Err(DispatchError::NoConsumerAvailable);
264 };
265 available
266 .into_iter()
267 .find(|registration| registration.consumer() == selected_id)
268 .cloned()
269 .ok_or_else(|| DispatchError::UnknownConsumerSelected(selected_id.as_str().to_owned()))
270 }
271
272 fn run_attempt(
276 &self,
277 message: &RoutingMessage,
278 selected: &ConsumerRegistration,
279 ) -> Result<AttemptResult, DispatchError> {
280 #[cfg(test)]
281 if let Some(probe) = &self.probe {
282 probe
283 .attempts
284 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
285 }
286 let consumer_pid = selected.participant();
287 let actor = AttemptActor(self.supervisor.spawn(ConversationConfig::new(
288 vec![consumer_pid],
289 None,
290 ChannelMode::Ephemeral,
291 CrashPolicy::RouteToNext,
292 ))?);
293 actor.0.pid()?;
294 #[cfg(test)]
295 if let Some(probe) = &self.probe {
296 let capture = probe
297 .observe_permission
298 .lock()
299 .map_err(|error| {
300 DispatchError::Conversation(format!(
301 "observe permission lock poisoned: {error}"
302 ))
303 })?
304 .is_some();
305 if capture {
306 let (captured, receiver) = mpsc::sync_channel(1);
307 actor.0.notify_on_participant_exit(consumer_pid, captured)?;
308 *probe.captured_exit.lock().map_err(|error| {
309 DispatchError::Conversation(format!("EXIT capture lock poisoned: {error}"))
310 })? = Some(receiver);
311 }
312 }
313 let receipt = ConsumptionReceipt::new(consumer_pid)?;
314 #[cfg(test)]
315 if let Some(probe) = &self.probe {
316 receipt.instrument(std::sync::Arc::clone(&probe.receipts))?;
317 let probe = std::sync::Arc::clone(probe);
318 receipt.observe(std::sync::Arc::new(move |event| {
319 probe.record_event(event);
320 }))?;
321 }
322 let mut registration = actor
323 .0
324 .register_dispatch_receipt(std::sync::Arc::clone(&receipt))?;
325 if receipt.is_pending()? {
326 if let Err(error) = actor
327 .0
328 .send_dispatch(dispatch_envelope(message)?, std::sync::Arc::clone(&receipt))
329 {
330 if receipt.is_pending()? {
331 return Err(error.into());
332 }
333 }
334 }
335 #[cfg(test)]
336 if let Some(probe) = &self.probe {
337 probe.arm_waiter(consumer_pid)?;
338 }
339 let result = observe_attempt(&receipt);
340 #[cfg(test)]
341 if let Some(probe) = &self.probe {
342 probe
343 .waiter_resumes
344 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
345 }
346 registration.release()?;
347 result
348 }
349}
350
351struct AttemptActor(crate::conversation::ConversationActor);
352
353impl Drop for AttemptActor {
354 fn drop(&mut self) {
355 self.0.finalize();
356 }
357}
358
359fn observe_attempt(receipt: &ConsumptionReceipt) -> Result<AttemptResult, DispatchError> {
360 match receipt.wait_decision()? {
361 AttemptDecision::Accepted(acceptance)
362 if acceptance.attempt == receipt.attempt()
363 && acceptance.participant == receipt.participant() =>
364 {
365 Ok(AttemptResult::Delivered)
366 }
367 AttemptDecision::Exited(crash_observed) => Ok(AttemptResult::Crashed(RerouteTiming {
368 crash_observed,
369 reroute_initiated: Instant::now(),
370 })),
371 AttemptDecision::Closed => Err(DispatchError::Conversation(
372 "dispatch closed by conversation finalization or supervisor shutdown".to_owned(),
373 )),
374 AttemptDecision::Abandoned => Err(DispatchError::Conversation(
375 "dispatch attempt explicitly abandoned before acceptance".to_owned(),
376 )),
377 AttemptDecision::Pending | AttemptDecision::Accepted(_) => {
378 Err(DispatchError::Conversation(
379 "dispatch receipt returned an invalid or unrelated terminal decision".to_owned(),
380 ))
381 }
382 }
383}
384
385#[derive(Debug)]
387enum AttemptResult {
388 Delivered,
390 Crashed(RerouteTiming),
393}
394
395fn dispatch_envelope(message: &RoutingMessage) -> Result<crate::envelope::Envelope, DispatchError> {
398 let payload = encode_message(message)?;
399 Ok(crate::envelope::Envelope::new(
400 payload,
401 None,
402 crate::channel::SchemaId::new(),
403 crate::envelope::PublisherId::default(),
404 ))
405}
406
407fn encode_message(message: &RoutingMessage) -> Result<Vec<u8>, DispatchError> {
410 let map: serde_json::Map<String, serde_json::Value> = message
411 .fields()
412 .map(|(name, value)| (name.to_owned(), field_to_json(value)))
413 .collect();
414 serde_json::to_vec(&serde_json::Value::Object(map)).map_err(|error| {
415 DispatchError::Conversation(format!("failed to encode dispatched message: {error}"))
416 })
417}
418
419fn field_to_json(value: &FieldValue) -> serde_json::Value {
423 match value {
424 FieldValue::Text(text) => serde_json::Value::String(text.clone()),
425 FieldValue::Integer(integer) => serde_json::Value::from(*integer),
426 FieldValue::Float(float) => serde_json::Number::from_f64(*float)
427 .map_or(serde_json::Value::Null, serde_json::Value::Number),
428 FieldValue::Boolean(boolean) => serde_json::Value::Bool(*boolean),
429 FieldValue::Null => serde_json::Value::Null,
430 }
431}
432
433#[cfg(test)]
436fn select_first() -> crate::routing::RoutingFunction {
437 use crate::routing::function::loader::{ModuleLoader, RoutingModule};
438 ModuleLoader::new().load(RoutingModule::new(
439 b"dispatch-first",
440 |_message, consumers| {
441 consumers
442 .first()
443 .map_or_else(RoutingDecision::none, |state| {
444 RoutingDecision::select(state.consumer.clone())
445 })
446 },
447 ))
448}
449
450#[cfg(test)]
451mod tests;
452
453#[cfg(test)]
454mod acceptance_tests;
455
456#[cfg(test)]
457mod receipt_tests;