Skip to main content

liminal_server/server/participant/
dispatch_impact.rs

1//! Typed post-commit tells for participant obligation dispatch.
2//!
3//! One accumulator belongs to one semantic request while its conversation owner
4//! is locked. Producers record effects immediately after each durable subcommit
5//! is installed. Finishing the accumulator preserves every committed prefix,
6//! including when a later operation refuses or fails.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use liminal_protocol::wire::{BindingEpoch, ConversationId, ParticipantId};
11
12/// Exhaustive reasons a committed participant operation can change dispatch
13/// permission.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
15pub enum DispatchEffect {
16    /// A Produced batch installed at least one live recipient obligation.
17    Published,
18    /// A normal or marker acknowledgement changed a dispatch cursor or verdict.
19    Acknowledged,
20    /// A current binding was installed, replaced, detached, or retired.
21    BindingChanged,
22    /// Coupled closure-debt episode state changed.
23    EpisodeChanged,
24    /// A permanent `Left` commit discharged one participant.
25    Retired,
26}
27
28/// Exact poststate binding eligible to be told about changed permission.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
30pub struct DispatchTarget {
31    participant_id: ParticipantId,
32    binding_epoch: BindingEpoch,
33}
34
35impl DispatchTarget {
36    /// Captures one exact current participant binding.
37    #[must_use]
38    pub const fn new(participant_id: ParticipantId, binding_epoch: BindingEpoch) -> Self {
39        Self {
40            participant_id,
41            binding_epoch,
42        }
43    }
44
45    /// Returns the permanent participant identity.
46    #[must_use]
47    pub const fn participant_id(self) -> ParticipantId {
48        self.participant_id
49    }
50
51    /// Returns the exact current binding, including connection incarnation.
52    #[must_use]
53    pub const fn binding_epoch(self) -> BindingEpoch {
54        self.binding_epoch
55    }
56}
57
58/// Complete request-level post-commit dispatch impact.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum DispatchImpact {
61    /// No committed subcommit changed dispatch permission.
62    Unchanged,
63    /// At least one truthful effect occurred for one conversation.
64    Changed {
65        /// Conversation whose locked authority produced the effects.
66        conversation_id: ConversationId,
67        /// Effect map. Individual truthful effects may have no current binding
68        /// target. May be empty when only a settlement cleared.
69        effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
70        /// Marker settlement epochs this request's durable drains CLEARED
71        /// (participant contract §0.16 condition 2).
72        ///
73        /// A separate lane from `effects` on purpose: an effect names a
74        /// participant BINDING eligible to be told about changed dispatch
75        /// permission, and a connection refused `MarkerSettlementBackpressure`
76        /// holds no binding — that is precisely why the amendment needs a
77        /// pushed wake instead of a `ParticipantDelivery`. Routing settlements
78        /// through `DispatchTarget` would therefore be routing them to the one
79        /// population that cannot receive them.
80        settled_epochs: Vec<u64>,
81    },
82}
83
84impl DispatchImpact {
85    /// Returns the changed conversation, if any.
86    #[must_use]
87    pub const fn conversation_id(&self) -> Option<ConversationId> {
88        match self {
89            Self::Unchanged => None,
90            Self::Changed {
91                conversation_id, ..
92            } => Some(*conversation_id),
93        }
94    }
95
96    /// Returns the marker settlement epochs cleared by this request.
97    #[must_use]
98    pub fn settled_epochs(&self) -> &[u64] {
99        match self {
100            Self::Unchanged => &[],
101            Self::Changed { settled_epochs, .. } => settled_epochs,
102        }
103    }
104
105    /// Returns the effect map for a changed impact.
106    #[must_use]
107    pub const fn effects(&self) -> Option<&BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>> {
108        match self {
109            Self::Unchanged => None,
110            Self::Changed { effects, .. } => Some(effects),
111        }
112    }
113
114    /// Unions and deduplicates all exact targets without applying effect
115    /// precedence.
116    #[must_use]
117    pub fn target_union(&self) -> BTreeSet<DispatchTarget> {
118        match self {
119            Self::Unchanged => BTreeSet::new(),
120            Self::Changed { effects, .. } => effects
121                .values()
122                .flat_map(|targets| targets.iter().copied())
123                .collect(),
124        }
125    }
126}
127
128/// Lossless request-scoped accumulator for installed subcommit effects.
129#[derive(Debug, Default)]
130pub struct DispatchImpactAccumulator {
131    effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
132    staged_effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
133    settled_epochs: Vec<u64>,
134}
135
136impl DispatchImpactAccumulator {
137    /// Starts an empty request accumulator.
138    #[must_use]
139    pub const fn new() -> Self {
140        Self {
141            effects: BTreeMap::new(),
142            staged_effects: BTreeMap::new(),
143            settled_epochs: Vec::new(),
144        }
145    }
146
147    /// Records one truthful effect after its durable subcommit is installed.
148    ///
149    /// Repeated effects union exact target sets. An empty target iterator still
150    /// records the effect because lack of a current binding does not erase a
151    /// committed permission change.
152    pub fn record(
153        &mut self,
154        effect: DispatchEffect,
155        targets: impl IntoIterator<Item = DispatchTarget>,
156    ) {
157        self.effects.entry(effect).or_default().extend(targets);
158    }
159
160    /// Stages an effect whose enclosing durability/reconciliation barrier has
161    /// not installed every coupled owner yet.
162    pub(crate) fn stage(
163        &mut self,
164        effect: DispatchEffect,
165        targets: impl IntoIterator<Item = DispatchTarget>,
166    ) {
167        self.staged_effects
168            .entry(effect)
169            .or_default()
170            .extend(targets);
171    }
172
173    /// Commits every staged effect after the enclosing barrier is installed.
174    pub(crate) fn install_staged(&mut self) {
175        let staged = core::mem::take(&mut self.staged_effects);
176        for (effect, targets) in staged {
177            self.record(effect, targets);
178        }
179    }
180
181    /// Reports whether a durable source awaits reconciliation before telling.
182    pub(crate) fn has_staged(&self) -> bool {
183        !self.staged_effects.is_empty()
184    }
185
186    /// Merges another installed-prefix accumulator without replacing any
187    /// earlier effect or target.
188    pub fn merge(&mut self, prefix: Self) {
189        for (effect, targets) in prefix.effects {
190            self.record(effect, targets);
191        }
192        for (effect, targets) in prefix.staged_effects {
193            self.stage(effect, targets);
194        }
195    }
196
197    /// Returns whether no committed subcommit recorded an effect.
198    #[must_use]
199    pub fn is_empty(&self) -> bool {
200        self.effects.is_empty()
201    }
202
203    /// Converts the request accumulator into its externally carried impact.
204    #[must_use]
205    pub fn finish(self, conversation_id: ConversationId) -> DispatchImpact {
206        if self.effects.is_empty() && self.settled_epochs.is_empty() {
207            DispatchImpact::Unchanged
208        } else {
209            DispatchImpact::Changed {
210                conversation_id,
211                effects: self.effects,
212                settled_epochs: self.settled_epochs,
213            }
214        }
215    }
216
217    /// Records one marker settlement epoch AFTER its drain row is durable.
218    ///
219    /// Ordering is the whole point: the wake promises the refused connection
220    /// that retrying now will not meet the same candidate, so it may only be
221    /// recorded once the drain that removed the candidate has appended and the
222    /// resulting owner is installed.
223    pub(crate) fn record_marker_settled(&mut self, refused_epoch: u64) {
224        self.settled_epochs.push(refused_epoch);
225    }
226}