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                    #[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                    // Host thread scheduling and attempt cleanup are part of
228                    // this span. Record them without refusing crash recovery;
229                    // the quiet-path tests own the one-millisecond proof.
230                    reroute_timings.push(timing);
231                    excluded.push(selected.consumer().clone());
232                }
233            }
234        }
235    }
236
237    /// Selects a consumer for `message`, excluding `excluded`, via the routing
238    /// function evaluated over the current group snapshot.
239    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    /// Links and arms one attempt before forwarding into the selected queue.
273    /// Consumption and EXIT commit to the same receipt; only its owned signal
274    /// wakes observation, and teardown removes its borrowed request metadata.
275    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/// Internal result of a single dispatch attempt.
386#[derive(Debug)]
387enum AttemptResult {
388    /// The selected consumer committed correlated consumption acceptance.
389    Delivered,
390    /// The linked consumer's EXIT signal fired; re-route, excluding it. Carries
391    /// the real crash-to-reroute timing along the event path.
392    Crashed(RerouteTiming),
393}
394
395/// Builds the envelope forwarded to the selected consumer, carrying the real
396/// dispatched message content (the routing message's fields), not a placeholder.
397fn 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
407/// Serializes the dispatched message's fields to JSON bytes so the forwarded
408/// envelope genuinely carries the message, not a synthetic substitute.
409fn 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
419/// Maps a routing [`FieldValue`] to its JSON representation. A non-finite float
420/// has no JSON number form and is encoded as null, matching `serde_json`'s own
421/// handling of non-finite numbers.
422fn 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/// Routing function that selects the first available consumer (the routing
434/// function genuinely drives selection — never random).
435#[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;