laminar_core/cluster/control/topology/
admission.rs1use serde::{Deserialize, Serialize};
4
5use super::{TopologyError, TopologyOperationId, TopologyVersion, TOPOLOGY_PROTOCOL_VERSION};
6use crate::checkpoint::{
7 CheckpointAssignmentFence, CheckpointParticipant, CommittedCheckpointRef, LeaderProof,
8};
9use crate::checkpoint_decision::CheckpointArtifactInventory;
10use crate::cluster::control::CatalogManifestRef;
11
12pub const MAX_TOPOLOGY_OPERATIONS: usize = 64;
14pub(crate) const MAX_TOPOLOGY_PLAN_BYTES: usize = 32 * 1024;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TopologyJournalUsage {
21 pub retained: usize,
23 pub capacity: usize,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct TopologyAdmissionPlan {
34 pub protocol_version: u16,
36 pub operation_id: TopologyOperationId,
38 pub expected_parent: TopologyVersion,
40 pub parent_manifest: CatalogManifestRef,
42 pub target_manifest: CatalogManifestRef,
44 pub assignment: CheckpointAssignmentFence,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub compatibility: Option<super::TopologyCompatibilityRef>,
49}
50
51impl TopologyAdmissionPlan {
52 pub(crate) fn validate(&self) -> Result<(), TopologyError> {
53 if !matches!(
54 self.protocol_version,
55 TOPOLOGY_PROTOCOL_VERSION
56 | super::TOPOLOGY_PREPARATION_PROTOCOL_VERSION
57 | super::TOPOLOGY_SUBMISSION_PROTOCOL_VERSION
58 ) {
59 return Err(TopologyError::Protocol(
60 "unsupported admission protocol".into(),
61 ));
62 }
63 if self.protocol_version != TOPOLOGY_PROTOCOL_VERSION {
64 self.compatibility
65 .as_ref()
66 .ok_or_else(|| {
67 TopologyError::Protocol("preparation requires a candidate descriptor".into())
68 })?
69 .validate()?;
70 } else if self.compatibility.is_some() {
71 return Err(TopologyError::Protocol(
72 "legacy admission cannot carry preparation evidence".into(),
73 ));
74 }
75 self.expected_parent.successor()?;
76 self.parent_manifest.validate()?;
77 self.target_manifest.validate()?;
78 if !self.assignment.is_canonical() || self.parent_manifest == self.target_manifest {
79 return Err(TopologyError::Invalid(
80 "admission requires an exact assignment and a changed candidate inventory".into(),
81 ));
82 }
83 Ok(())
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum TopologyAbortReason {
91 Requested,
93 LeaderChanged,
95 Recovery,
97 CheckpointAborted,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "phase", rename_all = "snake_case", deny_unknown_fields)]
104pub enum TopologyAdmissionPhase {
105 Planned,
107 Preparing,
109 Quiescing,
111 CutPrepared,
114 Committed,
117 Activating,
119 Active,
121 Aborted {
123 reason: TopologyAbortReason,
125 },
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct TopologyCutCommit {
132 pub checkpoint: CommittedCheckpointRef,
134 pub authority_sequence: u64,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct TopologyCheckpointCut {
142 pub inventory: CheckpointArtifactInventory,
144 pub bound_sequence: u64,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub committed: Option<TopologyCutCommit>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub completed_participants: Vec<CheckpointParticipant>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct TopologyPlanRef {
160 pub sha256: String,
162 pub encoded_len: u64,
164}
165
166impl TopologyPlanRef {
167 pub(crate) fn validate(&self) -> Result<(), TopologyError> {
168 if self.sha256.len() != 64
169 || !self
170 .sha256
171 .bytes()
172 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
173 || self.encoded_len == 0
174 || self.encoded_len > MAX_TOPOLOGY_PLAN_BYTES as u64
175 {
176 return Err(TopologyError::Invalid(
177 "invalid topology plan reference".into(),
178 ));
179 }
180 Ok(())
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct TopologyAdmissionStatus {
188 pub operation_id: TopologyOperationId,
190 pub plan: TopologyPlanRef,
192 pub admitted_by: LeaderProof,
194 pub admitted_sequence: u64,
196 pub status_sequence: u64,
198 #[serde(rename = "state")]
200 pub phase: TopologyAdmissionPhase,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub cut: Option<TopologyCheckpointCut>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub preparation: Option<super::TopologyPreparation>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub migration_root: Option<super::TopologyMigrationRootBinding>,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub target_preparations: Vec<super::TopologyTargetPreparationReceipt>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub commit: Option<super::TopologyCommit>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub activation: Option<super::TopologyActivation>,
220}
221
222impl TopologyAdmissionStatus {
223 pub(crate) fn is_planned(&self) -> bool {
224 self.phase == TopologyAdmissionPhase::Planned
225 }
226
227 pub(crate) fn is_preparing(&self) -> bool {
228 matches!(
229 self.phase,
230 TopologyAdmissionPhase::Planned
231 | TopologyAdmissionPhase::Preparing
232 | TopologyAdmissionPhase::Quiescing
233 | TopologyAdmissionPhase::CutPrepared
234 )
235 }
236
237 pub(crate) fn validate(&self, head: u64) -> Result<(), TopologyError> {
238 self.plan.validate()?;
239 if !self.admitted_by.is_canonical()
240 || self.admitted_sequence == 0
241 || self.status_sequence < self.admitted_sequence
242 || self.status_sequence > head
243 || (self.is_planned() && self.status_sequence != self.admitted_sequence)
244 || (!self.is_planned() && self.status_sequence == self.admitted_sequence)
245 {
246 return Err(TopologyError::Invalid(
247 "invalid topology admission status".into(),
248 ));
249 }
250 self.validate_preparation(head)?;
251 self.validate_target_preparations(head)?;
252 self.validate_commit(head)?;
253 self.validate_activation()?;
254 if let Some(binding) = &self.migration_root {
255 binding.root.validate()?;
256 if !matches!(
257 self.phase,
258 TopologyAdmissionPhase::CutPrepared
259 | TopologyAdmissionPhase::Committed
260 | TopologyAdmissionPhase::Activating
261 | TopologyAdmissionPhase::Active
262 | TopologyAdmissionPhase::Aborted { .. }
263 ) || self
264 .preparation
265 .as_ref()
266 .is_none_or(|p| p.complete_sequence.is_none())
267 || self.cut.as_ref().is_none_or(|cut| {
268 cut.committed.as_ref().is_none_or(|commit| {
269 binding.authority_sequence <= commit.authority_sequence
270 }) || cut
271 .inventory
272 .assignment_fence
273 .as_ref()
274 .is_none_or(|fence| cut.completed_participants != fence.participants)
275 })
276 || binding.authority_sequence > self.status_sequence
277 || binding.authority_sequence > head
278 {
279 return Err(TopologyError::Invalid(
280 "migration root requires the complete certified prepared cut".into(),
281 ));
282 }
283 }
284 match (&self.cut, self.phase) {
285 (
286 None,
287 TopologyAdmissionPhase::Planned
288 | TopologyAdmissionPhase::Preparing
289 | TopologyAdmissionPhase::Aborted { .. },
290 ) => {}
291 (
292 Some(cut),
293 TopologyAdmissionPhase::Quiescing
294 | TopologyAdmissionPhase::CutPrepared
295 | TopologyAdmissionPhase::Committed
296 | TopologyAdmissionPhase::Activating
297 | TopologyAdmissionPhase::Active
298 | TopologyAdmissionPhase::Aborted { .. },
299 ) => {
300 cut.inventory.validate().map_err(TopologyError::Invalid)?;
301 let fence = cut.inventory.assignment_fence.as_ref().ok_or_else(|| {
302 TopologyError::Invalid("topology cut requires an assignment fence".into())
303 })?;
304 if cut.bound_sequence <= self.admitted_sequence
305 || cut.bound_sequence > self.status_sequence
306 || fence.participant_incarnation(self.admitted_by.owner.node_id)
307 != Some(self.admitted_by.owner.boot_id)
308 || !cut
309 .completed_participants
310 .windows(2)
311 .all(|pair| pair[0].node_id < pair[1].node_id)
312 || cut.completed_participants.iter().any(|participant| {
313 fence.participant_incarnation(participant.node_id)
314 != Some(participant.boot_incarnation)
315 })
316 {
317 return Err(TopologyError::Invalid(
318 "invalid topology checkpoint binding or completion roster".into(),
319 ));
320 }
321 if let Some(commit) = &cut.committed {
322 commit
323 .checkpoint
324 .validate()
325 .map_err(TopologyError::Invalid)?;
326 if commit.checkpoint.epoch != cut.inventory.attempt.epoch
327 || commit.checkpoint.checkpoint_id != cut.inventory.attempt.checkpoint_id
328 || commit.authority_sequence <= cut.bound_sequence
329 || commit.authority_sequence > self.status_sequence
330 {
331 return Err(TopologyError::Invalid(
332 "topology cut Commit differs from its bound attempt".into(),
333 ));
334 }
335 } else if !cut.completed_participants.is_empty() {
336 return Err(TopologyError::Invalid(
337 "uncommitted cut cannot have completion receipts".into(),
338 ));
339 }
340 if matches!(
341 self.phase,
342 TopologyAdmissionPhase::CutPrepared
343 | TopologyAdmissionPhase::Committed
344 | TopologyAdmissionPhase::Activating
345 | TopologyAdmissionPhase::Active
346 ) && (cut.committed.is_none()
347 || cut.completed_participants != fence.participants)
348 {
349 return Err(TopologyError::Invalid(
350 "prepared cut requires every exact process completion".into(),
351 ));
352 }
353 if self.phase == TopologyAdmissionPhase::Quiescing
354 && cut.completed_participants == fence.participants
355 {
356 return Err(TopologyError::Invalid(
357 "every process completion must advance the cut to prepared".into(),
358 ));
359 }
360 }
361 _ => {
362 return Err(TopologyError::Invalid(
363 "topology phase and checkpoint binding disagree".into(),
364 ))
365 }
366 }
367 Ok(())
368 }
369
370 pub(crate) fn validate_successor(
371 &self,
372 after: &Self,
373 sequence: u64,
374 ) -> Result<(), TopologyError> {
375 if self.operation_id != after.operation_id
376 || self.plan != after.plan
377 || self.admitted_sequence != after.admitted_sequence
378 || self.admitted_by != after.admitted_by
379 || (!self.is_preparing() && !self.has_target_commit() && self != after)
380 || (self != after && (after.is_planned() || after.status_sequence != sequence))
381 {
382 return Err(TopologyError::Invalid(
383 "authority cannot rewrite a topology request or its terminal result".into(),
384 ));
385 }
386 self.validate_preparation_successor(after, sequence)?;
387 self.validate_target_preparation_successor(after, sequence)?;
388 self.validate_commit_successor(after, sequence)?;
389 self.validate_activation_successor(after, sequence)?;
390 match (&self.migration_root, &after.migration_root) {
391 (None, None) => {}
392 (Some(prior), Some(next)) if prior == next => {}
393 (None, Some(binding))
394 if self.phase == TopologyAdmissionPhase::CutPrepared
395 && after.phase == TopologyAdmissionPhase::CutPrepared
396 && binding.authority_sequence == sequence
397 && self.cut == after.cut
398 && self.preparation == after.preparation => {}
399 _ => {
400 return Err(TopologyError::Invalid(
401 "authority cannot replace a migration root or stage it outside a prepared cut"
402 .into(),
403 ))
404 }
405 }
406 if let Some(prior_cut) = &self.cut {
407 let next_cut = after.cut.as_ref().ok_or_else(|| {
408 TopologyError::Invalid("authority cannot forget a topology cut".into())
409 })?;
410 if prior_cut.inventory != next_cut.inventory
411 || prior_cut.bound_sequence != next_cut.bound_sequence
412 || prior_cut
413 .committed
414 .as_ref()
415 .is_some_and(|commit| next_cut.committed.as_ref() != Some(commit))
416 || prior_cut
417 .completed_participants
418 .iter()
419 .any(|participant| !next_cut.completed_participants.contains(participant))
420 || (self.phase == TopologyAdmissionPhase::CutPrepared
421 && self != after
422 && !matches!(after.phase, TopologyAdmissionPhase::Aborted { .. })
423 && !(after.phase == TopologyAdmissionPhase::CutPrepared
424 && self.migration_root.is_none()
425 && after.migration_root.is_some()
426 && prior_cut == next_cut
427 && self.preparation == after.preparation)
428 && !(self.same_restore_binding(after)
429 && after.target_preparations.len() == self.target_preparations.len() + 1)
430 && !(after.phase == TopologyAdmissionPhase::Committed
431 && prior_cut == next_cut
432 && self.preparation == after.preparation
433 && self.migration_root == after.migration_root
434 && self.target_preparations == after.target_preparations))
435 {
436 return Err(TopologyError::Invalid(
437 "authority cannot replace a cut or rewind its evidence".into(),
438 ));
439 }
440 } else if let Some(cut) = &after.cut {
441 if !matches!(
442 self.phase,
443 TopologyAdmissionPhase::Planned | TopologyAdmissionPhase::Preparing
444 ) || after.phase != TopologyAdmissionPhase::Quiescing
445 || cut.bound_sequence != sequence
446 || cut.committed.is_some()
447 || !cut.completed_participants.is_empty()
448 {
449 return Err(TopologyError::Invalid(
450 "new cut must bind its exact artifact admission append".into(),
451 ));
452 }
453 }
454 Ok(())
455 }
456}