laminar_core/cluster/control/leader_lease/
topology_commit.rs1use super::topology_admission::{CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS};
4use super::{
5 read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome,
6 ClusterCheckpointAuthorityError, LeaderAuthorityRecord, LeaderLeaseStore, LeaseError,
7 RecoveryFaultDisposition, TOPOLOGY_COMMIT_RECORD_VERSION,
8};
9use crate::checkpoint::LeaderProof;
10use crate::checkpoint_decision::DecisionError;
11use crate::cluster::control::{
12 CatalogManifestRef, LegacyTopologyBaseline, ProcessLeaseAuthority, TopologyAdmissionPhase,
13 TopologyAdmissionPlan, TopologyAdmissionStatus, TopologyCommit, TopologyError,
14 TopologyRestoreInput, TopologyVersion, TOPOLOGY_COMMIT_PROTOCOL_VERSION,
15};
16
17impl LeaderAuthorityRecord {
18 pub(super) fn committed_topology_operation(&self) -> Option<&TopologyAdmissionStatus> {
19 self.topology_operations
20 .iter()
21 .rev()
22 .find(|operation| operation.commit.is_some())
23 }
24
25 pub(super) fn live_topology_migration(&self) -> Option<&TopologyAdmissionStatus> {
30 self.committed_topology_operation().filter(|operation| {
31 operation.phase != TopologyAdmissionPhase::Active
32 || self
33 .commit_head
34 .as_ref()
35 .zip(operation.commit.as_ref())
36 .is_none_or(|(head, commit)| head.sequence <= commit.authority_sequence)
37 })
38 }
39
40 pub(super) fn committed_topology_identity(
41 &self,
42 ) -> Option<(TopologyVersion, &CatalogManifestRef)> {
43 self.committed_topology_operation()
44 .and_then(|operation| operation.commit.as_ref())
45 .map(|commit| (commit.topology_version, &commit.manifest))
46 .or_else(|| {
47 self.topology_baseline
48 .as_ref()
49 .map(|baseline| (baseline.topology_version, &baseline.manifest))
50 })
51 }
52
53 pub(super) fn validate_committed_topology_chain(
54 &self,
55 baseline: &LegacyTopologyBaseline,
56 ) -> Result<(), LeaseError> {
57 let mut version = baseline.topology_version;
58 let mut manifest = &baseline.manifest;
59 let mut sequence = baseline.authority_sequence;
60 for operation in &self.topology_operations {
61 let Some(commit) = &operation.commit else {
62 continue;
63 };
64 commit
65 .validate()
66 .map_err(|error| LeaseError::Invalid(error.to_string()))?;
67 if self.version < TOPOLOGY_COMMIT_RECORD_VERSION
68 || commit.parent_version != version
69 || &commit.parent_manifest != manifest
70 || commit.authority_sequence <= sequence
71 || commit.authority_sequence > self.lease.seq
72 {
73 return Err(LeaseError::Invalid(
74 "committed topology chain differs from its exact predecessor".into(),
75 ));
76 }
77 version = commit.topology_version;
78 manifest = &commit.manifest;
79 sequence = commit.authority_sequence;
80 }
81 if self.lease.catalog_manifest.as_ref() != Some(manifest) {
82 return Err(LeaseError::Invalid(
83 "catalog head differs from its committed topology decision".into(),
84 ));
85 }
86 Ok(())
87 }
88
89 pub(super) fn validate_committed_topology_successor(
90 &self,
91 next: &Self,
92 ) -> Result<(), LeaseError> {
93 let prior = self
94 .committed_topology_operation()
95 .and_then(|operation| operation.commit.as_ref());
96 let after = next
97 .committed_topology_operation()
98 .and_then(|operation| operation.commit.as_ref());
99 if prior == after {
100 if self.lease.catalog_manifest.is_some()
102 && self.lease.catalog_manifest != next.lease.catalog_manifest
103 {
104 return Err(LeaseError::Invalid(
105 "catalog replacement requires an atomic topology Commit".into(),
106 ));
107 }
108 return Ok(());
109 }
110 let commit = after.ok_or_else(|| {
111 LeaseError::Invalid("authority cannot forget a topology Commit".into())
112 })?;
113 let operation = next
114 .committed_topology_operation()
115 .ok_or_else(|| LeaseError::Invalid("Commit operation is missing".into()))?;
116 if next.version < TOPOLOGY_COMMIT_RECORD_VERSION
117 || self.committed_topology_identity()
118 != Some((commit.parent_version, &commit.parent_manifest))
119 || next.lease.catalog_manifest.as_ref() != Some(&commit.manifest)
120 || commit.authority_sequence != next.lease.seq
121 || !next.lease.matches_proof(&operation.admitted_by)
122 || self
123 .topology_operations
124 .iter()
125 .find(|entry| entry.operation_id == operation.operation_id)
126 .is_none_or(|entry| {
127 entry.phase != TopologyAdmissionPhase::CutPrepared || entry.commit.is_some()
128 })
129 {
130 return Err(LeaseError::Invalid(
131 "new catalog and target Commit must share their exact authority append".into(),
132 ));
133 }
134 Ok(())
135 }
136
137 pub(super) fn reject_pending_topology_commit(
138 &self,
139 ) -> Result<(), ClusterCheckpointAuthorityError> {
140 if self.topology_operations.iter().any(|operation| {
141 operation.has_target_commit()
142 && (operation.phase != TopologyAdmissionPhase::Active
143 || self.commit_head.as_ref().is_none_or(|head| {
144 operation
145 .cut
146 .as_ref()
147 .and_then(|cut| cut.committed.as_ref())
148 .is_none_or(|cut| head.sequence <= cut.authority_sequence)
149 }))
150 }) {
151 return Err(DecisionError::Conflict("committed topology requires target installation and Release; parent recovery cannot release intake".into()).into());
152 }
153 Ok(())
154 }
155
156 pub(super) fn audited_topology_binding<'a>(
160 &self,
161 round: &'a crate::cluster::control::RecoveryRound,
162 epoch: Option<u64>,
163 ) -> Option<&'a crate::cluster::control::TopologyRecoveryBinding> {
164 round
165 .topology_binding()
166 .filter(|_| epoch.is_some() || self.live_topology_migration().is_some())
167 }
168
169 pub(super) fn reject_unbound_topology_recovery(
172 &self,
173 epoch: Option<u64>,
174 ) -> Result<(), ClusterCheckpointAuthorityError> {
175 self.reject_pending_topology_commit()?;
176 let root_epoch = self
177 .committed_topology_operation()
178 .and_then(|operation| operation.cut.as_ref())
179 .and_then(|cut| cut.committed.as_ref())
180 .map(|cut| cut.checkpoint.epoch);
181 if epoch
182 .zip(root_epoch)
183 .is_some_and(|(epoch, root)| epoch <= root)
184 {
185 return Err(DecisionError::Conflict(
186 "committed topology cannot release its parent or an older cut".into(),
187 )
188 .into());
189 }
190 Ok(())
191 }
192}
193
194impl LeaderLeaseStore {
195 pub async fn commit_topology_target(
206 &self,
207 proof: &LeaderProof,
208 assignments: &AssignmentSnapshotStore,
209 processes: &ProcessLeaseAuthority,
210 input: &TopologyRestoreInput,
211 ) -> Result<TopologyAdmissionStatus, TopologyError> {
212 tokio::time::timeout(CONTROL_TIMEOUT, async {
213 for _ in 0..MAX_ADMISSION_ATTEMPTS {
214 let published = self.load_published_authority_head().await?.ok_or(TopologyError::Fenced)?;
215 let current = &published.record;
216 if !proof.is_canonical() || !current.lease.matches_proof(proof)
217 || proof.owner.node_id != input.process().participant.node_id
218 || proof.owner.boot_id != input.process().participant.boot_incarnation {
219 return Err(TopologyError::Fenced);
220 }
221 let index = current.topology_operations.iter().position(|entry| entry.operation_id == input.operation().operation_id)
222 .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
223 let operation = ¤t.topology_operations[index];
224 if operation.has_target_commit() {
225 let fresh = self.committed_topology_restore_input(assignments, processes, operation.operation_id, input.process()).await?;
226 if !fresh.is_committed_successor_of(input) {
227 return Err(TopologyError::Fenced);
228 }
229 return Ok(fresh.operation().clone());
230 }
231 if operation.phase != TopologyAdmissionPhase::CutPrepared || operation.admitted_by != *proof
232 || !operation.same_restore_binding(input.operation()) {
233 return Err(TopologyError::Fenced);
234 }
235 if !operation.target_preparation_complete() || operation.target_preparations.iter().any(|receipt| receipt.protocol_version != TOPOLOGY_COMMIT_PROTOCOL_VERSION) {
236 return Err(TopologyError::Protocol("Commit requires protocol four from every frozen owner/evidence process; protocol-three observations cannot be upgraded in place".into()));
237 }
238 if current.committed_topology_identity() != Some((input.plan().expected_parent, &input.plan().parent_manifest))
239 || current.active_checkpoint_artifacts.is_some() || current.artifact_cleanup.is_some()
240 || current.assignment_drain_reservation.is_some() || current.assignment_handoff_pin.is_some()
241 || current.recovery_fault_slots.iter().any(|slot| slot.active || slot.disposition == RecoveryFaultDisposition::Terminal)
242 || current.commit_head.as_ref().is_none_or(|head| head.sequence != input.root().cut.authority_sequence) {
243 return Err(TopologyError::Conflict("Commit requires the exact held parent and settled cut without competing authority".into()));
244 }
245 let fresh = self.topology_restore_input(assignments, processes, operation.operation_id, input.process()).await?;
246 if !fresh.same_restore_requirements(input) { return Err(TopologyError::Fenced); }
247 let mut lease = current.lease.clone();
248 lease.seq = lease.seq.checked_add(1).ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
249 lease.catalog_manifest = Some(input.plan().target_manifest.clone());
250 let sequence = lease.seq;
251 let mut next = current.preserve_with_lease(lease);
252 next.version = next.version.max(TOPOLOGY_COMMIT_RECORD_VERSION);
253 let operation = &mut next.topology_operations[index];
254 operation.phase = TopologyAdmissionPhase::Committed;
255 operation.status_sequence = sequence;
256 operation.commit = Some(TopologyCommit {
257 protocol_version: TOPOLOGY_COMMIT_PROTOCOL_VERSION,
258 operation_id: operation.operation_id,
259 parent_version: input.plan().expected_parent,
260 parent_manifest: input.plan().parent_manifest.clone(),
261 topology_version: input.descriptor().target_version,
262 manifest: input.plan().target_manifest.clone(),
263 authority_sequence: sequence,
264 });
265 match self.create_authority_record(Some(&published), &next).await? {
266 AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
267 let fresh = self.committed_topology_restore_input(assignments, processes, input.operation().operation_id, input.process()).await?;
268 if !fresh.is_committed_successor_of(input) { return Err(TopologyError::Fenced); }
269 return Ok(fresh.operation().clone());
270 }
271 AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
272 }
273 }
274 Err(TopologyError::Contended)
275 }).await.map_err(|_| TopologyError::Contended)?
276 }
277
278 pub(super) async fn audit_topology_commit(
279 &self,
280 operation: &TopologyAdmissionStatus,
281 plan: &TopologyAdmissionPlan,
282 ) -> Result<(), LeaseError> {
283 let Some(commit) = &operation.commit else {
284 return Ok(());
285 };
286 let record = read_authority_record(self.store.as_ref(), commit.authority_sequence)
287 .await?
288 .ok_or_else(|| {
289 LeaseError::Invalid("topology Commit authority anchor is missing".into())
290 })?;
291 if commit.parent_version != plan.expected_parent
292 || commit.parent_manifest != plan.parent_manifest
293 || commit.manifest != plan.target_manifest
294 || record.version < TOPOLOGY_COMMIT_RECORD_VERSION
295 || record.lease.catalog_manifest.as_ref() != Some(&commit.manifest)
296 || !record.lease.matches_proof(&operation.admitted_by)
297 || record
298 .topology_operations
299 .iter()
300 .find(|entry| entry.operation_id == operation.operation_id)
301 .is_none_or(|anchored| {
302 anchored.phase != TopologyAdmissionPhase::Committed
303 || anchored.commit != operation.commit
304 || !anchored.same_migration_binding(operation)
305 || anchored.target_preparations != operation.target_preparations
306 || anchored.activation.is_some()
307 || anchored.status_sequence != commit.authority_sequence
308 })
309 {
310 return Err(LeaseError::Invalid(
311 "topology Commit differs from its atomic catalog/root append".into(),
312 ));
313 }
314 Ok(())
315 }
316}