Skip to main content

liminal/routing/
dispatch.rs

1//! Conversation-mediated dispatch (ADR-007).
2//!
3//! A dispatch conversation replaces Kafka-style consumer group rebalancing with
4//! per-message, fault-aware consumer selection. For each message the dispatch
5//! conversation evaluates the group's routing function to select a target
6//! consumer, links the dispatch process to that consumer via a beamr process
7//! link, forwards the message, and observes completion. If the linked consumer
8//! crashes, the link fires immediately (no polling, no heartbeat) and the
9//! conversation re-routes the message to another available consumer, excluding
10//! the one that crashed. Selection is always via the routing function — never
11//! random — and adding or removing consumers takes effect on the next dispatch
12//! without a stop-the-world rebalance.
13
14#[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/// Outcome of a successful dispatch: the consumer the message was delivered to
29/// and the chain of consumers that crashed and were re-routed past, in order.
30#[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    /// Consumer the message was ultimately delivered to.
39    #[must_use]
40    pub const fn delivered_to(&self) -> &ConsumerId {
41        &self.delivered_to
42    }
43
44    /// Consumers that crashed mid-dispatch and were re-routed past, in order.
45    #[must_use]
46    pub fn rerouted_from(&self) -> &[ConsumerId] {
47        &self.rerouted_from
48    }
49
50    /// Real crash-to-reroute timings, one per consumer in [`Self::rerouted_from`]
51    /// and in the same order. Each spans the consumer's EXIT instant (captured
52    /// in the link handler) through to re-route initiation, so callers can
53    /// verify sub-millisecond, event-driven detection (R3 / CN7).
54    #[must_use]
55    pub fn reroute_timings(&self) -> &[RerouteTiming] {
56        &self.reroute_timings
57    }
58
59    /// True when the message was delivered without any consumer crash.
60    #[must_use]
61    pub fn delivered_first_try(&self) -> bool {
62        self.rerouted_from.is_empty()
63    }
64}
65
66/// Failure surfaced when a message cannot be dispatched to any consumer.
67#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
68pub enum DispatchError {
69    /// The routing function selected no consumer from the available set.
70    #[error("routing function selected no consumer from the available group set")]
71    NoConsumerAvailable,
72    /// The routing function selected a consumer that is not in the group.
73    #[error("routing function selected unknown consumer '{0}'")]
74    UnknownConsumerSelected(String),
75    /// Evaluating the group's routing function failed under supervision.
76    #[error("routing function evaluation failed: {0}")]
77    Evaluation(#[from] FunctionError),
78    /// The dispatch conversation infrastructure could not be driven.
79    #[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/// Real crash-to-reroute latency along the event path.
90///
91/// Records the instant the consumer's trapped EXIT signal was observed inside
92/// the conversation actor's link handler and the instant the dispatcher begins
93/// re-routing after releasing the failed attempt.
94///
95/// `crash_observed` is captured *inside* the EXIT handler the moment the beamr
96/// process link fires (see `ActorCore::handle_participant_exit`); it is not a
97/// post-detection sample. `reroute_initiated` is captured before the next
98/// selection, after observation and attempt cleanup.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct RerouteTiming {
101    crash_observed: Instant,
102    reroute_initiated: Instant,
103}
104
105impl RerouteTiming {
106    /// Elapsed time between the EXIT signal firing and re-route initiation.
107    #[must_use]
108    pub fn detection_to_reroute(&self) -> Duration {
109        self.reroute_initiated
110            .saturating_duration_since(self.crash_observed)
111    }
112
113    /// The instant the consumer's EXIT signal was observed in the link handler.
114    #[must_use]
115    pub const fn crash_observed(&self) -> Instant {
116        self.crash_observed
117    }
118}
119
120/// Drives conversation-mediated dispatch for one consumer group.
121///
122/// Each call to [`DispatchConversation::dispatch`] spawns a supervised
123/// conversation actor (a beamr process), selects a consumer via the group's
124/// routing function, links the conversation to the consumer, forwards the
125/// message, and re-routes on consumer crash.
126#[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    /// Creates a dispatch driver for `group` using a fresh conversation supervisor.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`DispatchError::Conversation`] if the beamr scheduler backing the
141    /// conversation supervisor cannot start.
142    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    /// Creates a dispatch driver reusing an existing conversation supervisor and
155    /// supervision executor.
156    #[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    /// Returns the consumer group this dispatch conversation routes over.
172    #[must_use]
173    pub const fn group(&self) -> &ConsumerGroup {
174        &self.group
175    }
176
177    /// Returns the conversation supervisor backing dispatch processes.
178    #[must_use]
179    pub const fn supervisor(&self) -> &ConversationSupervisor {
180        &self.supervisor
181    }
182
183    /// Dispatches `message` to a consumer selected by the group's routing function.
184    ///
185    /// Snapshots the group, evaluates the routing function over the live consumer
186    /// state, links a dispatch conversation process to the selected consumer,
187    /// forwards the message, and observes completion. On consumer crash the
188    /// message is re-routed to another consumer, excluding any that have crashed.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`DispatchError::NoConsumerAvailable`] if the routing function
193    /// selects no consumer, [`DispatchError::UnknownConsumerSelected`] if it
194    /// selects a consumer outside the group, [`DispatchError::Evaluation`] if the
195    /// supervised routing function fails, and [`DispatchError::Conversation`] if
196    /// the dispatch conversation process cannot be driven.
197    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                    // Re-routing is initiated here, the instant the dispatcher
219                    // woke from the EXIT notification. CN7 / R3: this is
220                    // sub-millisecond because detection is the beamr link firing
221                    // (no polling) and the wakeup is the very next step. The
222                    // timing spans the real EXIT instant captured in the link
223                    // handler through to this re-route initiation.
224                    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    /// Selects a consumer for `message`, excluding `excluded`, via the routing
236    /// function evaluated over the current group snapshot.
237    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    /// Links and arms one attempt before forwarding into the selected queue.
271    /// Consumption and EXIT commit to the same receipt; only its owned signal
272    /// wakes observation, and teardown removes its borrowed request metadata.
273    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/// Internal result of a single dispatch attempt.
384#[derive(Debug)]
385enum AttemptResult {
386    /// The selected consumer committed correlated consumption acceptance.
387    Delivered,
388    /// The linked consumer's EXIT signal fired; re-route, excluding it. Carries
389    /// the real crash-to-reroute timing along the event path.
390    Crashed(RerouteTiming),
391}
392
393/// Builds the envelope forwarded to the selected consumer, carrying the real
394/// dispatched message content (the routing message's fields), not a placeholder.
395fn 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
405/// Serializes the dispatched message's fields to JSON bytes so the forwarded
406/// envelope genuinely carries the message, not a synthetic substitute.
407fn 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
417/// Maps a routing [`FieldValue`] to its JSON representation. A non-finite float
418/// has no JSON number form and is encoded as null, matching `serde_json`'s own
419/// handling of non-finite numbers.
420fn 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/// Routing function that selects the first available consumer (the routing
432/// function genuinely drives selection — never random).
433#[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;