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