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}