Skip to main content

laminar_core/cluster/control/leader_lease/
topology_activation.rs

1//! Current runtime observations and Release share the catalog/checkpoint authority append.
2
3use uuid::Uuid;
4
5use super::topology_admission::{CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS};
6use super::{
7    read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome, LeaderLeaseStore,
8    LeaseError, RecoveryFaultDisposition, TOPOLOGY_INSTALLATION_RECORD_VERSION,
9};
10use crate::checkpoint::LeaderProof;
11use crate::cluster::control::{
12    ProcessLeaseAuthority, TopologyActivation, TopologyAdmissionPhase, TopologyAdmissionStatus,
13    TopologyError, TopologyInstallationReceipt, TopologyRelease, TopologyRestoreInput,
14    TOPOLOGY_INSTALLATION_PROTOCOL_VERSION,
15};
16
17impl LeaderLeaseStore {
18    /// Revalidate a published Release against the current full process/assignment roster and
19    /// fault inventory. Historical Release remains immutable across a harmless leader change.
20    ///
21    /// # Errors
22    /// Fails closed on damaged authority or the bounded read/process-audit budget.
23    pub async fn authorize_topology_release(
24        &self,
25        assignments: &AssignmentSnapshotStore,
26        processes: &ProcessLeaseAuthority,
27        input: &TopologyRestoreInput,
28        runtime_id: Uuid,
29    ) -> Result<bool, TopologyError> {
30        tokio::time::timeout(CONTROL_TIMEOUT, async {
31            let head = self.load_record().await?.ok_or(TopologyError::Fenced)?;
32            let fresh = self
33                .committed_topology_restore_input(
34                    assignments,
35                    processes,
36                    input.operation().operation_id,
37                    input.process(),
38                )
39                .await?;
40            if !fresh.same_installed_generation(input)
41                || head.committed_topology_operation() != Some(fresh.operation())
42            {
43                return Ok(false);
44            }
45            Self::require_topology_release_authority(&head, &fresh)?;
46            let Some(round) = &fresh.operation().activation else {
47                return Ok(false);
48            };
49            if fresh.operation().phase != TopologyAdmissionPhase::Active
50                || round.release.is_none()
51                || round.assignment != *fresh.assignment()
52                || round.processes != fresh.processes()
53                || !round.installation_complete()
54                || !round.installations.iter().any(|receipt| {
55                    receipt.process == input.process() && receipt.runtime_id == runtime_id
56                })
57            {
58                return Ok(false);
59            }
60            if round.recovery_round.is_some() {
61                // This first Release shares a recovery terminal; only that exact round may open
62                // its replacement runtime. The ordinary installation API cannot borrow it.
63                return Ok(false);
64            }
65            if let Some(recovery) = head.recovery_release_head.as_ref().filter(|recovery| {
66                round
67                    .release
68                    .as_ref()
69                    .is_some_and(|release| recovery.sequence > release.authority_sequence)
70            }) {
71                let terminal = self
72                    .recovery_release_terminal_from(&head, recovery)
73                    .await
74                    .map_err(super::topology_admission::topology_checkpoint_error)?;
75                if terminal.round.topology_binding().is_some_and(|binding| {
76                    fresh.operation().commit.as_ref() == Some(binding.commit())
77                }) {
78                    return Ok(false);
79                }
80            }
81            for process in &round.processes {
82                self.require_topology_process(processes, *process).await?;
83            }
84            let after = self.load_record().await?.ok_or(TopologyError::Fenced)?;
85            Self::require_topology_release_authority(&after, &fresh)?;
86            Ok(after.lease.proof() == head.lease.proof()
87                && after.recovery_fault_revision == head.recovery_fault_revision
88                && after.committed_topology_operation() == head.committed_topology_operation()
89                && assignments
90                    .load()
91                    .await
92                    .map_err(super::topology_admission::topology_assignment_error)?
93                    .is_some_and(|assignment| {
94                        !assignment.draining
95                            && assignment.assignment_fence().ok().as_ref()
96                                == Some(fresh.assignment())
97                    }))
98        })
99        .await
100        .map_err(|_| TopologyError::ReadTimedOut)?
101    }
102
103    /// Certify one actual held runtime, through the controller after runtime-owned readiness.
104    /// A historical restore/retirement receipt is insufficient. Every current owner/evidence
105    /// process must independently certify protocol five, and identical runtime retries are stable.
106    ///
107    /// # Errors
108    /// Rejects stale runtime/process/assignment/leader, mixed capability, competing checkpoint,
109    /// recovery/cleanup or an uncertain 15-second/16-CAS budget. Reread the same operation.
110    pub async fn certify_topology_installation(
111        &self,
112        assignments: &AssignmentSnapshotStore,
113        processes: &ProcessLeaseAuthority,
114        input: &TopologyRestoreInput,
115        runtime_id: Uuid,
116        protocol_version: u16,
117    ) -> Result<TopologyAdmissionStatus, TopologyError> {
118        self.certify_topology_installation_for_round(
119            assignments,
120            processes,
121            input,
122            runtime_id,
123            protocol_version,
124            None,
125        )
126        .await
127    }
128
129    /// Certify a held replacement runtime under the existing exact stopped/restore round.
130    /// Before the first topology Release this collects a new full installation roster. An already
131    /// released topology retains its original roster and requires the recovery readiness terminal.
132    ///
133    /// # Errors
134    /// Rejects a different target/cut, process term, fault set, round or competing runtime UUID.
135    pub async fn certify_topology_recovery_installation(
136        &self,
137        assignments: &AssignmentSnapshotStore,
138        processes: &ProcessLeaseAuthority,
139        input: &TopologyRestoreInput,
140        runtime_id: Uuid,
141        round: &crate::cluster::control::RecoveryRound,
142        epoch: u64,
143    ) -> Result<TopologyAdmissionStatus, TopologyError> {
144        self.certify_topology_installation_for_round(
145            assignments,
146            processes,
147            input,
148            runtime_id,
149            TOPOLOGY_INSTALLATION_PROTOCOL_VERSION,
150            Some((round, epoch)),
151        )
152        .await
153    }
154
155    async fn certify_topology_installation_for_round(
156        &self,
157        assignments: &AssignmentSnapshotStore,
158        processes: &ProcessLeaseAuthority,
159        input: &TopologyRestoreInput,
160        runtime_id: Uuid,
161        protocol_version: u16,
162        recovery: Option<(&crate::cluster::control::RecoveryRound, u64)>,
163    ) -> Result<TopologyAdmissionStatus, TopologyError> {
164        if protocol_version != TOPOLOGY_INSTALLATION_PROTOCOL_VERSION || runtime_id.is_nil() {
165            return Err(TopologyError::Protocol(
166                "installation requires protocol five and an exact nonzero runtime identity".into(),
167            ));
168        }
169        tokio::time::timeout(CONTROL_TIMEOUT, async {
170            for _ in 0..MAX_ADMISSION_ATTEMPTS {
171                let published = self
172                    .load_published_authority_head()
173                    .await?
174                    .ok_or(TopologyError::Fenced)?;
175                let current = &published.record;
176                let fresh = self
177                    .committed_topology_restore_input(
178                        assignments,
179                        processes,
180                        input.operation().operation_id,
181                        input.process(),
182                    )
183                    .await?;
184                if !fresh.same_restore_requirements(input)
185                    || Some(current.lease.proof()) != fresh.current_leader()
186                {
187                    return Err(TopologyError::Fenced);
188                }
189                let index = current
190                    .topology_operations
191                    .iter()
192                    .position(|operation| operation.operation_id == input.operation().operation_id)
193                    .ok_or(TopologyError::Fenced)?;
194                let operation = &current.topology_operations[index];
195                if operation != fresh.operation() {
196                    tokio::task::yield_now().await;
197                    continue;
198                }
199                if let Some((round, epoch)) = recovery {
200                    self.audit_recovery_topology_from(
201                        current,
202                        round,
203                        Some(epoch),
204                        Some((assignments, processes)),
205                    )
206                    .await
207                    .map_err(super::topology_admission::topology_checkpoint_error)?;
208                    let inventory = Self::recovery_fault_inventory_from(current);
209                    if round.topology_binding().is_none()
210                        || inventory.revision() != round.fault_revision()
211                        || inventory.faults() != round.faults
212                        || inventory.has_terminal_fault()
213                        || round.assignment_fence != *fresh.assignment()
214                        || fresh.current_leader().as_ref() != Some(&round.leader_proof)
215                        || round
216                            .topology_binding()
217                            .is_none_or(|binding| binding.processes() != fresh.processes())
218                        || current.active_checkpoint_artifacts.is_some()
219                        || current.artifact_cleanup.is_some()
220                        || current.assignment_drain_reservation.is_some()
221                    {
222                        return Err(TopologyError::Fenced);
223                    }
224                    if operation.phase == TopologyAdmissionPhase::Active {
225                        return Ok(operation.clone());
226                    }
227                } else {
228                    Self::require_topology_installation_authority(current, &fresh)?;
229                }
230                let same_round = operation.activation.as_ref().filter(|round| {
231                    round.leader == current.lease.proof()
232                        && round.recovery_round == recovery.map(|(round, _)| round.id)
233                });
234                if let Some(round) = same_round {
235                    if round.assignment != *fresh.assignment()
236                        || round.processes != fresh.processes()
237                    {
238                        return Err(TopologyError::Fenced);
239                    }
240                    if let Some(receipt) = round.installations.iter().find(|receipt| {
241                        receipt.process.participant.node_id == input.process().participant.node_id
242                    }) {
243                        if receipt.process != input.process()
244                            || receipt.runtime_id != runtime_id
245                            || receipt.protocol_version != protocol_version
246                        {
247                            return Err(TopologyError::Fenced);
248                        }
249                        return Ok(operation.clone());
250                    }
251                } else if operation
252                    .activation
253                    .as_ref()
254                    .is_some_and(|round| round.release.is_some())
255                {
256                    return Err(TopologyError::Fenced);
257                }
258                let mut lease = current.lease.clone();
259                lease.seq = lease
260                    .seq
261                    .checked_add(1)
262                    .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
263                let sequence = lease.seq;
264                let mut next = current.preserve_with_lease(lease);
265                next.version = next.version.max(TOPOLOGY_INSTALLATION_RECORD_VERSION);
266                if recovery.is_some() {
267                    next.version = next.version.max(super::TOPOLOGY_RECOVERY_RECORD_VERSION);
268                }
269                let operation = &mut next.topology_operations[index];
270                let mut round = same_round.cloned().unwrap_or_else(|| TopologyActivation {
271                    leader: current.lease.proof(),
272                    recovery_round: recovery.map(|(round, _)| round.id),
273                    assignment: fresh.assignment().clone(),
274                    processes: fresh.processes().to_vec(),
275                    authority_sequence: sequence,
276                    installations: Vec::new(),
277                    release: None,
278                });
279                round.installations.push(TopologyInstallationReceipt {
280                    process: input.process(),
281                    runtime_id,
282                    protocol_version,
283                    authority_sequence: sequence,
284                });
285                round
286                    .installations
287                    .sort_by_key(|receipt| receipt.process.participant.node_id);
288                operation.activation = Some(round);
289                operation.phase = TopologyAdmissionPhase::Activating;
290                operation.status_sequence = sequence;
291                for process in fresh.processes() {
292                    self.require_topology_process(processes, *process).await?;
293                }
294                match self
295                    .create_authority_record(Some(&published), &next)
296                    .await?
297                {
298                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
299                        let after = self
300                            .committed_topology_restore_input(
301                                assignments,
302                                processes,
303                                input.operation().operation_id,
304                                input.process(),
305                            )
306                            .await?;
307                        if !after.same_restore_requirements(input) {
308                            return Err(TopologyError::Fenced);
309                        }
310                        return Ok(after.operation().clone());
311                    }
312                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
313                }
314            }
315            Err(TopologyError::Contended)
316        })
317        .await
318        .map_err(|_| TopologyError::Contended)?
319    }
320
321    /// Publish Release only for the exact complete current installation roster. This permission
322    /// does not itself open a local gate or admit a sink epoch. Every local owner must match its
323    /// receipt and revalidate the current runtime before applying it.
324    ///
325    /// # Errors
326    /// Rejects incomplete/stale/divergent installation, leader loss, recovery/faults or competing
327    /// authority. A lost response may have committed Release; retry the same operation/round.
328    pub async fn release_topology_target(
329        &self,
330        proof: &LeaderProof,
331        assignments: &AssignmentSnapshotStore,
332        processes: &ProcessLeaseAuthority,
333        input: &TopologyRestoreInput,
334    ) -> Result<TopologyAdmissionStatus, TopologyError> {
335        tokio::time::timeout(CONTROL_TIMEOUT, async {
336            for _ in 0..MAX_ADMISSION_ATTEMPTS {
337                let published = self
338                    .load_published_authority_head()
339                    .await?
340                    .ok_or(TopologyError::Fenced)?;
341                let current = &published.record;
342                if !current.lease.matches_proof(proof)
343                    || proof.owner.node_id != input.process().participant.node_id
344                    || proof.owner.boot_id != input.process().participant.boot_incarnation
345                {
346                    return Err(TopologyError::Fenced);
347                }
348                let fresh = self
349                    .committed_topology_restore_input(
350                        assignments,
351                        processes,
352                        input.operation().operation_id,
353                        input.process(),
354                    )
355                    .await?;
356                if !fresh.same_restore_requirements(input)
357                    || fresh.current_leader().as_ref() != Some(proof)
358                {
359                    return Err(TopologyError::Fenced);
360                }
361                let index = current
362                    .topology_operations
363                    .iter()
364                    .position(|operation| operation.operation_id == input.operation().operation_id)
365                    .ok_or(TopologyError::Fenced)?;
366                let operation = &current.topology_operations[index];
367                if operation != fresh.operation() {
368                    tokio::task::yield_now().await;
369                    continue;
370                }
371                Self::require_topology_release_authority(current, &fresh)?;
372                let round = operation.activation.as_ref().ok_or_else(|| {
373                    TopologyError::Conflict("target runtime installation has not begun".into())
374                })?;
375                if round.assignment != *fresh.assignment() || round.processes != fresh.processes() {
376                    return Err(TopologyError::Fenced);
377                }
378                if !round.installation_complete() {
379                    return Err(TopologyError::Conflict(
380                        "Release requires every current owner and evidence runtime, including zero-vnode participants".into(),
381                    ));
382                }
383                if round.release.is_some() {
384                    return Ok(operation.clone());
385                }
386                Self::require_topology_installation_authority(current, &fresh)?;
387                if round.leader != *proof {
388                    return Err(TopologyError::Fenced);
389                }
390                for process in &round.processes {
391                    self.require_topology_process(processes, *process).await?;
392                }
393                let mut lease = current.lease.clone();
394                lease.seq = lease
395                    .seq
396                    .checked_add(1)
397                    .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
398                let sequence = lease.seq;
399                let mut next = current.preserve_with_lease(lease);
400                let operation = &mut next.topology_operations[index];
401                operation
402                    .activation
403                    .as_mut()
404                    .ok_or(TopologyError::Fenced)?
405                    .release = Some(TopologyRelease {
406                    authority_sequence: sequence,
407                });
408                operation.phase = TopologyAdmissionPhase::Active;
409                operation.status_sequence = sequence;
410                match self
411                    .create_authority_record(Some(&published), &next)
412                    .await?
413                {
414                    AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
415                        let after = self
416                            .committed_topology_restore_input(
417                                assignments,
418                                processes,
419                                input.operation().operation_id,
420                                input.process(),
421                            )
422                            .await?;
423                        if !after.same_restore_requirements(input) {
424                            return Err(TopologyError::Fenced);
425                        }
426                        return Ok(after.operation().clone());
427                    }
428                    AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
429                }
430            }
431            Err(TopologyError::Contended)
432        })
433        .await
434        .map_err(|_| TopologyError::Contended)?
435    }
436
437    fn require_topology_installation_authority(
438        head: &super::LeaderAuthorityRecord,
439        input: &TopologyRestoreInput,
440    ) -> Result<(), TopologyError> {
441        if head.active_checkpoint_artifacts.is_some() {
442            return Err(TopologyError::Conflict(
443                "held runtime installation cannot overlap a checkpoint".into(),
444            ));
445        }
446        Self::require_topology_release_authority(head, input)
447    }
448
449    fn require_topology_release_authority(
450        head: &super::LeaderAuthorityRecord,
451        input: &TopologyRestoreInput,
452    ) -> Result<(), TopologyError> {
453        // A released runtime continues to serve its own checkpoints. That ordinary control
454        // work must not withdraw its exact assignment authority during periodic refresh.
455        if head
456            .active_checkpoint_artifacts
457            .as_ref()
458            .is_some_and(|inventory| {
459                input.operation().phase != TopologyAdmissionPhase::Active
460                    || inventory.pipeline_identity != input.descriptor().target_pipeline
461                    || inventory.deployment_id != input.descriptor().deployment_id
462                    || inventory.assignment_fence.as_ref() != Some(input.assignment())
463            })
464            || head.artifact_cleanup.is_some()
465            || head.assignment_drain_reservation.is_some()
466            || head.assignment_handoff_pin.is_some()
467            || head
468                .recovery_fault_slots
469                .iter()
470                .any(|slot| slot.active || slot.disposition == RecoveryFaultDisposition::Terminal)
471            || head
472                .committed_topology_operation()
473                .is_none_or(|operation| operation.operation_id != input.operation().operation_id)
474        {
475            return Err(TopologyError::Conflict("installation/Release requires the exact committed target without competing checkpoint, assignment, cleanup or recovery authority".into()));
476        }
477        Ok(())
478    }
479
480    pub(super) async fn audit_topology_activation(
481        &self,
482        operation: &TopologyAdmissionStatus,
483    ) -> Result<(), LeaseError> {
484        let Some(round) = &operation.activation else {
485            return Ok(());
486        };
487        let sequences = std::iter::once(round.authority_sequence)
488            .chain(
489                round
490                    .installations
491                    .iter()
492                    .map(|receipt| receipt.authority_sequence),
493            )
494            .chain(
495                round
496                    .release
497                    .iter()
498                    .map(|release| release.authority_sequence),
499            );
500        for sequence in sequences {
501            let anchor = read_authority_record(self.store.as_ref(), sequence)
502                .await?
503                .ok_or_else(|| {
504                    LeaseError::Invalid(
505                        "topology installation/Release authority anchor is missing".into(),
506                    )
507                })?;
508            let stored = anchor
509                .topology_operations
510                .iter()
511                .find(|entry| entry.operation_id == operation.operation_id)
512                .ok_or_else(|| {
513                    LeaseError::Invalid("installation operation anchor is missing".into())
514                })?;
515            let installed = stored.activation.as_ref().ok_or_else(|| {
516                LeaseError::Invalid("installation round anchor is missing".into())
517            })?;
518            let expected_installations = round
519                .installations
520                .iter()
521                .filter(|receipt| receipt.authority_sequence <= sequence)
522                .cloned()
523                .collect::<Vec<_>>();
524            let expected_release = round
525                .release
526                .as_ref()
527                .filter(|release| release.authority_sequence <= sequence);
528            if anchor.version < TOPOLOGY_INSTALLATION_RECORD_VERSION
529                || !anchor.lease.matches_proof(&round.leader)
530                || !stored.same_migration_binding(operation)
531                || stored.status_sequence != sequence
532                || stored.phase
533                    != if expected_release.is_some() {
534                        TopologyAdmissionPhase::Active
535                    } else {
536                        TopologyAdmissionPhase::Activating
537                    }
538                || stored.commit != operation.commit
539                || installed.leader != round.leader
540                || installed.recovery_round != round.recovery_round
541                || installed.assignment != round.assignment
542                || installed.processes != round.processes
543                || installed.authority_sequence != round.authority_sequence
544                || installed.installations != expected_installations
545                || installed.release.as_ref() != expected_release
546            {
547                return Err(LeaseError::Invalid(
548                    "installation/Release differs from its exact immutable append".into(),
549                ));
550            }
551        }
552        Ok(())
553    }
554}