Skip to main content

coven_protocol/objects/
rotation.rs

1use super::*;
2
3/// Store-key work is in flight or committed but not fully adopted. Every cloud
4/// seal refuses while this holds, including while a local removal candidate may
5/// still publish and after a committed rotation whose key is not locally
6/// adopted or whose exact operation journal remains open.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
8#[error(
9    "store-key rotation is pending ({state:?}) while this device is sealing under generation \
10     {live_generation}; refusing to seal for the cloud until the pending state is completed"
11)]
12pub struct RotationPending {
13    pub state: RotationPendingState,
14    pub live_generation: u64,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RotationPendingState {
19    Candidate {
20        generation: u64,
21    },
22    LocalCommitted {
23        generation: u64,
24    },
25    PeerCommitted {
26        generation: u64,
27    },
28    CandidateAndPeer {
29        candidate_generation: u64,
30        peer_generation: u64,
31    },
32    LocalCommittedAndPeer {
33        local_generation: u64,
34        peer_generation: u64,
35    },
36}
37
38/// The exact store-key work that blocks sealing: a local candidate, an activated
39/// local removal awaiting adoption, a peer's committed generation awaiting
40/// adoption, or a local fact together with a peer fact. Durable database
41/// transitions and this in-memory copy move together at operation boundaries.
42///
43/// Shared (behind one `Arc`, via `CloudSyncConnection::shared_pending_rotation`)
44/// across every path that seals data for the cloud — changesets, heads, blobs,
45/// tombstones, snapshots — so a rotation this device can't adopt blocks all of
46/// them the same way, not just the removal call that discovered it. This is the
47/// structural half of the invariant: this device must never seal under a
48/// generation the store has already superseded.
49/// The protocol-state key that persists the serialized [`RotationGate`].
50/// Restored before the first sync cycle so a restart cannot forget an
51/// unfinished candidate or an unadopted committed rotation and resume sealing
52/// under an unauthorized key.
53pub const ROTATION_GATE_STATE_KEY: &str = "rotation_gate";
54
55#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
56#[serde(rename_all = "snake_case", deny_unknown_fields)]
57pub enum RotationGate {
58    /// This device's own rotation, with no unadopted peer generation.
59    Local(LocalRotation),
60    /// A generation the store committed that this device has not adopted, with
61    /// no local rotation of its own.
62    Peer { generation: NonZeroU64 },
63    /// Both facts at once: this device's rotation, and a peer generation it has
64    /// not adopted.
65    LocalAndPeer {
66        local: LocalRotation,
67        peer_generation: NonZeroU64,
68    },
69}
70
71/// This device's own rotation: a candidate it may still publish or lose, or its
72/// committed rotation awaiting local adoption. The commit consumes the candidate,
73/// so the two are the same fact at different points of its life — a device holds
74/// one or the other, never both.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[serde(rename_all = "snake_case", deny_unknown_fields)]
77pub enum LocalRotation {
78    Candidate {
79        generation: NonZeroU64,
80        mutation: crate::store_commit::ObjectHash,
81    },
82    Committed {
83        generation: NonZeroU64,
84        mutation: crate::store_commit::ObjectHash,
85    },
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
89pub enum RotationGateError {
90    #[error("rotation candidate names generation zero")]
91    CandidateGenerationZero,
92    #[error("a committed local rotation already owns the gate")]
93    CommittedLocalOwnsGate,
94    #[error("another rotation candidate already owns the gate")]
95    DifferentCandidateOwnsGate,
96    #[error("rotation commit does not own the pending candidate gate")]
97    CommitDoesNotOwnCandidate,
98    #[error("committed rotation names generation zero")]
99    CommittedGenerationZero,
100    #[error("rotation loss does not own the pending candidate gate")]
101    LossDoesNotOwnCandidate,
102    #[error("rotation adoption cannot close while a candidate is pending")]
103    CandidatePendingDuringAdoption,
104    #[error("rotation adoption does not own the committed gate")]
105    AdoptionDoesNotOwnCommitted,
106    #[error("adopted rotation names generation zero")]
107    AdoptedGenerationZero,
108}
109
110impl LocalRotation {
111    /// Reported through the replication layer's `PendingRotation::pending_generation`,
112    /// which exists for status reporting in tests and for hosts built with
113    /// `test-utils`.
114    #[cfg(any(test, feature = "test-utils"))]
115    fn generation(&self) -> NonZeroU64 {
116        match self {
117            Self::Candidate { generation, .. } | Self::Committed { generation, .. } => *generation,
118        }
119    }
120}
121
122impl RotationGate {
123    /// This device's own rotation, if the gate holds one.
124    fn local(&self) -> Option<LocalRotation> {
125        match self {
126            Self::Local(local) | Self::LocalAndPeer { local, .. } => Some(*local),
127            Self::Peer { .. } => None,
128        }
129    }
130
131    /// The unadopted peer generation, if the gate holds one.
132    fn peer(&self) -> Option<NonZeroU64> {
133        match self {
134            Self::Peer { generation }
135            | Self::LocalAndPeer {
136                peer_generation: generation,
137                ..
138            } => Some(*generation),
139            Self::Local(_) => None,
140        }
141    }
142
143    /// The gate holding both facts — `None` when neither is left, which is the
144    /// absence of a gate rather than an empty one.
145    fn from_parts(local: Option<LocalRotation>, peer: Option<NonZeroU64>) -> Option<Self> {
146        match (local, peer) {
147            (Some(local), Some(peer_generation)) => Some(Self::LocalAndPeer {
148                local,
149                peer_generation,
150            }),
151            (Some(local), None) => Some(Self::Local(local)),
152            (None, Some(generation)) => Some(Self::Peer { generation }),
153            (None, None) => None,
154        }
155    }
156
157    /// The gate `local` owns, keeping whatever peer fact came with it.
158    fn with_local(local: LocalRotation, peer: Option<NonZeroU64>) -> Self {
159        match peer {
160            Some(peer_generation) => Self::LocalAndPeer {
161                local,
162                peer_generation,
163            },
164            None => Self::Local(local),
165        }
166    }
167
168    pub fn pending_state(&self) -> RotationPendingState {
169        match self {
170            Self::Local(LocalRotation::Candidate { generation, .. }) => {
171                RotationPendingState::Candidate {
172                    generation: generation.get(),
173                }
174            }
175            Self::Local(LocalRotation::Committed { generation, .. }) => {
176                RotationPendingState::LocalCommitted {
177                    generation: generation.get(),
178                }
179            }
180            Self::Peer { generation } => RotationPendingState::PeerCommitted {
181                generation: generation.get(),
182            },
183            Self::LocalAndPeer {
184                local: LocalRotation::Candidate { generation, .. },
185                peer_generation,
186            } => RotationPendingState::CandidateAndPeer {
187                candidate_generation: generation.get(),
188                peer_generation: peer_generation.get(),
189            },
190            Self::LocalAndPeer {
191                local: LocalRotation::Committed { generation, .. },
192                peer_generation,
193            } => RotationPendingState::LocalCommittedAndPeer {
194                local_generation: generation.get(),
195                peer_generation: peer_generation.get(),
196            },
197        }
198    }
199
200    /// Stage `mutation` as this device's rotation candidate, on whatever gate is
201    /// already open (`None` when none is).
202    pub fn with_candidate(
203        gate: Option<Self>,
204        generation: u64,
205        mutation: crate::store_commit::ObjectHash,
206    ) -> Result<Self, RotationGateError> {
207        let Some(generation) = NonZeroU64::new(generation) else {
208            return Err(RotationGateError::CandidateGenerationZero);
209        };
210        let candidate = LocalRotation::Candidate {
211            generation,
212            mutation,
213        };
214        match gate.as_ref().and_then(Self::local) {
215            Some(LocalRotation::Committed { .. }) => Err(RotationGateError::CommittedLocalOwnsGate),
216            Some(existing) if existing != candidate => {
217                Err(RotationGateError::DifferentCandidateOwnsGate)
218            }
219            _ => Ok(Self::with_local(
220                candidate,
221                gate.as_ref().and_then(Self::peer),
222            )),
223        }
224    }
225
226    /// Promote this device's staged candidate to its committed rotation.
227    pub fn commit_candidate(
228        gate: Option<Self>,
229        generation: u64,
230        mutation: crate::store_commit::ObjectHash,
231    ) -> Result<Self, RotationGateError> {
232        let Some(generation) = NonZeroU64::new(generation) else {
233            return Err(RotationGateError::CommitDoesNotOwnCandidate);
234        };
235        let committed = LocalRotation::Committed {
236            generation,
237            mutation,
238        };
239        let local = gate.as_ref().and_then(Self::local);
240        // The gate must hold this exact candidate — or already hold the commit,
241        // which is the same fact arriving twice rather than a second rotation.
242        if local
243            != Some(LocalRotation::Candidate {
244                generation,
245                mutation,
246            })
247            && local != Some(committed)
248        {
249            return Err(RotationGateError::CommitDoesNotOwnCandidate);
250        }
251        Ok(Self::with_local(
252            committed,
253            gate.as_ref().and_then(Self::peer),
254        ))
255    }
256
257    /// Record that the store committed `generation`. Forward-only: an older
258    /// generation never displaces a newer one already recorded.
259    pub fn merge_peer_commit(
260        gate: Option<Self>,
261        generation: u64,
262    ) -> Result<Self, RotationGateError> {
263        let Some(generation) = NonZeroU64::new(generation) else {
264            return Err(RotationGateError::CommittedGenerationZero);
265        };
266        let peer_generation = gate
267            .as_ref()
268            .and_then(Self::peer)
269            .map_or(generation, |recorded| recorded.max(generation));
270        Ok(match gate.as_ref().and_then(Self::local) {
271            Some(local) => Self::LocalAndPeer {
272                local,
273                peer_generation,
274            },
275            None => Self::Peer {
276                generation: peer_generation,
277            },
278        })
279    }
280
281    pub fn remove_candidate(
282        self,
283        generation: u64,
284        mutation: crate::store_commit::ObjectHash,
285    ) -> Result<Option<Self>, RotationGateError> {
286        let lost = NonZeroU64::new(generation).map(|generation| LocalRotation::Candidate {
287            generation,
288            mutation,
289        });
290        if lost.is_none() || self.local() != lost {
291            return Err(RotationGateError::LossDoesNotOwnCandidate);
292        }
293        Ok(Self::from_parts(None, self.peer()))
294    }
295
296    pub fn complete_local_adoption(
297        self,
298        generation: u64,
299        mutation: crate::store_commit::ObjectHash,
300    ) -> Result<Option<Self>, RotationGateError> {
301        match self.local() {
302            Some(LocalRotation::Candidate { .. }) => {
303                return Err(RotationGateError::CandidatePendingDuringAdoption)
304            }
305            Some(LocalRotation::Committed {
306                generation: committed,
307                mutation: committed_mutation,
308            }) if committed.get() == generation && committed_mutation == mutation => {}
309            _ => return Err(RotationGateError::AdoptionDoesNotOwnCommitted),
310        }
311        // Adopting the local rotation adopts every peer generation it covers; a
312        // newer peer generation is a separate fact and stays.
313        Ok(Self::from_parts(
314            None,
315            self.peer().filter(|peer| peer.get() > generation),
316        ))
317    }
318
319    pub fn complete_peer_adoption(
320        self,
321        adopted_generation: u64,
322    ) -> Result<Option<Self>, RotationGateError> {
323        if adopted_generation == 0 {
324            return Err(RotationGateError::AdoptedGenerationZero);
325        }
326        Ok(Self::from_parts(
327            self.local(),
328            self.peer().filter(|peer| peer.get() > adopted_generation),
329        ))
330    }
331
332    /// The newest generation the gate names. Reported through the replication
333    /// layer's `PendingRotation::pending_generation`.
334    #[cfg(any(test, feature = "test-utils"))]
335    pub fn generation(&self) -> NonZeroU64 {
336        match self {
337            Self::Local(local) => local.generation(),
338            Self::Peer { generation } => *generation,
339            Self::LocalAndPeer {
340                local,
341                peer_generation,
342            } => local.generation().max(*peer_generation),
343        }
344    }
345}