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