1use super::topology_admission::{
4 topology_assignment_error, topology_checkpoint_error, CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS,
5};
6use super::{
7 read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome, LeaderLeaseStore,
8 OsPath, TOPOLOGY_MIGRATION_ROOT_RECORD_VERSION, TOPOLOGY_SOURCE_ROOT_RECORD_VERSION,
9};
10use crate::checkpoint::{CheckpointStore, CommittedCheckpointIndex, LeaderProof};
11use crate::checkpoint_decision::CheckpointDecisionStore;
12use crate::cluster::control::{
13 CatalogManifest, ProcessLeaseAuthority, TopologyAdmissionPhase, TopologyAdmissionStatus,
14 TopologyError, TopologyMigrationRoot, TopologyMigrationRootBinding, TopologyMigrationRootRef,
15 TopologyOperationId, TopologyPlanRef, TopologySourceInitialization,
16 MAX_TOPOLOGY_ROOT_MANIFEST_BYTES,
17};
18use object_store::{ObjectStoreExt, PutMode, PutOptions, PutPayload};
19use std::future::Future;
20
21fn root_path(reference: &TopologyMigrationRootRef) -> OsPath {
22 OsPath::from(format!(
23 "control/topology-migration-roots/v1/{}.json",
24 reference.sha256
25 ))
26}
27
28fn source_root_slot(operation: &TopologyAdmissionStatus) -> OsPath {
33 OsPath::from(format!(
34 "control/topology-source-root-staging/v1/{}/{}.json",
35 operation.operation_id.get(),
36 operation.plan.sha256,
37 ))
38}
39
40impl LeaderLeaseStore {
41 async fn build_topology_root(
42 &self,
43 checkpoint_store: &dyn CheckpointStore,
44 operation: &TopologyAdmissionStatus,
45 descriptor: &crate::cluster::control::ClusterTopologyValidation,
46 sources: Vec<TopologySourceInitialization>,
47 ) -> Result<TopologyMigrationRoot, TopologyError> {
48 TopologyMigrationRoot::object_mappings(descriptor)?;
49 let reference = &operation
50 .cut
51 .as_ref()
52 .and_then(|cut| cut.committed.as_ref())
53 .ok_or_else(|| TopologyError::Conflict("root requires a committed parent cut".into()))?
54 .checkpoint;
55 let index = CheckpointDecisionStore::new(self.store.clone())
56 .load_committed_checkpoint(reference)
57 .await
58 .map_err(|error| TopologyError::Invalid(error.to_string()))?;
59 validate_manifest_budget(&index)?;
60 if checkpoint_store.key_group_count().get() != index.vnode_count {
61 return Err(TopologyError::Invalid(
62 "root reader has a different vnode domain".into(),
63 ));
64 }
65 let mut manifests = Vec::with_capacity(index.participants.len());
68 for participant in &index.participants {
69 let manifest = checkpoint_store
70 .load_manifest_verified(
71 participant.participant_id,
72 index.checkpoint_id,
73 participant.manifest_len,
74 &participant.manifest_sha256,
75 )
76 .await
77 .map_err(|e| TopologyError::Invalid(e.to_string()))?
78 .ok_or_else(|| {
79 TopologyError::Invalid("migration root participant manifest is missing".into())
80 })?;
81 manifests.push(manifest);
82 }
83 if sources.is_empty() {
84 TopologyMigrationRoot::build(operation, descriptor, &index, &manifests)
85 } else {
86 TopologyMigrationRoot::build_with_sources(
87 operation, descriptor, &index, &manifests, sources,
88 )
89 }
90 }
91
92 #[allow(clippy::too_many_arguments)] pub async fn stage_topology_migration_root(
104 &self,
105 proof: &LeaderProof,
106 assignments: &AssignmentSnapshotStore,
107 processes: &ProcessLeaseAuthority,
108 checkpoint_store: &dyn CheckpointStore,
109 operation_id: TopologyOperationId,
110 expected_plan: &TopologyPlanRef,
111 ) -> Result<TopologyAdmissionStatus, TopologyError> {
112 self.stage_topology_migration_root_with_initialization(
113 proof,
114 assignments,
115 processes,
116 checkpoint_store,
117 operation_id,
118 expected_plan,
119 |_, _| async {
120 Err(TopologyError::Unsupported(
121 "new sources require the configured connector initialization path".into(),
122 ))
123 },
124 )
125 .await
126 }
127
128 #[allow(clippy::too_many_arguments)] pub async fn stage_topology_migration_root_with_initialization<F, Fut>(
142 &self,
143 proof: &LeaderProof,
144 assignments: &AssignmentSnapshotStore,
145 processes: &ProcessLeaseAuthority,
146 checkpoint_store: &dyn CheckpointStore,
147 operation_id: TopologyOperationId,
148 expected_plan: &TopologyPlanRef,
149 initialize: F,
150 ) -> Result<TopologyAdmissionStatus, TopologyError>
151 where
152 F: FnOnce(CatalogManifest, crate::cluster::control::ClusterTopologyValidation) -> Fut,
153 Fut: Future<Output = Result<Vec<TopologySourceInitialization>, TopologyError>>,
154 {
155 expected_plan.validate()?;
156 tokio::time::timeout(CONTROL_TIMEOUT, async {
157 let mut staged = None;
158 let mut initialize = Some(initialize);
159 for _ in 0..MAX_ADMISSION_ATTEMPTS {
160 let published = self
161 .load_published_authority_head()
162 .await?
163 .ok_or(TopologyError::Fenced)?;
164 let current = &published.record;
165 let index = current
166 .topology_operations
167 .iter()
168 .position(|entry| entry.operation_id == operation_id)
169 .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
170 let operation = ¤t.topology_operations[index];
171 if !current.lease.matches_proof(proof) || operation.admitted_by != *proof {
172 return Err(TopologyError::Fenced);
173 }
174 if operation.plan != *expected_plan
175 || operation.phase != TopologyAdmissionPhase::CutPrepared
176 {
177 return Err(TopologyError::Conflict(
178 "root staging requires the exact prepared request payload".into(),
179 ));
180 }
181 self.audit_topology_operation(operation).await?;
182 let plan = self.load_topology_plan(expected_plan).await?;
183 let descriptor = self
184 .require_topology_prepared(operation, &plan, processes)
185 .await?;
186 let assignment = assignments
187 .load()
188 .await
189 .map_err(topology_assignment_error)?
190 .ok_or(TopologyError::Fenced)?;
191 if assignment.draining
192 || assignment
193 .assignment_fence()
194 .map_err(|e| TopologyError::Invalid(e.to_string()))?
195 != plan.assignment
196 {
197 return Err(TopologyError::Fenced);
198 }
199 self.reject_consumed_checkpoint_assignment(current, &plan.assignment)
200 .await
201 .map_err(topology_checkpoint_error)?;
202 if operation.migration_root.is_some() {
203 return Ok(operation.clone());
204 }
205 if staged.is_none() {
206 let has_sources = descriptor.objects.iter().any(|object| {
207 object.kind == crate::cluster::control::CatalogObjectKind::Source
208 && object.transition == crate::cluster::control::topology::ClusterTopologyObjectTransition::AddFutureOnly
209 });
210 let sealed = if has_sources {
211 self.load_source_root_slot(operation, &plan, &descriptor).await?
212 } else { None };
213 let sources = if let Some(root) = &sealed {
214 root.source_initializations.clone()
215 } else if has_sources {
216 let target = self.load_catalog_manifest(&plan.target_manifest).await
217 .map_err(TopologyError::from)?;
218 initialize.take().ok_or_else(|| TopologyError::Invalid(
219 "source initialization callback was already consumed".into(),
220 ))?(target, descriptor.clone()).await?
221 } else { Vec::new() };
222 let root = self.build_topology_root(checkpoint_store, operation, &descriptor, sources).await?;
223 root.validate_binding(operation, &plan, &descriptor)?;
224 let root = if let Some(sealed) = sealed {
225 if root != sealed {
226 return Err(TopologyError::Invalid("sealed source root differs from the exact cut metadata".into()));
227 }
228 sealed
229 } else if has_sources {
230 self.seal_source_root_slot(operation, &plan, &descriptor, &root).await?
231 } else { root };
232 let (bytes, reference) = root.encode_and_reference()?;
233 self.stage_admission_blob(&root_path(&reference), &bytes)
234 .await?;
235 let record_version = if root.format_version == 2 {
236 TOPOLOGY_SOURCE_ROOT_RECORD_VERSION
237 } else { TOPOLOGY_MIGRATION_ROOT_RECORD_VERSION };
238 staged = Some((reference, record_version));
239 }
240 let mut lease = current.lease.clone();
241 lease.seq = lease
242 .seq
243 .checked_add(1)
244 .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
245 let sequence = lease.seq;
246 let mut next = current.preserve_with_lease(lease);
247 next.version = next.version.max(staged.as_ref().ok_or_else(|| {
248 TopologyError::Invalid("root staging lost its content reference".into())
249 })?.1);
250 let operation = &mut next.topology_operations[index];
251 operation.status_sequence = sequence;
252 operation.migration_root = Some(TopologyMigrationRootBinding {
253 root: staged
254 .as_ref()
255 .ok_or_else(|| {
256 TopologyError::Invalid("root staging lost its content reference".into())
257 })?
258 .0.clone(),
259 authority_sequence: sequence,
260 });
261 let result = operation.clone();
262 match self
263 .create_authority_record(Some(&published), &next)
264 .await?
265 {
266 AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
267 self.require_topology_prepared(&result, &plan, processes)
268 .await?;
269 return Ok(result);
270 }
271 AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
272 }
273 }
274 Err(TopologyError::Contended)
275 })
276 .await
277 .map_err(|_| TopologyError::Contended)?
278 }
279
280 async fn load_source_root_slot(
281 &self,
282 operation: &TopologyAdmissionStatus,
283 plan: &crate::cluster::control::TopologyAdmissionPlan,
284 descriptor: &crate::cluster::control::ClusterTopologyValidation,
285 ) -> Result<Option<TopologyMigrationRoot>, TopologyError> {
286 let result = match self.store.get(&source_root_slot(operation)).await {
287 Ok(result) => result,
288 Err(object_store::Error::NotFound { .. }) => return Ok(None),
289 Err(error) => {
290 return Err(TopologyError::Authority(super::LeaseError::Io(
291 error.to_string(),
292 )))
293 }
294 };
295 let expected_len = result.meta.size;
296 if expected_len == 0
297 || expected_len > crate::cluster::control::topology::MAX_TOPOLOGY_ROOT_BYTES
298 {
299 return Err(TopologyError::Invalid(
300 "sealed source root exceeds its 1 MiB bound".into(),
301 ));
302 }
303 let bytes = result
304 .bytes()
305 .await
306 .map_err(|e| super::LeaseError::Io(e.to_string()))?;
307 let root: TopologyMigrationRoot =
308 serde_json::from_slice(&bytes).map_err(|e| TopologyError::Invalid(e.to_string()))?;
309 let (canonical, _) = root.encode_and_reference()?;
310 if root.format_version != 2
311 || canonical.as_slice() != bytes.as_ref()
312 || bytes.len() as u64 != expected_len
313 {
314 return Err(TopologyError::Invalid(
315 "sealed source root has a noncanonical body".into(),
316 ));
317 }
318 root.validate_binding(operation, plan, descriptor)?;
319 Ok(Some(root))
320 }
321
322 async fn seal_source_root_slot(
323 &self,
324 operation: &TopologyAdmissionStatus,
325 plan: &crate::cluster::control::TopologyAdmissionPlan,
326 descriptor: &crate::cluster::control::ClusterTopologyValidation,
327 proposed: &TopologyMigrationRoot,
328 ) -> Result<TopologyMigrationRoot, TopologyError> {
329 let (bytes, _) = proposed.encode_and_reference()?;
330 let write = self
331 .store
332 .put_opts(
333 &source_root_slot(operation),
334 PutPayload::from(bytes),
335 PutOptions {
336 mode: PutMode::Create,
337 ..PutOptions::default()
338 },
339 )
340 .await;
341 let winner = self
344 .load_source_root_slot(operation, plan, descriptor)
345 .await?
346 .ok_or_else(|| match write {
347 Err(error) => TopologyError::Authority(super::LeaseError::Io(error.to_string())),
348 Ok(_) => {
349 TopologyError::Invalid("sealed source root disappeared after creation".into())
350 }
351 })?;
352 let mut expected = proposed.clone();
353 expected
354 .source_initializations
355 .clone_from(&winner.source_initializations);
356 if winner != expected {
357 return Err(TopologyError::Invalid(
358 "source root slot changed preserved cut requirements".into(),
359 ));
360 }
361 Ok(winner)
362 }
363
364 pub(super) async fn load_topology_root(
365 &self,
366 reference: &TopologyMigrationRootRef,
367 ) -> Result<TopologyMigrationRoot, TopologyError> {
368 reference.validate()?;
369 let bytes = self
370 .read_admission_blob(&root_path(reference), reference.encoded_len)
371 .await?;
372 let root: TopologyMigrationRoot =
373 serde_json::from_slice(&bytes).map_err(|e| TopologyError::Invalid(e.to_string()))?;
374 let (canonical, actual) = root.encode_and_reference()?;
375 if actual != *reference || canonical.as_slice() != bytes.as_ref() {
376 return Err(TopologyError::Invalid(
377 "migration root differs from its canonical immutable reference".into(),
378 ));
379 }
380 Ok(root)
381 }
382
383 pub(super) async fn audit_topology_migration_root(
384 &self,
385 operation: &TopologyAdmissionStatus,
386 plan: &crate::cluster::control::TopologyAdmissionPlan,
387 descriptor: Option<&crate::cluster::control::ClusterTopologyValidation>,
388 ) -> Result<(), TopologyError> {
389 let Some(binding) = &operation.migration_root else {
390 return Ok(());
391 };
392 let root = self.load_topology_root(&binding.root).await?;
393 let descriptor =
394 descriptor.ok_or_else(|| TopologyError::Protocol("root has no descriptor".into()))?;
395 root.validate_binding(operation, plan, descriptor)?;
396 let record = read_authority_record(self.store.as_ref(), binding.authority_sequence)
397 .await?
398 .ok_or_else(|| {
399 TopologyError::Invalid("migration root authority anchor is missing".into())
400 })?;
401 if root.format_version == 2 && record.version < TOPOLOGY_SOURCE_ROOT_RECORD_VERSION {
402 return Err(TopologyError::Protocol(
403 "source initialization roots require authority format 18".into(),
404 ));
405 }
406 if record
407 .topology_operations
408 .iter()
409 .find(|entry| entry.operation_id == operation.operation_id)
410 .is_none_or(|anchored| {
411 anchored.plan != operation.plan
412 || anchored.admitted_by != operation.admitted_by
413 || anchored.admitted_sequence != operation.admitted_sequence
414 || anchored.status_sequence != binding.authority_sequence
415 || anchored.phase != TopologyAdmissionPhase::CutPrepared
416 || anchored.cut != operation.cut
417 || anchored.preparation != operation.preparation
418 || anchored.migration_root.as_ref() != Some(binding)
419 })
420 {
421 return Err(TopologyError::Invalid(
422 "migration root differs from its first immutable authority append".into(),
423 ));
424 }
425 Ok(())
426 }
427
428 pub async fn topology_migration_root(
433 &self,
434 operation_id: TopologyOperationId,
435 ) -> Result<Option<TopologyMigrationRoot>, TopologyError> {
436 tokio::time::timeout(CONTROL_TIMEOUT, async {
437 let Some(current) = self.load_record().await? else {
438 return Ok(None);
439 };
440 let Some(operation) = current
441 .topology_operations
442 .iter()
443 .find(|e| e.operation_id == operation_id)
444 else {
445 return Ok(None);
446 };
447 self.audit_topology_operation(operation).await?;
448 match &operation.migration_root {
449 Some(binding) => self.load_topology_root(&binding.root).await.map(Some),
450 None => Ok(None),
451 }
452 })
453 .await
454 .map_err(|_| TopologyError::ReadTimedOut)?
455 }
456}
457
458pub(super) fn validate_manifest_budget(
459 index: &CommittedCheckpointIndex,
460) -> Result<(), TopologyError> {
461 index
462 .participants
463 .iter()
464 .try_fold(0_u64, |total, p| total.checked_add(p.manifest_len))
465 .filter(|total| *total <= MAX_TOPOLOGY_ROOT_MANIFEST_BYTES)
466 .ok_or_else(|| {
467 TopologyError::Unsupported(
468 "migration root manifest metadata exceeds the aggregate 16 MiB budget".into(),
469 )
470 })?;
471 Ok(())
472}