From ad2c1bfc594df46861b749f4d29820114a2c6f9f Mon Sep 17 00:00:00 2001 From: Weny Xu Date: Fri, 31 Jul 2026 21:40:27 +0800 Subject: [PATCH] fix(meta): preserve legacy WAL options compatibility (#8707) * fix: preserve legacy region WAL options format Signed-off-by: WenyXu * test: add downgrade compatibility coverage Signed-off-by: WenyXu * refactor: deduplicate compat restart Signed-off-by: WenyXu * test: harden downgrade compatibility check Signed-off-by: WenyXu --------- Signed-off-by: WenyXu --- .github/scripts/run-compat.py | 106 ++++++++++--- src/common/meta/src/wal_provider.rs | 69 +++++++-- tests/compatibility/README.md | 32 ++-- .../cases/downgrade_compatibility/case.toml | 8 + .../cases/downgrade_compatibility/setup.sql | 11 ++ .../downgrade_compatibility/verify.result | 8 + .../cases/downgrade_compatibility/verify.sql | 1 + tests/compatibility/ci.toml | 3 + tests/runner/src/cmd/compat.rs | 142 +++++++++++++----- tests/runner/src/env/bare.rs | 40 ++--- 10 files changed, 327 insertions(+), 93 deletions(-) create mode 100644 tests/compatibility/cases/downgrade_compatibility/case.toml create mode 100644 tests/compatibility/cases/downgrade_compatibility/setup.sql create mode 100644 tests/compatibility/cases/downgrade_compatibility/verify.result create mode 100644 tests/compatibility/cases/downgrade_compatibility/verify.sql diff --git a/.github/scripts/run-compat.py b/.github/scripts/run-compat.py index 1521b70c14..1b83798b35 100644 --- a/.github/scripts/run-compat.py +++ b/.github/scripts/run-compat.py @@ -21,7 +21,7 @@ here: - read `tests/compatibility/ci.toml` - validate the checked-in recent-release window - preview selected cases with `compat --dry-run` -- run the real compat check for each sampled `from` version +- run the real compat check for each sampled upgrade and downgrade version """ from __future__ import annotations @@ -64,39 +64,43 @@ def parse_args() -> argparse.Namespace: return parser.parse_args() -def load_from_versions(config_path: Path) -> list[str]: +def load_versions( + config_path: Path, key: str, *, required: bool = True +) -> list[str]: if not config_path.is_file(): raise SystemExit(f"Compatibility CI config not found: {config_path}") # Parse the simple TOML string array without depending on Python 3.11+ - # tomllib. This intentionally supports only the checked-in shape used by - # the CI job: from_versions = ["vX.Y.Z", ...]. + # tomllib. This intentionally supports only the checked-in string-array + # shapes used by the CI job. content = config_path.read_text(encoding="utf-8") content_without_comments = "\n".join( line.split("#", 1)[0] for line in content.splitlines() ) match = re.search( - r"(?ms)^\s*from_versions\s*=\s*(\[[^\]]*\])", + rf"(?ms)^\s*{re.escape(key)}\s*=\s*(\[[^\]]*\])", content_without_comments, ) if match is None: - raise SystemExit(f"{config_path} must define from_versions") + if required: + raise SystemExit(f"{config_path} must define {key}") + return [] try: versions = ast.literal_eval(match.group(1)) except (SyntaxError, ValueError) as err: - raise SystemExit(f"Invalid from_versions in {config_path}: {err}") from err + raise SystemExit(f"Invalid {key} in {config_path}: {err}") from err if not isinstance(versions, list) or not versions: - raise SystemExit(f"{config_path} must define a non-empty from_versions list") + raise SystemExit(f"{config_path} must define a non-empty {key} list") seen: set[str] = set() validated: list[str] = [] for version in versions: if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None: - raise SystemExit(f"Invalid compat from version: {version!r}") + raise SystemExit(f"Invalid compat {key} version: {version!r}") if version in seen: - raise SystemExit(f"Duplicate compat from version: {version}") + raise SystemExit(f"Duplicate compat {key} version: {version}") seen.add(version) validated.append(version) @@ -128,6 +132,26 @@ def run_command(command: list[str], *, env: dict[str, str] | None = None) -> Non subprocess.run(command, check=True, env=env) +def run_compat( + *, + base_command: list[str], + preview_title: str, + compatibility_title: str, + preserve_state: bool, +) -> None: + with github_group(preview_title): + run_command([*base_command, "--dry-run"]) + + real_command = [*base_command] + if preserve_state: + real_command.append("--preserve-state") + + env = os.environ.copy() + env.setdefault("RUST_BACKTRACE", "1") + with github_group(compatibility_title): + run_command(real_command, env=env) + + def run_for_version( *, runner: Path, @@ -144,17 +168,43 @@ def run_for_version( str(to_bins_dir), ] - with github_group(f"Preview {from_version} -> current"): - run_command([*base_command, "--dry-run"]) + run_compat( + base_command=base_command, + preview_title=f"Preview {from_version} -> current", + compatibility_title=f"Compatibility {from_version} -> current", + preserve_state=preserve_state, + ) - real_command = [*base_command] - if preserve_state: - real_command.append("--preserve-state") - env = os.environ.copy() - env.setdefault("RUST_BACKTRACE", "1") - with github_group(f"Compatibility {from_version} -> current"): - run_command(real_command, env=env) +def run_downgrade_for_version( + *, + runner: Path, + current_bins_dir: Path, + to_version: str, + preserve_state: bool, +) -> None: + for topology in ("distributed", "standalone"): + base_command = [ + str(runner), + "compat", + "--from-bins-dir", + str(current_bins_dir), + "--to-version", + to_version, + "--topology", + topology, + "--test-filter", + "^downgrade_compatibility$", + "--expect-cases", + "1", + ] + + run_compat( + base_command=base_command, + preview_title=f"Preview current -> {to_version} ({topology})", + compatibility_title=f"Compatibility current -> {to_version} ({topology})", + preserve_state=preserve_state, + ) def main() -> int: @@ -164,7 +214,10 @@ def main() -> int: to_bins_dir = Path(args.to_bins_dir) check_inputs(runner, to_bins_dir) - from_versions = load_from_versions(config_path) + from_versions = load_versions(config_path, "from_versions") + downgrade_to_versions = load_versions( + config_path, "downgrade_to_versions", required=False + ) print("Compatibility from-version window:", flush=True) for version in from_versions: @@ -178,6 +231,19 @@ def main() -> int: preserve_state=args.preserve_state, ) + if downgrade_to_versions: + print("Compatibility downgrade-to-version window:", flush=True) + for version in downgrade_to_versions: + print(f" - {version}", flush=True) + + for to_version in downgrade_to_versions: + run_downgrade_for_version( + runner=runner, + current_bins_dir=to_bins_dir, + to_version=to_version, + preserve_state=args.preserve_state, + ) + return 0 diff --git a/src/common/meta/src/wal_provider.rs b/src/common/meta/src/wal_provider.rs index 5b07f30619..61efff0b06 100644 --- a/src/common/meta/src/wal_provider.rs +++ b/src/common/meta/src/wal_provider.rs @@ -144,15 +144,31 @@ where .collect() } +fn serialize_region_wal_options( + value: &RegionWalOptions, + serializer: S, +) -> std::result::Result +where + S: Serializer, +{ + let values = value + .iter() + .map(|(region_number, wal_options)| { + serde_json::to_string(wal_options).map(|encoded| (*region_number, encoded)) + }) + .collect::, _>>() + .map_err(serde::ser::Error::custom)?; + values.serialize(serializer) +} + /// Serde helpers for [`RegionWalOptions`] persisted in metadata. /// -/// New metadata stores WAL options as structured JSON objects. The deserializer -/// also accepts the legacy format whose map values are JSON strings encoded from -/// [`WalOptions`]. +/// Metadata writes WAL options as JSON strings encoded from [`WalOptions`] for +/// compatibility. The deserializer also accepts structured JSON objects. pub mod region_wal_options_serde { use super::*; - /// Serializes region WAL options in structured form. + /// Serializes region WAL options as encoded JSON strings. pub fn serialize( value: &RegionWalOptions, serializer: S, @@ -160,7 +176,7 @@ pub mod region_wal_options_serde { where S: Serializer, { - value.serialize(serializer) + serialize_region_wal_options(value, serializer) } /// Deserializes region WAL options from either structured or legacy encoded form. @@ -177,7 +193,7 @@ pub mod region_wal_options_serde { pub mod optional_region_wal_options_serde { use super::*; - /// Serializes optional region WAL options in structured form. + /// Serializes optional region WAL options as encoded JSON strings. pub fn serialize( value: &Option, serializer: S, @@ -185,7 +201,10 @@ pub mod optional_region_wal_options_serde { where S: Serializer, { - value.serialize(serializer) + match value { + Some(value) => serialize_region_wal_options(value, serializer), + None => serializer.serialize_none(), + } } /// Deserializes optional region WAL options from structured or legacy encoded form. @@ -417,6 +436,12 @@ mod tests { region_wal_options: RegionWalOptions, } + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct OptionalRegionWalOptionsWrapper { + #[serde(with = "optional_region_wal_options_serde")] + region_wal_options: Option, + } + #[test] fn test_deserialize_legacy_region_wal_options_from_encoded_map() { let legacy_region_wal_options = HashMap::from([ @@ -470,7 +495,7 @@ mod tests { } #[test] - fn test_serialize_structured_region_wal_options() { + fn test_serialize_legacy_region_wal_options() { let wrapper = RegionWalOptionsWrapper { region_wal_options: HashMap::from([(1, WalOptions::RaftEngine)]), }; @@ -479,7 +504,33 @@ mod tests { assert_eq!( encoded, - r#"{"region_wal_options":{"1":{"wal.provider":"raft_engine"}}}"# + r#"{"region_wal_options":{"1":"{\"wal.provider\":\"raft_engine\"}"}}"# + ); + } + + #[test] + fn test_serialize_optional_region_wal_options() { + let wrapper = OptionalRegionWalOptionsWrapper { + region_wal_options: Some(HashMap::from([(1, WalOptions::RaftEngine)])), + }; + + let encoded = serde_json::to_string(&wrapper).unwrap(); + + assert_eq!( + encoded, + r#"{"region_wal_options":{"1":"{\"wal.provider\":\"raft_engine\"}"}}"# + ); + assert_eq!( + serde_json::from_str::(&encoded).unwrap(), + wrapper + ); + + let none = OptionalRegionWalOptionsWrapper { + region_wal_options: None, + }; + assert_eq!( + serde_json::to_string(&none).unwrap(), + r#"{"region_wal_options":null}"# ); } diff --git a/tests/compatibility/README.md b/tests/compatibility/README.md index c4a20a4139..a88fddeaf7 100644 --- a/tests/compatibility/README.md +++ b/tests/compatibility/README.md @@ -1,6 +1,6 @@ # GreptimeDB Compatibility Test Framework -Compatibility tests verify that a newer version of GreptimeDB can read data written by an older version. +Compatibility tests verify that one GreptimeDB version can restart on state written by another version. Tests are run via `cargo sqlness compat` and reuse the sqlness-runner infrastructure. @@ -16,6 +16,12 @@ cargo run -p sqlness-runner -- compat --from-version v0.9.5 # Test between two local binary directories: cargo run -p sqlness-runner -- compat --from-bins-dir ./bins/old --to-bins-dir ./bins/new +# Test a downgrade from the current build to a released binary: +cargo run -p sqlness-runner -- compat --from-bins-dir ./bins/current --to-version v1.1.4 + +# Run a compatibility case in standalone mode: +cargo run -p sqlness-runner -- compat --topology standalone --test-filter "downgrade_compatibility" + # Run a specific case: cargo run -p sqlness-runner -- compat --test-filter "basic_table" @@ -30,7 +36,7 @@ cargo run -p sqlness-runner -- compat --help - **Docker** (for etcd): PR1 always uses Docker etcd for distributed metadata. External metadata stores are future work. - **From binary**: Either `--from-version ` to auto-pull a release, or `--from-bins-dir ` to use a local build. The binary `greptime` must exist directly inside the given directory. -- **To binary**: Defaults to the current debug build (`target/debug/greptime`). Override with `--to-bins-dir `. +- **To binary**: Defaults to the current debug build (`target/debug/greptime`). Override with `--to-bins-dir ` or fetch a release with `--to-version `. - **Custom target-dir**: If you use a non-default `CARGO_TARGET_DIR`, the debug binary won't be at `target/debug/greptime`. Pass `--from-bins-dir` / `--to-bins-dir` explicitly pointing to your custom target directory. Alternatively, run `cargo build -p greptime` without a custom target-dir. ## Case Format @@ -40,8 +46,8 @@ Each compat case is a directory under `tests/compatibility/cases/` containing th ``` my_case/ case.toml # Metadata (required) - setup.sql # SQL to run on old version (required) - verify.sql # SQL to run on new version (required) + setup.sql # SQL to run on the from version (required) + verify.sql # SQL to run on the to version (required) verify.result # Expected output from verify.sql ``` @@ -51,7 +57,7 @@ my_case/ name = "my_case" reason = "Why this compatibility case exists" introduced_by = "PR #1234 or feature name" -topologies = ["distributed"] # full distributed topology, including flownode +topologies = ["distributed", "standalone"] from_range = ["*"] to_range = ["*"] features = ["table"] @@ -110,17 +116,21 @@ The GitHub Actions workflow delegates the window loading and compat invocation to `.github/scripts/run-compat.py`; the workflow YAML should stay as a thin wrapper around artifact download/extraction and this script. -### `setup.sql` — Setup Phase (Old Version) +`downgrade_to_versions` optionally lists releases that CI restarts after the +PR-built cluster. Those runs select only the `downgrade_compatibility` case in +both distributed and standalone topologies. -SQL statements executed on the **old** version cluster. These must succeed (any error fails the case). Setup output is NOT compared against any result file. +### `setup.sql` — Setup Phase (From Version) + +SQL statements executed on the **from** version cluster. These must succeed (any error fails the case). Setup output is NOT compared against any result file. Rules: - Statements are semicolon-terminated - `--` prefix for ordinary comments - `-- SQLNESS ...` interceptor comments follow ordinary sqlness semantics -### `verify.sql` — Verify Phase (New Version) +### `verify.sql` — Verify Phase (To Version) -SQL statements executed on the **new** version cluster. Output is compared against `verify.result` in sqlness snapshot style. +SQL statements executed on the **to** version cluster. Output is compared against `verify.result` in sqlness snapshot style. ### `verify.result` — Expected Output @@ -142,7 +152,7 @@ If output differs from expected, the run fails and `verify.result` is updated wi ## PR1 Limitations - **Sqlness interceptors**: `-- SQLNESS ...` comments are applied per statement using the same interceptor registry as the ordinary sqlness runner, including the GreptimeDB `PROTOCOL` interceptor. For `PROTOCOL POSTGRES`, the namespace prelude uses `SET search_path` instead of `USE`. Avoid unqualified PostgreSQL-protocol table names starting with `pg_`: GreptimeDB's current PostgreSQL compatibility parser rewrites them to `pg_catalog.`. -- **Full distributed topology**: The compat runner starts 1 metasrv + 3 datanodes + 1 frontend + 1 flownode. +- **Distributed topology**: The compat runner starts 1 metasrv + 3 datanodes + 1 frontend + 1 flownode. Standalone compatibility runs need no external metadata store. - **No comment-based compat config**: The compat runner does not define extra compatibility configuration in SQL comments; sqlness comments keep their normal sqlness meaning. ## Namespace Isolation @@ -156,7 +166,7 @@ Each case runs in its own database namespace to prevent cross-case interference: ## Batch Behavior -- All cases in a run share one cluster lifecycle: start old cluster → run all setups → restart with new binary → run all verifies +- All cases in a run share one cluster lifecycle: start from-version cluster → run all setups → restart with to-version binary → run all verifies - Cases run **serially** (no parallelism in PR1). Namespace state is session/protocol state and cannot be shared concurrently. - Same namespace across cases is rejected. diff --git a/tests/compatibility/cases/downgrade_compatibility/case.toml b/tests/compatibility/cases/downgrade_compatibility/case.toml new file mode 100644 index 0000000000..930a55cd04 --- /dev/null +++ b/tests/compatibility/cases/downgrade_compatibility/case.toml @@ -0,0 +1,8 @@ +name = "downgrade_compatibility" +reason = "Verify v1.1.4 can reopen a table whose region WAL options were written by the current binary." +introduced_by = "fix: preserve legacy region WAL options format" +topologies = ["distributed", "standalone"] +from_range = [">=v1.2.0"] +to_range = ["=v1.1.4"] +features = ["table", "wal", "downgrade"] +owner = "metasrv" diff --git a/tests/compatibility/cases/downgrade_compatibility/setup.sql b/tests/compatibility/cases/downgrade_compatibility/setup.sql new file mode 100644 index 0000000000..998c5025b2 --- /dev/null +++ b/tests/compatibility/cases/downgrade_compatibility/setup.sql @@ -0,0 +1,11 @@ +CREATE TABLE t_downgrade_compatibility( + ts TIMESTAMP TIME INDEX, + host STRING PRIMARY KEY, + val INT +); + +INSERT INTO t_downgrade_compatibility VALUES +('2024-02-09 00:00:00+0000', 'host_a', 1), +('2024-02-09 00:01:00+0000', 'host_b', 2); + +ADMIN FLUSH_TABLE('t_downgrade_compatibility'); diff --git a/tests/compatibility/cases/downgrade_compatibility/verify.result b/tests/compatibility/cases/downgrade_compatibility/verify.result new file mode 100644 index 0000000000..e87b18b1bd --- /dev/null +++ b/tests/compatibility/cases/downgrade_compatibility/verify.result @@ -0,0 +1,8 @@ +SELECT ts, host, val FROM t_downgrade_compatibility ORDER BY ts, host; + ++---------------------+--------+-----+ +| ts | host | val | ++---------------------+--------+-----+ +| 2024-02-09T00:00:00 | host_a | 1 | +| 2024-02-09T00:01:00 | host_b | 2 | ++---------------------+--------+-----+ diff --git a/tests/compatibility/cases/downgrade_compatibility/verify.sql b/tests/compatibility/cases/downgrade_compatibility/verify.sql new file mode 100644 index 0000000000..e369dafe66 --- /dev/null +++ b/tests/compatibility/cases/downgrade_compatibility/verify.sql @@ -0,0 +1 @@ +SELECT ts, host, val FROM t_downgrade_compatibility ORDER BY ts, host; diff --git a/tests/compatibility/ci.toml b/tests/compatibility/ci.toml index 587f822bb8..2990fe8be5 100644 --- a/tests/compatibility/ci.toml +++ b/tests/compatibility/ci.toml @@ -4,3 +4,6 @@ # versions against the PR-built `to` binary. Broader historical windows should # run in nightly or release-validation workflows. from_versions = ["v1.0.0", "v1.1.0"] + +# Recent releases that must be able to reopen tables written by the PR build. +downgrade_to_versions = ["v1.1.4"] diff --git a/tests/runner/src/cmd/compat.rs b/tests/runner/src/cmd/compat.rs index 308d7f19fa..ce33673a4a 100644 --- a/tests/runner/src/cmd/compat.rs +++ b/tests/runner/src/cmd/compat.rs @@ -15,7 +15,7 @@ use std::path::PathBuf; use std::sync::Arc; -use clap::Parser; +use clap::{Parser, ValueEnum}; use sqlness::QueryContext; use sqlness::interceptor::template::DELIMITER as TEMPLATE_DELIMITER; use sqlness::interceptor::{InterceptorRef, Registry}; @@ -26,15 +26,29 @@ use crate::env::bare::{Env, StoreConfig, WalConfig}; use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY}; use crate::util; -const COMPAT_TOPOLOGY: &str = "distributed"; const COMMENT_PREFIX: &str = "--"; const INTERCEPTOR_PREFIX: &str = "-- SQLNESS"; const QUERY_DELIMITER: char = ';'; -/// Run compatibility tests in bare distributed mode. +#[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 an old-version distributed cluster, runs setup SQLs, -/// then restarts the cluster with a new version on preserved state, +/// 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: @@ -57,6 +71,12 @@ pub struct CompatCommand { #[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)] @@ -66,6 +86,14 @@ pub struct CompatCommand { #[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, @@ -93,9 +121,14 @@ pub struct CompatCommand { 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 && !self.setup_etcd { + 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" ); @@ -117,18 +150,30 @@ impl CompatCommand { cases.retain(|c| filter_re.is_match(&c.metadata.name)); // Filter by topology - cases.retain(|c| c.metadata.topologies.iter().any(|t| t == COMPAT_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, COMPAT_TOPOLOGY + self.test_filter, + topology.as_str() ); } else { println!( "No compat cases found matching filter '{}' and topology '{}'", - self.test_filter, COMPAT_TOPOLOGY + self.test_filter, + topology.as_str() ); } return; @@ -168,11 +213,19 @@ impl CompatCommand { .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(|| util::get_binary_dir("debug")); - let to_ver_str = try_infer_version(&dry_run_to_bins_dir).map(|v| v.to_string()); + 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()); @@ -208,9 +261,18 @@ impl CompatCommand { .as_deref() .and_then(|s| compat_case::Version::parse(s).ok()); - let to_bins_dir = - resolve_bins(self.to_bins_dir.as_ref(), None, self.pull_version_on_need).await; - let to_version = try_infer_version(&to_bins_dir).map(|v| v.to_string()); + 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()); @@ -264,6 +326,15 @@ impl CompatCommand { ); } + if let Some(expected_cases) = self.expect_cases { + assert_eq!( + cases.len(), + expected_cases, + "Expected {expected_cases} compatibility cases after all filtering, found {}", + cases.len() + ); + } + if cases.is_empty() { if dry_run { println!("DRY-RUN: no compat cases would run after version-range filtering"); @@ -275,7 +346,7 @@ impl CompatCommand { if dry_run { println!("DRY-RUN: would run {} compat case(s)", cases.len()); - println!(" topology: {}", COMPAT_TOPOLOGY); + println!(" topology: {}", topology.as_str()); println!( " from version: {}", from_version.as_deref().unwrap_or( @@ -312,7 +383,7 @@ impl CompatCommand { println!( "Running {} compat case(s) with topology {}:", cases.len(), - COMPAT_TOPOLOGY + topology.as_str() ); for c in &cases { println!( @@ -337,14 +408,15 @@ impl CompatCommand { // ---- 7. Build interceptor registry ---- let interceptor_registry = create_interceptor_registry(); - // ---- 7b. Create Env for bare distributed mode ---- + // ---- 7b. Create Env for the selected topology ---- + let setup_etcd = topology == CompatTopology::Distributed && self.setup_etcd; let store_config = StoreConfig { - store_addrs: if self.setup_etcd { + store_addrs: if setup_etcd { vec!["127.0.0.1:2379".to_string()] } else { vec![] }, - setup_etcd: self.setup_etcd, + setup_etcd, setup_pg: None, setup_mysql: None, enable_flat_format: false, @@ -364,15 +436,18 @@ impl CompatCommand { // ---- 7c. Etcd cleanup guard ---- // Arm this only immediately before starting the cluster. Earlier validation // failures should not stop an unrelated local container named `etcd`. - let mut etcd_guard = if self.setup_etcd { + let mut etcd_guard = if setup_etcd { Some(EtcdGuard::new()) } else { None }; - // ---- 8. Run setup phase on old cluster ---- - println!("Starting old-version distributed cluster with flownode..."); - let mut db = env.compat_start_distributed(0).await; + // ---- 8. Run setup phase on the from-version cluster ---- + println!("Starting from-version {} cluster...", topology.as_str()); + let mut db = match topology { + CompatTopology::Distributed => env.compat_start_distributed(0).await, + CompatTopology::Standalone => env.compat_start_standalone(0).await, + }; println!("Running setup phase..."); for case in &cases { @@ -382,16 +457,13 @@ impl CompatCommand { println!(" Setup: {} - OK", case.metadata.name); } - // ---- 9. Switch to "to" binary and restart cluster ---- + // ---- 9. Switch to the to-version binary and restart cluster ---- // to_bins_dir was already resolved during version-range filtering - println!("Restarting cluster with new-version binary on preserved state..."); - env.compat_restart_all( - &db, - to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"), - ) - .await; + println!("Restarting cluster with to-version binary on preserved state..."); + let to_bins_dir = to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode"); + env.compat_restart(&db, to_bins_dir).await; - // ---- 10. Run verify phase on new cluster ---- + // ---- 10. Run verify phase on the to-version cluster ---- println!("Running verify phase..."); let mut failed = Vec::new(); for case in &cases { @@ -412,7 +484,7 @@ impl CompatCommand { // ---- 12. Cleanup ---- // Etcd is always cleaned up; --preserve-state only preserves sqlness_home. - if self.setup_etcd { + if setup_etcd { println!("Stopping etcd"); util::stop_rm_etcd(); } diff --git a/tests/runner/src/env/bare.rs b/tests/runner/src/env/bare.rs index 9f9c0c7983..ed248a1fe7 100644 --- a/tests/runner/src/env/bare.rs +++ b/tests/runner/src/env/bare.rs @@ -648,37 +648,41 @@ impl Env { self.start_distributed(id).await } - /// Full restart of all distributed processes with a new binary directory, - /// preserving the same context and data. - /// After restart, waits for the frontend gRPC endpoint to become ready. - pub(crate) async fn compat_restart_all(&self, db: &GreptimeDB, bins_dir: PathBuf) { - *db.active_bins_dir.lock().unwrap() = Some(bins_dir); - self.restart_server(db, true).await; - self.wait_frontend_ready(db).await; + /// Start a standalone GreptimeDB instance. Exposed for compat runner. + pub(crate) async fn compat_start_standalone(&self, id: usize) -> GreptimeDB { + self.start_standalone(id).await } - /// Wait for frontend gRPC readiness after restart. - async fn wait_frontend_ready(&self, db: &GreptimeDB) { - let frontend_mode = db - .ctx - .get_server_mode(SERVER_MODE_FRONTEND_IDX) - .cloned() - .unwrap(); - if let Some(addr) = frontend_mode.check_addrs().first() { - println!("Waiting for frontend gRPC readiness at {addr}..."); + /// Restart a compatibility instance with a new binary directory. + pub(crate) async fn compat_restart(&self, db: &GreptimeDB, bins_dir: PathBuf) { + *db.active_bins_dir.lock().unwrap() = Some(bins_dir); + self.restart_server(db, true).await; + self.wait_query_ready(db).await; + } + + /// Wait for the query endpoint to become ready after restart. + async fn wait_query_ready(&self, db: &GreptimeDB) { + let server_mode_idx = if db.is_standalone { + SERVER_MODE_STANDALONE_IDX + } else { + SERVER_MODE_FRONTEND_IDX + }; + let server_mode = db.ctx.get_server_mode(server_mode_idx).cloned().unwrap(); + if let Some(addr) = server_mode.check_addrs().first() { + println!("Waiting for query endpoint readiness at {addr}..."); crate::util::retry_with_backoff( || async { let mut client = db.client.lock().await; match client.grpc_query("SELECT 1").await { Ok(_) => Ok(()), - Err(e) => Err(format!("Frontend not ready: {e}")), + Err(e) => Err(format!("Query endpoint not ready: {e}")), } }, 10, std::time::Duration::from_secs(1), ) .await - .unwrap_or_else(|e| panic!("Frontend failed to become ready: {e}")); + .unwrap_or_else(|e| panic!("Query endpoint failed to become ready: {e}")); } } }