1use super::ObjectRef;
16use crate::cli::CliError;
17use crate::cli::executor::{self, DeploymentExecutor};
18use crate::cli::{git, progress};
19use crate::client::DeploymentMode;
20use crate::client::{Client, ClusterConfig, DeploymentKind, PendingStatement, ReplacementMvRecord};
21use crate::config::Settings;
22use crate::log;
23use crate::project::SchemaQualifier;
24use crate::project::analysis::changeset::ChangeSet;
25use crate::project::analysis::deployment_snapshot::{self, DeploymentSnapshot};
26use crate::project::analysis::deps::extract_external_indexes;
27use crate::project::ast::Statement;
28use crate::project::ir::compiled::{DatabaseObject, FullyQualifiedName};
29use crate::project::ir::graph::Project;
30use crate::project::ir::object_id::ObjectId;
31use crate::project::resolve::normalize::{self, NormalizingVisitor};
32use crate::verbose;
33use mz_ore::option::OptionExt;
34use mz_sql_parser::ast::display::AstDisplay;
35use mz_sql_parser::ast::{CreateClusterStatement, Ident};
36use std::collections::BTreeSet;
37
38fn validate_stage_name(stage_name: &str) -> Result<(), CliError> {
42 if stage_name.len() + 1 > Ident::MAX_LENGTH / 2 {
45 return Err(CliError::InvalidEnvironmentName {
46 name: stage_name.to_string(),
47 });
48 }
49 Ok(())
50}
51use std::fmt;
52use std::path::Path;
53use std::time::Instant;
54
55struct StageAnalysis<'a> {
60 objects: Vec<ObjectRef<'a>>,
61 sinks: Vec<ObjectRef<'a>>,
62 replacement_mvs: Vec<ObjectRef<'a>>,
63 schema_set: BTreeSet<SchemaQualifier>,
64 cluster_set: BTreeSet<String>,
65}
66
67struct PartitionedObjects<'a> {
71 objects: Vec<ObjectRef<'a>>,
72 sinks: Vec<ObjectRef<'a>>,
73 replacement_mvs: Vec<ObjectRef<'a>>,
74 table_count: usize,
75}
76
77#[derive(serde::Serialize)]
80struct StageResult {
81 deploy_id: String,
82 objects_deployed: usize,
83 #[serde(skip)]
84 duration: std::time::Duration,
85}
86
87impl fmt::Display for StageResult {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 write!(
90 f,
91 " \u{2713} Successfully deployed {} objects to '{}' staging environment ({:.1}s)",
92 self.objects_deployed,
93 self.deploy_id,
94 self.duration.as_secs_f64()
95 )
96 }
97}
98
99#[derive(serde::Serialize)]
100struct StagePlan {
101 deploy_id: String,
102 schemas: Vec<StagePlanSchema>,
103 clusters: Vec<StagePlanCluster>,
104 objects: Vec<StagePlanObject>,
105 sinks: Vec<StagePlanObject>,
106 replacement_mvs: Vec<StagePlanObject>,
107}
108
109#[derive(serde::Serialize)]
110struct StagePlanSchema {
111 database: String,
112 schema: String,
113 staging_schema: String,
114}
115
116#[derive(serde::Serialize)]
117struct StagePlanCluster {
118 production_cluster: String,
119 staging_cluster: String,
120}
121
122#[derive(serde::Serialize)]
123struct StagePlanObject {
124 database: String,
125 schema: String,
126 object: String,
127}
128
129impl fmt::Display for StagePlan {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 writeln!(f, "Stage plan for '{}':", self.deploy_id)?;
132
133 if !self.schemas.is_empty() {
134 writeln!(f, "\nSchemas ({}):", self.schemas.len())?;
135 for s in &self.schemas {
136 writeln!(
137 f,
138 " {}.{} \u{2192} {}",
139 s.database, s.schema, s.staging_schema
140 )?;
141 }
142 }
143
144 if !self.clusters.is_empty() {
145 writeln!(f, "\nClusters ({}):", self.clusters.len())?;
146 for c in &self.clusters {
147 writeln!(
148 f,
149 " {} \u{2192} {}",
150 c.production_cluster, c.staging_cluster
151 )?;
152 }
153 }
154
155 if !self.objects.is_empty() {
156 writeln!(f, "\nObjects ({}):", self.objects.len())?;
157 for o in &self.objects {
158 writeln!(f, " {}.{}.{}", o.database, o.schema, o.object)?;
159 }
160 }
161
162 if !self.sinks.is_empty() {
163 writeln!(f, "\nSinks ({}):", self.sinks.len())?;
164 for s in &self.sinks {
165 writeln!(f, " {}.{}.{}", s.database, s.schema, s.object)?;
166 }
167 }
168
169 if !self.replacement_mvs.is_empty() {
170 writeln!(f, "\nReplacement MVs ({}):", self.replacement_mvs.len())?;
171 for m in &self.replacement_mvs {
172 writeln!(f, " {}.{}.{}", m.database, m.schema, m.object)?;
173 }
174 }
175
176 Ok(())
177 }
178}
179
180pub async fn run(
201 settings: &Settings,
202 stage_name: Option<&str>,
203 allow_dirty: bool,
204 no_rollback: bool,
205 dry_run: bool,
206 redeploy_schemas: &[String],
207 redeploy_all: bool,
208) -> Result<(), CliError> {
209 let profile = settings.connection();
210 let directory = &settings.directory;
211 let start_time = Instant::now();
212
213 if !allow_dirty && git::is_dirty(directory) {
214 return Err(CliError::GitDirty);
215 }
216
217 let stage_name = stage_name
218 .owned()
219 .or_else(|| git::get_git_commit(directory).map(|sha| sha.chars().take(7).collect()))
220 .unwrap_or_else(executor::generate_random_env_name);
221 validate_stage_name(&stage_name)?;
222
223 let planned_project = super::compile::run(settings, true).await?;
224 let staging_suffix = format!("_{}", stage_name);
225
226 let client = Client::connect_with_profile(profile.clone())
227 .await
228 .map_err(CliError::Connection)?;
229
230 crate::cli::commands::setup::verify(&client, settings.emulator()).await?;
231 let role =
232 crate::cli::commands::setup::validate_connection(&client, settings.emulator()).await?;
233 crate::cli::commands::setup::require_deployer(role)?;
234
235 let forced_dirty_schemas = resolve_redeploy_schemas(&planned_project, redeploy_schemas)?;
236
237 let Some(analysis) = analyze_project_changes(
238 &client,
239 &planned_project,
240 &stage_name,
241 forced_dirty_schemas,
242 redeploy_all,
243 )
244 .await?
245 else {
246 return Ok(());
247 };
248
249 validate_project_for_stage(
250 &client,
251 &planned_project,
252 directory,
253 &analysis.schema_set,
254 &analysis.cluster_set,
255 )
256 .await?;
257
258 if !dry_run {
259 if let Err(e) = record_stage_metadata(
266 &client,
267 directory,
268 &stage_name,
269 &staging_suffix,
270 &analysis.objects,
271 &analysis.sinks,
272 &analysis.replacement_mvs,
273 &planned_project.replacement_schemas,
274 )
275 .await
276 {
277 if no_rollback {
278 progress::error("Deployment failed (skipping rollback due to --no-rollback flag)");
279 } else {
280 progress::error("Deployment failed, rolling back...");
281 rollback_staging_resources(&client, &stage_name).await;
282 }
283 return Err(e);
284 }
285 }
286
287 if dry_run {
288 let plan = StagePlan {
289 deploy_id: stage_name.to_string(),
290 schemas: analysis
291 .schema_set
292 .iter()
293 .map(|sq| StagePlanSchema {
294 database: sq.database.clone(),
295 schema: sq.schema.clone(),
296 staging_schema: format!("{}{}", sq.schema, staging_suffix),
297 })
298 .collect(),
299 clusters: analysis
300 .cluster_set
301 .iter()
302 .map(|c| StagePlanCluster {
303 production_cluster: c.clone(),
304 staging_cluster: format!("{}{}", c, staging_suffix),
305 })
306 .collect(),
307 objects: analysis
308 .objects
309 .iter()
310 .map(|(id, _)| StagePlanObject {
311 database: id.expect_database().to_string(),
312 schema: id.schema().to_string(),
313 object: id.object().to_string(),
314 })
315 .collect(),
316 sinks: analysis
317 .sinks
318 .iter()
319 .map(|(id, _)| StagePlanObject {
320 database: id.expect_database().to_string(),
321 schema: id.schema().to_string(),
322 object: id.object().to_string(),
323 })
324 .collect(),
325 replacement_mvs: analysis
326 .replacement_mvs
327 .iter()
328 .map(|(id, _)| StagePlanObject {
329 database: id.expect_database().to_string(),
330 schema: id.schema().to_string(),
331 object: id.object().to_string(),
332 })
333 .collect(),
334 };
335 log::output(&plan);
336 return Ok(());
337 }
338
339 let success_count = create_resources_with_rollback(
340 &client,
341 &stage_name,
342 &staging_suffix,
343 &analysis.schema_set,
344 &analysis.cluster_set,
345 &planned_project,
346 &analysis.objects,
347 &analysis.replacement_mvs,
348 no_rollback,
349 dry_run,
350 )
351 .await?;
352
353 let result = StageResult {
354 deploy_id: stage_name.to_string(),
355 objects_deployed: success_count,
356 duration: start_time.elapsed(),
357 };
358 log::output(&result);
359 log::print_deploy_id(&stage_name);
360 Ok(())
361}
362
363fn parse_qualified_schema(raw: &str) -> Result<SchemaQualifier, CliError> {
367 let unqualified = || {
368 CliError::Message(format!(
369 "invalid --redeploy-schema '{}': expected a qualified 'database.schema' name",
370 raw
371 ))
372 };
373 let name = mz_sql_parser::parser::parse_item_name(raw).map_err(|_| unqualified())?;
374 let [database, schema] = name.0.as_slice() else {
375 return Err(unqualified());
376 };
377 Ok(SchemaQualifier::new(
378 database.as_str().to_string(),
379 schema.as_str().to_string(),
380 ))
381}
382
383fn resolve_redeploy_schemas(
386 planned_project: &Project,
387 redeploy_schemas: &[String],
388) -> Result<BTreeSet<SchemaQualifier>, CliError> {
389 if redeploy_schemas.is_empty() {
390 return Ok(BTreeSet::new());
391 }
392
393 let objects: Vec<_> = planned_project.iter_objects().collect();
394 let project_schemas = SchemaQualifier::collect_from(&objects);
395
396 let mut resolved = BTreeSet::new();
397 for raw in redeploy_schemas {
398 let sq = parse_qualified_schema(raw)?;
399 if !project_schemas.contains(&sq) {
400 let available = project_schemas
401 .iter()
402 .map(|s| format!("{}.{}", s.database, s.schema))
403 .collect::<Vec<_>>()
404 .join(", ");
405 return Err(CliError::Message(format!(
406 "--redeploy-schema '{}.{}' is not a schema in this project; available: {}",
407 sq.database, sq.schema, available
408 )));
409 }
410 resolved.insert(sq);
411 }
412 Ok(resolved)
413}
414
415async fn analyze_project_changes<'a>(
420 client: &Client,
421 planned_project: &'a Project,
422 stage_name: &str,
423 forced_dirty_schemas: BTreeSet<SchemaQualifier>,
424 redeploy_all: bool,
425) -> Result<Option<StageAnalysis<'a>>, CliError> {
426 progress::stage_start("Analyzing project changes");
427 let analyze_start = Instant::now();
428
429 if client
430 .deployments()
431 .get_deployment_metadata(stage_name)
432 .await?
433 .is_some()
434 {
435 return Err(CliError::InvalidEnvironmentName {
436 name: format!("deployment '{}' already exists", stage_name),
437 });
438 }
439
440 let new_snapshot = deployment_snapshot::build_snapshot_from_planned(planned_project)?;
441 let production_snapshot = deployment_snapshot::load_from_database(client, None).await?;
442
443 let dirty_schemas = if redeploy_all {
444 new_snapshot.schemas.keys().cloned().collect()
445 } else {
446 forced_dirty_schemas
447 };
448
449 let change_set = if production_snapshot.objects.is_empty() {
450 None
451 } else {
452 Some(ChangeSet::from_deployment_snapshot_comparison(
453 &production_snapshot,
454 &new_snapshot,
455 planned_project,
456 &dirty_schemas,
457 ))
458 };
459
460 if let Some(ref cs) = change_set {
483 validate_no_new_objects_in_existing_stable_schemas(cs, &production_snapshot)?;
484 }
485
486 let objects = select_stage_objects(planned_project, change_set.as_ref())?;
487 if objects.is_empty() && change_set.as_ref().is_some_and(ChangeSet::is_empty) {
488 progress::success("No changes detected compared to production, skipping deployment");
489 return Ok(None);
490 }
491
492 let replacement_object_ids = change_set
493 .as_ref()
494 .map(|cs| cs.changed_replacement_objects.clone())
495 .unwrap_or_default();
496 let partitioned = partition_objects(objects, &replacement_object_ids);
497 log_partition_summary(&partitioned);
498
499 let object_ids: BTreeSet<_> = partitioned
500 .objects
501 .iter()
502 .map(|(id, _)| id.clone())
503 .collect();
504 client
505 .validation()
506 .validate_table_dependencies(planned_project, &object_ids)
507 .await?;
508
509 let (schema_set, cluster_set) =
510 collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
511
512 let analyze_duration = analyze_start.elapsed();
513 progress::stage_success(
514 &format!(
515 "Ready to deploy {} view(s)/materialized view(s)",
516 partitioned.objects.len()
517 ),
518 analyze_duration,
519 );
520
521 Ok(Some(StageAnalysis {
522 objects: partitioned.objects,
523 sinks: partitioned.sinks,
524 replacement_mvs: partitioned.replacement_mvs,
525 schema_set,
526 cluster_set,
527 }))
528}
529
530fn select_stage_objects<'a>(
534 planned_project: &'a Project,
535 change_set: Option<&ChangeSet>,
536) -> Result<Vec<ObjectRef<'a>>, CliError> {
537 if let Some(cs) = change_set {
538 if cs.is_empty() {
539 return Ok(Vec::new());
540 }
541 verbose!("{}", cs);
542 Ok(planned_project.get_sorted_objects_filtered(&cs.objects_to_deploy)?)
543 } else {
544 verbose!("Full deployment: no production deployment found");
545 Ok(planned_project.get_sorted_objects()?)
546 }
547}
548
549fn partition_objects<'a>(
554 objects: Vec<ObjectRef<'a>>,
555 replacement_object_ids: &BTreeSet<ObjectId>,
556) -> PartitionedObjects<'a> {
557 let mut kept = Vec::new();
558 let mut sinks = Vec::new();
559 let mut replacement_mvs = Vec::new();
560 let mut table_count = 0;
561
562 for (object_id, typed_obj) in objects {
563 match &typed_obj.stmt {
564 Statement::CreateTable(_)
565 | Statement::CreateTableFromSource(_)
566 | Statement::CreateSource(_)
567 | Statement::CreateSecret(_)
568 | Statement::CreateConnection(_) => {
569 table_count += 1;
570 }
571 Statement::CreateSink(_) => sinks.push((object_id, typed_obj)),
572 Statement::CreateMaterializedView(_) if replacement_object_ids.contains(&object_id) => {
573 replacement_mvs.push((object_id, typed_obj));
574 }
575 _ => kept.push((object_id, typed_obj)),
576 }
577 }
578
579 PartitionedObjects {
580 objects: kept,
581 sinks,
582 replacement_mvs,
583 table_count,
584 }
585}
586
587fn log_partition_summary(partitioned: &PartitionedObjects<'_>) {
589 if partitioned.table_count > 0 {
590 verbose!(
591 "Skipped {} table(s)/source(s) - use 'mz-deploy apply' for those",
592 partitioned.table_count
593 );
594 }
595 if !partitioned.sinks.is_empty() {
596 verbose!(
597 "Found {} sink(s) - will be created during apply after swap",
598 partitioned.sinks.len()
599 );
600 }
601 if !partitioned.replacement_mvs.is_empty() {
602 verbose!(
603 "Found {} replacement MV(s) - will use CREATE REPLACEMENT protocol",
604 partitioned.replacement_mvs.len()
605 );
606 }
607}
608
609fn collect_stage_resources(
615 objects: &[ObjectRef<'_>],
616 replacement_mvs: &[ObjectRef<'_>],
617) -> (BTreeSet<SchemaQualifier>, BTreeSet<String>) {
618 let mut schema_set = BTreeSet::new();
619 let mut cluster_set = BTreeSet::new();
620
621 for (object_id, typed_obj) in objects.iter().chain(replacement_mvs.iter()) {
622 schema_set.insert(SchemaQualifier::new(
623 object_id.expect_database().to_string(),
624 object_id.schema().to_string(),
625 ));
626 cluster_set.extend(typed_obj.clusters());
627 }
628
629 (schema_set, cluster_set)
630}
631
632async fn validate_project_for_stage(
636 client: &Client,
637 planned_project: &Project,
638 directory: &Path,
639 schema_set: &BTreeSet<SchemaQualifier>,
640 cluster_set: &BTreeSet<String>,
641) -> Result<(), CliError> {
642 progress::stage_start("Validating project");
643 let validate_start = Instant::now();
644 client
645 .validation()
646 .validate_project(planned_project, directory)
647 .await?;
648 client
649 .validation()
650 .validate_cluster_isolation(planned_project)
651 .await?;
652 client
653 .validation()
654 .validate_privileges(planned_project)
655 .await?;
656 client
657 .validation()
658 .validate_schema_ownership(schema_set)
659 .await?;
660 client
661 .validation()
662 .validate_cluster_ownership(cluster_set)
663 .await?;
664 client
665 .validation()
666 .validate_sink_connections_exist(planned_project)
667 .await?;
668 let validate_duration = validate_start.elapsed();
669 progress::stage_success("All validations passed", validate_duration);
670 Ok(())
671}
672
673async fn record_stage_metadata(
678 client: &Client,
679 directory: &Path,
680 stage_name: &str,
681 staging_suffix: &str,
682 objects: &[ObjectRef<'_>],
683 sinks: &[ObjectRef<'_>],
684 replacement_mvs: &[ObjectRef<'_>],
685 replacement_schemas: &BTreeSet<SchemaQualifier>,
686) -> Result<(), CliError> {
687 progress::stage_start("Recording deployment metadata");
688 let metadata_start = Instant::now();
689 let metadata = executor::collect_deployment_metadata(client, directory).await;
690
691 let mut staging_snapshot = DeploymentSnapshot::default();
692
693 for (object_id, typed_obj) in objects {
694 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
695 staging_snapshot.objects.insert(object_id.clone(), hash);
696 staging_snapshot.schemas.insert(
697 SchemaQualifier::new(
698 object_id.expect_database().to_string(),
699 object_id.schema().to_string(),
700 ),
701 DeploymentKind::Objects,
702 );
703 }
704
705 for (object_id, typed_obj) in sinks {
706 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
707 staging_snapshot.objects.insert(object_id.clone(), hash);
708 staging_snapshot
709 .schemas
710 .entry(SchemaQualifier::new(
711 object_id.expect_database().to_string(),
712 object_id.schema().to_string(),
713 ))
714 .or_insert(DeploymentKind::Sinks);
715 }
716
717 for (object_id, typed_obj) in replacement_mvs {
718 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
719 staging_snapshot.objects.insert(object_id.clone(), hash);
720 staging_snapshot.schemas.insert(
721 SchemaQualifier::new(
722 object_id.expect_database().to_string(),
723 object_id.schema().to_string(),
724 ),
725 DeploymentKind::Replacement,
726 );
727 }
728
729 for sq in replacement_schemas {
734 if staging_snapshot.schemas.contains_key(sq) {
735 staging_snapshot
736 .schemas
737 .insert(sq.clone(), DeploymentKind::Replacement);
738 }
739 }
740
741 deployment_snapshot::write_to_database(
742 client,
743 &staging_snapshot,
744 stage_name,
745 &metadata,
746 None,
747 DeploymentMode::Stage,
748 )
749 .await?;
750
751 if !sinks.is_empty() {
752 let pending_statements: Vec<PendingStatement> = sinks
753 .iter()
754 .enumerate()
755 .map(|(idx, (object_id, typed_obj))| {
756 let original_fqn: FullyQualifiedName = object_id.clone().into();
757 let mut visitor = NormalizingVisitor::fully_qualifying(&original_fqn);
758 let stmt = typed_obj
759 .stmt
760 .clone()
761 .normalize_name_with(&visitor, &original_fqn.to_item_name())
762 .normalize_dependencies_with(&mut visitor);
763 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
764 #[allow(clippy::as_conversions)]
765 PendingStatement {
766 deploy_id: stage_name.to_string(),
767 sequence_num: idx as i32,
768 database: object_id.expect_database().to_string(),
769 schema: object_id.schema().to_string(),
770 object: object_id.object().to_string(),
771 object_hash: hash,
772 statement_sql: stmt.to_string(),
773 statement_kind: "sink".to_string(),
774 executed_at: None,
775 }
776 })
777 .collect();
778
779 client
780 .deployments()
781 .insert_pending_statements(&pending_statements)
782 .await?;
783 verbose!(
784 "Stored {} pending sink statement(s)",
785 pending_statements.len()
786 );
787 }
788
789 if !replacement_mvs.is_empty() {
790 let records: Vec<ReplacementMvRecord> = replacement_mvs
791 .iter()
792 .map(|(object_id, _)| ReplacementMvRecord {
793 deploy_id: stage_name.to_string(),
794 target_database: object_id.expect_database().to_string(),
795 target_schema: object_id.schema().to_string(),
796 target_name: object_id.object().to_string(),
797 replacement_schema: format!("{}{}", object_id.schema(), staging_suffix),
798 })
799 .collect();
800 client
801 .deployments()
802 .insert_replacement_mvs(&records)
803 .await?;
804 verbose!("Stored {} replacement MV record(s)", records.len());
805 }
806
807 let metadata_duration = metadata_start.elapsed();
808 progress::stage_success("Deployment metadata recorded", metadata_duration);
809 Ok(())
810}
811
812#[allow(clippy::too_many_arguments)]
818async fn create_resources_with_rollback<'a>(
819 client: &Client,
820 stage_name: &str,
821 staging_suffix: &str,
822 schema_set: &BTreeSet<SchemaQualifier>,
823 cluster_set: &BTreeSet<String>,
824 planned_project: &'a Project,
825 objects: &'a [(ObjectId, &'a DatabaseObject)],
826 replacement_mvs: &'a [(ObjectId, &'a DatabaseObject)],
827 no_rollback: bool,
828 dry_run: bool,
829) -> Result<usize, CliError> {
830 let executor = DeploymentExecutor::with_dry_run(client, dry_run);
831
832 let result = async {
833 create_databases_and_schemas(&executor, planned_project, schema_set, staging_suffix)
834 .await?;
835 create_staging_clusters(&executor, client, stage_name, cluster_set, staging_suffix).await?;
836 deploy_objects_to_staging(
837 &executor,
838 objects,
839 replacement_mvs,
840 planned_project,
841 cluster_set,
842 staging_suffix,
843 )
844 .await
845 }
846 .await;
847
848 match result {
849 Ok(count) => Ok(count),
850 Err(e) if dry_run || no_rollback => {
851 if !dry_run {
852 progress::error("Deployment failed (skipping rollback due to --no-rollback flag)");
853 }
854 Err(e)
855 }
856 Err(e) => {
857 progress::error("Deployment failed, rolling back...");
858 let (schemas, clusters) = rollback_staging_resources(client, stage_name).await;
859
860 if schemas > 0 || clusters > 0 {
861 progress::success(&format!(
862 "Rolled back: {} schema(s), {} cluster(s)",
863 schemas, clusters
864 ));
865 }
866
867 Err(e)
868 }
869 }
870}
871
872async fn create_databases_and_schemas(
877 executor: &DeploymentExecutor<'_>,
878 planned_project: &Project,
879 schema_set: &BTreeSet<SchemaQualifier>,
880 staging_suffix: &str,
881) -> Result<(), CliError> {
882 let schema_set_dbs: BTreeSet<&str> = schema_set.iter().map(|sq| sq.database.as_str()).collect();
885 for db in &planned_project.databases {
886 if !schema_set_dbs.contains(db.name.as_str()) {
887 executor.ensure_database(&db.name).await?;
888 verbose!(" Ensured database {} exists", db.name);
889 }
890 }
891
892 progress::stage_start("Creating staging schemas and applying setup statements");
894 let schema_start = Instant::now();
895 executor
896 .prepare_databases_and_schemas(planned_project, schema_set, Some(staging_suffix))
897 .await?;
898 let schema_duration = schema_start.elapsed();
899 progress::stage_success(
900 &format!(
901 "Created {} staging schema(s) with setup statements",
902 schema_set.len()
903 ),
904 schema_duration,
905 );
906
907 if !executor.is_dry_run() {
909 for sq in schema_set {
910 executor.ensure_schema(&sq.database, &sq.schema).await?;
911 verbose!(" Ensured schema {}.{} exists", sq.database, sq.schema);
912 }
913 }
914
915 Ok(())
916}
917
918async fn create_staging_clusters(
924 executor: &DeploymentExecutor<'_>,
925 client: &Client,
926 stage_name: &str,
927 cluster_set: &BTreeSet<String>,
928 staging_suffix: &str,
929) -> Result<(), CliError> {
930 let cluster_names: Vec<String> = cluster_set.iter().cloned().collect();
932 executor
933 .record_deployment_clusters(stage_name, &cluster_names)
934 .await?;
935
936 progress::stage_start("Creating staging clusters");
937 let cluster_start = Instant::now();
938 let mut created_clusters = 0;
939
940 let existing_staging_clusters = if !executor.is_dry_run() {
942 let staging_cluster_names: Vec<String> = cluster_set
943 .iter()
944 .map(|name| format!("{}{}", name, staging_suffix))
945 .collect();
946 client
947 .introspection()
948 .check_clusters_exist(&staging_cluster_names)
949 .await?
950 } else {
951 BTreeSet::new()
952 };
953
954 for prod_cluster in cluster_set {
955 let staging_cluster = format!("{}{}", prod_cluster, staging_suffix);
956
957 if executor.is_dry_run() {
958 let placeholder = ClusterConfig::Managed {
960 create_stmt: CreateClusterStatement {
961 name: Ident::new_unchecked(""),
962 options: Vec::new(),
963 features: Vec::new(),
964 if_not_exists: false,
965 },
966 grants: Vec::new(),
967 };
968 executor
969 .create_cluster(&staging_cluster, prod_cluster, &placeholder)
970 .await?;
971 created_clusters += 1;
972 continue;
973 }
974
975 if existing_staging_clusters.contains(&staging_cluster) {
977 verbose!(" Cluster '{}' already exists, skipping", staging_cluster);
978 continue;
979 }
980
981 let config = client
983 .introspection()
984 .get_cluster_config(prod_cluster)
985 .await?;
986
987 let config = match config {
988 Some(config) => config,
989 None => {
990 return Err(CliError::ClusterNotFound {
991 name: prod_cluster.clone(),
992 });
993 }
994 };
995
996 executor
997 .create_cluster(&staging_cluster, prod_cluster, &config)
998 .await?;
999 created_clusters += 1;
1000
1001 log_cluster_creation(&staging_cluster, prod_cluster, &config);
1002 }
1003
1004 let cluster_duration = cluster_start.elapsed();
1005 progress::stage_success(
1006 &format!("Created {} cluster(s)", created_clusters),
1007 cluster_duration,
1008 );
1009
1010 Ok(())
1011}
1012
1013fn log_cluster_creation(staging_cluster: &str, prod_cluster: &str, config: &ClusterConfig) {
1015 match config {
1016 ClusterConfig::Managed {
1017 create_stmt,
1018 grants,
1019 } => {
1020 verbose!(
1021 " Created managed cluster '{}' ({}, {} grant(s), cloned from '{}')",
1022 staging_cluster,
1023 create_stmt.to_ast_string_simple(),
1024 grants.len(),
1025 prod_cluster
1026 );
1027 }
1028 ClusterConfig::Unmanaged { replicas, grants } => {
1029 verbose!(
1030 " Created unmanaged cluster '{}' with {} replica(s), {} grant(s) (cloned from '{}')",
1031 staging_cluster,
1032 replicas.len(),
1033 grants.len(),
1034 prod_cluster
1035 );
1036 for replica in replicas {
1037 verbose!(
1038 " - {} (size: {}{})",
1039 replica.name,
1040 replica.size,
1041 replica
1042 .availability_zone
1043 .as_ref()
1044 .map(|az| format!(", az: {}", az))
1045 .unwrap_or_default()
1046 );
1047 }
1048 }
1049 }
1050}
1051
1052async fn deploy_objects_to_staging<'a>(
1059 executor: &DeploymentExecutor<'_>,
1060 objects: &'a [(ObjectId, &'a DatabaseObject)],
1061 replacement_mvs: &'a [(ObjectId, &'a DatabaseObject)],
1062 planned_project: &'a Project,
1063 cluster_set: &BTreeSet<String>,
1064 staging_suffix: &str,
1065) -> Result<usize, CliError> {
1066 progress::stage_start("Deploying objects to staging");
1067 let deploy_start = Instant::now();
1068
1069 let objects_to_deploy_set: BTreeSet<_> = objects
1072 .iter()
1073 .chain(replacement_mvs.iter())
1074 .map(|(oid, _)| oid.clone())
1075 .collect();
1076
1077 let mut external_indexes: Vec<_> = planned_project
1079 .iter_objects()
1080 .filter(|object| !objects_to_deploy_set.contains(&object.id))
1081 .flat_map(extract_external_indexes)
1082 .filter_map(|(cluster, index)| cluster_set.contains(&cluster.name).then_some(index))
1083 .collect();
1084
1085 normalize::transform_cluster_names_for_staging(&mut external_indexes, staging_suffix);
1087 for index in external_indexes {
1088 verbose!("Creating external index {}", index);
1089 executor.execute_sql(&index).await?;
1090 }
1091
1092 let replacement_object_ids: BTreeSet<ObjectId> =
1095 replacement_mvs.iter().map(|(oid, _)| oid.clone()).collect();
1096
1097 let mut success_count = 0;
1098
1099 for (idx, (object_id, typed_obj)) in objects.iter().enumerate() {
1101 verbose!(
1102 "Applying {}/{}: {}{} (to schema {}{})",
1103 idx + 1,
1104 objects.len(),
1105 object_id.object(),
1106 staging_suffix,
1107 object_id.schema(),
1108 staging_suffix
1109 );
1110
1111 deploy_single_object(
1112 executor,
1113 object_id,
1114 typed_obj,
1115 staging_suffix,
1116 planned_project,
1117 &objects_to_deploy_set,
1118 &replacement_object_ids,
1119 |stmt| stmt,
1120 )
1121 .await?;
1122 success_count += 1;
1123 }
1124
1125 for (idx, (object_id, typed_obj)) in replacement_mvs.iter().enumerate() {
1127 verbose!(
1128 "Applying replacement MV {}/{}: {} FOR {}",
1129 idx + 1,
1130 replacement_mvs.len(),
1131 object_id.object(),
1132 object_id
1133 );
1134
1135 let production_target = object_id.to_unresolved_item_name();
1136 deploy_single_object(
1137 executor,
1138 object_id,
1139 typed_obj,
1140 staging_suffix,
1141 planned_project,
1142 &objects_to_deploy_set,
1143 &replacement_object_ids,
1144 |stmt| match stmt {
1145 Statement::CreateMaterializedView(mut mv) => {
1146 mv.replacement_for =
1147 Some(mz_sql_parser::ast::RawItemName::Name(production_target));
1148 Statement::CreateMaterializedView(mv)
1149 }
1150 other => other,
1151 },
1152 )
1153 .await?;
1154 success_count += 1;
1155 }
1156
1157 let deploy_duration = deploy_start.elapsed();
1158 progress::stage_success(
1159 &format!("Deployed {} view(s)/materialized view(s)", success_count),
1160 deploy_duration,
1161 );
1162
1163 Ok(success_count)
1164}
1165
1166async fn rollback_staging_resources(client: &Client, environment: &str) -> (usize, usize) {
1179 let staging_schemas = best_effort_fetch(
1180 client
1181 .introspection()
1182 .get_staging_schemas(environment)
1183 .await,
1184 "query staging schemas",
1185 );
1186 let staging_clusters = best_effort_fetch(
1187 client
1188 .introspection()
1189 .get_staging_clusters(environment)
1190 .await,
1191 "query staging clusters",
1192 );
1193
1194 let schema_count = staging_schemas.len();
1195 let cluster_count = staging_clusters.len();
1196
1197 if !staging_schemas.is_empty() {
1198 verbose!("Dropping staging schemas...");
1199 if let Err(e) = client
1200 .introspection()
1201 .drop_staging_schemas(&staging_schemas)
1202 .await
1203 {
1204 verbose!("Warning: Failed to drop some schemas: {}", e);
1205 } else {
1206 for sq in &staging_schemas {
1207 verbose!(" Dropped {}.{}", sq.database, sq.schema);
1208 }
1209 }
1210 }
1211
1212 if !staging_clusters.is_empty() {
1213 verbose!("Dropping staging clusters...");
1214 if let Err(e) = client
1215 .introspection()
1216 .drop_staging_clusters(&staging_clusters)
1217 .await
1218 {
1219 verbose!("Warning: Failed to drop some clusters: {}", e);
1220 } else {
1221 for cluster in &staging_clusters {
1222 verbose!(" Dropped {}", cluster);
1223 }
1224 }
1225 }
1226
1227 verbose!("Deleting deployment records...");
1228 best_effort_delete(
1229 client
1230 .deployments()
1231 .delete_deployment_clusters(environment)
1232 .await,
1233 "delete cluster records",
1234 );
1235 best_effort_delete(
1236 client
1237 .deployments()
1238 .delete_pending_statements(environment)
1239 .await,
1240 "delete pending statements",
1241 );
1242 best_effort_delete(
1243 client
1244 .deployments()
1245 .delete_replacement_mvs(environment)
1246 .await,
1247 "delete replacement MV records",
1248 );
1249 best_effort_delete(
1250 client.deployments().delete_deployment(environment).await,
1251 "delete deployment records",
1252 );
1253
1254 (schema_count, cluster_count)
1255}
1256
1257fn best_effort_fetch<T, E: fmt::Display>(result: Result<Vec<T>, E>, action: &str) -> Vec<T> {
1262 match result {
1263 Ok(values) => values,
1264 Err(e) => {
1265 verbose!("Warning: Failed to {}: {}", action, e);
1266 vec![]
1267 }
1268 }
1269}
1270
1271fn best_effort_delete<E: fmt::Display>(result: Result<(), E>, action: &str) {
1273 if let Err(e) = result {
1274 verbose!("Warning: Failed to {}: {}", action, e);
1275 }
1276}
1277
1278async fn deploy_single_object(
1289 executor: &DeploymentExecutor<'_>,
1290 object_id: &ObjectId,
1291 typed_obj: &DatabaseObject,
1292 staging_suffix: &str,
1293 planned_project: &Project,
1294 objects_to_deploy_set: &BTreeSet<ObjectId>,
1295 replacement_objects: &BTreeSet<ObjectId>,
1296 transform: impl FnOnce(Statement) -> Statement,
1297) -> Result<(), CliError> {
1298 let original_fqn: FullyQualifiedName = object_id.clone().into();
1299
1300 let mut visitor = NormalizingVisitor::staging(
1301 &original_fqn,
1302 staging_suffix.to_string(),
1303 &planned_project.external_dependencies,
1304 Some(objects_to_deploy_set),
1305 replacement_objects,
1306 );
1307
1308 let stmt = typed_obj
1309 .stmt
1310 .clone()
1311 .normalize_name_with(&visitor, &original_fqn.to_item_name())
1312 .normalize_dependencies_with(&mut visitor)
1313 .normalize_cluster_with(&visitor);
1314
1315 let stmt = transform(stmt);
1316 executor.execute_sql(&stmt).await?;
1317
1318 let mut indexes = typed_obj.indexes.clone();
1320 let mut grants = typed_obj.grants.clone();
1321 let mut comments = typed_obj.comments.clone();
1322
1323 visitor.normalize_index_references(&mut indexes);
1324 visitor.normalize_index_clusters(&mut indexes);
1325 visitor.normalize_grant_references(&mut grants);
1326 visitor.normalize_comment_references(&mut comments);
1327
1328 for index in &indexes {
1329 executor.execute_sql(index).await?;
1330 }
1331
1332 for grant in &grants {
1333 executor.execute_sql(grant).await?;
1334 }
1335
1336 for comment in &comments {
1337 executor.execute_sql(comment).await?;
1338 }
1339
1340 Ok(())
1341}
1342
1343fn validate_no_new_objects_in_existing_stable_schemas(
1346 change_set: &ChangeSet,
1347 production_snapshot: &DeploymentSnapshot,
1348) -> Result<(), CliError> {
1349 let blocked: Vec<_> = change_set
1350 .new_replacement_objects
1351 .iter()
1352 .filter(|obj| {
1353 !production_snapshot.objects.contains_key(obj)
1354 && production_snapshot
1355 .objects
1356 .keys()
1357 .any(|prod| prod.database() == obj.database() && prod.schema() == obj.schema())
1358 })
1359 .collect();
1360
1361 if blocked.is_empty() {
1362 return Ok(());
1363 }
1364
1365 let first = blocked[0];
1366 Err(CliError::NewObjectInExistingStableSchema {
1367 database: first.expect_database().to_string(),
1368 schema: first.schema().to_string(),
1369 objects: blocked.iter().map(|o| o.object().to_string()).collect(),
1370 })
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375 use super::*;
1376 use crate::project::analysis::deployment_snapshot::build_snapshot_from_planned;
1377 use crate::project::ir::compiled;
1378 use crate::project::ir::object_id::ObjectId;
1379 use std::collections::{BTreeMap, BTreeSet};
1380
1381 #[mz_ore::test]
1382 fn parse_qualified_schema_requires_two_parts() {
1383 let sq = parse_qualified_schema("app.core").expect("qualified name parses");
1385 assert_eq!(
1386 sq,
1387 SchemaQualifier::new("app".to_string(), "core".to_string())
1388 );
1389
1390 let sq = parse_qualified_schema("app.\"select\"").expect("quoted keyword parses");
1392 assert_eq!(
1393 sq,
1394 SchemaQualifier::new("app".to_string(), "select".to_string())
1395 );
1396
1397 assert!(parse_qualified_schema("core").is_err());
1399 assert!(parse_qualified_schema("app.core.orders").is_err());
1400 }
1401
1402 fn make_typed_object(sqls: &[&str]) -> DatabaseObject {
1407 let mut stmt = None;
1408 let mut indexes = Vec::new();
1409
1410 for sql in sqls {
1411 let parsed = mz_sql_parser::parser::parse_statements(sql).unwrap();
1412 for p in parsed {
1413 match p.ast {
1414 mz_sql_parser::ast::Statement::CreateView(s) => {
1415 stmt = Some(Statement::CreateView(s));
1416 }
1417 mz_sql_parser::ast::Statement::CreateMaterializedView(s) => {
1418 stmt = Some(Statement::CreateMaterializedView(s));
1419 }
1420 mz_sql_parser::ast::Statement::CreateTable(s) => {
1421 stmt = Some(Statement::CreateTable(s));
1422 }
1423 mz_sql_parser::ast::Statement::CreateSource(s) => {
1424 stmt = Some(Statement::CreateSource(s));
1425 }
1426 mz_sql_parser::ast::Statement::CreateConnection(s) => {
1427 stmt = Some(Statement::CreateConnection(s));
1428 }
1429 mz_sql_parser::ast::Statement::CreateSecret(s) => {
1430 stmt = Some(Statement::CreateSecret(s));
1431 }
1432 mz_sql_parser::ast::Statement::CreateIndex(s) => {
1433 indexes.push(s);
1434 }
1435 other => panic!("Unexpected statement type: {:?}", other),
1436 }
1437 }
1438 }
1439
1440 DatabaseObject {
1441 path: std::path::PathBuf::from("test.sql"),
1442 stmt: stmt.expect("Expected at least one CREATE statement"),
1443 indexes,
1444 grants: vec![],
1445 comments: vec![],
1446 tests: vec![],
1447 }
1448 }
1449
1450 fn make_planned_project(objects: Vec<(&str, &str, &str, DatabaseObject)>) -> Project {
1452 let mut db_map: BTreeMap<String, BTreeMap<String, Vec<DatabaseObject>>> = BTreeMap::new();
1454
1455 for (database, schema, _name, typed_obj) in objects {
1456 db_map
1457 .entry(database.to_string())
1458 .or_default()
1459 .entry(schema.to_string())
1460 .or_default()
1461 .push(typed_obj);
1462 }
1463
1464 let databases: Vec<compiled::Database> = db_map
1465 .into_iter()
1466 .map(|(db_name, schemas)| compiled::Database {
1467 name: db_name,
1468 schemas: schemas
1469 .into_iter()
1470 .map(|(schema_name, objs)| compiled::Schema {
1471 name: schema_name,
1472 objects: objs,
1473 mod_statements: None,
1474 })
1475 .collect(),
1476 mod_statements: None,
1477 })
1478 .collect();
1479
1480 let typed_project = compiled::Project {
1481 databases,
1482 replacement_schemas: BTreeSet::new(),
1483 };
1484
1485 Project::from(typed_project)
1486 }
1487
1488 #[cfg_attr(miri, ignore)] #[mz_ore::test]
1490 fn test_full_deploy_view_not_indexed_mixed_types() {
1491 let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
1492 let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1493 let source_obj = make_typed_object(&[
1494 "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1495 ]);
1496 let conn_obj =
1497 make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1498 let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1499
1500 let objects: Vec<ObjectRef> = vec![
1501 (
1502 ObjectId::new("db".into(), "public".into(), "my_view".into()),
1503 &view_obj,
1504 ),
1505 (
1506 ObjectId::new("db".into(), "public".into(), "my_table".into()),
1507 &table_obj,
1508 ),
1509 (
1510 ObjectId::new("db".into(), "public".into(), "my_source".into()),
1511 &source_obj,
1512 ),
1513 (
1514 ObjectId::new("db".into(), "public".into(), "my_conn".into()),
1515 &conn_obj,
1516 ),
1517 (
1518 ObjectId::new("db".into(), "public".into(), "my_secret".into()),
1519 &secret_obj,
1520 ),
1521 ];
1522
1523 let replacement_ids = BTreeSet::new();
1524 let partitioned = partition_objects(objects, &replacement_ids);
1525
1526 assert_eq!(
1528 partitioned.objects.len(),
1529 1,
1530 "Only the view should be staged"
1531 );
1532 assert_eq!(partitioned.objects[0].0.object(), "my_view");
1533
1534 assert_eq!(
1536 partitioned.table_count, 4,
1537 "Table, source, connection, and secret should all be skipped"
1538 );
1539
1540 assert!(partitioned.sinks.is_empty());
1542 assert!(partitioned.replacement_mvs.is_empty());
1543
1544 let (schema_set, cluster_set) =
1546 collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1547
1548 assert_eq!(schema_set.len(), 1);
1550 assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1551
1552 assert!(
1554 cluster_set.is_empty(),
1555 "View without index should not require any clusters"
1556 );
1557 }
1558
1559 #[cfg_attr(miri, ignore)] #[mz_ore::test]
1561 fn test_full_deploy_view_indexed_different_cluster() {
1562 let view_obj = make_typed_object(&[
1563 "CREATE VIEW my_view AS SELECT 1",
1564 "CREATE INDEX my_idx IN CLUSTER index_cluster ON my_view (column1)",
1565 ]);
1566 let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1567 let source_obj = make_typed_object(&[
1568 "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1569 ]);
1570 let conn_obj =
1571 make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1572 let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1573
1574 let objects: Vec<ObjectRef> = vec![
1575 (
1576 ObjectId::new("db".into(), "public".into(), "my_view".into()),
1577 &view_obj,
1578 ),
1579 (
1580 ObjectId::new("db".into(), "public".into(), "my_table".into()),
1581 &table_obj,
1582 ),
1583 (
1584 ObjectId::new("db".into(), "public".into(), "my_source".into()),
1585 &source_obj,
1586 ),
1587 (
1588 ObjectId::new("db".into(), "public".into(), "my_conn".into()),
1589 &conn_obj,
1590 ),
1591 (
1592 ObjectId::new("db".into(), "public".into(), "my_secret".into()),
1593 &secret_obj,
1594 ),
1595 ];
1596
1597 let replacement_ids = BTreeSet::new();
1598 let partitioned = partition_objects(objects, &replacement_ids);
1599
1600 assert_eq!(partitioned.objects.len(), 1);
1602 assert_eq!(partitioned.objects[0].0.object(), "my_view");
1603 assert_eq!(partitioned.table_count, 4);
1604
1605 let (schema_set, cluster_set) =
1607 collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1608
1609 assert_eq!(schema_set.len(), 1);
1611 assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1612
1613 assert_eq!(
1615 cluster_set.len(),
1616 1,
1617 "Should only have index_cluster, got: {:?}",
1618 cluster_set
1619 );
1620 assert!(
1621 cluster_set.contains("index_cluster"),
1622 "Should stage index_cluster from the view's index"
1623 );
1624 assert!(
1625 !cluster_set.contains("source_cluster"),
1626 "Should NOT stage source_cluster (source is not staged)"
1627 );
1628 }
1629
1630 #[cfg_attr(miri, ignore)] #[mz_ore::test]
1632 fn test_incremental_deploy_view_updated_not_indexed() {
1633 let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
1635 let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1636 let source_obj = make_typed_object(&[
1637 "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1638 ]);
1639 let conn_obj =
1640 make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1641 let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1642
1643 let planned_project = make_planned_project(vec![
1644 ("db", "public", "my_view", view_obj),
1645 ("db", "storage", "my_table", table_obj),
1646 ("db", "storage", "my_source", source_obj),
1647 ("db", "storage", "my_conn", conn_obj),
1648 ("db", "storage", "my_secret", secret_obj),
1649 ]);
1650
1651 let new_snapshot = build_snapshot_from_planned(&planned_project).unwrap();
1653
1654 let mut old_snapshot = DeploymentSnapshot::default();
1656 for (object_id, hash) in &new_snapshot.objects {
1657 if object_id.object() == "my_view" {
1658 old_snapshot
1660 .objects
1661 .insert(object_id.clone(), "old_hash".to_string());
1662 } else {
1663 old_snapshot.objects.insert(object_id.clone(), hash.clone());
1664 }
1665 }
1666
1667 let change_set = ChangeSet::from_deployment_snapshot_comparison(
1669 &old_snapshot,
1670 &new_snapshot,
1671 &planned_project,
1672 &BTreeSet::new(),
1673 );
1674
1675 assert!(
1677 change_set.objects_to_deploy.contains(&ObjectId::new(
1678 "db".into(),
1679 "public".into(),
1680 "my_view".into()
1681 )),
1682 "Changed view should be in objects_to_deploy"
1683 );
1684
1685 let objects = planned_project
1687 .get_sorted_objects_filtered(&change_set.objects_to_deploy)
1688 .unwrap();
1689
1690 let partitioned = partition_objects(objects, &change_set.changed_replacement_objects);
1691
1692 assert_eq!(
1694 partitioned.objects.len(),
1695 1,
1696 "Only the changed view should be staged, got: {:?}",
1697 partitioned
1698 .objects
1699 .iter()
1700 .map(|(id, _)| id.object())
1701 .collect::<Vec<_>>()
1702 );
1703 assert_eq!(partitioned.objects[0].0.object(), "my_view");
1704
1705 let (schema_set, cluster_set) =
1706 collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1707
1708 assert_eq!(schema_set.len(), 1);
1709 assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1710 assert!(
1711 cluster_set.is_empty(),
1712 "View without index should not require any clusters"
1713 );
1714 }
1715
1716 #[cfg_attr(miri, ignore)] #[mz_ore::test]
1718 fn test_incremental_deploy_view_updated_indexed_different_cluster() {
1719 let view_obj = make_typed_object(&[
1721 "CREATE VIEW my_view AS SELECT 1",
1722 "CREATE INDEX my_idx IN CLUSTER index_cluster ON my_view (column1)",
1723 ]);
1724 let table_obj = make_typed_object(&["CREATE TABLE my_table (id INT)"]);
1725 let source_obj = make_typed_object(&[
1726 "CREATE SOURCE my_source IN CLUSTER source_cluster FROM LOAD GENERATOR COUNTER",
1727 ]);
1728 let conn_obj =
1729 make_typed_object(&["CREATE CONNECTION my_conn TO KAFKA (BROKER 'localhost:9092')"]);
1730 let secret_obj = make_typed_object(&["CREATE SECRET my_secret AS 'hunter2'"]);
1731
1732 let planned_project = make_planned_project(vec![
1733 ("db", "public", "my_view", view_obj),
1734 ("db", "storage", "my_table", table_obj),
1735 ("db", "storage", "my_source", source_obj),
1736 ("db", "storage", "my_conn", conn_obj),
1737 ("db", "storage", "my_secret", secret_obj),
1738 ]);
1739
1740 let new_snapshot = build_snapshot_from_planned(&planned_project).unwrap();
1742
1743 let mut old_snapshot = DeploymentSnapshot::default();
1745 for (object_id, hash) in &new_snapshot.objects {
1746 if object_id.object() == "my_view" {
1747 old_snapshot
1748 .objects
1749 .insert(object_id.clone(), "old_hash".to_string());
1750 } else {
1751 old_snapshot.objects.insert(object_id.clone(), hash.clone());
1752 }
1753 }
1754
1755 let change_set = ChangeSet::from_deployment_snapshot_comparison(
1757 &old_snapshot,
1758 &new_snapshot,
1759 &planned_project,
1760 &BTreeSet::new(),
1761 );
1762
1763 assert!(
1764 change_set.objects_to_deploy.contains(&ObjectId::new(
1765 "db".into(),
1766 "public".into(),
1767 "my_view".into()
1768 )),
1769 "Changed view should be in objects_to_deploy"
1770 );
1771
1772 let objects = planned_project
1774 .get_sorted_objects_filtered(&change_set.objects_to_deploy)
1775 .unwrap();
1776
1777 let partitioned = partition_objects(objects, &change_set.changed_replacement_objects);
1778
1779 assert_eq!(
1781 partitioned.objects.len(),
1782 1,
1783 "Only the changed view should be staged, got: {:?}",
1784 partitioned
1785 .objects
1786 .iter()
1787 .map(|(id, _)| id.object())
1788 .collect::<Vec<_>>()
1789 );
1790 assert_eq!(partitioned.objects[0].0.object(), "my_view");
1791
1792 let (schema_set, cluster_set) =
1793 collect_stage_resources(&partitioned.objects, &partitioned.replacement_mvs);
1794
1795 assert_eq!(schema_set.len(), 1);
1796 assert!(schema_set.contains(&SchemaQualifier::new("db".into(), "public".into())));
1797
1798 assert_eq!(
1800 cluster_set.len(),
1801 1,
1802 "Should only have index_cluster, got: {:?}",
1803 cluster_set
1804 );
1805 assert!(
1806 cluster_set.contains("index_cluster"),
1807 "Should stage index_cluster from the view's index"
1808 );
1809 assert!(
1810 !cluster_set.contains("source_cluster"),
1811 "Should NOT stage source_cluster"
1812 );
1813 }
1814
1815 fn make_empty_change_set() -> ChangeSet {
1816 ChangeSet {
1817 changed_objects: BTreeSet::new(),
1818 dirty_schemas: BTreeSet::new(),
1819 dirty_clusters: BTreeSet::new(),
1820 objects_to_deploy: BTreeSet::new(),
1821 new_replacement_objects: BTreeSet::new(),
1822 changed_replacement_objects: BTreeSet::new(),
1823 }
1824 }
1825
1826 #[mz_ore::test]
1827 fn test_validate_no_new_replacement_objects_first_deploy() {
1828 let cs = make_empty_change_set();
1829 let snapshot = DeploymentSnapshot::default();
1830 assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1831 }
1832
1833 #[mz_ore::test]
1834 fn test_validate_new_replacement_objects_in_brand_new_schema() {
1835 let mut cs = make_empty_change_set();
1836 cs.new_replacement_objects.insert(ObjectId::new(
1837 "db".into(),
1838 "analytics".into(),
1839 "new_mv".into(),
1840 ));
1841
1842 let mut snapshot = DeploymentSnapshot::default();
1844 snapshot.objects.insert(
1845 ObjectId::new("db".into(), "public".into(), "existing_mv".into()),
1846 "hash1".into(),
1847 );
1848
1849 assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1850 }
1851
1852 #[mz_ore::test]
1853 fn test_validate_new_replacement_objects_in_existing_production_schema() {
1854 let mut cs = make_empty_change_set();
1855 cs.new_replacement_objects.insert(ObjectId::new(
1856 "db".into(),
1857 "analytics".into(),
1858 "new_mv".into(),
1859 ));
1860
1861 let mut snapshot = DeploymentSnapshot::default();
1863 snapshot.objects.insert(
1864 ObjectId::new("db".into(), "analytics".into(), "existing_mv".into()),
1865 "hash1".into(),
1866 );
1867
1868 let result = validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot);
1869 assert!(result.is_err());
1870 match result.unwrap_err() {
1871 CliError::NewObjectInExistingStableSchema {
1872 database,
1873 schema,
1874 objects,
1875 } => {
1876 assert_eq!(database, "db");
1877 assert_eq!(schema, "analytics");
1878 assert_eq!(objects, vec!["new_mv"]);
1879 }
1880 other => panic!("Expected NewObjectInExistingStableSchema, got: {:?}", other),
1881 }
1882 }
1883
1884 #[mz_ore::test]
1885 fn test_validate_changed_replacement_objects_only() {
1886 let mut cs = make_empty_change_set();
1887 cs.changed_replacement_objects.insert(ObjectId::new(
1889 "db".into(),
1890 "analytics".into(),
1891 "changed_mv".into(),
1892 ));
1893
1894 let mut snapshot = DeploymentSnapshot::default();
1895 snapshot.objects.insert(
1896 ObjectId::new("db".into(), "analytics".into(), "changed_mv".into()),
1897 "hash1".into(),
1898 );
1899
1900 assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1901 }
1902
1903 #[mz_ore::test]
1904 fn test_validate_mixed_new_in_new_schema_changed_in_existing() {
1905 let mut cs = make_empty_change_set();
1906 cs.new_replacement_objects.insert(ObjectId::new(
1908 "db".into(),
1909 "new_schema".into(),
1910 "new_mv".into(),
1911 ));
1912 cs.changed_replacement_objects.insert(ObjectId::new(
1914 "db".into(),
1915 "existing_schema".into(),
1916 "changed_mv".into(),
1917 ));
1918
1919 let mut snapshot = DeploymentSnapshot::default();
1921 snapshot.objects.insert(
1922 ObjectId::new("db".into(), "existing_schema".into(), "changed_mv".into()),
1923 "hash1".into(),
1924 );
1925
1926 assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1928 }
1929
1930 #[mz_ore::test]
1931 fn test_validate_transitioning_objects_in_existing_schema_allowed() {
1932 let mut cs = make_empty_change_set();
1933 cs.new_replacement_objects.insert(ObjectId::new(
1935 "db".into(),
1936 "analytics".into(),
1937 "existing_mv".into(),
1938 ));
1939
1940 let mut snapshot = DeploymentSnapshot::default();
1942 snapshot.objects.insert(
1943 ObjectId::new("db".into(), "analytics".into(), "existing_mv".into()),
1944 "hash1".into(),
1945 );
1946
1947 assert!(validate_no_new_objects_in_existing_stable_schemas(&cs, &snapshot).is_ok());
1949 }
1950
1951 fn make_planned_project_with_replacement_schemas(
1952 objects: Vec<(&str, &str, &str, DatabaseObject)>,
1953 replacement_schemas: BTreeSet<SchemaQualifier>,
1954 ) -> Project {
1955 let mut db_map: BTreeMap<String, BTreeMap<String, Vec<DatabaseObject>>> = BTreeMap::new();
1956
1957 for (database, schema, _name, typed_obj) in objects {
1958 db_map
1959 .entry(database.to_string())
1960 .or_default()
1961 .entry(schema.to_string())
1962 .or_default()
1963 .push(typed_obj);
1964 }
1965
1966 let databases: Vec<compiled::Database> = db_map
1967 .into_iter()
1968 .map(|(db_name, schemas)| compiled::Database {
1969 name: db_name,
1970 schemas: schemas
1971 .into_iter()
1972 .map(|(schema_name, objs)| compiled::Schema {
1973 name: schema_name,
1974 objects: objs,
1975 mod_statements: None,
1976 })
1977 .collect(),
1978 mod_statements: None,
1979 })
1980 .collect();
1981
1982 let typed_project = compiled::Project {
1983 databases,
1984 replacement_schemas,
1985 };
1986
1987 Project::from(typed_project)
1988 }
1989
1990 #[cfg_attr(miri, ignore)] #[mz_ore::test]
1992 fn test_build_snapshot_replacement_schema_kind() {
1993 let mv_obj =
1994 make_typed_object(&["CREATE MATERIALIZED VIEW my_mv IN CLUSTER compute AS SELECT 1"]);
1995 let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
1996
1997 let mut replacement_schemas = BTreeSet::new();
1998 replacement_schemas.insert(SchemaQualifier::new("db".into(), "stable".into()));
1999
2000 let planned_project = make_planned_project_with_replacement_schemas(
2001 vec![
2002 ("db", "stable", "my_mv", mv_obj),
2003 ("db", "regular", "my_view", view_obj),
2004 ],
2005 replacement_schemas,
2006 );
2007
2008 let snapshot = build_snapshot_from_planned(&planned_project).unwrap();
2009
2010 assert_eq!(
2012 snapshot
2013 .schemas
2014 .get(&SchemaQualifier::new("db".into(), "stable".into())),
2015 Some(&DeploymentKind::Replacement),
2016 "Replacement schema should have Replacement kind in snapshot"
2017 );
2018
2019 assert_eq!(
2021 snapshot
2022 .schemas
2023 .get(&SchemaQualifier::new("db".into(), "regular".into())),
2024 Some(&DeploymentKind::Objects),
2025 "Regular schema should have Objects kind in snapshot"
2026 );
2027 }
2028
2029 #[cfg_attr(miri, ignore)] #[mz_ore::test]
2031 fn test_build_snapshot_no_replacement_schemas_all_objects() {
2032 let mv_obj =
2033 make_typed_object(&["CREATE MATERIALIZED VIEW my_mv IN CLUSTER compute AS SELECT 1"]);
2034 let view_obj = make_typed_object(&["CREATE VIEW my_view AS SELECT 1"]);
2035
2036 let planned_project = make_planned_project(vec![
2037 ("db", "stable", "my_mv", mv_obj),
2038 ("db", "regular", "my_view", view_obj),
2039 ]);
2040
2041 let snapshot = build_snapshot_from_planned(&planned_project).unwrap();
2042
2043 assert_eq!(
2045 snapshot
2046 .schemas
2047 .get(&SchemaQualifier::new("db".into(), "stable".into())),
2048 Some(&DeploymentKind::Objects),
2049 );
2050 assert_eq!(
2051 snapshot
2052 .schemas
2053 .get(&SchemaQualifier::new("db".into(), "regular".into())),
2054 Some(&DeploymentKind::Objects),
2055 );
2056 }
2057
2058 #[cfg_attr(miri, ignore)] #[mz_ore::test]
2060 fn test_record_stage_metadata_transition_override() {
2061 let mv_obj =
2065 make_typed_object(&["CREATE MATERIALIZED VIEW my_mv IN CLUSTER compute AS SELECT 1"]);
2066
2067 let objects: Vec<ObjectRef> = vec![(
2069 ObjectId::new("db".into(), "stable".into(), "my_mv".into()),
2070 &mv_obj,
2071 )];
2072 let sinks: Vec<ObjectRef> = vec![];
2073 let replacement_mvs: Vec<ObjectRef> = vec![];
2074
2075 let mut replacement_schemas = BTreeSet::new();
2077 replacement_schemas.insert(SchemaQualifier::new("db".into(), "stable".into()));
2078
2079 let mut staging_snapshot = DeploymentSnapshot::default();
2081
2082 for (object_id, typed_obj) in &objects {
2083 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
2084 staging_snapshot.objects.insert(object_id.clone(), hash);
2085 staging_snapshot.schemas.insert(
2086 SchemaQualifier::new(
2087 object_id.expect_database().to_string(),
2088 object_id.schema().to_string(),
2089 ),
2090 DeploymentKind::Objects,
2091 );
2092 }
2093
2094 for (object_id, typed_obj) in &sinks {
2095 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
2096 staging_snapshot.objects.insert(object_id.clone(), hash);
2097 staging_snapshot
2098 .schemas
2099 .entry(SchemaQualifier::new(
2100 object_id.expect_database().to_string(),
2101 object_id.schema().to_string(),
2102 ))
2103 .or_insert(DeploymentKind::Sinks);
2104 }
2105
2106 for (object_id, typed_obj) in &replacement_mvs {
2107 let hash = deployment_snapshot::compute_typed_hash(typed_obj);
2108 staging_snapshot.objects.insert(object_id.clone(), hash);
2109 staging_snapshot.schemas.insert(
2110 SchemaQualifier::new(
2111 object_id.expect_database().to_string(),
2112 object_id.schema().to_string(),
2113 ),
2114 DeploymentKind::Replacement,
2115 );
2116 }
2117
2118 assert_eq!(
2120 staging_snapshot
2121 .schemas
2122 .get(&SchemaQualifier::new("db".into(), "stable".into())),
2123 Some(&DeploymentKind::Objects),
2124 "Before override, schema should be Objects (from regular objects path)"
2125 );
2126
2127 for sq in &replacement_schemas {
2129 if staging_snapshot.schemas.contains_key(sq) {
2130 staging_snapshot
2131 .schemas
2132 .insert(sq.clone(), DeploymentKind::Replacement);
2133 }
2134 }
2135
2136 assert_eq!(
2138 staging_snapshot
2139 .schemas
2140 .get(&SchemaQualifier::new("db".into(), "stable".into())),
2141 Some(&DeploymentKind::Replacement),
2142 "After override, schema should be Replacement"
2143 );
2144 }
2145
2146 #[mz_ore::test]
2147 fn test_record_stage_metadata_override_only_applies_to_existing_schemas() {
2148 let replacement_schemas =
2151 BTreeSet::from([SchemaQualifier::new("db".into(), "nonexistent".into())]);
2152
2153 let mut staging_snapshot = DeploymentSnapshot::default();
2154
2155 for sq in &replacement_schemas {
2157 if staging_snapshot.schemas.contains_key(sq) {
2158 staging_snapshot
2159 .schemas
2160 .insert(sq.clone(), DeploymentKind::Replacement);
2161 }
2162 }
2163
2164 assert!(
2166 staging_snapshot.schemas.is_empty(),
2167 "Override should not create entries for schemas with no objects"
2168 );
2169 }
2170
2171 #[mz_ore::test]
2172 fn test_validate_stage_name_length() {
2173 assert!(validate_stage_name("prod").is_ok());
2174 assert!(validate_stage_name(&"a".repeat(Ident::MAX_LENGTH / 2 - 1)).is_ok());
2175 assert!(validate_stage_name(&"a".repeat(Ident::MAX_LENGTH)).is_err());
2176 }
2177}