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 candidate replacement lost its exact owner")]
103    ReplacementLostOwner,
104    #[error("rotation adoption cannot close while a candidate is pending")]
105    CandidatePendingDuringAdoption,
106    #[error("rotation adoption does not own the committed gate")]
107    AdoptionDoesNotOwnCommitted,
108    #[error("adopted rotation names generation zero")]
109    AdoptedGenerationZero,
110}
111
112impl LocalRotation {
113    /// Reported through the replication layer's `PendingRotation::pending_generation`,
114    /// which exists for status reporting in tests and for hosts built with
115    /// `test-utils`.
116    #[cfg(any(test, feature = "test-utils"))]
117    fn generation(&self) -> NonZeroU64 {
118        match self {
119            Self::Candidate { generation, .. } | Self::Committed { generation, .. } => *generation,
120        }
121    }
122}
123
124impl RotationGate {
125    /// This device's own rotation, if the gate holds one.
126    fn local(&self) -> Option<LocalRotation> {
127        match self {
128            Self::Local(local) | Self::LocalAndPeer { local, .. } => Some(*local),
129            Self::Peer { .. } => None,
130        }
131    }
132
133    /// The unadopted peer generation, if the gate holds one.
134    fn peer(&self) -> Option<NonZeroU64> {
135        match self {
136            Self::Peer { generation }
137            | Self::LocalAndPeer {
138                peer_generation: generation,
139                ..
140            } => Some(*generation),
141            Self::Local(_) => None,
142        }
143    }
144
145    /// The gate holding both facts — `None` when neither is left, which is the
146    /// absence of a gate rather than an empty one.
147    fn from_parts(local: Option<LocalRotation>, peer: Option<NonZeroU64>) -> Option<Self> {
148        match (local, peer) {
149            (Some(local), Some(peer_generation)) => Some(Self::LocalAndPeer {
150                local,
151                peer_generation,
152            }),
153            (Some(local), None) => Some(Self::Local(local)),
154            (None, Some(generation)) => Some(Self::Peer { generation }),
155            (None, None) => None,
156        }
157    }
158
159    /// The gate `local` owns, keeping whatever peer fact came with it.
160    fn with_local(local: LocalRotation, peer: Option<NonZeroU64>) -> Self {
161        match peer {
162            Some(peer_generation) => Self::LocalAndPeer {
163                local,
164                peer_generation,
165            },
166            None => Self::Local(local),
167        }
168    }
169
170    pub fn pending_state(&self) -> RotationPendingState {
171        match self {
172            Self::Local(LocalRotation::Candidate { generation, .. }) => {
173                RotationPendingState::Candidate {
174                    generation: generation.get(),
175                }
176            }
177            Self::Local(LocalRotation::Committed { generation, .. }) => {
178                RotationPendingState::LocalCommitted {
179                    generation: generation.get(),
180                }
181            }
182            Self::Peer { generation } => RotationPendingState::PeerCommitted {
183                generation: generation.get(),
184            },
185            Self::LocalAndPeer {
186                local: LocalRotation::Candidate { generation, .. },
187                peer_generation,
188            } => RotationPendingState::CandidateAndPeer {
189                candidate_generation: generation.get(),
190                peer_generation: peer_generation.get(),
191            },
192            Self::LocalAndPeer {
193                local: LocalRotation::Committed { generation, .. },
194                peer_generation,
195            } => RotationPendingState::LocalCommittedAndPeer {
196                local_generation: generation.get(),
197                peer_generation: peer_generation.get(),
198            },
199        }
200    }
201
202    /// Stage `mutation` as this device's rotation candidate, on whatever gate is
203    /// already open (`None` when none is).
204    pub fn with_candidate(
205        gate: Option<Self>,
206        generation: u64,
207        mutation: crate::store_commit::ObjectHash,
208    ) -> Result<Self, RotationGateError> {
209        let Some(generation) = NonZeroU64::new(generation) else {
210            return Err(RotationGateError::CandidateGenerationZero);
211        };
212        let candidate = LocalRotation::Candidate {
213            generation,
214            mutation,
215        };
216        match gate.as_ref().and_then(Self::local) {
217            Some(LocalRotation::Committed { .. }) => Err(RotationGateError::CommittedLocalOwnsGate),
218            Some(existing) if existing != candidate => {
219                Err(RotationGateError::DifferentCandidateOwnsGate)
220            }
221            _ => Ok(Self::with_local(
222                candidate,
223                gate.as_ref().and_then(Self::peer),
224            )),
225        }
226    }
227
228    /// Promote this device's staged candidate to its committed rotation.
229    pub fn commit_candidate(
230        gate: Option<Self>,
231        generation: u64,
232        mutation: crate::store_commit::ObjectHash,
233    ) -> Result<Self, RotationGateError> {
234        let Some(generation) = NonZeroU64::new(generation) else {
235            return Err(RotationGateError::CommitDoesNotOwnCandidate);
236        };
237        let committed = LocalRotation::Committed {
238            generation,
239            mutation,
240        };
241        let local = gate.as_ref().and_then(Self::local);
242        // The gate must hold this exact candidate — or already hold the commit,
243        // which is the same fact arriving twice rather than a second rotation.
244        if local
245            != Some(LocalRotation::Candidate {
246                generation,
247                mutation,
248            })
249            && local != Some(committed)
250        {
251            return Err(RotationGateError::CommitDoesNotOwnCandidate);
252        }
253        Ok(Self::with_local(
254            committed,
255            gate.as_ref().and_then(Self::peer),
256        ))
257    }
258
259    /// Record that the store committed `generation`. Forward-only: an older
260    /// generation never displaces a newer one already recorded.
261    pub fn merge_peer_commit(
262        gate: Option<Self>,
263        generation: u64,
264    ) -> Result<Self, RotationGateError> {
265        let Some(generation) = NonZeroU64::new(generation) else {
266            return Err(RotationGateError::CommittedGenerationZero);
267        };
268        let peer_generation = gate
269            .as_ref()
270            .and_then(Self::peer)
271            .map_or(generation, |recorded| recorded.max(generation));
272        Ok(match gate.as_ref().and_then(Self::local) {
273            Some(local) => Self::LocalAndPeer {
274                local,
275                peer_generation,
276            },
277            None => Self::Peer {
278                generation: peer_generation,
279            },
280        })
281    }
282
283    pub fn remove_candidate(
284        self,
285        generation: u64,
286        mutation: crate::store_commit::ObjectHash,
287    ) -> Result<Option<Self>, RotationGateError> {
288        let lost = NonZeroU64::new(generation).map(|generation| LocalRotation::Candidate {
289            generation,
290            mutation,
291        });
292        if lost.is_none() || self.local() != lost {
293            return Err(RotationGateError::LossDoesNotOwnCandidate);
294        }
295        Ok(Self::from_parts(None, self.peer()))
296    }
297
298    pub fn replace_candidate_mutation(
299        self,
300        generation: u64,
301        previous: crate::store_commit::ObjectHash,
302        replacement: crate::store_commit::ObjectHash,
303    ) -> Result<Self, RotationGateError> {
304        let Some(generation) = NonZeroU64::new(generation) else {
305            return Err(RotationGateError::ReplacementLostOwner);
306        };
307        if self.local()
308            != Some(LocalRotation::Candidate {
309                generation,
310                mutation: previous,
311            })
312        {
313            return Err(RotationGateError::ReplacementLostOwner);
314        }
315        Ok(Self::with_local(
316            LocalRotation::Candidate {
317                generation,
318                mutation: replacement,
319            },
320            self.peer(),
321        ))
322    }
323
324    pub fn complete_local_adoption(
325        self,
326        generation: u64,
327        mutation: crate::store_commit::ObjectHash,
328    ) -> Result<Option<Self>, RotationGateError> {
329        match self.local() {
330            Some(LocalRotation::Candidate { .. }) => {
331                return Err(RotationGateError::CandidatePendingDuringAdoption)
332            }
333            Some(LocalRotation::Committed {
334                generation: committed,
335                mutation: committed_mutation,
336            }) if committed.get() == generation && committed_mutation == mutation => {}
337            _ => return Err(RotationGateError::AdoptionDoesNotOwnCommitted),
338        }
339        // Adopting the local rotation adopts every peer generation it covers; a
340        // newer peer generation is a separate fact and stays.
341        Ok(Self::from_parts(
342            None,
343            self.peer().filter(|peer| peer.get() > generation),
344        ))
345    }
346
347    pub fn complete_peer_adoption(
348        self,
349        adopted_generation: u64,
350    ) -> Result<Option<Self>, RotationGateError> {
351        if adopted_generation == 0 {
352            return Err(RotationGateError::AdoptedGenerationZero);
353        }
354        Ok(Self::from_parts(
355            self.local(),
356            self.peer().filter(|peer| peer.get() > adopted_generation),
357        ))
358    }
359
360    /// The newest generation the gate names. Reported through the replication
361    /// layer's `PendingRotation::pending_generation`.
362    #[cfg(any(test, feature = "test-utils"))]
363    pub fn generation(&self) -> NonZeroU64 {
364        match self {
365            Self::Local(local) => local.generation(),
366            Self::Peer { generation } => *generation,
367            Self::LocalAndPeer {
368                local,
369                peer_generation,
370            } => local.generation().max(*peer_generation),
371        }
372    }
373}