Skip to main content

laminar_core/cluster/control/topology/
admission.rs

1//! Immutable request, held parent cut and irreversible target catalog decision.
2
3use 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
12/// Maximum retained request identities until topology journal retention is implemented.
13pub const MAX_TOPOLOGY_OPERATIONS: usize = 64;
14pub(crate) const MAX_TOPOLOGY_PLAN_BYTES: usize = 32 * 1024;
15
16/// Occupancy of the bounded topology request journal. Every admitted request, including Active
17/// and Aborted ones, keeps its identity so a retry returns its original outcome. Admission is
18/// rejected once `retained` reaches `capacity`; terminal-prefix compaction is not implemented.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub struct TopologyJournalUsage {
21    /// Request identities currently retained.
22    pub retained: usize,
23    /// Fixed maximum number of retained identities.
24    pub capacity: usize,
25}
26
27/// Exact request and assignment evidence reserved before checkpoint cutover.
28///
29/// Admission does not certify the candidate's operator/connector compatibility or authorize its
30/// execution. The DB planner must supply those certificates before any future cut transition.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct TopologyAdmissionPlan {
34    /// Protocol supported by the coordinator.
35    pub protocol_version: u16,
36    /// Caller identity, reused on retries.
37    pub operation_id: TopologyOperationId,
38    /// Expected committed logical parent.
39    pub expected_parent: TopologyVersion,
40    /// Exact unchanged predecessor inventory.
41    pub parent_manifest: CatalogManifestRef,
42    /// Complete candidate inventory, staged without changing the active catalog.
43    pub target_manifest: CatalogManifestRef,
44    /// Owner-complete map and process roster; never a reachable-node majority.
45    pub assignment: CheckpointAssignmentFence,
46    /// Exact canonical DB candidate report, required by preparation protocol two.
47    #[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/// Definitive disposition of an operation that has not started cutover.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum TopologyAbortReason {
91    /// Explicit coordinator cancellation before quiescence.
92    Requested,
93    /// Current leader term changed before cutover.
94    LeaderChanged,
95    /// Recovery was durably requested before cutover.
96    Recovery,
97    /// The bound old-topology checkpoint received a definitive Abort.
98    CheckpointAborted,
99}
100
101/// Old-topology preparation states. No state here authorizes the candidate graph.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "phase", rename_all = "snake_case", deny_unknown_fields)]
104pub enum TopologyAdmissionPhase {
105    /// Durable reservation; active graph and catalog still belong to the parent.
106    Planned,
107    /// Participants are durably certifying the exact candidate; intake remains open.
108    Preparing,
109    /// One exact old-topology attempt is admitted; its barrier and sink settlement are pending.
110    Quiescing,
111    /// Every frozen process has applied the exact Commit and held intake and sink succession.
112    /// The leader's receipt also certifies globally aggregated external sink settlement.
113    CutPrepared,
114    /// Catalog and exact migration root are committed. Target installation/Release remain pending.
115    /// Leader changes and recovery faults must preserve this decision; it cannot abort.
116    Committed,
117    /// Exact committed runtime installation receipts are being collected with intake held.
118    Activating,
119    /// Participant-complete Release is committed. Local activation remains separately observed.
120    Active,
121    /// Definitive pre-target-commit abort; no candidate actors were authorized.
122    Aborted {
123        /// Durable reason.
124        reason: TopologyAbortReason,
125    },
126}
127
128/// Exact old-topology Commit retained while a cut is prepared.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct TopologyCutCommit {
132    /// Complete source/channel/state/output cut; never a scalar source offset.
133    pub checkpoint: CommittedCheckpointRef,
134    /// Immutable shared authority append of the definitive Commit.
135    pub authority_sequence: u64,
136}
137
138/// Binding installed atomically with artifact admission, before any source barrier is injected.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(deny_unknown_fields)]
141pub struct TopologyCheckpointCut {
142    /// Exact deployment, old pipeline ABI, attempt and owner-complete process roster.
143    pub inventory: CheckpointArtifactInventory,
144    /// Shared append that bound this attempt and admitted its artifacts.
145    pub bound_sequence: u64,
146    /// A Commit alone does not prove that the leader finished external sink settlement.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub committed: Option<TopologyCutCommit>,
149    /// Sorted processes whose runtime-owned tails finished applying the cut.
150    /// The leader reports after external sink settlement; followers finish their local checkpoint.
151    /// Their intake remains held; these receipts do not prove actor retirement or target readiness.
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub completed_participants: Vec<CheckpointParticipant>,
154}
155
156/// Small immutable plan reference carried by authority renewals.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct TopologyPlanRef {
160    /// SHA-256 of canonical request evidence, including its operation identity.
161    pub sha256: String,
162    /// Exact encoded length.
163    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/// Durable status of one payload-bound request, including its optional target catalog decision.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct TopologyAdmissionStatus {
188    /// Original request identity.
189    pub operation_id: TopologyOperationId,
190    /// Exact immutable request payload.
191    pub plan: TopologyPlanRef,
192    /// Leader term that won admission.
193    pub admitted_by: LeaderProof,
194    /// Authority append that won admission.
195    pub admitted_sequence: u64,
196    /// Exact append of the current disposition.
197    pub status_sequence: u64,
198    /// Definitive migration phase; Commit does not authorize target activation.
199    #[serde(rename = "state")]
200    pub phase: TopologyAdmissionPhase,
201    /// Exact old-topology cut, absent until barrier preparation is authorized.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub cut: Option<TopologyCheckpointCut>,
204    /// Immutable descriptor and monotonic exact-process compatibility certificates.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub preparation: Option<super::TopologyPreparation>,
207    /// Exact-cut restore/initialization requirements. This is never a target Commit or Release.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub migration_root: Option<super::TopologyMigrationRootBinding>,
210    /// Historical exact-process target restore and parent retirement observations.
211    /// These receipts do not authorize installation, output or intake release.
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub target_preparations: Vec<super::TopologyTargetPreparationReceipt>,
214    /// Irreversible target catalog decision; absent throughout preparation and pre-commit abort.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub commit: Option<super::TopologyCommit>,
217    /// Current exact-runtime installation round and immutable participant-complete Release.
218    #[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}