// Copyright 2023 Greptime Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; use clap::{Parser, ValueEnum}; use sqlness::QueryContext; use sqlness::interceptor::template::DELIMITER as TEMPLATE_DELIMITER; use sqlness::interceptor::{InterceptorRef, Registry}; use crate::cmd::bare::ServerAddr; use crate::cmd::compat_case::{self, CompatCase, try_infer_version, version_matches_range}; use crate::cmd::datanode_overlay::{ DatanodeOverlay, DatanodeProtectionPolicy, PreparedDatanodeOverlay, }; use crate::env::bare::{Env, GreptimeDB, StoreConfig, WalConfig}; use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY}; use crate::util; const COMMENT_PREFIX: &str = "--"; const INTERCEPTOR_PREFIX: &str = "-- SQLNESS"; const QUERY_DELIMITER: char = ';'; #[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] enum CompatTopology { Distributed, Standalone, } impl CompatTopology { fn as_str(self) -> &'static str { match self { Self::Distributed => "distributed", Self::Standalone => "standalone", } } } /// Run compatibility tests in bare mode. /// /// Starts a "from" distributed cluster, runs setup SQLs, /// then restarts the cluster with a "to" version on preserved state, /// and runs verify SQLs comparing results against `verify.result` files. /// /// PR1 notes: /// - Sqlness interceptor comments are supported for each statement. /// - The runner starts the full distributed topology, including flownode. #[derive(Debug, Parser)] pub struct CompatCommand { /// Version of the "from" GreptimeDB binary (e.g. "v0.9.5") or "current". /// If neither --from-version nor --from-bins-dir is specified, the /// current debug build is used for both from and to. #[clap(long)] from_version: Option, /// Path to the directory containing the "from" GreptimeDB binary. #[clap(long)] from_bins_dir: Option, /// Path to the directory containing the "to" GreptimeDB binary. /// Defaults to the current debug build. #[clap(long)] to_bins_dir: Option, /// Version of the "to" GreptimeDB binary (e.g. "v1.1.4") or "current". /// Downloads the release binary when needed. Cannot be used with /// `--to-bins-dir`. #[clap(long)] to_version: Option, /// Directory of compatibility test cases. /// Defaults to `tests/compatibility/cases` relative to workspace root. #[clap(long)] case_dir: Option, /// Name of test cases to run. Accepts a regexp. #[clap(long, default_value = ".*")] test_filter: String, /// Require exactly this many cases after all filters are applied. #[clap(long)] expect_cases: Option, /// Topology to start for the compatibility test. #[clap(long, value_enum, default_value_t = CompatTopology::Distributed)] topology: CompatTopology, /// Fail this run as soon as one case fails. #[clap(long, default_value = "false")] fail_fast: bool, /// Preserve persistent state in the temporary directory after run. /// Etcd is always cleaned up regardless of this flag. #[clap(long, default_value = "false")] preserve_state: bool, /// Pull different versions of GreptimeDB on need. #[clap(long, default_value = "true")] pull_version_on_need: bool, /// Whether to set up etcd via Docker. Required for PR1 distributed compat. /// External metadata stores are not supported by the compat MVP yet. #[clap(long, default_value = "true")] setup_etcd: bool, /// Perform discovery and filtering only; print what would run without /// starting any services, mutating files, or running setup/verify. #[clap(long, default_value = "false")] dry_run: bool, } impl CompatCommand { pub async fn run(self) { let dry_run = self.dry_run; let topology = self.topology; if self.to_bins_dir.is_some() && self.to_version.is_some() { panic!("--to-version cannot be used with --to-bins-dir"); } // ---- 1. Validate MVP runtime constraints ---- if !dry_run && topology == CompatTopology::Distributed && !self.setup_etcd { panic!( "compat MVP requires Docker etcd (--setup-etcd=true); external metadata stores are not supported yet" ); } // ---- 2. Resolve case directory ---- let case_dir = self.case_dir.unwrap_or_else(default_compat_case_dir); if !case_dir.is_dir() { panic!("Case directory not found: {}", case_dir.display()); } // ---- 3. Discover cases ---- let mut cases = compat_case::discover_cases(&case_dir).unwrap_or_else(|e| panic!("{e}")); // Filter by test_filter let filter_re = regex::Regex::new(&self.test_filter) .unwrap_or_else(|e| panic!("Invalid test filter regex '{}': {e}", self.test_filter)); cases.retain(|c| filter_re.is_match(&c.metadata.name)); // Filter by topology cases.retain(|c| { c.metadata .topologies .iter() .any(|candidate| candidate == topology.as_str()) }); if cases.is_empty() { if let Some(expected_cases) = self.expect_cases { panic!( "Expected {expected_cases} compatibility cases after name and topology filtering, found 0" ); } if dry_run { println!( "DRY-RUN: no compat cases found matching filter '{}' and topology '{}'", self.test_filter, topology.as_str() ); } else { println!( "No compat cases found matching filter '{}' and topology '{}'", self.test_filter, topology.as_str() ); } return; } // ---- 3b. Validate metadata (incl. version constraints) before filtering ---- // Must run before version-range filtering so invalid constraints like // `>=not-a-version` cause a hard error instead of silent skip. compat_case::validate_cases_metadata(&cases).unwrap_or_else(|e| panic!("{e}")); // ---- 3c. Validate namespace dedup before version filtering ---- // Validate globally for all selected topology/name cases so duplicated // namespaces cannot hide behind version filters. compat_case::validate_case_namespaces(&cases).unwrap_or_else(|e| panic!("{e}")); // ---- 4. Resolve "from" and "to" versions ---- let (from_bins_dir, from_version, from_ver_parsed, to_bins_dir, to_version, to_ver_parsed) = if dry_run { // Dry-run: resolve versions without panicking on missing binaries. // try_infer_version returns None gracefully when the binary is absent. let dry_run_from_bins_dir = self .from_bins_dir .clone() .unwrap_or_else(|| util::get_binary_dir("debug")); let from_ver_str = self .from_version .as_deref() .and_then(|v| { if v == "current" { None } else { Some(v.to_string()) } }) .or_else(|| try_infer_version(&dry_run_from_bins_dir).map(|v| v.to_string())); let from_ver_parsed = from_ver_str .as_deref() .and_then(|s| compat_case::Version::parse(s).ok()); let dry_run_to_bins_dir = self.to_bins_dir.clone().unwrap_or_else(|| { self.to_version .as_deref() .filter(|version| *version != "current") .map(|version| PathBuf::from(util::get_workspace_root()).join(version)) .unwrap_or_else(|| util::get_binary_dir("debug")) }); let to_ver_str = self .to_version .as_deref() .filter(|version| *version != "current") .map(str::to_string) .or_else(|| try_infer_version(&dry_run_to_bins_dir).map(|v| v.to_string())); let to_ver_parsed = to_ver_str .as_deref() .and_then(|s| compat_case::Version::parse(s).ok()); ( Some(dry_run_from_bins_dir), from_ver_str, from_ver_parsed, Some(dry_run_to_bins_dir), to_ver_str, to_ver_parsed, ) } else { // Normal path: resolve bins (may panic if binary not found). let from_bins_dir = resolve_bins( self.from_bins_dir.as_ref(), self.from_version.as_deref(), self.pull_version_on_need, ) .await; let from_version = if let Some(ref ver) = self.from_version { if ver != "current" { Some(ver.clone()) } else { try_infer_version(&from_bins_dir).map(|v| v.to_string()) } } else { try_infer_version(&from_bins_dir).map(|v| v.to_string()) }; let from_ver_parsed = from_version .as_deref() .and_then(|s| compat_case::Version::parse(s).ok()); let to_bins_dir = resolve_bins( self.to_bins_dir.as_ref(), self.to_version.as_deref(), self.pull_version_on_need, ) .await; let to_version = self .to_version .as_deref() .filter(|version| *version != "current") .map(str::to_string) .or_else(|| try_infer_version(&to_bins_dir).map(|v| v.to_string())); let to_ver_parsed = to_version .as_deref() .and_then(|s| compat_case::Version::parse(s).ok()); ( Some(from_bins_dir), from_version, from_ver_parsed, Some(to_bins_dir), to_version, to_ver_parsed, ) }; // ---- 5b. Filter by version range ---- let pre_filter_count = cases.len(); cases.retain(|c| { let from_ok = version_matches_range(from_ver_parsed.as_ref(), &c.metadata.from_range); if !from_ok { let from_label = from_ver_parsed .as_ref() .map(|v| v.to_string()) .unwrap_or_else(|| "unknown".to_string()); println!( "Skipping case '{}': from_range {:?} does not match version '{}'", c.metadata.name, c.metadata.from_range, from_label ); } from_ok }); cases.retain(|c| { let to_ok = version_matches_range(to_ver_parsed.as_ref(), &c.metadata.to_range); if !to_ok { let to_label = to_ver_parsed .as_ref() .map(|v| v.to_string()) .unwrap_or_else(|| "unknown".to_string()); println!( "Skipping case '{}': to_range {:?} does not match version '{}'", c.metadata.name, c.metadata.to_range, to_label ); } to_ok }); if pre_filter_count != cases.len() { println!( "Version-range filtering: {} → {} cases", pre_filter_count, cases.len() ); } if cases.is_empty() { if let Some(expected_cases) = self.expect_cases { panic!( "Expected {expected_cases} compatibility cases after all filtering, found 0" ); } if dry_run { println!("DRY-RUN: no compat cases would run after version-range filtering"); } else { println!("No compat cases remaining after version-range filtering"); } return; } let wal_config = WalConfig::RaftEngine; let profiles = prepare_compat_profiles(cases, &wal_config).unwrap_or_else(|error| panic!("{error}")); emit_protected_path_warnings(&profiles); if let Some(expected_cases) = self.expect_cases { assert_eq!( profiles.case_count(), expected_cases, "Expected {expected_cases} compatibility cases after all filtering, found {}", profiles.case_count() ); } if dry_run { println!( "DRY-RUN: would run {} compat case(s)", profiles.case_count() ); println!(" topology: {}", topology.as_str()); println!( " from version: {}", from_version.as_deref().unwrap_or( "unknown (use --from-version, --from-bins-dir, or build debug binary)" ) ); println!( " to version: {}", to_version .as_deref() .unwrap_or("unknown (use --to-bins-dir or build debug binary)") ); if pre_filter_count != profiles.case_count() { println!(); println!( "Version-range filtering reduced {} → {} cases (see 'Skipping case' messages above)", pre_filter_count, profiles.case_count() ); } println!(); for c in profiles.cases() { println!(" case: {}", c.metadata.name); println!(" namespace: {}", c.namespace); println!(" from_range: {:?}", c.metadata.from_range); println!(" to_range: {:?}", c.metadata.to_range); println!(" features: {:?}", c.metadata.features); } println!("DRY-RUN: compatibility profiles:"); for profile in profiles.iter() { println!(" profile: {}", profile.profile_id()); println!(" cases: {}", profile.case_names().join(", ")); for source in profile.sources() { println!(" sidecar: {}", source.display()); } } println!(); println!("Dry run complete. Remove --dry-run to execute."); return; } println!( "Running {} compat case(s) with topology {}:", profiles.case_count(), topology.as_str() ); for c in profiles.cases() { println!( " - {} (namespace: {}, topologies: {:?})", c.metadata.name, c.namespace, c.metadata.topologies ); } // ---- 6. Build interceptor registry ---- let interceptor_registry = create_interceptor_registry(); let to_bins_dir = to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"); let profile_config = ProfileRunConfig { interceptor_registry: &interceptor_registry, from_bins_dir, to_bins_dir, pull_version_on_need: self.pull_version_on_need, setup_etcd: self.setup_etcd, fail_fast: self.fail_fast, topology, preserve_state: self.preserve_state, wal_config, }; let mut failures = Vec::new(); let mut preserved_states = Vec::new(); for profile in profiles.iter() { let outcome = run_profile(profile, &profile_config).await; failures.extend(outcome.failures); if let Some(state) = outcome.preserved_state { preserved_states.push(state); } if outcome.stop_remaining_profiles { break; } } for state in &preserved_states { println!("Preserved compat profile state: {}", state.display()); } if failures.is_empty() { println!("\n\x1b[32mAll compat tests passed!\x1b[0m"); } else { panic!("\n\x1b[31mFailed cases: {}\x1b[0m", failures.join(", ")); } } } /// Cases sharing one old-stage datanode configuration lifecycle. #[derive(Debug)] enum CompatProfile { Baseline { cases: Vec, }, Overlay { overlay: Arc, cases: Vec, sources: Vec, }, } impl CompatProfile { fn profile_id(&self) -> &str { match self { Self::Baseline { .. } => "baseline", Self::Overlay { overlay, .. } => overlay.profile_id(), } } fn cases(&self) -> &[CompatCase] { match self { Self::Baseline { cases } | Self::Overlay { cases, .. } => cases, } } fn case_names(&self) -> Vec<&str> { self.cases() .iter() .map(|case| case.metadata.name.as_str()) .collect() } fn sources(&self) -> &[PathBuf] { match self { Self::Baseline { .. } => &[], Self::Overlay { sources, .. } => sources, } } fn overlay(&self) -> Option> { match self { Self::Baseline { .. } => None, Self::Overlay { overlay, .. } => Some(Arc::clone(overlay)), } } fn touched_protected_paths(&self) -> &[crate::cmd::datanode_overlay::DottedPath] { match self { Self::Baseline { .. } => &[], Self::Overlay { overlay, .. } => overlay.touched_protected_paths(), } } } /// Deterministically ordered compatibility profiles. #[derive(Debug)] struct CompatProfiles(Vec); impl CompatProfiles { fn iter(&self) -> impl Iterator { self.0.iter() } fn cases(&self) -> impl Iterator { self.0.iter().flat_map(CompatProfile::cases) } fn case_count(&self) -> usize { self.0.iter().map(|profile| profile.cases().len()).sum() } } #[derive(Debug)] struct OverlayProfileGroup { overlay: Arc, cases: Vec, sources: Vec, } fn prepare_compat_profiles( cases: Vec, wal_config: &WalConfig, ) -> Result { let protection = DatanodeProtectionPolicy::for_wal(wal_config); let mut baseline_cases = Vec::new(); let mut overlays: BTreeMap<[u8; 32], OverlayProfileGroup> = BTreeMap::new(); for case in cases { let Some(reference) = case.metadata.old_datanode_overlay() else { baseline_cases.push(case); continue; }; let overlay = DatanodeOverlay::load(&case.dir, reference).map_err(|error| { format!( "Failed to load old datanode overlay for compatibility case '{}': {error}", case.metadata.name ) })?; let prepared = Arc::new(overlay.prepare(&protection).map_err(|error| { format!( "Failed to prepare old datanode overlay for compatibility case '{}': {error}", case.metadata.name ) })?); let key = *prepared.profile_key(); let source = prepared.source().to_path_buf(); let entry = overlays.entry(key).or_insert_with(|| OverlayProfileGroup { overlay: Arc::clone(&prepared), cases: Vec::new(), sources: Vec::new(), }); entry.cases.push(case); entry.sources.push(source); } baseline_cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); let mut profiles = Vec::new(); if !baseline_cases.is_empty() { profiles.push(CompatProfile::Baseline { cases: baseline_cases, }); } for ( _, OverlayProfileGroup { overlay, mut cases, mut sources, }, ) in overlays { cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); sources.sort(); sources.dedup(); profiles.push(CompatProfile::Overlay { overlay, cases, sources, }); } Ok(CompatProfiles(profiles)) } fn emit_protected_path_warnings(profiles: &CompatProfiles) { for profile in profiles.iter() { let paths: Vec<_> = profile .touched_protected_paths() .iter() .map(ToString::to_string) .collect(); if paths.is_empty() { continue; } println!( "{}", format_protected_path_warning(profile.profile_id(), profile.case_names(), paths) ); } } fn format_protected_path_warning( profile_id: &str, mut case_names: Vec<&str>, mut paths: Vec, ) -> String { case_names.sort_unstable(); paths.sort_unstable(); format!( "Warning: datanode overlay profile {profile_id} touches runner-owned paths [{}] for cases [{}]", paths.join(", "), case_names.join(", ") ) } #[derive(Debug)] struct ProfileOutcome { failures: Vec, stop_remaining_profiles: bool, preserved_state: Option, } /// Pure compatibility profile progress policy, independent of process ownership. #[derive(Default)] struct ProfileProgress { successful_setup_indexes: Vec, failures: Vec, fail_fast_setup_failure: bool, fail_fast_verify_failure: bool, cleanup_failed: bool, } impl ProfileProgress { fn record_setup_success(&mut self, case_index: usize) { self.successful_setup_indexes.push(case_index); } fn record_setup_failure(&mut self, failure: String, fail_fast: bool) -> bool { self.failures.push(failure); self.fail_fast_setup_failure = fail_fast; fail_fast } fn record_verify_failure(&mut self, failure: String, fail_fast: bool) -> bool { self.failures.push(failure); self.fail_fast_verify_failure = fail_fast; fail_fast } fn record_cleanup_failure(&mut self, failure: String) { self.failures.push(failure); self.cleanup_failed = true; } fn should_transition_to_current(&self) -> bool { !self.successful_setup_indexes.is_empty() && !self.fail_fast_setup_failure } fn should_stop_remaining_profiles(&self) -> bool { self.fail_fast_setup_failure || self.fail_fast_verify_failure || self.cleanup_failed } } struct ProfileRunConfig<'a> { interceptor_registry: &'a Registry, from_bins_dir: Option, to_bins_dir: PathBuf, pull_version_on_need: bool, setup_etcd: bool, fail_fast: bool, topology: CompatTopology, preserve_state: bool, wal_config: WalConfig, } async fn run_profile(profile: &CompatProfile, config: &ProfileRunConfig<'_>) -> ProfileOutcome { println!("Running compatibility profile {}", profile.profile_id()); let temp_dir = tempfile::Builder::new() .prefix(&format!("sqlness-compat-{}-", profile.profile_id())) .tempdir() .unwrap(); let profile_state = ProfileStateGuard::new(temp_dir, config.preserve_state); let sqlness_home = profile_state.path().to_path_buf(); // Standalone runs need no etcd. let setup_etcd = config.topology == CompatTopology::Distributed && config.setup_etcd; unsafe { std::env::set_var( "SQLNESS_HOME", sqlness_home.join("copy").display().to_string(), ); } let store_config = StoreConfig { store_addrs: setup_etcd .then(|| "127.0.0.1:2379".to_string()) .into_iter() .collect(), setup_etcd, setup_pg: None, setup_mysql: None, enable_flat_format: false, enable_gc: false, }; let env = Env::new( sqlness_home.clone(), ServerAddr::default(), config.wal_config.clone(), config.pull_version_on_need, config.from_bins_dir.clone(), store_config, vec![], ); if let Some(overlay) = profile.overlay() { env.activate_compat_old(overlay); } // Arm immediately before starting the profile so earlier preflight failures // cannot remove a developer-owned etcd container. let etcd_guard = setup_etcd.then(EtcdGuard::new); println!( "Starting old-version {} cluster...", config.topology.as_str() ); let db = match config.topology { CompatTopology::Distributed => env.compat_start_distributed(0).await, CompatTopology::Standalone => env.compat_start_standalone(0).await, }; let mut progress = ProfileProgress::default(); for (case_index, case) in profile.cases().iter().enumerate() { match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Setup).await { Ok(()) => { println!(" Setup: {} - OK", case.metadata.name); progress.record_setup_success(case_index); } Err(error) => { println!(" Setup: {} - FAILED: {error}", case.metadata.name); if progress.record_setup_failure( format!("{} (setup): {error}", case.metadata.name), config.fail_fast, ) { break; } } } } if progress.should_transition_to_current() { println!("Restarting cluster with new-version binary on preserved state..."); env.activate_compat_current(); env.compat_restart(&db, config.to_bins_dir.clone()).await; println!("Running verify phase..."); for case_index in progress.successful_setup_indexes.clone() { let case = &profile.cases()[case_index]; match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Verify) .await { Ok(()) => println!(" Verify: {} - PASSED", case.metadata.name), Err(error) => { println!(" Verify: {} - FAILED: {error}", case.metadata.name); if progress.record_verify_failure( format!("{} (verify): {error}", case.metadata.name), config.fail_fast, ) { break; } } } } } else if progress.successful_setup_indexes.is_empty() { println!("Skipping current-version restart: no setup cases succeeded"); } let cleanup = cleanup_profile(db, etcd_guard, setup_etcd, profile_state).await; for failure in cleanup.failures { progress.record_cleanup_failure(failure); } let stop_remaining_profiles = progress.should_stop_remaining_profiles(); ProfileOutcome { failures: progress.failures, stop_remaining_profiles, preserved_state: cleanup.preserved_state, } } struct CleanupOutcome { failures: Vec, preserved_state: Option, } /// Owns one profile state directory until normal cleanup or unwind finalization. struct ProfileStateGuard { temp_dir: Option, preserve_state: bool, } impl ProfileStateGuard { fn new(temp_dir: tempfile::TempDir, preserve_state: bool) -> Self { Self { temp_dir: Some(temp_dir), preserve_state, } } fn path(&self) -> &std::path::Path { self.temp_dir.as_ref().unwrap().path() } fn finalize(mut self) -> CleanupOutcome { let temp_dir = self.temp_dir.take().unwrap(); let mut failures = Vec::new(); if self.preserve_state { let path = temp_dir.keep(); if let Err(error) = std::fs::metadata(&path) { failures.push(format!( "failed to confirm preserved profile state {}: {error}", path.display() )); } return CleanupOutcome { failures, preserved_state: Some(path), }; } println!("Removing state in {}", temp_dir.path().display()); if let Err(error) = temp_dir.close() { failures.push(format!("failed to remove profile state: {error}")); } CleanupOutcome { failures, preserved_state: None, } } } impl Drop for ProfileStateGuard { fn drop(&mut self) { let Some(temp_dir) = self.temp_dir.take() else { return; }; if self.preserve_state { let path = temp_dir.keep(); println!( "Preserved compat profile state after abnormal exit: {}", path.display() ); } } } async fn cleanup_profile( mut db: GreptimeDB, mut etcd_guard: Option, setup_etcd: bool, profile_state: ProfileStateGuard, ) -> CleanupOutcome { db.compat_stop(); drop(db); let mut failures = Vec::new(); if setup_etcd { println!("Stopping etcd"); match util::stop_rm_etcd_checked() { Ok(()) => { if let Some(guard) = etcd_guard.as_mut() { guard.disarm(); } } Err(error) => failures.push(format!("profile etcd cleanup: {error}")), } } // On failed checked cleanup this Drop performs one best-effort retry before // profile state is finalized. drop(etcd_guard); let mut state_outcome = profile_state.finalize(); failures.append(&mut state_outcome.failures); CleanupOutcome { failures, preserved_state: state_outcome.preserved_state, } } /// Guard that stops/removes Docker etcd on drop (panic or early exit). /// Disarm before normal cleanup to avoid double-cleanup. /// /// The guard refuses to arm if a container named `etcd` already exists, so a /// failed compat run never deletes a developer-owned container with that name. struct EtcdGuard { active: bool, } impl EtcdGuard { fn new() -> Self { let inspect_status = std::process::Command::new("docker") .args(["container", "inspect", "etcd"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status(); if inspect_status.is_ok_and(|status| status.success()) { panic!( "A Docker container named `etcd` already exists. \ Remove it before running compat tests so the cleanup guard \ cannot delete a container it did not create." ); } Self { active: true } } fn disarm(&mut self) { self.active = false; } } impl Drop for EtcdGuard { fn drop(&mut self) { if self.active { println!("EtcdGuard: emergency etcd cleanup (panic or early exit)"); if let Err(error) = util::stop_rm_etcd_checked() { println!("EtcdGuard: emergency etcd cleanup failed: {error}"); } } } } /// Phase of compat execution. #[derive(Clone, Copy, PartialEq, Eq)] enum CompatPhase { Setup, Verify, } /// Create an interceptor registry matching the ordinary sqlness runner. fn create_interceptor_registry() -> Registry { let mut interceptor_registry: Registry = Default::default(); interceptor_registry.register( protocol_interceptor::PREFIX, Arc::new(protocol_interceptor::ProtocolInterceptorFactory), ); interceptor_registry } /// Resolve binary directory: explicit path takes priority, then version (pulls if needed), /// otherwise default to current debug build. /// /// Validates that `/greptime` exists after resolution and canonicalizes the path. async fn resolve_bins( bins_dir: Option<&PathBuf>, version: Option<&str>, pull_version_on_need: bool, ) -> PathBuf { let dir = if let Some(dir) = bins_dir { dir.clone() } else if let Some(ver) = version { if ver == "current" { util::get_binary_dir("debug") } else { util::maybe_pull_binary(ver, pull_version_on_need).await; let root = std::path::PathBuf::from(util::get_workspace_root()); std::path::PathBuf::from_iter([root, std::path::PathBuf::from(ver)]) } } else { // Default: current debug build util::get_binary_dir("debug") }; // Canonicalize when possible (may fail if dir doesn't exist) let dir = match dir.canonicalize() { Ok(canon) => canon, Err(e) => panic!( "Cannot resolve binary directory '{}': {e}. \ Use --from-bins-dir / --to-bins-dir to specify the correct path, \ or --from-version to pull a release.", dir.display() ), }; if !dir.join(util::PROGRAM).is_file() { panic!( "greptime binary not found in '{}'. \ Use --from-bins-dir / --to-bins-dir to specify the correct directory, \ or build greptime first (e.g. `cargo build -p greptime`). \ Note: if you use a custom target-dir, the binary may be elsewhere; \ pass the actual directory with --from-bins-dir or --to-bins-dir.", dir.display() ); } dir } /// Default case directory: `tests/compatibility/cases` relative to workspace root. fn default_compat_case_dir() -> PathBuf { let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // CARGO_MANIFEST_DIR is tests/runner // Pop to tests/ path.pop(); path.push("compatibility"); path.push("cases"); path } /// Run a single compat phase (setup or verify) for one case. async fn run_compat_phase( db: &crate::env::bare::GreptimeDB, case: &CompatCase, registry: &Registry, phase: CompatPhase, ) -> Result<(), String> { let sql_file = match phase { CompatPhase::Setup => case.dir.join("setup.sql"), CompatPhase::Verify => case.dir.join("verify.sql"), }; let sql_content = std::fs::read_to_string(&sql_file) .map_err(|e| format!("Failed to read {}: {e}", sql_file.display()))?; let mut statements = parse_sql_file(&sql_content, registry)?; // Execute statements let mut verify_output = String::new(); for statement in &mut statements { let (display, results) = statement.execute(db, &case.namespace).await?; match phase { CompatPhase::Setup => { // Setup: just check for success (already returned Ok) } CompatPhase::Verify => { verify_output.push_str(&display); for result in results { verify_output.push_str(&result); verify_output.push('\n'); verify_output.push('\n'); } } } } if phase == CompatPhase::Verify { trim_trailing_blank_lines(&mut verify_output); let result_path = case.dir.join("verify.result"); // If verify.result doesn't exist, generate it from actual output but // return an error so the author must review, commit, and rerun. if !result_path.is_file() { std::fs::write(&result_path, &verify_output) .map_err(|e| format!("Failed to create {}: {e}", result_path.display()))?; return Err(format!( "Created missing verify.result for case '{}'; review the generated file, commit it, and rerun", case.metadata.name )); } let expected = std::fs::read_to_string(&result_path) .map_err(|e| format!("Failed to read {}: {e}", result_path.display()))?; if verify_output != expected { // Update the result file with actual output to aid local update. std::fs::write(&result_path, &verify_output) .map_err(|e| format!("Failed to update {}: {e}", result_path.display()))?; // Generate a simple diff let diff = simple_diff(&expected, &verify_output); return Err(format!( "Result mismatch for case '{}'.\nDiff:\n{diff}", case.metadata.name )); } } Ok(()) } /// Keep generated snapshots compatible with `git diff --check` by avoiding a /// trailing blank line at EOF while preserving the final newline. fn trim_trailing_blank_lines(output: &mut String) { while output.ends_with("\n\n") { output.pop(); } } /// Execute the namespace prelude (CREATE DATABASE IF NOT EXISTS + USE) for a case. /// This is NOT written into verify.result. /// /// The prelude is protocol-aware: /// - `CREATE DATABASE` is always sent via gRPC so it works regardless of /// statement-level protocol directives. /// - For Postgres-protocol statements, `SET search_path` selects the case /// namespace instead of running `USE` (which is not valid PG SQL). /// - For MySQL and default/gRPC statements, `USE ` runs through the /// statement's effective context. async fn run_namespace_prelude( db: &crate::env::bare::GreptimeDB, namespace: &str, query_ctx: &QueryContext, ) -> Result<(), String> { // CREATE DATABASE always via gRPC — no protocol override let create_db = format!("CREATE DATABASE IF NOT EXISTS {namespace}"); let default_ctx = QueryContext::default(); db.compat_query(&create_db, &default_ctx).await?; // Postgres: select the namespace via search_path instead of USE. if query_ctx .context .get(PROTOCOL_KEY) .is_some_and(|p| p == POSTGRES) { let set_search_path = format!("SET search_path TO '{namespace}'"); db.compat_query(&set_search_path, query_ctx).await?; return Ok(()); } // MySQL / default (gRPC): execute USE let use_db = format!("USE {namespace}"); db.compat_query(&use_db, query_ctx).await?; Ok(()) } /// A parsed SQL statement with sqlness comments and interceptors. struct ParsedStatement { comment_lines: Vec, display_query: Vec, execute_query: Vec, interceptors: Vec, } impl ParsedStatement { fn new() -> Self { Self { comment_lines: Vec::new(), display_query: Vec::new(), execute_query: Vec::new(), interceptors: Vec::new(), } } fn push_comment(&mut self, line: String) { self.comment_lines.push(line); } fn push_interceptor(&mut self, line: &str, registry: &Registry) -> Result<(), String> { let Some((_, remaining)) = line.split_once(INTERCEPTOR_PREFIX) else { return Err(format!( "Missing sqlness interceptor prefix in line: {line}" )); }; let interceptor = registry.create(remaining).map_err(|e| e.to_string())?; self.interceptors.push(interceptor); Ok(()) } fn append_query_line(&mut self, line: &str) { self.display_query.push(line.to_string()); self.execute_query.push(line.to_string()); } fn is_empty(&self) -> bool { self.comment_lines.is_empty() && self.display_query.is_empty() && self.execute_query.is_empty() && self.interceptors.is_empty() } fn has_query(&self) -> bool { !self.execute_query.is_empty() } fn display_text(&self) -> String { let mut output = String::new(); for comment in &self.comment_lines { output.push_str(comment); output.push('\n'); } for line in &self.display_query { output.push_str(line); } output.push('\n'); output.push('\n'); output } fn concat_query_lines(&self) -> String { self.execute_query .iter() .fold(String::new(), |query, line| query + line) .trim_start() .to_string() } async fn before_execute_intercept(&mut self) -> QueryContext { let mut context = QueryContext::default(); for interceptor in &self.interceptors { interceptor .before_execute_async(&mut self.execute_query, &mut context) .await; } context } async fn after_execute_intercept(&self, result: &mut String) { for interceptor in &self.interceptors { interceptor.after_execute_async(result).await; } } async fn execute( &mut self, db: &crate::env::bare::GreptimeDB, namespace: &str, ) -> Result<(String, Vec), String> { let display = self.display_text(); let context = self.before_execute_intercept().await; db.compat_prepare_query_context(&context).await; run_namespace_prelude(db, namespace, &context).await?; let sql = self.concat_query_lines(); let mut results = Vec::new(); for sql in sql.split(TEMPLATE_DELIMITER) { if sql.trim().is_empty() { continue; } let sql = if sql.ends_with(QUERY_DELIMITER) { sql.to_string() } else { format!("{sql};") }; let mut result = db.compat_query(&sql, &context).await?; self.after_execute_intercept(&mut result).await; results.push(result); } Ok((display, results)) } } /// Parse a SQL file into statements using the same sqlness comment/interceptor /// conventions as the ordinary runner. fn parse_sql_file(content: &str, registry: &Registry) -> Result, String> { let mut statements = Vec::new(); let mut current_stmt = ParsedStatement::new(); for line in content.lines() { if line.starts_with(COMMENT_PREFIX) { current_stmt.push_comment(line.to_string()); if line.starts_with(INTERCEPTOR_PREFIX) { current_stmt.push_interceptor(line, registry)?; } continue; } if line.is_empty() { continue; } current_stmt.append_query_line(line); // Check for statement terminator if line.ends_with(QUERY_DELIMITER) { if current_stmt.has_query() { statements.push(current_stmt); } current_stmt = ParsedStatement::new(); } else { current_stmt.append_query_line("\n"); } } // Flush any remaining statement if !current_stmt.is_empty() && current_stmt.has_query() { statements.push(current_stmt); } if statements.is_empty() { return Err("No SQL statements found in file".to_string()); } Ok(statements) } /// Generate a simple line-based diff between expected and actual. fn simple_diff(expected: &str, actual: &str) -> String { let mut diff = String::new(); let expected_lines: Vec<&str> = expected.lines().collect(); let actual_lines: Vec<&str> = actual.lines().collect(); let max_len = expected_lines.len().max(actual_lines.len()); for i in 0..max_len { let exp = expected_lines.get(i).unwrap_or(&"(missing)"); let act = actual_lines.get(i).unwrap_or(&"(missing)"); if exp != act { diff.push_str(&format!(" Line {}:\n", i + 1)); diff.push_str(&format!(" expected: {exp}\n")); diff.push_str(&format!(" actual: {act}\n")); } } if diff.is_empty() { diff.push_str(" (files differ but no line-level diff found — may be whitespace)\n"); } diff } #[cfg(test)] mod tests { use std::path::Path; use super::*; use crate::cmd::compat_case::{CaseMetadata, OldConfigMetadata}; fn test_case(temp_dir: &Path, name: &str, overlay: Option<&str>) -> CompatCase { let case_dir = temp_dir.join(name); std::fs::create_dir_all(&case_dir).unwrap(); if let Some(overlay) = overlay { std::fs::write(case_dir.join("overlay.toml"), overlay).unwrap(); } CompatCase { metadata: CaseMetadata { name: name.to_string(), reason: "test".to_string(), introduced_by: "test".to_string(), topologies: vec![CompatTopology::Distributed.as_str().to_string()], from_range: vec!["*".to_string()], to_range: vec!["*".to_string()], features: vec!["table".to_string()], owner: "test".to_string(), namespace: None, old_config: overlay.map(|_| OldConfigMetadata { datanode: PathBuf::from("overlay.toml"), }), }, dir: case_dir, namespace: name.to_string(), } } #[test] fn test_trim_trailing_blank_lines_preserves_single_final_newline() { let mut output = "SELECT 1;\n\n+---+\n\n".to_string(); trim_trailing_blank_lines(&mut output); assert_eq!(output, "SELECT 1;\n\n+---+\n"); } #[test] fn profiles_are_baseline_first_and_group_by_full_semantic_digest() { let temp_dir = tempfile::tempdir().unwrap(); let profiles = prepare_compat_profiles( vec![ test_case(temp_dir.path(), "overlay_b", Some("[x]\nb = 2\na = 1\n")), test_case(temp_dir.path(), "baseline", None), test_case(temp_dir.path(), "overlay_a", Some("[x]\na = 1\nb = 2\n")), test_case(temp_dir.path(), "overlay_c", Some("value = 3\n")), ], &WalConfig::RaftEngine, ) .unwrap(); let profiles: Vec<_> = profiles.iter().collect(); assert!(matches!(profiles[0], CompatProfile::Baseline { .. })); assert_eq!(profiles[0].case_names(), ["baseline"]); assert_eq!(profiles[1].case_names(), ["overlay_a", "overlay_b"]); assert_eq!(profiles[1].sources().len(), 2); let overlay_keys: Vec<_> = profiles[1..] .iter() .map(|profile| match profile { CompatProfile::Overlay { overlay, .. } => *overlay.profile_key(), CompatProfile::Baseline { .. } => unreachable!(), }) .collect(); assert!(overlay_keys.windows(2).all(|keys| keys[0] < keys[1])); } #[test] fn non_fail_fast_setup_verifies_only_successful_cases() { let mut progress = ProfileProgress::default(); progress.record_setup_success(0); assert!(!progress.record_setup_failure("case_b (setup): failed".to_string(), false)); progress.record_setup_success(2); assert!(progress.should_transition_to_current()); assert_eq!(progress.successful_setup_indexes, [0, 2]); assert!(!progress.should_stop_remaining_profiles()); } #[test] fn fail_fast_setup_blocks_current_and_later_profiles() { let mut progress = ProfileProgress::default(); progress.record_setup_success(0); assert!(progress.record_setup_failure("case_b (setup): failed".to_string(), true)); assert!(!progress.should_transition_to_current()); assert!(progress.should_stop_remaining_profiles()); } #[test] fn zero_successful_setups_skips_current() { let mut progress = ProfileProgress::default(); progress.record_setup_failure("case_a (setup): failed".to_string(), false); assert!(!progress.should_transition_to_current()); assert!(!progress.should_stop_remaining_profiles()); } #[test] fn verify_fail_fast_stops_later_profiles_after_cleanup() { let mut progress = ProfileProgress::default(); progress.record_setup_success(0); assert!(progress.record_verify_failure("case_a (verify): failed".to_string(), true)); assert!(progress.should_stop_remaining_profiles()); } #[test] fn non_fail_fast_aggregates_failures_but_cleanup_failure_stops_profiles() { let mut progress = ProfileProgress::default(); progress.record_setup_failure("case_a (setup): failed".to_string(), false); progress.record_setup_success(1); progress.record_verify_failure("case_b (verify): failed".to_string(), false); assert!(!progress.should_stop_remaining_profiles()); progress.record_cleanup_failure("failed to remove profile state".to_string()); assert_eq!(progress.failures.len(), 3); assert!(progress.should_stop_remaining_profiles()); } #[test] fn protected_path_warning_is_sorted_and_value_free() { let warning = format_protected_path_warning( "abcdef123456", vec!["case_z", "case_a"], vec!["wal.provider".to_string(), "mode".to_string()], ); assert_eq!( warning, "Warning: datanode overlay profile abcdef123456 touches runner-owned paths [mode, wal.provider] for cases [case_a, case_z]" ); assert!(!warning.contains("secret-overlay-value")); } #[test] fn profile_state_guard_preserves_state_on_forced_unwind() { let temp_dir = tempfile::tempdir().unwrap(); let path = temp_dir.path().to_path_buf(); let unwind = std::panic::catch_unwind(|| { let _state = ProfileStateGuard::new(temp_dir, true); panic!("forced profile unwind"); }); assert!(unwind.is_err()); assert!(path.is_dir()); std::fs::remove_dir_all(path).unwrap(); } #[test] fn profile_state_guard_removes_state_on_drop_without_preservation() { let temp_dir = tempfile::tempdir().unwrap(); let path = temp_dir.path().to_path_buf(); drop(ProfileStateGuard::new(temp_dir, false)); assert!(!path.exists()); } #[test] fn profile_state_guard_normal_finalization_transfers_ownership_once() { let temp_dir = tempfile::tempdir().unwrap(); let path = temp_dir.path().to_path_buf(); let cleanup = ProfileStateGuard::new(temp_dir, true).finalize(); assert!(cleanup.failures.is_empty()); assert_eq!(cleanup.preserved_state.as_deref(), Some(path.as_path())); assert!(path.is_dir()); std::fs::remove_dir_all(path).unwrap(); } }