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}