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        /// Whether this request's durable drains removed at least one head of
82        /// the conversation's immutable-candidate lane, marker or binding
83        /// terminal alike.
84        ///
85        /// A connection fate refused by candidate-lane `Precedence` is parked
86        /// until exactly this happens, so this is the tell that wakes it. It is
87        /// a lane of its own for the same reason `settled_epochs` is: the
88        /// parked fate belongs to a connection that has already gone, so no
89        /// `DispatchTarget` can name it.
90        candidate_lane_drained: bool,
91    },
92}
93
94impl DispatchImpact {
95    /// Returns the changed conversation, if any.
96    #[must_use]
97    pub const fn conversation_id(&self) -> Option<ConversationId> {
98        match self {
99            Self::Unchanged => None,
100            Self::Changed {
101                conversation_id, ..
102            } => Some(*conversation_id),
103        }
104    }
105
106    /// Returns the marker settlement epochs cleared by this request.
107    #[must_use]
108    pub fn settled_epochs(&self) -> &[u64] {
109        match self {
110            Self::Unchanged => &[],
111            Self::Changed { settled_epochs, .. } => settled_epochs,
112        }
113    }
114
115    /// Returns whether a durable drain removed a candidate-lane head.
116    #[must_use]
117    pub const fn candidate_lane_drained(&self) -> bool {
118        match self {
119            Self::Unchanged => false,
120            Self::Changed {
121                candidate_lane_drained,
122                ..
123            } => *candidate_lane_drained,
124        }
125    }
126
127    /// Returns the effect map for a changed impact.
128    #[must_use]
129    pub const fn effects(&self) -> Option<&BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>> {
130        match self {
131            Self::Unchanged => None,
132            Self::Changed { effects, .. } => Some(effects),
133        }
134    }
135
136    /// Unions and deduplicates all exact targets without applying effect
137    /// precedence.
138    #[must_use]
139    pub fn target_union(&self) -> BTreeSet<DispatchTarget> {
140        match self {
141            Self::Unchanged => BTreeSet::new(),
142            Self::Changed { effects, .. } => effects
143                .values()
144                .flat_map(|targets| targets.iter().copied())
145                .collect(),
146        }
147    }
148}
149
150/// Lossless request-scoped accumulator for installed subcommit effects.
151#[derive(Debug, Default)]
152pub struct DispatchImpactAccumulator {
153    effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
154    staged_effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
155    settled_epochs: Vec<u64>,
156    candidate_lane_drained: bool,
157}
158
159impl DispatchImpactAccumulator {
160    /// Starts an empty request accumulator.
161    #[must_use]
162    pub const fn new() -> Self {
163        Self {
164            effects: BTreeMap::new(),
165            staged_effects: BTreeMap::new(),
166            settled_epochs: Vec::new(),
167            candidate_lane_drained: false,
168        }
169    }
170
171    /// Records one truthful effect after its durable subcommit is installed.
172    ///
173    /// Repeated effects union exact target sets. An empty target iterator still
174    /// records the effect because lack of a current binding does not erase a
175    /// committed permission change.
176    pub fn record(
177        &mut self,
178        effect: DispatchEffect,
179        targets: impl IntoIterator<Item = DispatchTarget>,
180    ) {
181        self.effects.entry(effect).or_default().extend(targets);
182    }
183
184    /// Stages an effect whose enclosing durability/reconciliation barrier has
185    /// not installed every coupled owner yet.
186    pub(crate) fn stage(
187        &mut self,
188        effect: DispatchEffect,
189        targets: impl IntoIterator<Item = DispatchTarget>,
190    ) {
191        self.staged_effects
192            .entry(effect)
193            .or_default()
194            .extend(targets);
195    }
196
197    /// Commits every staged effect after the enclosing barrier is installed.
198    pub(crate) fn install_staged(&mut self) {
199        let staged = core::mem::take(&mut self.staged_effects);
200        for (effect, targets) in staged {
201            self.record(effect, targets);
202        }
203    }
204
205    /// Reports whether a durable source awaits reconciliation before telling.
206    pub(crate) fn has_staged(&self) -> bool {
207        !self.staged_effects.is_empty()
208    }
209
210    /// Merges another installed-prefix accumulator without replacing any
211    /// earlier effect or target.
212    pub fn merge(&mut self, prefix: Self) {
213        for (effect, targets) in prefix.effects {
214            self.record(effect, targets);
215        }
216        for (effect, targets) in prefix.staged_effects {
217            self.stage(effect, targets);
218        }
219    }
220
221    /// Returns whether no committed subcommit recorded an effect.
222    #[must_use]
223    pub fn is_empty(&self) -> bool {
224        self.effects.is_empty()
225    }
226
227    /// Converts the request accumulator into its externally carried impact.
228    #[must_use]
229    pub fn finish(self, conversation_id: ConversationId) -> DispatchImpact {
230        if self.effects.is_empty() && self.settled_epochs.is_empty() && !self.candidate_lane_drained
231        {
232            DispatchImpact::Unchanged
233        } else {
234            DispatchImpact::Changed {
235                conversation_id,
236                effects: self.effects,
237                settled_epochs: self.settled_epochs,
238                candidate_lane_drained: self.candidate_lane_drained,
239            }
240        }
241    }
242
243    /// Records one marker settlement epoch AFTER its drain row is durable.
244    ///
245    /// Ordering is the whole point: the wake promises the refused connection
246    /// that retrying now will not meet the same candidate, so it may only be
247    /// recorded once the drain that removed the candidate has appended and the
248    /// resulting owner is installed.
249    pub(crate) fn record_marker_settled(&mut self, refused_epoch: u64) {
250        self.settled_epochs.push(refused_epoch);
251    }
252
253    /// Records that a candidate-lane head left the lane AFTER its drain row is
254    /// durable and the resulting owner is installed.
255    ///
256    /// Same ordering rule as [`Self::record_marker_settled`]: the tell wakes a
257    /// parked connection fate to retry its terminal admission, which must not
258    /// meet the candidate this drain removed.
259    pub(crate) const fn record_candidate_lane_drained(&mut self) {
260        self.candidate_lane_drained = true;
261    }
262}