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}