Skip to main content

liminal_protocol/lifecycle/
observer_recovery_transactions.rs

1//! The ownership barriers between an observer-recovery decision and its commit, moved out of
2//! `observer_recovery.rs` unchanged so that file stays within the 500-line bound.
3
4use alloc::vec::Vec;
5
6use super::{ObserverProgressAdvanceError, ObserverProgressTrackError, ObserverRecoveryAggregate};
7use crate::wire::{
8    ConversationId, DeliverySeq, ObserverEpoch, ObserverRecoveryAccepted, ObserverRecoveryResponse,
9};
10
11/// Total transactional observer-recovery decision against the owned aggregate.
12#[derive(Debug, PartialEq, Eq)]
13pub enum ObserverRecoveryTransactionDecision {
14    /// The whole batch was refused; the aggregate is unchanged.
15    Respond {
16        /// Unchanged aggregate returned to its owner.
17        aggregate: ObserverRecoveryAggregate,
18        /// Exact refusal response.
19        response: ObserverRecoveryResponse,
20    },
21    /// Every entry validated; the arm plan may commit atomically.
22    Commit(ObserverRecoveryTransaction),
23}
24
25/// Ownership barrier between arm selection and atomic arm installation.
26///
27/// The aggregate is unreachable while the transaction is pending, so no
28/// progress can advance between the equal-epoch read and the installation.
29/// Consuming [`Self::commit`] installs every arm of the plan at once — a
30/// subset is unrepresentable — and consuming [`Self::abort`] installs none,
31/// so a crash at any point leaves either the complete arm plan or no arm at
32/// all, never a partially-armed request.
33#[derive(Debug, PartialEq, Eq)]
34pub struct ObserverRecoveryTransaction {
35    pub(super) aggregate: ObserverRecoveryAggregate,
36    pub(super) commit: ObserverRecoveryCommit,
37}
38
39impl ObserverRecoveryTransaction {
40    /// Borrows the complete equal-epoch arm plan for the durable append.
41    #[must_use]
42    pub fn arms(&self) -> &[ObserverRecoveryArm] {
43        self.commit.arms()
44    }
45
46    /// Borrows the exact request-ordered success response.
47    #[must_use]
48    pub const fn outcome(&self) -> &ObserverRecoveryAccepted {
49        self.commit.outcome()
50    }
51
52    /// Installs the whole arm plan after a confirmed durable append.
53    ///
54    /// Installation is idempotent: replaying a durable recovery against the
55    /// post-state reinstalls the identical equal-epoch arms, so at most one
56    /// arm per conversation ever exists and crash replay converges.
57    #[must_use]
58    pub fn commit(mut self) -> (ObserverRecoveryAggregate, ObserverRecoveryAccepted) {
59        let (arms, outcome) = self.commit.into_parts();
60        for arm in arms {
61            self.aggregate
62                .armed
63                .insert(arm.conversation_id(), arm.refused_epoch());
64        }
65        (self.aggregate, outcome)
66    }
67
68    /// Cancels a failed durable append; not one arm is installed.
69    #[must_use]
70    pub fn abort(self) -> ObserverRecoveryAggregate {
71        self.aggregate
72    }
73}
74
75/// Total transactional progress-advance decision against the owned aggregate.
76#[derive(Debug, PartialEq, Eq)]
77pub enum ObserverProgressAdvanceDecision {
78    /// The advance was refused; the aggregate is unchanged.
79    Refuse {
80        /// Unchanged aggregate returned to its owner.
81        aggregate: ObserverRecoveryAggregate,
82        /// Exact refusal.
83        error: ObserverProgressAdvanceError,
84    },
85    /// The advance validated; the progress write and arm fire may commit
86    /// atomically.
87    Commit(ObserverProgressAdvanceTransaction),
88}
89
90/// Ownership barrier between a validated progress advance and its durable
91/// append.
92///
93/// The aggregate is unreachable while the transaction is pending, so no
94/// recovery batch can read the not-yet-durable progress and no other advance
95/// can race the fired-arm plan. Consuming [`Self::commit`] applies the
96/// progress write and removes the fired arm as ONE mutation — after the
97/// caller has confirmed the durable append of the progress row, the arm-row
98/// deletion, and the wake — and consuming [`Self::abort`] returns the
99/// aggregate byte-for-byte unchanged, arm still installed, so a failed
100/// append never leaves live progress ahead of durable state or a
101/// phantom-fired arm.
102#[derive(Debug, PartialEq, Eq)]
103pub struct ObserverProgressAdvanceTransaction {
104    pub(super) aggregate: ObserverRecoveryAggregate,
105    pub(super) conversation_id: ConversationId,
106    pub(super) presented_progress: DeliverySeq,
107    pub(super) fired: Option<ObserverRecoveryArm>,
108}
109
110impl ObserverProgressAdvanceTransaction {
111    /// Returns the conversation whose progress row the durable append writes.
112    #[must_use]
113    pub const fn conversation_id(&self) -> ConversationId {
114        self.conversation_id
115    }
116
117    /// Returns the new hard observer progress for the durable append.
118    #[must_use]
119    pub const fn presented_progress(&self) -> DeliverySeq {
120        self.presented_progress
121    }
122
123    /// Returns the fired-arm plan for the durable append.
124    ///
125    /// `Some` names the installed arm whose durable row the same append must
126    /// delete and whose parked rows the same append must wake; `None` means
127    /// the conversation carried no arm.
128    #[must_use]
129    pub const fn fired_arm(&self) -> Option<ObserverRecoveryArm> {
130        self.fired
131    }
132
133    /// Applies the advance after a confirmed durable append.
134    ///
135    /// The progress write and the arm removal are one mutation: the returned
136    /// arm is exactly [`Self::fired_arm`], surrendered so the caller wakes
137    /// the parked rows it durably committed to waking.
138    #[must_use]
139    pub fn commit(mut self) -> (ObserverRecoveryAggregate, Option<ObserverRecoveryArm>) {
140        self.aggregate
141            .progress
142            .insert(self.conversation_id, self.presented_progress);
143        if self.fired.is_some() {
144            self.aggregate.armed.remove(&self.conversation_id);
145        }
146        (self.aggregate, self.fired)
147    }
148
149    /// Cancels a failed durable append; neither progress nor arm changes.
150    #[must_use]
151    pub fn abort(self) -> ObserverRecoveryAggregate {
152        self.aggregate
153    }
154}
155
156/// Total transactional registration decision against the owned aggregate.
157#[derive(Debug, PartialEq, Eq)]
158pub enum ObserverProgressTrackDecision {
159    /// The registration was refused; the aggregate is unchanged.
160    Refuse {
161        /// Unchanged aggregate returned to its owner.
162        aggregate: ObserverRecoveryAggregate,
163        /// Exact refusal.
164        error: ObserverProgressTrackError,
165    },
166    /// The registration validated; the progress row may commit atomically.
167    Commit(ObserverProgressTrackTransaction),
168}
169
170/// Ownership barrier between a validated registration and its durable append.
171///
172/// The aggregate is unreachable while the transaction is pending, so no
173/// recovery batch can plan an equal-epoch arm against a progress row whose
174/// durable append is not yet confirmed. Consuming [`Self::commit`] installs
175/// the new progress row — after the caller has confirmed the durable append —
176/// and consuming [`Self::abort`] returns the aggregate byte-for-byte
177/// unchanged, conversation still untracked, so a failed append never leaves
178/// a live progress row that durable state does not hold.
179#[derive(Debug, PartialEq, Eq)]
180pub struct ObserverProgressTrackTransaction {
181    pub(super) aggregate: ObserverRecoveryAggregate,
182    pub(super) conversation_id: ConversationId,
183    pub(super) observer_progress: DeliverySeq,
184}
185
186impl ObserverProgressTrackTransaction {
187    /// Returns the conversation whose progress row the durable append writes.
188    #[must_use]
189    pub const fn conversation_id(&self) -> ConversationId {
190        self.conversation_id
191    }
192
193    /// Returns the registered hard observer progress for the durable append.
194    #[must_use]
195    pub const fn observer_progress(&self) -> DeliverySeq {
196        self.observer_progress
197    }
198
199    /// Installs the registered progress row after a confirmed durable append.
200    #[must_use]
201    pub fn commit(mut self) -> ObserverRecoveryAggregate {
202        self.aggregate
203            .progress
204            .insert(self.conversation_id, self.observer_progress);
205        self.aggregate
206    }
207
208    /// Cancels a failed durable append; the conversation stays untracked.
209    #[must_use]
210    pub fn abort(self) -> ObserverRecoveryAggregate {
211        self.aggregate
212    }
213}
214
215/// One equal observer epoch that must be armed by an accepted recovery batch.
216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
217pub struct ObserverRecoveryArm {
218    pub(super) conversation_id: ConversationId,
219    pub(super) refused_epoch: ObserverEpoch,
220}
221
222impl ObserverRecoveryArm {
223    /// Returns the conversation whose progress event must wake the parked rows.
224    #[must_use]
225    pub const fn conversation_id(self) -> ConversationId {
226        self.conversation_id
227    }
228
229    /// Returns the exact refusal epoch being armed.
230    #[must_use]
231    pub const fn refused_epoch(self) -> ObserverEpoch {
232        self.refused_epoch
233    }
234}
235
236/// Whole-batch observer-recovery commit selected after exhaustive validation.
237///
238/// The arm list and response are produced together. A consumer must persist all
239/// arms atomically before sending [`Self::outcome`], so no validation failure or
240/// crash can expose a partially armed request.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct ObserverRecoveryCommit {
243    pub(super) arms: Vec<ObserverRecoveryArm>,
244    pub(super) outcome: ObserverRecoveryAccepted,
245}
246
247impl ObserverRecoveryCommit {
248    /// Borrows the complete equal-epoch arm plan in request order.
249    #[must_use]
250    pub fn arms(&self) -> &[ObserverRecoveryArm] {
251        &self.arms
252    }
253
254    /// Borrows the exact request-ordered success response.
255    #[must_use]
256    pub const fn outcome(&self) -> &ObserverRecoveryAccepted {
257        &self.outcome
258    }
259
260    /// Consumes the commit into the atomic arm plan and success response.
261    #[must_use]
262    pub fn into_parts(self) -> (Vec<ObserverRecoveryArm>, ObserverRecoveryAccepted) {
263        (self.arms, self.outcome)
264    }
265}