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        /// Nonempty effect map. Individual truthful effects may have no current
68        /// binding target.
69        effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
70    },
71}
72
73impl DispatchImpact {
74    /// Returns the changed conversation, if any.
75    #[must_use]
76    pub const fn conversation_id(&self) -> Option<ConversationId> {
77        match self {
78            Self::Unchanged => None,
79            Self::Changed {
80                conversation_id, ..
81            } => Some(*conversation_id),
82        }
83    }
84
85    /// Returns the effect map for a changed impact.
86    #[must_use]
87    pub const fn effects(&self) -> Option<&BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>> {
88        match self {
89            Self::Unchanged => None,
90            Self::Changed { effects, .. } => Some(effects),
91        }
92    }
93
94    /// Unions and deduplicates all exact targets without applying effect
95    /// precedence.
96    #[must_use]
97    pub fn target_union(&self) -> BTreeSet<DispatchTarget> {
98        match self {
99            Self::Unchanged => BTreeSet::new(),
100            Self::Changed { effects, .. } => effects
101                .values()
102                .flat_map(|targets| targets.iter().copied())
103                .collect(),
104        }
105    }
106}
107
108/// Lossless request-scoped accumulator for installed subcommit effects.
109#[derive(Debug, Default)]
110pub struct DispatchImpactAccumulator {
111    effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
112    staged_effects: BTreeMap<DispatchEffect, BTreeSet<DispatchTarget>>,
113}
114
115impl DispatchImpactAccumulator {
116    /// Starts an empty request accumulator.
117    #[must_use]
118    pub const fn new() -> Self {
119        Self {
120            effects: BTreeMap::new(),
121            staged_effects: BTreeMap::new(),
122        }
123    }
124
125    /// Records one truthful effect after its durable subcommit is installed.
126    ///
127    /// Repeated effects union exact target sets. An empty target iterator still
128    /// records the effect because lack of a current binding does not erase a
129    /// committed permission change.
130    pub fn record(
131        &mut self,
132        effect: DispatchEffect,
133        targets: impl IntoIterator<Item = DispatchTarget>,
134    ) {
135        self.effects.entry(effect).or_default().extend(targets);
136    }
137
138    /// Stages an effect whose enclosing durability/reconciliation barrier has
139    /// not installed every coupled owner yet.
140    pub(crate) fn stage(
141        &mut self,
142        effect: DispatchEffect,
143        targets: impl IntoIterator<Item = DispatchTarget>,
144    ) {
145        self.staged_effects
146            .entry(effect)
147            .or_default()
148            .extend(targets);
149    }
150
151    /// Commits every staged effect after the enclosing barrier is installed.
152    pub(crate) fn install_staged(&mut self) {
153        let staged = core::mem::take(&mut self.staged_effects);
154        for (effect, targets) in staged {
155            self.record(effect, targets);
156        }
157    }
158
159    /// Reports whether a durable source awaits reconciliation before telling.
160    pub(crate) fn has_staged(&self) -> bool {
161        !self.staged_effects.is_empty()
162    }
163
164    /// Merges another installed-prefix accumulator without replacing any
165    /// earlier effect or target.
166    pub fn merge(&mut self, prefix: Self) {
167        for (effect, targets) in prefix.effects {
168            self.record(effect, targets);
169        }
170        for (effect, targets) in prefix.staged_effects {
171            self.stage(effect, targets);
172        }
173    }
174
175    /// Returns whether no committed subcommit recorded an effect.
176    #[must_use]
177    pub fn is_empty(&self) -> bool {
178        self.effects.is_empty()
179    }
180
181    /// Converts the request accumulator into its externally carried impact.
182    #[must_use]
183    pub fn finish(self, conversation_id: ConversationId) -> DispatchImpact {
184        if self.effects.is_empty() {
185            DispatchImpact::Unchanged
186        } else {
187            DispatchImpact::Changed {
188                conversation_id,
189                effects: self.effects,
190            }
191        }
192    }
193}