Skip to main content

laminar_db/db/topology/
submission.rs

1//! Durable submission through the existing database-owned topology driver.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use laminar_core::cluster::control::{
7    AssignmentSnapshotStore, CatalogManifest, CatalogManifestRef, ClusterController, LeaderProof,
8    LegacyTopologyBaseline, LocalProcessAuthorityIdentity, TopologyAdmissionPlan,
9    TopologyAdoptionOutcome, TopologyError, TOPOLOGY_SUBMISSION_PROTOCOL_VERSION,
10};
11use laminar_sql::parser::StreamingStatement;
12
13use super::{DbError, LaminarDB, TopologyAdmissionStatus, TopologyOperationId, TopologyVersion};
14
15pub(super) const SUBMISSION_TIMEOUT: Duration = Duration::from_secs(45);
16
17/// One atomic candidate graph and the request identity reused on every retry.
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct ClusterTopologyRequest {
21    /// Nonzero caller UUID; a changed payload must use a new identity.
22    pub operation_id: TopologyOperationId,
23    /// Exact committed parent, checked atomically with admission.
24    pub expected_parent_version: TopologyVersion,
25    /// Ordered individual DDL statements; the entire candidate is validated before admission.
26    pub statements: Vec<String>,
27}
28
29/// Explicit adoption of an existing sealed inventory after a coordinated binary upgrade.
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct ClusterTopologyAdoptionRequest {
33    /// Nonzero caller UUID, preserved by retries.
34    pub operation_id: TopologyOperationId,
35    /// Exact original manifest; its historical bytes and generations are preserved.
36    pub expected_manifest: CatalogManifestRef,
37    /// Existing checkpoint/control deployment UUID; adoption never initializes it.
38    pub expected_deployment_id: String,
39    /// Operator assertion that every process has completed the coordinated binary upgrade.
40    pub coordinated_upgrade_complete: bool,
41}
42
43impl LaminarDB {
44    /// Admit an atomic topology candidate and return its durable operation receipt.
45    ///
46    /// The existing recovery monitor owns all subsequent phases. Cancellation or a lost response
47    /// cannot revoke admission. This response does not claim target activation; query status with
48    /// the same UUID. Identical retries return the recorded operation before compiling again.
49    /// A follower forwards at most once to the durable leader within the same 45 second budget.
50    ///
51    /// # Errors
52    /// Rejects changed payloads, stale parents, unsupported plans, missing live coordinator,
53    /// unresolved authority, compiler contention and bounded deadlines. A timeout is uncertain:
54    /// reread status and retry the same identity rather than allocate a replacement request.
55    pub async fn submit_cluster_topology_change(
56        &self,
57        request: &ClusterTopologyRequest,
58    ) -> Result<TopologyAdmissionStatus, DbError> {
59        self.submit_cluster_topology_until(request, SUBMISSION_TIMEOUT, true)
60            .await
61    }
62
63    /// One-hop HTTP receiver; never forwards a previously forwarded submission again.
64    ///
65    /// # Errors
66    /// Uses the ordinary admission rules and rejects a receiver that lost durable leadership.
67    #[doc(hidden)]
68    pub async fn submit_cluster_topology_forwarded(
69        &self,
70        request: &ClusterTopologyRequest,
71        remaining: Duration,
72    ) -> Result<TopologyAdmissionStatus, DbError> {
73        self.submit_cluster_topology_until(request, remaining.min(SUBMISSION_TIMEOUT), false)
74            .await
75    }
76
77    async fn submit_cluster_topology_until(
78        &self,
79        request: &ClusterTopologyRequest,
80        timeout: Duration,
81        allow_forwarding: bool,
82    ) -> Result<TopologyAdmissionStatus, DbError> {
83        if timeout.is_zero() {
84            return Err(TopologyError::Contended.into());
85        }
86        let deadline = tokio::time::Instant::now()
87            .checked_add(timeout)
88            .ok_or(TopologyError::Contended)?;
89        tokio::time::timeout_at(
90            deadline,
91            Box::pin(async {
92                super::planning::validate_request_bounds(&request.statements)?;
93                let controller = self.topology_submission_controller()?;
94                let authority = controller
95                    .checkpoint_authority()
96                    .map_err(|error| TopologyError::Protocol(error.to_string()))?;
97                if !allow_forwarding {
98                    self.topology_submission_route(&controller, false).await?;
99                }
100                // Lookup precedes running/parent/compiler gates. The original terminal result remains
101                // available after the cluster has moved to a later topology or is recovering.
102                if authority
103                    .topology_operation_status(request.operation_id)
104                    .await?
105                    .is_some()
106                {
107                    let (operation, plan, _target, descriptor) = authority
108                        .topology_preparation_input(request.operation_id)
109                        .await?;
110                    if plan.protocol_version != TOPOLOGY_SUBMISSION_PROTOCOL_VERSION
111                        || plan.expected_parent != request.expected_parent_version
112                        || descriptor.statements != request.statements
113                    {
114                        return Err(TopologyError::Conflict(
115                            "operation identity was already bound to another payload".into(),
116                        )
117                        .into());
118                    }
119                    return Ok(operation);
120                }
121                self.ensure_topology_submission_available()?;
122                if let Some(address) = self
123                    .topology_submission_route(&controller, allow_forwarding)
124                    .await?
125                {
126                    let operation: TopologyAdmissionStatus = self
127                        .forward_topology_request(
128                            &address,
129                            "/api/v1/cluster/topology/operations",
130                            request,
131                            deadline,
132                        )
133                        .await?;
134                    if operation.operation_id != request.operation_id {
135                        return Err(TopologyError::Invalid(
136                            "leader receipt belongs to another operation".into(),
137                        )
138                        .into());
139                    }
140                    return Ok(operation);
141                }
142                let before = controller
143                    .try_live_local_process_authority_identity()
144                    .map_err(|_| TopologyError::Fenced)?;
145                let proof = controller
146                    .capture_leader_proof()
147                    .ok_or(TopologyError::Fenced)?;
148                let (descriptor, target) = Box::pin(self.plan_cluster_topology_change(
149                    request.expected_parent_version,
150                    &request.statements,
151                ))
152                .await?;
153                let assignments = controller.snapshot_store().ok_or_else(|| {
154                    TopologyError::Protocol(
155                        "submission requires the configured assignment authority".into(),
156                    )
157                })?;
158                let assignment = assignments
159                    .load()
160                    .await
161                    .map_err(|error| TopologyError::Protocol(error.to_string()))?
162                    .ok_or_else(|| {
163                        TopologyError::Conflict("assignment inventory is uninitialized".into())
164                    })?
165                    .assignment_fence()
166                    .map_err(|error| TopologyError::Invalid(error.to_string()))?;
167                if assignment.participant_incarnation(before.participant.node_id)
168                    != Some(before.participant.boot_incarnation)
169                    || controller
170                        .checkpoint_assignment_fence(assignment.assignment_version)
171                        .as_ref()
172                        != Some(&assignment)
173                    || controller.try_live_local_process_authority_identity().ok() != Some(before)
174                    || !controller.proof_is_live(&proof)
175                {
176                    return Err(TopologyError::Fenced.into());
177                }
178                let compatibility = authority.stage_topology_compatibility(&descriptor).await?;
179                let plan = TopologyAdmissionPlan {
180                    protocol_version: TOPOLOGY_SUBMISSION_PROTOCOL_VERSION,
181                    operation_id: request.operation_id,
182                    expected_parent: request.expected_parent_version,
183                    parent_manifest: descriptor.parent_manifest,
184                    target_manifest: descriptor.target_manifest,
185                    assignment,
186                    compatibility: Some(compatibility),
187                };
188                let admitted = self
189                    .admit_submitted_plan(&controller, &proof, before, assignments, &plan, &target)
190                    .await?;
191                tracing::info!(operation = %request.operation_id.get(), phase = ?admitted.phase,
192                "admitted durable topology request; coordinator owns further progress");
193                Ok(admitted)
194            }),
195        )
196        .await
197        .map_err(|_| TopologyError::Contended)?
198    }
199
200    /// Retry one immutable plan until admitted while this leader holds periodic checkpoints.
201    async fn admit_submitted_plan(
202        &self,
203        controller: &ClusterController,
204        proof: &LeaderProof,
205        before: LocalProcessAuthorityIdentity,
206        assignments: &AssignmentSnapshotStore,
207        plan: &TopologyAdmissionPlan,
208        target: &CatalogManifest,
209    ) -> Result<TopologyAdmissionStatus, DbError> {
210        let authority = controller
211            .checkpoint_authority()
212            .map_err(|error| TopologyError::Protocol(error.to_string()))?;
213        let _checkpoints = controller.hold_periodic_checkpoints();
214        loop {
215            self.ensure_topology_submission_available()?;
216            if !controller.proof_is_live(proof)
217                || controller.try_live_local_process_authority_identity().ok() != Some(before)
218            {
219                return Err(TopologyError::Fenced.into());
220            }
221            match authority
222                .admit_topology_plan(proof, assignments, plan, target)
223                .await
224            {
225                Ok(admitted) => return Ok(admitted),
226                Err(TopologyError::Contended) => {
227                    // Do not rebuild or change the operation UUID after checkpoint/CAS
228                    // contention. The caller's timeout bounds this retry and the hold, and
229                    // authority rereads resolve a lost successful append response.
230                    tokio::time::sleep(Duration::from_millis(25)).await;
231                }
232                Err(error) => return Err(error.into()),
233            }
234        }
235    }
236
237    /// Explicitly adopt topology one without rewriting the existing catalog or checkpoint storage.
238    ///
239    /// # Errors
240    /// Requires the coordinated-upgrade assertion, exact existing deployment/manifest, live
241    /// leadership and the existing bounded adoption CAS. A follower forwards at most once.
242    pub async fn adopt_cluster_topology(
243        &self,
244        request: &ClusterTopologyAdoptionRequest,
245    ) -> Result<LegacyTopologyBaseline, DbError> {
246        self.adopt_cluster_topology_until(request, SUBMISSION_TIMEOUT, true)
247            .await
248    }
249
250    /// One-hop HTTP adoption receiver using the caller's remaining bounded budget.
251    ///
252    /// # Errors
253    /// Uses explicit adoption rules and rejects lost leadership or invalid evidence.
254    #[doc(hidden)]
255    pub async fn adopt_cluster_topology_forwarded(
256        &self,
257        request: &ClusterTopologyAdoptionRequest,
258        remaining: Duration,
259    ) -> Result<LegacyTopologyBaseline, DbError> {
260        self.adopt_cluster_topology_until(request, remaining.min(SUBMISSION_TIMEOUT), false)
261            .await
262    }
263
264    async fn adopt_cluster_topology_until(
265        &self,
266        request: &ClusterTopologyAdoptionRequest,
267        timeout: Duration,
268        allow_forwarding: bool,
269    ) -> Result<LegacyTopologyBaseline, DbError> {
270        if timeout.is_zero() {
271            return Err(TopologyError::Contended.into());
272        }
273        let deadline = tokio::time::Instant::now()
274            .checked_add(timeout)
275            .ok_or(TopologyError::Contended)?;
276        tokio::time::timeout_at(deadline, async {
277            if !request.coordinated_upgrade_complete {
278                return Err(TopologyError::Protocol("complete the coordinated binary upgrade on every process before explicit legacy adoption".into()).into());
279            }
280            let deployment = uuid::Uuid::parse_str(&request.expected_deployment_id)
281                .map_err(|_| TopologyError::Invalid("deployment must be a canonical nonzero UUID".into()))?;
282            if deployment.is_nil() || deployment.to_string() != request.expected_deployment_id {
283                return Err(TopologyError::Invalid("deployment must be a canonical nonzero UUID".into()).into());
284            }
285            let controller = self.topology_submission_controller()?;
286            self.ensure_topology_preparation_available()?;
287            if let Some(address) = self.topology_submission_route(&controller, allow_forwarding).await? {
288                let baseline: LegacyTopologyBaseline = self.forward_topology_request(&address, "/api/v1/cluster/topology/adopt", request, deadline).await?;
289                if baseline.manifest != request.expected_manifest
290                    || baseline.deployment_id != request.expected_deployment_id {
291                    return Err(TopologyError::Invalid("leader adoption receipt differs from the exact request".into()).into());
292                }
293                return Ok(baseline);
294            }
295            let authority = controller.checkpoint_authority()
296                .map_err(|error| TopologyError::Protocol(error.to_string()))?;
297            let proof = controller.capture_leader_proof().ok_or(TopologyError::Fenced)?;
298            let catalog = self.catalog_manifest_store.lock().clone()
299                .ok_or_else(|| TopologyError::Protocol("adoption requires configured catalog authority".into()))?;
300            let (manifest, _) = catalog.load_with_topology().await?
301                .ok_or_else(|| TopologyError::Conflict("legacy catalog is not sealed".into()))?;
302            if manifest.reference().map_err(TopologyError::from)? != request.expected_manifest
303                || self.catalog_manifest_inventory()? != manifest.entries {
304                return Err(TopologyError::Conflict("adoption requires the exact sealed, replayed live catalog".into()).into());
305            }
306            let baseline = match authority.adopt_legacy_topology(&proof, request.operation_id,
307                &request.expected_manifest, &request.expected_deployment_id).await? {
308                TopologyAdoptionOutcome::Created(baseline) | TopologyAdoptionOutcome::Existing(baseline) => baseline,
309            };
310            self.ensure_topology_preparation_available()?;
311            if !controller.proof_is_live(&proof) {
312                return Err(TopologyError::Fenced.into());
313            }
314            *self.replayed_topology.lock() = Some(super::ReplayedTopology::LEGACY_BASELINE);
315            Ok(baseline)
316        }).await.map_err(|_| TopologyError::Contended)?
317    }
318
319    pub(in crate::db) async fn submit_cluster_topology_sql(
320        &self,
321        sql: &str,
322        statement: &StreamingStatement,
323    ) -> Result<crate::handle::ExecuteResult, DbError> {
324        let (name, _, _) = super::catalog_changes::statement_identity(self, sql, statement)?;
325        let parent = self
326            .cluster_topology_status()
327            .await?
328            .committed_version
329            .ok_or_else(|| {
330                TopologyError::Protocol(
331                    "explicit legacy topology adoption is required before runtime DDL".into(),
332                )
333            })?;
334        let request = ClusterTopologyRequest {
335            operation_id: uuid::Uuid::new_v4().try_into()?,
336            expected_parent_version: parent,
337            statements: vec![sql.to_owned()],
338        };
339        let status = Box::pin(self.submit_cluster_topology_change(&request))
340            .await
341            .map_err(|source| {
342                if matches!(
343                    &source,
344                    DbError::Topology(
345                        TopologyError::Contended
346                            | TopologyError::Authority(_)
347                            | TopologyError::Fenced
348                    )
349                ) {
350                    DbError::TopologySubmission {
351                        operation_id: request.operation_id,
352                        source: Box::new(source),
353                    }
354                } else {
355                    source
356                }
357            })?;
358        Ok(crate::handle::ExecuteResult::Ddl(crate::handle::DdlInfo {
359            statement_type: "TOPOLOGY MIGRATION".into(),
360            object_name: name,
361            applied: false,
362            topology_operation: Some(Box::new(status)),
363        }))
364    }
365
366    fn topology_submission_controller(&self) -> Result<Arc<ClusterController>, DbError> {
367        if !self.is_cluster_runtime() {
368            return Err(TopologyError::Unsupported(
369                "topology submission requires cluster mode".into(),
370            )
371            .into());
372        }
373        self.cluster_controller.lock().clone().ok_or_else(|| {
374            TopologyError::Protocol("submission requires the configured cluster controller".into())
375                .into()
376        })
377    }
378
379    fn ensure_topology_submission_available(&self) -> Result<(), DbError> {
380        self.ensure_topology_preparation_available()?;
381        if self.cluster_intake_fenced()
382            || self.force_ckpt_tx.lock().is_none()
383            || self
384                .recovery_monitor
385                .lock()
386                .as_ref()
387                .is_none_or(tokio::task::JoinHandle::is_finished)
388        {
389            return Err(TopologyError::Conflict("submission requires released parent intake and the live checkpoint/recovery coordinator".into()).into());
390        }
391        Ok(())
392    }
393
394    async fn topology_submission_route(
395        &self,
396        controller: &ClusterController,
397        allow_forwarding: bool,
398    ) -> Result<Option<String>, DbError> {
399        let authority = controller
400            .checkpoint_authority()
401            .map_err(|error| TopologyError::Protocol(error.to_string()))?;
402        let lease = authority
403            .load()
404            .await
405            .map_err(TopologyError::from)?
406            .ok_or(TopologyError::Fenced)?;
407        if controller
408            .capture_leader_proof()
409            .as_ref()
410            .is_some_and(|proof| lease.matches_proof(proof))
411        {
412            return Ok(None);
413        }
414        if !allow_forwarding || !controller.process_lease_is_live() {
415            return Err(TopologyError::Fenced.into());
416        }
417        let watch = controller.members_watch();
418        let members = watch.borrow();
419        let address = members
420            .iter()
421            .find(|member| member.id.0 == lease.owner.node.0)
422            .map(|member| member.rpc_address.trim())
423            .filter(|address| !address.is_empty())
424            .ok_or_else(|| {
425                TopologyError::Conflict(
426                    "durable leader HTTP address is unresolved; retry the same request identity"
427                        .into(),
428                )
429            })?;
430        Ok(Some(address.to_owned()))
431    }
432}