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}