mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-08-18 20:18:30 +00:00
bb6d55a99f
* feat: support old-stage datanode config overlays Signed-off-by: discord9 <discord9@163.com> * fix: derive compat overlay policy from WAL config Signed-off-by: discord9 <discord9@163.com> --------- Signed-off-by: discord9 <discord9@163.com>
1506 lines
51 KiB
Rust
1506 lines
51 KiB
Rust
// 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<String>,
|
|
|
|
/// Path to the directory containing the "from" GreptimeDB binary.
|
|
#[clap(long)]
|
|
from_bins_dir: Option<PathBuf>,
|
|
|
|
/// Path to the directory containing the "to" GreptimeDB binary.
|
|
/// Defaults to the current debug build.
|
|
#[clap(long)]
|
|
to_bins_dir: Option<PathBuf>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Directory of compatibility test cases.
|
|
/// Defaults to `tests/compatibility/cases` relative to workspace root.
|
|
#[clap(long)]
|
|
case_dir: Option<PathBuf>,
|
|
|
|
/// 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<usize>,
|
|
|
|
/// 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<CompatCase>,
|
|
},
|
|
Overlay {
|
|
overlay: Arc<PreparedDatanodeOverlay>,
|
|
cases: Vec<CompatCase>,
|
|
sources: Vec<PathBuf>,
|
|
},
|
|
}
|
|
|
|
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<Arc<PreparedDatanodeOverlay>> {
|
|
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<CompatProfile>);
|
|
|
|
impl CompatProfiles {
|
|
fn iter(&self) -> impl Iterator<Item = &CompatProfile> {
|
|
self.0.iter()
|
|
}
|
|
|
|
fn cases(&self) -> impl Iterator<Item = &CompatCase> {
|
|
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<PreparedDatanodeOverlay>,
|
|
cases: Vec<CompatCase>,
|
|
sources: Vec<PathBuf>,
|
|
}
|
|
|
|
fn prepare_compat_profiles(
|
|
cases: Vec<CompatCase>,
|
|
wal_config: &WalConfig,
|
|
) -> Result<CompatProfiles, String> {
|
|
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>,
|
|
) -> 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<String>,
|
|
stop_remaining_profiles: bool,
|
|
preserved_state: Option<PathBuf>,
|
|
}
|
|
|
|
/// Pure compatibility profile progress policy, independent of process ownership.
|
|
#[derive(Default)]
|
|
struct ProfileProgress {
|
|
successful_setup_indexes: Vec<usize>,
|
|
failures: Vec<String>,
|
|
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<PathBuf>,
|
|
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<String>,
|
|
preserved_state: Option<PathBuf>,
|
|
}
|
|
|
|
/// Owns one profile state directory until normal cleanup or unwind finalization.
|
|
struct ProfileStateGuard {
|
|
temp_dir: Option<tempfile::TempDir>,
|
|
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<EtcdGuard>,
|
|
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 `<dir>/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 <ns>` 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<String>,
|
|
display_query: Vec<String>,
|
|
execute_query: Vec<String>,
|
|
interceptors: Vec<InterceptorRef>,
|
|
}
|
|
|
|
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>), 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<Vec<ParsedStatement>, 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();
|
|
}
|
|
}
|