1use std::collections::BTreeMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18
19use clap::{Parser, ValueEnum};
20use sqlness::QueryContext;
21use sqlness::interceptor::template::DELIMITER as TEMPLATE_DELIMITER;
22use sqlness::interceptor::{InterceptorRef, Registry};
23
24use crate::cmd::bare::ServerAddr;
25use crate::cmd::compat_case::{self, CompatCase, try_infer_version, version_matches_range};
26use crate::cmd::datanode_overlay::{
27 DatanodeOverlay, DatanodeProtectionPolicy, PreparedDatanodeOverlay,
28};
29use crate::env::bare::{Env, GreptimeDB, StoreConfig, WalConfig};
30use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY};
31use crate::util;
32
33const COMMENT_PREFIX: &str = "--";
34const INTERCEPTOR_PREFIX: &str = "-- SQLNESS";
35const QUERY_DELIMITER: char = ';';
36
37#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
38enum CompatTopology {
39 Distributed,
40 Standalone,
41}
42
43impl CompatTopology {
44 fn as_str(self) -> &'static str {
45 match self {
46 Self::Distributed => "distributed",
47 Self::Standalone => "standalone",
48 }
49 }
50}
51
52#[derive(Debug, Parser)]
62pub struct CompatCommand {
63 #[clap(long)]
67 from_version: Option<String>,
68
69 #[clap(long)]
71 from_bins_dir: Option<PathBuf>,
72
73 #[clap(long)]
76 to_bins_dir: Option<PathBuf>,
77
78 #[clap(long)]
82 to_version: Option<String>,
83
84 #[clap(long)]
87 case_dir: Option<PathBuf>,
88
89 #[clap(long, default_value = ".*")]
91 test_filter: String,
92
93 #[clap(long)]
95 expect_cases: Option<usize>,
96
97 #[clap(long, value_enum, default_value_t = CompatTopology::Distributed)]
99 topology: CompatTopology,
100
101 #[clap(long, default_value = "false")]
103 fail_fast: bool,
104
105 #[clap(long, default_value = "false")]
108 preserve_state: bool,
109
110 #[clap(long, default_value = "true")]
112 pull_version_on_need: bool,
113
114 #[clap(long, default_value = "true")]
117 setup_etcd: bool,
118
119 #[clap(long, default_value = "false")]
122 dry_run: bool,
123}
124
125impl CompatCommand {
126 pub async fn run(self) {
127 let dry_run = self.dry_run;
128 let topology = self.topology;
129
130 if self.to_bins_dir.is_some() && self.to_version.is_some() {
131 panic!("--to-version cannot be used with --to-bins-dir");
132 }
133
134 if !dry_run && topology == CompatTopology::Distributed && !self.setup_etcd {
136 panic!(
137 "compat MVP requires Docker etcd (--setup-etcd=true); external metadata stores are not supported yet"
138 );
139 }
140
141 let case_dir = self.case_dir.unwrap_or_else(default_compat_case_dir);
143
144 if !case_dir.is_dir() {
145 panic!("Case directory not found: {}", case_dir.display());
146 }
147
148 let mut cases = compat_case::discover_cases(&case_dir).unwrap_or_else(|e| panic!("{e}"));
150
151 let filter_re = regex::Regex::new(&self.test_filter)
153 .unwrap_or_else(|e| panic!("Invalid test filter regex '{}': {e}", self.test_filter));
154 cases.retain(|c| filter_re.is_match(&c.metadata.name));
155
156 cases.retain(|c| {
158 c.metadata
159 .topologies
160 .iter()
161 .any(|candidate| candidate == topology.as_str())
162 });
163
164 if cases.is_empty() {
165 if let Some(expected_cases) = self.expect_cases {
166 panic!(
167 "Expected {expected_cases} compatibility cases after name and topology filtering, found 0"
168 );
169 }
170 if dry_run {
171 println!(
172 "DRY-RUN: no compat cases found matching filter '{}' and topology '{}'",
173 self.test_filter,
174 topology.as_str()
175 );
176 } else {
177 println!(
178 "No compat cases found matching filter '{}' and topology '{}'",
179 self.test_filter,
180 topology.as_str()
181 );
182 }
183 return;
184 }
185
186 compat_case::validate_cases_metadata(&cases).unwrap_or_else(|e| panic!("{e}"));
190
191 compat_case::validate_case_namespaces(&cases).unwrap_or_else(|e| panic!("{e}"));
195
196 let (from_bins_dir, from_version, from_ver_parsed, to_bins_dir, to_version, to_ver_parsed) =
198 if dry_run {
199 let dry_run_from_bins_dir = self
202 .from_bins_dir
203 .clone()
204 .unwrap_or_else(|| util::get_binary_dir("debug"));
205 let from_ver_str = self
206 .from_version
207 .as_deref()
208 .and_then(|v| {
209 if v == "current" {
210 None
211 } else {
212 Some(v.to_string())
213 }
214 })
215 .or_else(|| try_infer_version(&dry_run_from_bins_dir).map(|v| v.to_string()));
216 let from_ver_parsed = from_ver_str
217 .as_deref()
218 .and_then(|s| compat_case::Version::parse(s).ok());
219
220 let dry_run_to_bins_dir = self.to_bins_dir.clone().unwrap_or_else(|| {
221 self.to_version
222 .as_deref()
223 .filter(|version| *version != "current")
224 .map(|version| PathBuf::from(util::get_workspace_root()).join(version))
225 .unwrap_or_else(|| util::get_binary_dir("debug"))
226 });
227 let to_ver_str = self
228 .to_version
229 .as_deref()
230 .filter(|version| *version != "current")
231 .map(str::to_string)
232 .or_else(|| try_infer_version(&dry_run_to_bins_dir).map(|v| v.to_string()));
233 let to_ver_parsed = to_ver_str
234 .as_deref()
235 .and_then(|s| compat_case::Version::parse(s).ok());
236
237 (
238 Some(dry_run_from_bins_dir),
239 from_ver_str,
240 from_ver_parsed,
241 Some(dry_run_to_bins_dir),
242 to_ver_str,
243 to_ver_parsed,
244 )
245 } else {
246 let from_bins_dir = resolve_bins(
248 self.from_bins_dir.as_ref(),
249 self.from_version.as_deref(),
250 self.pull_version_on_need,
251 )
252 .await;
253
254 let from_version = if let Some(ref ver) = self.from_version {
255 if ver != "current" {
256 Some(ver.clone())
257 } else {
258 try_infer_version(&from_bins_dir).map(|v| v.to_string())
259 }
260 } else {
261 try_infer_version(&from_bins_dir).map(|v| v.to_string())
262 };
263
264 let from_ver_parsed = from_version
265 .as_deref()
266 .and_then(|s| compat_case::Version::parse(s).ok());
267
268 let to_bins_dir = resolve_bins(
269 self.to_bins_dir.as_ref(),
270 self.to_version.as_deref(),
271 self.pull_version_on_need,
272 )
273 .await;
274 let to_version = self
275 .to_version
276 .as_deref()
277 .filter(|version| *version != "current")
278 .map(str::to_string)
279 .or_else(|| try_infer_version(&to_bins_dir).map(|v| v.to_string()));
280 let to_ver_parsed = to_version
281 .as_deref()
282 .and_then(|s| compat_case::Version::parse(s).ok());
283
284 (
285 Some(from_bins_dir),
286 from_version,
287 from_ver_parsed,
288 Some(to_bins_dir),
289 to_version,
290 to_ver_parsed,
291 )
292 };
293
294 let pre_filter_count = cases.len();
296 cases.retain(|c| {
297 let from_ok = version_matches_range(from_ver_parsed.as_ref(), &c.metadata.from_range);
298 if !from_ok {
299 let from_label = from_ver_parsed
300 .as_ref()
301 .map(|v| v.to_string())
302 .unwrap_or_else(|| "unknown".to_string());
303 println!(
304 "Skipping case '{}': from_range {:?} does not match version '{}'",
305 c.metadata.name, c.metadata.from_range, from_label
306 );
307 }
308 from_ok
309 });
310 cases.retain(|c| {
311 let to_ok = version_matches_range(to_ver_parsed.as_ref(), &c.metadata.to_range);
312 if !to_ok {
313 let to_label = to_ver_parsed
314 .as_ref()
315 .map(|v| v.to_string())
316 .unwrap_or_else(|| "unknown".to_string());
317 println!(
318 "Skipping case '{}': to_range {:?} does not match version '{}'",
319 c.metadata.name, c.metadata.to_range, to_label
320 );
321 }
322 to_ok
323 });
324
325 if pre_filter_count != cases.len() {
326 println!(
327 "Version-range filtering: {} → {} cases",
328 pre_filter_count,
329 cases.len()
330 );
331 }
332
333 if cases.is_empty() {
334 if let Some(expected_cases) = self.expect_cases {
335 panic!(
336 "Expected {expected_cases} compatibility cases after all filtering, found 0"
337 );
338 }
339 if dry_run {
340 println!("DRY-RUN: no compat cases would run after version-range filtering");
341 } else {
342 println!("No compat cases remaining after version-range filtering");
343 }
344 return;
345 }
346
347 let wal_config = WalConfig::RaftEngine;
348 let profiles =
349 prepare_compat_profiles(cases, &wal_config).unwrap_or_else(|error| panic!("{error}"));
350 emit_protected_path_warnings(&profiles);
351
352 if let Some(expected_cases) = self.expect_cases {
353 assert_eq!(
354 profiles.case_count(),
355 expected_cases,
356 "Expected {expected_cases} compatibility cases after all filtering, found {}",
357 profiles.case_count()
358 );
359 }
360
361 if dry_run {
362 println!(
363 "DRY-RUN: would run {} compat case(s)",
364 profiles.case_count()
365 );
366 println!(" topology: {}", topology.as_str());
367 println!(
368 " from version: {}",
369 from_version.as_deref().unwrap_or(
370 "unknown (use --from-version, --from-bins-dir, or build debug binary)"
371 )
372 );
373 println!(
374 " to version: {}",
375 to_version
376 .as_deref()
377 .unwrap_or("unknown (use --to-bins-dir or build debug binary)")
378 );
379 if pre_filter_count != profiles.case_count() {
380 println!();
381 println!(
382 "Version-range filtering reduced {} → {} cases (see 'Skipping case' messages above)",
383 pre_filter_count,
384 profiles.case_count()
385 );
386 }
387 println!();
388 for c in profiles.cases() {
389 println!(" case: {}", c.metadata.name);
390 println!(" namespace: {}", c.namespace);
391 println!(" from_range: {:?}", c.metadata.from_range);
392 println!(" to_range: {:?}", c.metadata.to_range);
393 println!(" features: {:?}", c.metadata.features);
394 }
395 println!("DRY-RUN: compatibility profiles:");
396 for profile in profiles.iter() {
397 println!(" profile: {}", profile.profile_id());
398 println!(" cases: {}", profile.case_names().join(", "));
399 for source in profile.sources() {
400 println!(" sidecar: {}", source.display());
401 }
402 }
403 println!();
404 println!("Dry run complete. Remove --dry-run to execute.");
405 return;
406 }
407
408 println!(
409 "Running {} compat case(s) with topology {}:",
410 profiles.case_count(),
411 topology.as_str()
412 );
413 for c in profiles.cases() {
414 println!(
415 " - {} (namespace: {}, topologies: {:?})",
416 c.metadata.name, c.namespace, c.metadata.topologies
417 );
418 }
419
420 let interceptor_registry = create_interceptor_registry();
422 let to_bins_dir = to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode");
423 let profile_config = ProfileRunConfig {
424 interceptor_registry: &interceptor_registry,
425 from_bins_dir,
426 to_bins_dir,
427 pull_version_on_need: self.pull_version_on_need,
428 setup_etcd: self.setup_etcd,
429 fail_fast: self.fail_fast,
430 topology,
431 preserve_state: self.preserve_state,
432 wal_config,
433 };
434 let mut failures = Vec::new();
435 let mut preserved_states = Vec::new();
436 for profile in profiles.iter() {
437 let outcome = run_profile(profile, &profile_config).await;
438 failures.extend(outcome.failures);
439 if let Some(state) = outcome.preserved_state {
440 preserved_states.push(state);
441 }
442 if outcome.stop_remaining_profiles {
443 break;
444 }
445 }
446
447 for state in &preserved_states {
448 println!("Preserved compat profile state: {}", state.display());
449 }
450
451 if failures.is_empty() {
452 println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
453 } else {
454 panic!("\n\x1b[31mFailed cases: {}\x1b[0m", failures.join(", "));
455 }
456 }
457}
458
459#[derive(Debug)]
461enum CompatProfile {
462 Baseline {
463 cases: Vec<CompatCase>,
464 },
465 Overlay {
466 overlay: Arc<PreparedDatanodeOverlay>,
467 cases: Vec<CompatCase>,
468 sources: Vec<PathBuf>,
469 },
470}
471
472impl CompatProfile {
473 fn profile_id(&self) -> &str {
474 match self {
475 Self::Baseline { .. } => "baseline",
476 Self::Overlay { overlay, .. } => overlay.profile_id(),
477 }
478 }
479
480 fn cases(&self) -> &[CompatCase] {
481 match self {
482 Self::Baseline { cases } | Self::Overlay { cases, .. } => cases,
483 }
484 }
485
486 fn case_names(&self) -> Vec<&str> {
487 self.cases()
488 .iter()
489 .map(|case| case.metadata.name.as_str())
490 .collect()
491 }
492
493 fn sources(&self) -> &[PathBuf] {
494 match self {
495 Self::Baseline { .. } => &[],
496 Self::Overlay { sources, .. } => sources,
497 }
498 }
499
500 fn overlay(&self) -> Option<Arc<PreparedDatanodeOverlay>> {
501 match self {
502 Self::Baseline { .. } => None,
503 Self::Overlay { overlay, .. } => Some(Arc::clone(overlay)),
504 }
505 }
506
507 fn touched_protected_paths(&self) -> &[crate::cmd::datanode_overlay::DottedPath] {
508 match self {
509 Self::Baseline { .. } => &[],
510 Self::Overlay { overlay, .. } => overlay.touched_protected_paths(),
511 }
512 }
513}
514
515#[derive(Debug)]
517struct CompatProfiles(Vec<CompatProfile>);
518
519impl CompatProfiles {
520 fn iter(&self) -> impl Iterator<Item = &CompatProfile> {
521 self.0.iter()
522 }
523
524 fn cases(&self) -> impl Iterator<Item = &CompatCase> {
525 self.0.iter().flat_map(CompatProfile::cases)
526 }
527
528 fn case_count(&self) -> usize {
529 self.0.iter().map(|profile| profile.cases().len()).sum()
530 }
531}
532
533#[derive(Debug)]
534struct OverlayProfileGroup {
535 overlay: Arc<PreparedDatanodeOverlay>,
536 cases: Vec<CompatCase>,
537 sources: Vec<PathBuf>,
538}
539
540fn prepare_compat_profiles(
541 cases: Vec<CompatCase>,
542 wal_config: &WalConfig,
543) -> Result<CompatProfiles, String> {
544 let protection = DatanodeProtectionPolicy::for_wal(wal_config);
545 let mut baseline_cases = Vec::new();
546 let mut overlays: BTreeMap<[u8; 32], OverlayProfileGroup> = BTreeMap::new();
547
548 for case in cases {
549 let Some(reference) = case.metadata.old_datanode_overlay() else {
550 baseline_cases.push(case);
551 continue;
552 };
553 let overlay = DatanodeOverlay::load(&case.dir, reference).map_err(|error| {
554 format!(
555 "Failed to load old datanode overlay for compatibility case '{}': {error}",
556 case.metadata.name
557 )
558 })?;
559 let prepared = Arc::new(overlay.prepare(&protection).map_err(|error| {
560 format!(
561 "Failed to prepare old datanode overlay for compatibility case '{}': {error}",
562 case.metadata.name
563 )
564 })?);
565 let key = *prepared.profile_key();
566 let source = prepared.source().to_path_buf();
567 let entry = overlays.entry(key).or_insert_with(|| OverlayProfileGroup {
568 overlay: Arc::clone(&prepared),
569 cases: Vec::new(),
570 sources: Vec::new(),
571 });
572 entry.cases.push(case);
573 entry.sources.push(source);
574 }
575
576 baseline_cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name));
577 let mut profiles = Vec::new();
578 if !baseline_cases.is_empty() {
579 profiles.push(CompatProfile::Baseline {
580 cases: baseline_cases,
581 });
582 }
583 for (
584 _,
585 OverlayProfileGroup {
586 overlay,
587 mut cases,
588 mut sources,
589 },
590 ) in overlays
591 {
592 cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name));
593 sources.sort();
594 sources.dedup();
595 profiles.push(CompatProfile::Overlay {
596 overlay,
597 cases,
598 sources,
599 });
600 }
601
602 Ok(CompatProfiles(profiles))
603}
604
605fn emit_protected_path_warnings(profiles: &CompatProfiles) {
606 for profile in profiles.iter() {
607 let paths: Vec<_> = profile
608 .touched_protected_paths()
609 .iter()
610 .map(ToString::to_string)
611 .collect();
612 if paths.is_empty() {
613 continue;
614 }
615 println!(
616 "{}",
617 format_protected_path_warning(profile.profile_id(), profile.case_names(), paths)
618 );
619 }
620}
621
622fn format_protected_path_warning(
623 profile_id: &str,
624 mut case_names: Vec<&str>,
625 mut paths: Vec<String>,
626) -> String {
627 case_names.sort_unstable();
628 paths.sort_unstable();
629 format!(
630 "Warning: datanode overlay profile {profile_id} touches runner-owned paths [{}] for cases [{}]",
631 paths.join(", "),
632 case_names.join(", ")
633 )
634}
635
636#[derive(Debug)]
637struct ProfileOutcome {
638 failures: Vec<String>,
639 stop_remaining_profiles: bool,
640 preserved_state: Option<PathBuf>,
641}
642
643#[derive(Default)]
645struct ProfileProgress {
646 successful_setup_indexes: Vec<usize>,
647 failures: Vec<String>,
648 fail_fast_setup_failure: bool,
649 fail_fast_verify_failure: bool,
650 cleanup_failed: bool,
651}
652
653impl ProfileProgress {
654 fn record_setup_success(&mut self, case_index: usize) {
655 self.successful_setup_indexes.push(case_index);
656 }
657
658 fn record_setup_failure(&mut self, failure: String, fail_fast: bool) -> bool {
659 self.failures.push(failure);
660 self.fail_fast_setup_failure = fail_fast;
661 fail_fast
662 }
663
664 fn record_verify_failure(&mut self, failure: String, fail_fast: bool) -> bool {
665 self.failures.push(failure);
666 self.fail_fast_verify_failure = fail_fast;
667 fail_fast
668 }
669
670 fn record_cleanup_failure(&mut self, failure: String) {
671 self.failures.push(failure);
672 self.cleanup_failed = true;
673 }
674
675 fn should_transition_to_current(&self) -> bool {
676 !self.successful_setup_indexes.is_empty() && !self.fail_fast_setup_failure
677 }
678
679 fn should_stop_remaining_profiles(&self) -> bool {
680 self.fail_fast_setup_failure || self.fail_fast_verify_failure || self.cleanup_failed
681 }
682}
683
684struct ProfileRunConfig<'a> {
685 interceptor_registry: &'a Registry,
686 from_bins_dir: Option<PathBuf>,
687 to_bins_dir: PathBuf,
688 pull_version_on_need: bool,
689 setup_etcd: bool,
690 fail_fast: bool,
691 topology: CompatTopology,
692 preserve_state: bool,
693 wal_config: WalConfig,
694}
695
696async fn run_profile(profile: &CompatProfile, config: &ProfileRunConfig<'_>) -> ProfileOutcome {
697 println!("Running compatibility profile {}", profile.profile_id());
698 let temp_dir = tempfile::Builder::new()
699 .prefix(&format!("sqlness-compat-{}-", profile.profile_id()))
700 .tempdir()
701 .unwrap();
702 let profile_state = ProfileStateGuard::new(temp_dir, config.preserve_state);
703 let sqlness_home = profile_state.path().to_path_buf();
704 let setup_etcd = config.topology == CompatTopology::Distributed && config.setup_etcd;
706 unsafe {
707 std::env::set_var(
708 "SQLNESS_HOME",
709 sqlness_home.join("copy").display().to_string(),
710 );
711 }
712
713 let store_config = StoreConfig {
714 store_addrs: setup_etcd
715 .then(|| "127.0.0.1:2379".to_string())
716 .into_iter()
717 .collect(),
718 setup_etcd,
719 setup_pg: None,
720 setup_mysql: None,
721 enable_flat_format: false,
722 enable_gc: false,
723 };
724 let env = Env::new(
725 sqlness_home.clone(),
726 ServerAddr::default(),
727 config.wal_config.clone(),
728 config.pull_version_on_need,
729 config.from_bins_dir.clone(),
730 store_config,
731 vec![],
732 );
733 if let Some(overlay) = profile.overlay() {
734 env.activate_compat_old(overlay);
735 }
736
737 let etcd_guard = setup_etcd.then(EtcdGuard::new);
740 println!(
741 "Starting old-version {} cluster...",
742 config.topology.as_str()
743 );
744 let db = match config.topology {
745 CompatTopology::Distributed => env.compat_start_distributed(0).await,
746 CompatTopology::Standalone => env.compat_start_standalone(0).await,
747 };
748
749 let mut progress = ProfileProgress::default();
750 for (case_index, case) in profile.cases().iter().enumerate() {
751 match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Setup).await {
752 Ok(()) => {
753 println!(" Setup: {} - OK", case.metadata.name);
754 progress.record_setup_success(case_index);
755 }
756 Err(error) => {
757 println!(" Setup: {} - FAILED: {error}", case.metadata.name);
758 if progress.record_setup_failure(
759 format!("{} (setup): {error}", case.metadata.name),
760 config.fail_fast,
761 ) {
762 break;
763 }
764 }
765 }
766 }
767
768 if progress.should_transition_to_current() {
769 println!("Restarting cluster with new-version binary on preserved state...");
770 env.activate_compat_current();
771 env.compat_restart(&db, config.to_bins_dir.clone()).await;
772
773 println!("Running verify phase...");
774 for case_index in progress.successful_setup_indexes.clone() {
775 let case = &profile.cases()[case_index];
776 match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Verify)
777 .await
778 {
779 Ok(()) => println!(" Verify: {} - PASSED", case.metadata.name),
780 Err(error) => {
781 println!(" Verify: {} - FAILED: {error}", case.metadata.name);
782 if progress.record_verify_failure(
783 format!("{} (verify): {error}", case.metadata.name),
784 config.fail_fast,
785 ) {
786 break;
787 }
788 }
789 }
790 }
791 } else if progress.successful_setup_indexes.is_empty() {
792 println!("Skipping current-version restart: no setup cases succeeded");
793 }
794
795 let cleanup = cleanup_profile(db, etcd_guard, setup_etcd, profile_state).await;
796 for failure in cleanup.failures {
797 progress.record_cleanup_failure(failure);
798 }
799 let stop_remaining_profiles = progress.should_stop_remaining_profiles();
800 ProfileOutcome {
801 failures: progress.failures,
802 stop_remaining_profiles,
803 preserved_state: cleanup.preserved_state,
804 }
805}
806
807struct CleanupOutcome {
808 failures: Vec<String>,
809 preserved_state: Option<PathBuf>,
810}
811
812struct ProfileStateGuard {
814 temp_dir: Option<tempfile::TempDir>,
815 preserve_state: bool,
816}
817
818impl ProfileStateGuard {
819 fn new(temp_dir: tempfile::TempDir, preserve_state: bool) -> Self {
820 Self {
821 temp_dir: Some(temp_dir),
822 preserve_state,
823 }
824 }
825
826 fn path(&self) -> &std::path::Path {
827 self.temp_dir.as_ref().unwrap().path()
828 }
829
830 fn finalize(mut self) -> CleanupOutcome {
831 let temp_dir = self.temp_dir.take().unwrap();
832 let mut failures = Vec::new();
833 if self.preserve_state {
834 let path = temp_dir.keep();
835 if let Err(error) = std::fs::metadata(&path) {
836 failures.push(format!(
837 "failed to confirm preserved profile state {}: {error}",
838 path.display()
839 ));
840 }
841 return CleanupOutcome {
842 failures,
843 preserved_state: Some(path),
844 };
845 }
846
847 println!("Removing state in {}", temp_dir.path().display());
848 if let Err(error) = temp_dir.close() {
849 failures.push(format!("failed to remove profile state: {error}"));
850 }
851 CleanupOutcome {
852 failures,
853 preserved_state: None,
854 }
855 }
856}
857
858impl Drop for ProfileStateGuard {
859 fn drop(&mut self) {
860 let Some(temp_dir) = self.temp_dir.take() else {
861 return;
862 };
863 if self.preserve_state {
864 let path = temp_dir.keep();
865 println!(
866 "Preserved compat profile state after abnormal exit: {}",
867 path.display()
868 );
869 }
870 }
871}
872
873async fn cleanup_profile(
874 mut db: GreptimeDB,
875 mut etcd_guard: Option<EtcdGuard>,
876 setup_etcd: bool,
877 profile_state: ProfileStateGuard,
878) -> CleanupOutcome {
879 db.compat_stop();
880 drop(db);
881 let mut failures = Vec::new();
882 if setup_etcd {
883 println!("Stopping etcd");
884 match util::stop_rm_etcd_checked() {
885 Ok(()) => {
886 if let Some(guard) = etcd_guard.as_mut() {
887 guard.disarm();
888 }
889 }
890 Err(error) => failures.push(format!("profile etcd cleanup: {error}")),
891 }
892 }
893 drop(etcd_guard);
896
897 let mut state_outcome = profile_state.finalize();
898 failures.append(&mut state_outcome.failures);
899 CleanupOutcome {
900 failures,
901 preserved_state: state_outcome.preserved_state,
902 }
903}
904
905struct EtcdGuard {
911 active: bool,
912}
913
914impl EtcdGuard {
915 fn new() -> Self {
916 let inspect_status = std::process::Command::new("docker")
917 .args(["container", "inspect", "etcd"])
918 .stdout(std::process::Stdio::null())
919 .stderr(std::process::Stdio::null())
920 .status();
921 if inspect_status.is_ok_and(|status| status.success()) {
922 panic!(
923 "A Docker container named `etcd` already exists. \
924 Remove it before running compat tests so the cleanup guard \
925 cannot delete a container it did not create."
926 );
927 }
928 Self { active: true }
929 }
930
931 fn disarm(&mut self) {
932 self.active = false;
933 }
934}
935
936impl Drop for EtcdGuard {
937 fn drop(&mut self) {
938 if self.active {
939 println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
940 if let Err(error) = util::stop_rm_etcd_checked() {
941 println!("EtcdGuard: emergency etcd cleanup failed: {error}");
942 }
943 }
944 }
945}
946
947#[derive(Clone, Copy, PartialEq, Eq)]
949enum CompatPhase {
950 Setup,
951 Verify,
952}
953
954fn create_interceptor_registry() -> Registry {
956 let mut interceptor_registry: Registry = Default::default();
957 interceptor_registry.register(
958 protocol_interceptor::PREFIX,
959 Arc::new(protocol_interceptor::ProtocolInterceptorFactory),
960 );
961 interceptor_registry
962}
963
964async fn resolve_bins(
969 bins_dir: Option<&PathBuf>,
970 version: Option<&str>,
971 pull_version_on_need: bool,
972) -> PathBuf {
973 let dir = if let Some(dir) = bins_dir {
974 dir.clone()
975 } else if let Some(ver) = version {
976 if ver == "current" {
977 util::get_binary_dir("debug")
978 } else {
979 util::maybe_pull_binary(ver, pull_version_on_need).await;
980 let root = std::path::PathBuf::from(util::get_workspace_root());
981 std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)])
982 }
983 } else {
984 util::get_binary_dir("debug")
986 };
987
988 let dir = match dir.canonicalize() {
990 Ok(canon) => canon,
991 Err(e) => panic!(
992 "Cannot resolve binary directory '{}': {e}. \
993 Use --from-bins-dir / --to-bins-dir to specify the correct path, \
994 or --from-version to pull a release.",
995 dir.display()
996 ),
997 };
998
999 if !dir.join(util::PROGRAM).is_file() {
1000 panic!(
1001 "greptime binary not found in '{}'. \
1002 Use --from-bins-dir / --to-bins-dir to specify the correct directory, \
1003 or build greptime first (e.g. `cargo build -p greptime`). \
1004 Note: if you use a custom target-dir, the binary may be elsewhere; \
1005 pass the actual directory with --from-bins-dir or --to-bins-dir.",
1006 dir.display()
1007 );
1008 }
1009
1010 dir
1011}
1012
1013fn default_compat_case_dir() -> PathBuf {
1015 let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1016 path.pop();
1019 path.push("compatibility");
1020 path.push("cases");
1021 path
1022}
1023
1024async fn run_compat_phase(
1026 db: &crate::env::bare::GreptimeDB,
1027 case: &CompatCase,
1028 registry: &Registry,
1029 phase: CompatPhase,
1030) -> Result<(), String> {
1031 let sql_file = match phase {
1032 CompatPhase::Setup => case.dir.join("setup.sql"),
1033 CompatPhase::Verify => case.dir.join("verify.sql"),
1034 };
1035
1036 let sql_content = std::fs::read_to_string(&sql_file)
1037 .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?;
1038
1039 let mut statements = parse_sql_file(&sql_content, registry)?;
1040
1041 let mut verify_output = String::new();
1043
1044 for statement in &mut statements {
1045 let (display, results) = statement.execute(db, &case.namespace).await?;
1046
1047 match phase {
1048 CompatPhase::Setup => {
1049 }
1051 CompatPhase::Verify => {
1052 verify_output.push_str(&display);
1053 for result in results {
1054 verify_output.push_str(&result);
1055 verify_output.push('\n');
1056 verify_output.push('\n');
1057 }
1058 }
1059 }
1060 }
1061
1062 if phase == CompatPhase::Verify {
1063 trim_trailing_blank_lines(&mut verify_output);
1064
1065 let result_path = case.dir.join("verify.result");
1066
1067 if !result_path.is_file() {
1070 std::fs::write(&result_path, &verify_output)
1071 .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?;
1072 return Err(format!(
1073 "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun",
1074 case.metadata.name
1075 ));
1076 }
1077
1078 let expected = std::fs::read_to_string(&result_path)
1079 .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?;
1080
1081 if verify_output != expected {
1082 std::fs::write(&result_path, &verify_output)
1084 .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?;
1085
1086 let diff = simple_diff(&expected, &verify_output);
1088 return Err(format!(
1089 "Result mismatch for case '{}'.\nDiff:\n{diff}",
1090 case.metadata.name
1091 ));
1092 }
1093 }
1094
1095 Ok(())
1096}
1097
1098fn trim_trailing_blank_lines(output: &mut String) {
1101 while output.ends_with("\n\n") {
1102 output.pop();
1103 }
1104}
1105
1106async fn run_namespace_prelude(
1117 db: &crate::env::bare::GreptimeDB,
1118 namespace: &str,
1119 query_ctx: &QueryContext,
1120) -> Result<(), String> {
1121 let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}");
1123 let default_ctx = QueryContext::default();
1124 db.compat_query(&create_db, &default_ctx).await?;
1125
1126 if query_ctx
1128 .context
1129 .get(PROTOCOL_KEY)
1130 .is_some_and(|p| p == POSTGRES)
1131 {
1132 let set_search_path = format!("SET search_path TO '{namespace}'");
1133 db.compat_query(&set_search_path, query_ctx).await?;
1134 return Ok(());
1135 }
1136
1137 let use_db = format!("USE {namespace}");
1139 db.compat_query(&use_db, query_ctx).await?;
1140
1141 Ok(())
1142}
1143
1144struct ParsedStatement {
1146 comment_lines: Vec<String>,
1147 display_query: Vec<String>,
1148 execute_query: Vec<String>,
1149 interceptors: Vec<InterceptorRef>,
1150}
1151
1152impl ParsedStatement {
1153 fn new() -> Self {
1154 Self {
1155 comment_lines: Vec::new(),
1156 display_query: Vec::new(),
1157 execute_query: Vec::new(),
1158 interceptors: Vec::new(),
1159 }
1160 }
1161
1162 fn push_comment(&mut self, line: String) {
1163 self.comment_lines.push(line);
1164 }
1165
1166 fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> {
1167 let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else {
1168 return Err(format!(
1169 "Missing sqlness interceptor prefix in line: {line}"
1170 ));
1171 };
1172 let interceptor = registry.create(remaining).map_err(|e| e.to_string())?;
1173 self.interceptors.push(interceptor);
1174 Ok(())
1175 }
1176
1177 fn append_query_line(&mut self, line: &str) {
1178 self.display_query.push(line.to_string());
1179 self.execute_query.push(line.to_string());
1180 }
1181
1182 fn is_empty(&self) -> bool {
1183 self.comment_lines.is_empty()
1184 && self.display_query.is_empty()
1185 && self.execute_query.is_empty()
1186 && self.interceptors.is_empty()
1187 }
1188
1189 fn has_query(&self) -> bool {
1190 !self.execute_query.is_empty()
1191 }
1192
1193 fn display_text(&self) -> String {
1194 let mut output = String::new();
1195 for comment in &self.comment_lines {
1196 output.push_str(comment);
1197 output.push('\n');
1198 }
1199 for line in &self.display_query {
1200 output.push_str(line);
1201 }
1202 output.push('\n');
1203 output.push('\n');
1204 output
1205 }
1206
1207 fn concat_query_lines(&self) -> String {
1208 self.execute_query
1209 .iter()
1210 .fold(String::new(), |query, line| query + line)
1211 .trim_start()
1212 .to_string()
1213 }
1214
1215 async fn before_execute_intercept(&mut self) -> QueryContext {
1216 let mut context = QueryContext::default();
1217 for interceptor in &self.interceptors {
1218 interceptor
1219 .before_execute_async(&mut self.execute_query, &mut context)
1220 .await;
1221 }
1222 context
1223 }
1224
1225 async fn after_execute_intercept(&self, result: &mut String) {
1226 for interceptor in &self.interceptors {
1227 interceptor.after_execute_async(result).await;
1228 }
1229 }
1230
1231 async fn execute(
1232 &mut self,
1233 db: &crate::env::bare::GreptimeDB,
1234 namespace: &str,
1235 ) -> Result<(String, Vec<String>), String> {
1236 let display = self.display_text();
1237 let context = self.before_execute_intercept().await;
1238 db.compat_prepare_query_context(&context).await;
1239 run_namespace_prelude(db, namespace, &context).await?;
1240 let sql = self.concat_query_lines();
1241 let mut results = Vec::new();
1242
1243 for sql in sql.split(TEMPLATE_DELIMITER) {
1244 if sql.trim().is_empty() {
1245 continue;
1246 }
1247 let sql = if sql.ends_with(QUERY_DELIMITER) {
1248 sql.to_string()
1249 } else {
1250 format!("{sql};")
1251 };
1252 let mut result = db.compat_query(&sql, &context).await?;
1253 self.after_execute_intercept(&mut result).await;
1254 results.push(result);
1255 }
1256
1257 Ok((display, results))
1258 }
1259}
1260
1261fn parse_sql_file(content: &str, registry: &Registry) -> Result<Vec<ParsedStatement>, String> {
1264 let mut statements = Vec::new();
1265 let mut current_stmt = ParsedStatement::new();
1266
1267 for line in content.lines() {
1268 if line.starts_with(COMMENT_PREFIX) {
1269 current_stmt.push_comment(line.to_string());
1270
1271 if line.starts_with(INTERCEPTOR_PREFIX) {
1272 current_stmt.push_interceptor(line, registry)?;
1273 }
1274 continue;
1275 }
1276
1277 if line.is_empty() {
1278 continue;
1279 }
1280
1281 current_stmt.append_query_line(line);
1282
1283 if line.ends_with(QUERY_DELIMITER) {
1285 if current_stmt.has_query() {
1286 statements.push(current_stmt);
1287 }
1288 current_stmt = ParsedStatement::new();
1289 } else {
1290 current_stmt.append_query_line("\n");
1291 }
1292 }
1293
1294 if !current_stmt.is_empty() && current_stmt.has_query() {
1296 statements.push(current_stmt);
1297 }
1298
1299 if statements.is_empty() {
1300 return Err("No SQL statements found in file".to_string());
1301 }
1302
1303 Ok(statements)
1304}
1305
1306fn simple_diff(expected: &str, actual: &str) -> String {
1308 let mut diff = String::new();
1309 let expected_lines: Vec<&str> = expected.lines().collect();
1310 let actual_lines: Vec<&str> = actual.lines().collect();
1311 let max_len = expected_lines.len().max(actual_lines.len());
1312
1313 for i in 0..max_len {
1314 let exp = expected_lines.get(i).unwrap_or(&"(missing)");
1315 let act = actual_lines.get(i).unwrap_or(&"(missing)");
1316 if exp != act {
1317 diff.push_str(&format!(" Line {}:\n", i + 1));
1318 diff.push_str(&format!(" expected: {exp}\n"));
1319 diff.push_str(&format!(" actual: {act}\n"));
1320 }
1321 }
1322
1323 if diff.is_empty() {
1324 diff.push_str(" (files differ but no line-level diff found — may be whitespace)\n");
1325 }
1326
1327 diff
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use std::path::Path;
1333
1334 use super::*;
1335 use crate::cmd::compat_case::{CaseMetadata, OldConfigMetadata};
1336
1337 fn test_case(temp_dir: &Path, name: &str, overlay: Option<&str>) -> CompatCase {
1338 let case_dir = temp_dir.join(name);
1339 std::fs::create_dir_all(&case_dir).unwrap();
1340 if let Some(overlay) = overlay {
1341 std::fs::write(case_dir.join("overlay.toml"), overlay).unwrap();
1342 }
1343 CompatCase {
1344 metadata: CaseMetadata {
1345 name: name.to_string(),
1346 reason: "test".to_string(),
1347 introduced_by: "test".to_string(),
1348 topologies: vec![CompatTopology::Distributed.as_str().to_string()],
1349 from_range: vec!["*".to_string()],
1350 to_range: vec!["*".to_string()],
1351 features: vec!["table".to_string()],
1352 owner: "test".to_string(),
1353 namespace: None,
1354 old_config: overlay.map(|_| OldConfigMetadata {
1355 datanode: PathBuf::from("overlay.toml"),
1356 }),
1357 },
1358 dir: case_dir,
1359 namespace: name.to_string(),
1360 }
1361 }
1362
1363 #[test]
1364 fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
1365 let mut output = "SELECT 1;\n\n+---+\n\n".to_string();
1366 trim_trailing_blank_lines(&mut output);
1367
1368 assert_eq!(output, "SELECT 1;\n\n+---+\n");
1369 }
1370
1371 #[test]
1372 fn profiles_are_baseline_first_and_group_by_full_semantic_digest() {
1373 let temp_dir = tempfile::tempdir().unwrap();
1374 let profiles = prepare_compat_profiles(
1375 vec![
1376 test_case(temp_dir.path(), "overlay_b", Some("[x]\nb = 2\na = 1\n")),
1377 test_case(temp_dir.path(), "baseline", None),
1378 test_case(temp_dir.path(), "overlay_a", Some("[x]\na = 1\nb = 2\n")),
1379 test_case(temp_dir.path(), "overlay_c", Some("value = 3\n")),
1380 ],
1381 &WalConfig::RaftEngine,
1382 )
1383 .unwrap();
1384 let profiles: Vec<_> = profiles.iter().collect();
1385
1386 assert!(matches!(profiles[0], CompatProfile::Baseline { .. }));
1387 assert_eq!(profiles[0].case_names(), ["baseline"]);
1388 assert_eq!(profiles[1].case_names(), ["overlay_a", "overlay_b"]);
1389 assert_eq!(profiles[1].sources().len(), 2);
1390 let overlay_keys: Vec<_> = profiles[1..]
1391 .iter()
1392 .map(|profile| match profile {
1393 CompatProfile::Overlay { overlay, .. } => *overlay.profile_key(),
1394 CompatProfile::Baseline { .. } => unreachable!(),
1395 })
1396 .collect();
1397 assert!(overlay_keys.windows(2).all(|keys| keys[0] < keys[1]));
1398 }
1399
1400 #[test]
1401 fn non_fail_fast_setup_verifies_only_successful_cases() {
1402 let mut progress = ProfileProgress::default();
1403 progress.record_setup_success(0);
1404 assert!(!progress.record_setup_failure("case_b (setup): failed".to_string(), false));
1405 progress.record_setup_success(2);
1406
1407 assert!(progress.should_transition_to_current());
1408 assert_eq!(progress.successful_setup_indexes, [0, 2]);
1409 assert!(!progress.should_stop_remaining_profiles());
1410 }
1411
1412 #[test]
1413 fn fail_fast_setup_blocks_current_and_later_profiles() {
1414 let mut progress = ProfileProgress::default();
1415 progress.record_setup_success(0);
1416
1417 assert!(progress.record_setup_failure("case_b (setup): failed".to_string(), true));
1418 assert!(!progress.should_transition_to_current());
1419 assert!(progress.should_stop_remaining_profiles());
1420 }
1421
1422 #[test]
1423 fn zero_successful_setups_skips_current() {
1424 let mut progress = ProfileProgress::default();
1425 progress.record_setup_failure("case_a (setup): failed".to_string(), false);
1426
1427 assert!(!progress.should_transition_to_current());
1428 assert!(!progress.should_stop_remaining_profiles());
1429 }
1430
1431 #[test]
1432 fn verify_fail_fast_stops_later_profiles_after_cleanup() {
1433 let mut progress = ProfileProgress::default();
1434 progress.record_setup_success(0);
1435
1436 assert!(progress.record_verify_failure("case_a (verify): failed".to_string(), true));
1437 assert!(progress.should_stop_remaining_profiles());
1438 }
1439
1440 #[test]
1441 fn non_fail_fast_aggregates_failures_but_cleanup_failure_stops_profiles() {
1442 let mut progress = ProfileProgress::default();
1443 progress.record_setup_failure("case_a (setup): failed".to_string(), false);
1444 progress.record_setup_success(1);
1445 progress.record_verify_failure("case_b (verify): failed".to_string(), false);
1446
1447 assert!(!progress.should_stop_remaining_profiles());
1448 progress.record_cleanup_failure("failed to remove profile state".to_string());
1449 assert_eq!(progress.failures.len(), 3);
1450 assert!(progress.should_stop_remaining_profiles());
1451 }
1452
1453 #[test]
1454 fn protected_path_warning_is_sorted_and_value_free() {
1455 let warning = format_protected_path_warning(
1456 "abcdef123456",
1457 vec!["case_z", "case_a"],
1458 vec!["wal.provider".to_string(), "mode".to_string()],
1459 );
1460
1461 assert_eq!(
1462 warning,
1463 "Warning: datanode overlay profile abcdef123456 touches runner-owned paths [mode, wal.provider] for cases [case_a, case_z]"
1464 );
1465 assert!(!warning.contains("secret-overlay-value"));
1466 }
1467
1468 #[test]
1469 fn profile_state_guard_preserves_state_on_forced_unwind() {
1470 let temp_dir = tempfile::tempdir().unwrap();
1471 let path = temp_dir.path().to_path_buf();
1472
1473 let unwind = std::panic::catch_unwind(|| {
1474 let _state = ProfileStateGuard::new(temp_dir, true);
1475 panic!("forced profile unwind");
1476 });
1477
1478 assert!(unwind.is_err());
1479 assert!(path.is_dir());
1480 std::fs::remove_dir_all(path).unwrap();
1481 }
1482
1483 #[test]
1484 fn profile_state_guard_removes_state_on_drop_without_preservation() {
1485 let temp_dir = tempfile::tempdir().unwrap();
1486 let path = temp_dir.path().to_path_buf();
1487
1488 drop(ProfileStateGuard::new(temp_dir, false));
1489
1490 assert!(!path.exists());
1491 }
1492
1493 #[test]
1494 fn profile_state_guard_normal_finalization_transfers_ownership_once() {
1495 let temp_dir = tempfile::tempdir().unwrap();
1496 let path = temp_dir.path().to_path_buf();
1497
1498 let cleanup = ProfileStateGuard::new(temp_dir, true).finalize();
1499
1500 assert!(cleanup.failures.is_empty());
1501 assert_eq!(cleanup.preserved_state.as_deref(), Some(path.as_path()));
1502 assert!(path.is_dir());
1503 std::fs::remove_dir_all(path).unwrap();
1504 }
1505}