Files
datastore/ci/jobs/integration_test_job.py
zeekayandHanzo Dev ee284e8075 finish the rebrand where a person actually reads it
The fork already renamed its internals - the built binary is datastore, the
packages are datastore-server and friends, and the shipped config.xml already
says Datastore - but the surfaces a human reads still said ClickHouse. Issue
templates asked which ClickHouse version was affected, the PR template told
contributors to close upstream's issues, and CI job names, log lines, error
messages and argparse descriptions named a product this repo does not ship.

Only prose moved: display names, log and error messages, comments, docstrings
and form labels. Everything a machine resolves stayed exactly as it was,
because renaming those breaks a build rather than a sentence - binary and path
tokens such as clickhouse-client and /etc/clickhouse-server, python and shell
identifiers, CLICKHOUSE_* environment variables, the /ClickHouse container
mount, docker images in a namespace we do not own, S3 buckets, maven and pypi
coordinates, and every URL pointing at upstream. The attribution in NOTICE and
README is untouched.

Two links were wrong rather than merely stale, so they are corrected here. The
PR template's Closes and Related placeholders sent contributors to upstream's
tracker instead of this one, and an earlier pass had rewritten a source
reference to github.com/Datastore/Datastore, which does not resolve.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 14:07:52 -07:00

1525 lines
67 KiB
Python

import argparse
import os
import re
import shlex
import subprocess
import time
from pathlib import Path
from typing import List, Optional, Tuple
from ci.jobs.scripts.bugfix_validation import bugfix_build_types, find_master_builds
from ci.jobs.scripts.find_tests import Targeting
from ci.jobs.scripts.integration_tests_configs import (
IMAGES_ENV,
LLVM_COVERAGE_SKIP_PREFIXES,
force_heavy_modules_sequential,
get_optimal_test_batch,
)
from ci.jobs.scripts.workflow_hooks.pr_labels_and_category import Labels
from ci.praktika.info import Info
from ci.praktika.result import Result
from ci.praktika.utils import Shell, Utils
repo_dir = Utils.cwd()
temp_path = f"{repo_dir}/ci/tmp"
MAX_FAILS_BEFORE_DROP = 5
# Flaky-check best-effort scope cap: the maximum number of changed test modules a single
# flaky-check run will execute. A PR can mechanically touch a large number of integration
# test modules (e.g. a repo-wide lint/format change), and running every one of them
# repeatedly under `--dist=each` cannot fit the flaky-check time budget - the job would be
# hard-killed by the external CI timeout before producing any report. When the cap is
# exceeded the extra modules are skipped (best effort) and the selected ones get full
# flakiness coverage instead of a truncated run. See FLAKY_CHECK_TIME_LIMIT for the hard
# time guarantee that backstops this.
MAX_FLAKY_CHECK_MODULES = 10
OOM_IN_DMESG_TEST_NAME = "OOM in dmesg"
ncpu = Utils.cpu_count()
mem_gb = round(Utils.physical_memory() // (1024**3), 1)
MAX_CPUS_PER_WORKER = 5
MAX_MEM_PER_WORKER = 11
# Flaky/targeted checks run with --dist=each, so every worker runs the full set
# of changed modules concurrently (each with its own Docker cluster) instead of
# splitting modules across workers. A worker's peak footprint is therefore much
# larger, so it needs a bigger memory budget to avoid exhausting the container
# cgroup and tripping the kernel OOM killer (see OOM_IN_DMESG_TEST_NAME).
MAX_MEM_PER_WORKER_DIST_EACH = 20
INFRASTRUCTURE_ERROR_PATTERNS = [
"timed out after",
"TimeoutExpired",
"Cannot connect to the Docker daemon",
"Error response from daemon",
"Name or service not known",
"Temporary failure in name resolution",
"Network is unreachable",
"Connection reset by peer",
"No space left on device",
"Cannot allocate memory",
"OCI runtime create failed",
"toomanyrequests",
"pull access denied",
"Got exception pulling images:", # docker pull failure during cluster.start()
]
def _is_infrastructure_error(result: Result) -> bool:
"""Returns True if the result is a failure caused by infrastructure issues."""
if not result.info:
return False
if result.status == Result.Status.ERROR:
return any(pattern in result.info for pattern in INFRASTRUCTURE_ERROR_PATTERNS)
# Docker compose/pull infrastructure failures may appear with FAIL status
# when pytest reports fixture (setup phase) errors as test failures.
# Require both docker context and an infrastructure pattern to avoid
# false positives on genuine test failures.
if result.status == Result.Status.FAIL:
has_docker_context = (
"'docker'" in result.info or "images_pull_cmd" in result.info
)
return has_docker_context and any(
p in result.info for p in INFRASTRUCTURE_ERROR_PATTERNS
)
return False
def _mark_infrastructure_errors(results: list) -> int:
"""Scan results, label infrastructure errors with INFRA and change their status to SKIPPED.
Returns the number of results that were relabeled.
"""
count = 0
for r in results:
if _is_infrastructure_error(r):
r.set_label(Result.Label.INFRA)
r.status = Result.Status.SKIPPED
count += 1
if count:
print(f"Marked {count} test result(s) as infrastructure errors")
return count
def quote_tests(tests: List[str]) -> str:
"""Join test node IDs into a shell-safe, space-separated string.
A parametrized integration test node ID can contain spaces, parentheses and
quotes when the test is parametrized with SQL (e.g.
`test.py::test_simple_append[SELECT now() FROM numbers(2)]`). The pytest
command is executed through a shell, so each node ID must be quoted to
survive as a single argument instead of being split or mis-parsed.
"""
return " ".join(shlex.quote(t) for t in tests)
def start_docker_in_docker():
with open("./ci/tmp/docker-in-docker.log", "w") as log_file:
dockerd_proc = subprocess.Popen(
"./ci/jobs/scripts/docker_in_docker.sh",
stdout=log_file,
stderr=subprocess.STDOUT,
)
retries = 20
for i in range(retries):
# On last retry, show errors; otherwise suppress them
cmd = "docker info > /dev/null" if i == retries - 1 else "docker info > /dev/null 2>&1"
if Shell.check(cmd, verbose=True):
break
if i == retries - 1:
raise RuntimeError(
f"Docker daemon didn't responded after {retries} attempts"
)
time.sleep(2)
print(f"Started docker-in-docker asynchronously with PID {dockerd_proc.pid}")
_COMPOSE_DIR = Path("./tests/integration/compose")
# Explicit mapping for with_* flags whose compose file name cannot be derived
# by simply prepending "docker_compose_" and appending ".yml".
_WITH_FLAG_TO_COMPOSE: dict[str, List[str]] = {
"mysql57": ["docker_compose_mysql.yml"],
"mysql8": ["docker_compose_mysql_8_0.yml"],
"dremio26": ["docker_compose_dremio_26_0.yml"],
"kerberos_kdc": ["docker_compose_kerberos_kdc.yml"],
# with_iceberg_catalog can use any of the iceberg catalogs; include them all
"iceberg_catalog": [
"docker_compose_iceberg_rest_catalog.yml",
"docker_compose_iceberg_hms_catalog.yml",
"docker_compose_iceberg_lakekeeper_catalog.yml",
"docker_compose_iceberg_nessie_catalog.yml",
],
"hms_catalog": ["docker_compose_iceberg_hms_catalog.yml"],
"glue_catalog": ["docker_compose_glue_catalog.yml"],
"prometheus_writer": ["docker_compose_prometheus_writer.yml"],
"prometheus_reader": ["docker_compose_prometheus_reader.yml"],
"prometheus_receiver": ["docker_compose_prometheus_receiver.yml"],
# with_odbc_drivers implicitly sets up mysql8 + postgres
"odbc_drivers": ["docker_compose_mysql_8_0.yml", "docker_compose_postgres.yml"],
# Flags with no separate compose file of their own
"jdbc_bridge": [],
"net_trics": [],
}
def get_compose_files_for_test_modules(test_modules: List[str]) -> List[Path]:
"""Return compose files needed by the given test modules.
Grep every Python source file in each test suite directory for:
- `with_X=True` patterns (mapped via `_WITH_FLAG_TO_COMPOSE` or the obvious
`docker_compose_{X}.yml` naming convention), and
- explicit `docker_compose_*.yml` file name strings (used e.g. via
`extra_parameters={"docker_compose_file_name": "..."}` calls).
"""
needed: set[Path] = set()
suite_dirs = {m.split("/")[0] for m in test_modules}
for suite_dir in suite_dirs:
suite_path = Path("./tests/integration/") / suite_dir
if not suite_path.is_dir():
continue
for py_file in suite_path.glob("**/*.py"):
try:
content = py_file.read_text(errors="replace")
except OSError:
continue
# 1. with_X=True → compose file via mapping or naming convention
for m in re.finditer(r"\bwith_(\w+)\s*=\s*True", content):
flag = m.group(1)
if flag in _WITH_FLAG_TO_COMPOSE:
for fname in _WITH_FLAG_TO_COMPOSE[flag]:
p = _COMPOSE_DIR / fname
if p.exists():
needed.add(p)
else:
p = _COMPOSE_DIR / f"docker_compose_{flag}.yml"
if p.exists():
needed.add(p)
# 2. Directly named compose files (e.g. in extra_parameters dicts)
for m in re.finditer(r"(docker_compose_\w+\.yml)", content):
p = _COMPOSE_DIR / m.group(1)
if p.exists():
needed.add(p)
return sorted(needed)
def get_images_from_compose_files(compose_files: List[Path]) -> List[str]:
"""Parse compose files and return a deduplicated list of image references.
Environment variable placeholders like `${DOCKER_NGINX_DAV_TAG:-latest}` are
resolved from `os.environ`. For clickhouse images that appear without a tag
(e.g. `clickhouse/integration-test`) the tag is looked up from `IMAGES_ENV`.
Images with still-unresolvable variables are silently skipped.
"""
known_image_tags: dict[str, str] = {}
for image_name, env_var in IMAGES_ENV.items():
tag = os.environ.get(env_var)
if tag:
known_image_tags[image_name] = tag
def resolve_image(raw: str) -> Optional[str]:
def replace_var(m: re.Match) -> str:
var_name = m.group(1)
default = m.group(2) if m.group(2) is not None else "latest"
return os.environ.get(var_name, default)
resolved = re.sub(r"\$\{(\w+)(?::-([^}]*))?\}", replace_var, raw)
if "${" in resolved:
return None # Still-unresolvable variable — skip
# Append the correct tag for tagless known clickhouse images
if ":" not in resolved and resolved in known_image_tags:
resolved = f"{resolved}:{known_image_tags[resolved]}"
return resolved
images: set[str] = set()
for compose_file in compose_files:
try:
content = compose_file.read_text()
except OSError:
continue
for m in re.finditer(r"^\s+image:\s+(.+)$", content, re.MULTILINE):
# Strip inline YAML comments from unquoted values before resolving
# (e.g. `coredns/coredns:1.9.3 # :latest broke this test`).
raw = re.sub(r"\s+#.*$", "", m.group(1).strip())
resolved = resolve_image(raw)
if resolved:
images.add(resolved)
return sorted(images)
def prefetch_images(
images: List[str], retries: int = 3, pull_timeout: int = 300
) -> bool:
"""Pull every image in parallel using `ci/prefetch-integration-test-images`.
Images with no manifest for the current architecture (e.g. amd64-only images
on arm64 runners) are silently skipped. Returns True on success, False if any
image fails to pull for a real reason.
"""
if not images:
print("No images to pre-fetch.")
return True
script = f"{repo_dir}/ci/jobs/scripts/prefetch-integration-test-images"
env = {
**os.environ,
"PULL_RETRIES": str(retries),
"PULL_TIMEOUT": str(pull_timeout),
}
return Shell.check(
f"{script} {' '.join(images)}",
verbose=True,
env=env,
)
def parse_args():
parser = argparse.ArgumentParser(description="Datastore Build Job")
parser.add_argument("--options", help="Job parameters: ...")
parser.add_argument(
"--test",
help="Optional. Test name patterns (space-separated)",
default=[],
nargs="+",
action="extend",
)
parser.add_argument(
"--count",
help="Optional. Number of times to repeat each test",
default=None,
type=int,
)
parser.add_argument(
"--debug",
help="Optional. Open python debug console on exception",
default=False,
action="store_true",
)
parser.add_argument(
"--path",
help="Optional. Path to custom clickhouse binary",
type=str,
default="",
)
parser.add_argument(
"--path_1",
help="Optional. Path to custom server config",
type=str,
default="",
)
parser.add_argument(
"--workers",
help="Optional. Number of parallel workers for pytest",
default=None,
type=int,
)
parser.add_argument(
"--session-timeout",
help="Optional. Session timeout in seconds",
default=None,
type=int,
)
parser.add_argument(
"--param",
help=(
"Optional. Comma-separated KEY=VALUE pairs to inject as environment "
"variables for pytest (e.g. --param PYTEST_ADDOPTS=-vv,CUSTOM_FLAG=1)"
),
type=str,
default="",
)
return parser.parse_args()
def merge_profraw_files(llvm_profdata_cmd: str, job_params: list):
"""Merge all profraw files into final profdata file.
Args:
llvm_profdata_cmd: Path to llvm-profdata tool
job_params: List of job parameters for naming output file
"""
import subprocess
from pathlib import Path
# Find all profraw files
profraw_files = [str(p) for p in Path(".").rglob("*.profraw")]
if not profraw_files:
print("No profraw files found", flush=True)
return
joined_job_params = "_".join(job_params) if job_params else "all"
joined_job_params = joined_job_params.replace(" ", "_").replace("/", "_")
final_file = f"./it-{joined_job_params}.profdata"
print(f"Merging {len(profraw_files)} profraw files into {final_file}", flush=True)
result = subprocess.run(
[llvm_profdata_cmd, "merge", "-sparse", "-failure-mode=warn"]
+ profraw_files
+ ["-o", final_file],
capture_output=True,
text=True,
)
# Check for corrupted files in stderr
corrupted_count = result.stderr.count(
"invalid instrumentation profile"
) + result.stderr.count("file header is corrupt")
if corrupted_count > 0:
print(f" WARNING: Found {corrupted_count} corrupted profraw files", flush=True)
# Extract and display corrupted filenames from stderr
corrupted_files = set()
for line in result.stderr.split("\n"):
if (
"invalid instrumentation profile" in line
or "file header is corrupt" in line
or "error:" in line.lower()
):
print(f" {line.strip()}", flush=True)
# Extract filename from error message (format: "error: file.profraw: ..." or "warning: file.profraw: ...")
parts = line.split(":")
if len(parts) >= 2:
potential_file = parts[1].strip()
if potential_file.endswith(".profraw"):
corrupted_files.add(potential_file)
if corrupted_files:
print(f" Corrupted files: {', '.join(corrupted_files)}", flush=True)
if result.returncode == 0:
print(f"Successfully created final coverage file: {final_file}", flush=True)
# Delete merged profraw files to save disk space
deleted_count = 0
for profraw_file in profraw_files:
try:
Path(profraw_file).unlink()
deleted_count += 1
except Exception as e:
print(f" WARNING: Failed to delete {profraw_file}: {e}", flush=True)
print(f" Deleted {deleted_count} profraw files", flush=True)
return final_file
else:
print("ERROR: Failed to create final coverage file", flush=True)
if result.stderr:
print(result.stderr, flush=True)
return None
def get_parallel_sequential_tests_to_run(
batch_num: int,
total_batches: int,
args_test: List[str],
workers: int,
job_options: str,
info: Info,
no_strict: bool = False,
) -> Tuple[List[str], List[str]]:
if args_test:
batch_num = 1
total_batches = 1
test_files = [
str(p.relative_to("./tests/integration/"))
for p in Path("./tests/integration/").glob("test_*/test*.py")
]
if "amd_llvm_coverage" in (job_options or ""):
before = len(test_files)
test_files = [
f
for f in test_files
if not any(f.startswith(prefix) for prefix in LLVM_COVERAGE_SKIP_PREFIXES)
]
print(
f"LLVM coverage: skipped {before - len(test_files)} test files matching LLVM_COVERAGE_SKIP_PREFIXES"
)
assert len(test_files) > 100
parallel_test_modules, sequential_test_modules = get_optimal_test_batch(
test_files, total_batches, batch_num, workers, job_options, info
)
if "excluded_from_llvm" in (job_options or ""):
excluded_from_llvm_set = {
f
for f in (parallel_test_modules + sequential_test_modules)
if any(f.startswith(prefix) for prefix in LLVM_COVERAGE_SKIP_PREFIXES)
or "is_built_with_llvm_coverage" in Path(f"./tests/integration/{f}").read_text()
}
parallel_test_modules = [f for f in parallel_test_modules if f in excluded_from_llvm_set]
sequential_test_modules = [f for f in sequential_test_modules if f in excluded_from_llvm_set]
print(
f"LLVM coverage disabled-only: kept {len(parallel_test_modules)} parallel and "
f"{len(sequential_test_modules)} sequential test files "
f"(from LLVM_COVERAGE_SKIP_PREFIXES or containing is_built_with_llvm_coverage)"
)
if not args_test:
return parallel_test_modules, sequential_test_modules
# there are following possible values for args.test:
# 1) test suit (e.g. test_directory or test_directory/)
# 2) test module (e.g. test_directory/test_module or test_directory/test_module.py)
# 3) test case (e.g. test_directory/test_module.py::test_case or test_directory/test_module::test_case[test_param])
def normalize_test_path(test_arg: str) -> str:
"""Normalize test path by removing integration test directory prefixes."""
# Handle: tests/integration/, integration/, ./tests/integration/, or full paths
if "tests/integration/" in test_arg:
# Extract everything after tests/integration/
test_arg = test_arg.split("tests/integration/", 1)[1]
elif test_arg.startswith("integration/"):
# Handle integration/ prefix
test_arg = test_arg[len("integration/"):]
return test_arg
def test_match(test_file: str, test_arg: str) -> bool:
if "/" not in test_arg:
return f"{test_arg}/" in test_file
if test_arg.endswith(".py"):
return test_file == test_arg
parts = test_arg.split("::", maxsplit=1)
test_module = parts[0]
if test_file.removesuffix(".py") != test_module.removesuffix(".py"):
return False
# When a specific test function is requested, verify it exists in the
# file. Targeted CI runs pull test names from CIDB, but the test may
# have been moved or removed since the record was written. Passing a
# stale nodeID to pytest causes the entire collection to fail with
# exit-code 5 ("no tests collected"), aborting all other tests too.
if len(parts) > 1:
test_func = parts[1].split("[")[0] # strip parametrization
file_path = Path("./tests/integration/") / test_file
try:
content = file_path.read_text()
if f"def {test_func}(" not in content:
print(
f"WARNING: test function '{test_func}' not found in {test_file}, skipping stale target"
)
return False
except OSError:
return False
return True
parallel_tests = []
sequential_tests = []
for test_arg in args_test:
# Normalize the test path first
normalized_test_arg = normalize_test_path(test_arg)
matched = False
for test_file in parallel_test_modules:
if test_match(test_file, normalized_test_arg):
parallel_tests.append(normalized_test_arg)
matched = True
for test_file in sequential_test_modules:
if test_match(test_file, normalized_test_arg):
sequential_tests.append(normalized_test_arg)
matched = True
if not no_strict:
assert matched, f"Test [{test_arg}] not found"
return parallel_tests, sequential_tests
def tail(filepath: str, buff_len: int = 1024) -> List[str]:
with open(filepath, "rb") as f:
# Get file size to avoid seeking before start of file
f.seek(0, os.SEEK_END)
file_size = f.tell()
if file_size <= buff_len:
# File is smaller than buffer, read from beginning
f.seek(0)
else:
# File is larger, seek from end
f.seek(-buff_len, os.SEEK_END)
f.readline() # Skip partial line
data = f.read()
return data.decode(errors="replace")
def run_pytest_and_collect_results(
command: str, env: str, report_name: str, timeout: int = None
) -> Tuple[Result, bool]:
"""
Runs a pytest command and reports whether the run was cut short by a timeout.
Returns `(test_result, timed_out)`. `timed_out` is True when the run did not finish
on its own but was stopped by either:
- the graceful xdist `--session-timeout` (pytest interrupts itself and writes the
`xdist.dsession.Interrupted: session-timeout:` marker to the log), or
- the hard subprocess backstop (`Shell.run` `SIGTERM`s/`SIGKILL`s pytest after
`timeout` seconds, e.g. when a test hangs past the session-timeout).
Callers use `timed_out` to tell an empty result set caused by expected time-budget
exhaustion (best effort, may be downgraded to `SKIPPED`) apart from one caused by a
real pytest/harness failure (no timeout - must stay `ERROR`).
"""
run_sw = Utils.Stopwatch()
test_result = Result.from_pytest_run(
command=command,
env=env,
cwd="./tests/integration/",
pytest_report_file=f"{temp_path}/pytest_{report_name}.jsonl",
pytest_logfile=f"{temp_path}/pytest_{report_name}.log",
logfile=f"{temp_path}/{report_name}.log",
timeout=timeout,
)
timed_out = False
if "!!!!!!! xdist.dsession.Interrupted: session-timeout:" in tail(
f"{temp_path}/{report_name}.log"
):
timed_out = True
test_result.info = "ERROR: session-timeout occurred during test execution"
assert test_result.status == Result.Status.ERROR
test_result.results.append(
Result(
name="Timeout",
status=Result.Status.FAIL,
info=test_result.info,
)
)
elif timeout is not None and run_sw.duration >= timeout:
# The graceful session-timeout marker is absent but the run still reached the
# hard subprocess `timeout`: `Shell.run` killed pytest before it could finish
# (a normal run returns well under `timeout`). Treat this as a timeout so an
# empty result is reported as best-effort rather than as a harness failure.
timed_out = True
return test_result, timed_out
def is_empty_best_effort_skip(
is_flaky_check: bool,
is_targeted_check: bool,
has_results: bool,
timed_out: bool,
) -> bool:
"""
Decide whether a flaky/targeted check that produced no test results should be
reported as `SKIPPED` (best effort) instead of falling through to `create_from`'s
default `ERROR`.
Only the expected timeout path is downgraded: a flaky/targeted run whose time budget
was exhausted (graceful xdist session-timeout or the hard subprocess backstop) before
any test produced a result is best-effort `SKIPPED`. When no timeout was observed, an
empty result means pytest failed to produce any output for some other reason (it
crashed before writing the jsonl report, or exited with a plugin/internal error and no
test rows). That is a real harness failure and must stay `ERROR`, so this returns
False.
"""
return (is_flaky_check or is_targeted_check) and not has_results and timed_out
def main():
sw = Utils.Stopwatch()
info = Info()
args = parse_args()
job_params = args.options.split(",") if args.options else []
job_params = [to.strip() for to in job_params]
use_old_analyzer = False
use_distributed_plan = False
use_database_disk = False
is_flaky_check = False
is_bugfix_validation = False
is_parallel = False
is_sequential = False
is_targeted_check = False
is_llvm_coverage = False
llvm_profdata_cmd = None
# Set on_error_hook to collect logs on hard timeout
Result.from_fs(info.job_name).set_on_error_hook(
"""
dmesg -T >./ci/tmp/dmesg.log
sudo chown -R $(id -u):$(id -g) ./tests/integration
tar -czf ./ci/tmp/logs.tar.gz \
./tests/integration/test_*/_instances*/ \
./ci/tmp/*.log \
./ci/tmp/*.jsonl || :
"""
).set_files(
[
"./ci/tmp/logs.tar.gz",
"./ci/tmp/dmesg.log",
"./ci/tmp/docker-in-docker.log",
],
strict=False,
)
if args.param:
for item in args.param.split(","):
print(f"Setting env variable: {item}")
key, _, value = item.partition("=")
key = key.strip()
if not key:
continue
os.environ[key] = value.strip()
java_path = Shell.get_output(
"update-alternatives --config java | sed -n 's/.*(providing \/usr\/bin\/java): //p'",
verbose=True,
)
repeat_option = ""
if "bugfix" in info.job_name.lower():
is_bugfix_validation = True
batch_num, total_batches = 1, 1
for to in job_params:
if "/" in to:
batch_num, total_batches = map(int, to.split("/"))
elif any(build in to for build in ("amd_", "arm_")):
if "amd_llvm_coverage" in to:
is_llvm_coverage = True
elif to == "old analyzer":
use_old_analyzer = True
elif to == "distributed plan":
use_distributed_plan = True
elif to == "db disk":
use_database_disk = True
elif to == "flaky":
is_flaky_check = True
elif to == "parallel":
is_parallel = True
elif to == "sequential":
is_sequential = True
elif "bugfix" in to.lower() or "validation" in to.lower():
is_bugfix_validation = True
elif "targeted" in to:
is_targeted_check = True
else:
assert False, f"Unknown job option [{to}]"
if args.count:
repeat_option = f"--count {args.count} --random-order"
# For flaky/targeted checks, --count is not used. Instead, --dist=each runs N workers
# each executing all modules independently with their own isolated Docker cluster
# (ClickHouseCluster appends PYTEST_XDIST_WORKER to project_name for isolation).
if args.workers:
workers = args.workers
else:
# --dist=each (flaky/targeted checks) makes every worker run all modules,
# so budget more memory per worker than the module-splitting loadfile runs.
mem_per_worker = (
MAX_MEM_PER_WORKER_DIST_EACH
if is_flaky_check or is_targeted_check
else MAX_MEM_PER_WORKER
)
print("ncpu:", ncpu)
print("mem_gb:", mem_gb)
workers = min(ncpu // MAX_CPUS_PER_WORKER, mem_gb // mem_per_worker) or 1
clickhouse_path = f"{Utils.cwd()}/ci/tmp/clickhouse"
clickhouse_server_config_dir = f"{Utils.cwd()}/programs/server"
if info.is_local_run:
if args.path:
clickhouse_path = args.path
else:
paths_to_check = [
clickhouse_path, # it's set for CI runs, but we need to check it
f"{Utils.cwd()}/build/programs/clickhouse",
f"{Utils.cwd()}/clickhouse",
]
for path in paths_to_check:
if Path(path).is_file():
clickhouse_path = path
break
else:
raise FileNotFoundError(
"Datastore binary not found in any of the paths: "
+ ", ".join(paths_to_check)
+ ". You can also specify path to binary via --path argument"
)
if args.path_1:
clickhouse_server_config_dir = args.path_1
assert Path(
clickhouse_server_config_dir
), f"Datastore config dir does not exist [{clickhouse_server_config_dir}]"
print(f"Using Datastore binary at [{clickhouse_path}]")
changed_test_modules = []
if is_bugfix_validation or is_flaky_check or is_targeted_check:
if info.is_local_run:
assert (
args.test
), "--test must be provided for flaky or bugfix job flavor with local run"
else:
if is_bugfix_validation and Labels.PR_BUGFIX not in info.pr_labels and Labels.PR_CRITICAL_BUGFIX not in info.pr_labels:
# Not a bugfix PR - run a simple sanity test
changed_test_modules = ["test_accept_invalid_certificate/test.py"]
else:
# TODO: reduce scope to modified test cases instead of entire modules
changed_files = info.get_changed_files()
for file in changed_files:
if Targeting.is_integration_test_file(file):
changed_test_modules.append(
file.removeprefix("tests/integration/")
)
if not changed_test_modules and Labels.CI_FORCE_ALL in info.pr_labels:
print(
f"NOTE: No changed test modules found, but '{Labels.CI_FORCE_ALL}' label forces run - using sanity test"
)
changed_test_modules = ["test_accept_invalid_certificate/test.py"]
# Best-effort scope cap for the flaky check (see MAX_FLAKY_CHECK_MODULES). When a PR
# touches more changed test modules than can be repeatedly run within the time budget,
# run a deterministic subset (sorted for reproducibility) and skip the rest rather than
# truncating the whole run. The remaining ones are reported below as skipped so the
# reduced coverage is explicit, not silent.
skipped_flaky_modules = []
if is_flaky_check and len(changed_test_modules) > MAX_FLAKY_CHECK_MODULES:
changed_test_modules = sorted(changed_test_modules)
skipped_flaky_modules = changed_test_modules[MAX_FLAKY_CHECK_MODULES:]
changed_test_modules = changed_test_modules[:MAX_FLAKY_CHECK_MODULES]
print(
f"Flaky check: best-effort scope cap - running {len(changed_test_modules)} of "
f"{len(changed_test_modules) + len(skipped_flaky_modules)} changed modules "
f"to fit the time budget.\n"
f" Running: {changed_test_modules}\n"
f" Skipped (best effort): {skipped_flaky_modules}"
)
if is_bugfix_validation:
# Download the master-HEAD binaries matching this job's runner arch:
# the aarch64 job runs on an ARM runner and must use the ARM builds.
build_types = bugfix_build_types(info.job_name)
bt_paths = {bt: f"{temp_path}/clickhouse_{bt}" for bt in build_types}
# In local runs, only reuse existing binaries; probing master commits in S3
# depends on `master_commits` workflow data populated by CI workflow hooks
# and is not available locally.
if info.is_local_run:
missing = [str(p) for p in bt_paths.values() if not Path(p).is_file()]
assert not missing, (
"Local bugfix validation requires all build-type binaries to be "
f"present under {temp_path}; missing: {missing}"
)
build_urls = None
else:
build_urls = find_master_builds(build_types)
assert build_urls, "Could not find master builds in S3"
if build_urls:
for bt, url in build_urls.items():
bt_path = bt_paths[bt]
if not info.is_local_run or not Path(bt_path).is_file():
print(f"NOTE: Downloading {bt} build to [{bt_path}]")
Shell.run(
f"wget -nv -O {bt_path} {url}", verbose=True, strict=True
)
Shell.run(f"chmod +x {bt_path}", verbose=True)
clickhouse_path = f"{temp_path}/clickhouse_{build_types[0]}"
if is_bugfix_validation or is_flaky_check:
assert (
changed_test_modules or (info.is_local_run and args.test)
), "No changed test modules found, either job must be skipped or bug in changed test search logic"
Shell.check(f"chmod +x {clickhouse_path}", verbose=True, strict=True)
Shell.check(f"{clickhouse_path} --version", verbose=True, strict=True)
targeted_tests = []
if is_targeted_check:
assert not args.test, "--test not supposed to be used for targeted check ???"
targeter = Targeting(info=info)
tests, results_with_info = targeter.get_all_relevant_tests_with_info()
# no subtask level for integration tests - cannot add this info to the report now
# results.append(results_with_info)
if not tests:
# early exit
Result.create_from(
status=Result.Status.SKIPPED,
info="No failed tests found from previous runs",
).complete_job()
# Parse test names from the query result
for test_ in tests:
if test_.strip():
test_name = test_.strip()
targeted_tests.append(
test_name.split("[")[0]
) # remove parametrization - does not work with test repeat with --count
print(f"Parsed {len(targeted_tests)} test names: {targeted_tests}")
if not Shell.check("docker info > /dev/null 2>&1", verbose=True):
start_docker_in_docker()
Shell.check("docker info > /dev/null", verbose=True, strict=True)
parallel_test_modules, sequential_test_modules = (
get_parallel_sequential_tests_to_run(
batch_num,
total_batches,
args.test or targeted_tests or changed_test_modules,
workers,
args.options,
info,
no_strict=is_targeted_check or is_flaky_check, # targeted check might want to run test that was removed on a merge-commit; flaky check might pick up a changed test filtered out by SKIP_LIST in the private fork
)
)
if is_flaky_check or is_targeted_check:
# The flaky/targeted parallel bucket runs `--dist=each`: every worker runs
# every parallel module at once. TEST_CONFIGS `dist_each_sequential` modules
# would start one cluster per worker and OOM small runners, so move them to
# the looped sequential phase. Normal `--dist=loadfile` runs do not call this.
before = list(parallel_test_modules)
parallel_test_modules, sequential_test_modules = force_heavy_modules_sequential(
parallel_test_modules, sequential_test_modules
)
moved = [m for m in before if m not in parallel_test_modules]
if moved:
print(f"Forced heavy modules to the sequential phase (avoid concurrent --dist=each clusters): {moved}")
if is_sequential:
parallel_test_modules = []
assert not is_parallel
elif is_parallel:
sequential_test_modules = []
assert not is_sequential
# If this PR only touches test files (no production/config code changed),
# this batch only needs to run whichever of parallel_test_modules /
# sequential_test_modules actually contains a changed module - the other
# side would produce results identical to master and can be dropped
# outright (saving the time to run it), and if neither side contains a
# changed module the whole batch can be skipped. Placed after the
# is_sequential/is_parallel handling above so it sees the modules this
# job invocation will actually run, not the pre-flavor-filter set.
if (
total_batches > 1
and not is_flaky_check
and not is_targeted_check
and not is_bugfix_validation
and not args.test
):
changed_files = info.get_changed_files()
if changed_files and all(
Targeting.is_functional_test_file(f)
or Targeting.is_integration_test_file(f)
or Targeting.is_ci_job_script(f)
for f in changed_files
):
changed_integration_modules = {
f.removeprefix("tests/integration/")
for f in changed_files
if Targeting.is_integration_test_file(f)
}
if not changed_integration_modules:
Result.create_from(
status=Result.Status.SKIPPED,
info="Only non-integration test files changed in this PR - nothing for this job to run",
).complete_job()
if not (changed_integration_modules & set(parallel_test_modules)):
parallel_test_modules = []
if not (changed_integration_modules & set(sequential_test_modules)):
sequential_test_modules = []
if not parallel_test_modules and not sequential_test_modules:
Result.create_from(
status=Result.Status.SKIPPED,
info="Only test files changed in this PR and none of the changed test modules fall into this batch",
).complete_job()
if (is_targeted_check or is_flaky_check) and not parallel_test_modules and not sequential_test_modules:
# Targeted check: all selected tests were stale (removed or renamed since the CIDB record).
# Flaky check: all changed tests were filtered out (e.g. by SKIP_LIST in the private fork).
# Either way, skip gracefully instead of producing a "no results" error.
skip_info = (
"All targeted tests are stale (removed or renamed)"
if is_targeted_check
else "All changed tests were filtered out (e.g. by SKIP_LIST)"
)
Result.create_from(
status=Result.Status.SKIPPED,
info=skip_info,
).complete_job()
if is_flaky_check or is_targeted_check:
# Sort by module file so all tests from the same file are consecutive.
# With --dist=each, pytest preserves CLI argument order and uses it as the
# collection order. If tests from different modules interleave (e.g. CIDB
# returns them sorted by failure time), pytest finalizes and re-enters
# module-scoped fixtures between them, breaking tests that call
# cluster.add_instance() inside the fixture.
# For regular jobs, preserve the duration-aware ordering from get_optimal_test_batch.
parallel_test_modules = sorted(parallel_test_modules, key=lambda t: t.split("::")[0])
sequential_test_modules = sorted(sequential_test_modules, key=lambda t: t.split("::")[0])
# Setup environment variables for tests
for image_name, env_name in IMAGES_ENV.items():
tag = info.docker_tag(image_name)
if tag:
print(f"Setting environment variable [{env_name}] to [{tag}]")
os.environ[env_name] = tag
else:
assert False, f"No tag found for image [{image_name}]"
# Pre-fetch all Docker images needed by the selected test suites.
# This is done after IMAGES_ENV vars are set so tag resolution works correctly.
# Fail fast here rather than discovering missing images mid-test-run.
all_test_modules = parallel_test_modules + sequential_test_modules
compose_files = get_compose_files_for_test_modules(all_test_modules)
print(
f"Compose files detected for this batch ({len(compose_files)}): "
+ ", ".join(str(f.name) for f in compose_files)
)
images_to_prefetch = get_images_from_compose_files(compose_files)
if not prefetch_images(images_to_prefetch):
Result.create_from(
status=Result.Status.ERROR,
info="Failed to pre-pull Docker images needed by the test batch",
labels=[Result.Label.INFRA],
).complete_job()
test_env = {
"CLICKHOUSE_TESTS_BASE_CONFIG_DIR": clickhouse_server_config_dir,
"CLICKHOUSE_TESTS_SERVER_BIN_PATH": clickhouse_path,
"CLICKHOUSE_BINARY": clickhouse_path, # some test cases support alternative binary location
"CLICKHOUSE_TESTS_CLIENT_BIN_PATH": clickhouse_path,
"CLICKHOUSE_USE_OLD_ANALYZER": "1" if use_old_analyzer else "0",
"CLICKHOUSE_USE_DISTRIBUTED_PLAN": "1" if use_distributed_plan else "0",
"CLICKHOUSE_USE_DATABASE_DISK": "1" if use_database_disk else "0",
"PYTEST_CLEANUP_CONTAINERS": "1",
"JAVA_PATH": java_path,
# PromQL compliance: deterministic JSON for post-hook (see promql_compliance_hook.py).
"COMPLIANCE_RESULT_FILE": os.environ.get(
"COMPLIANCE_RESULT_FILE", os.path.join(temp_path, "promql_compliance_result.json")
),
}
if is_llvm_coverage:
test_env["LLVM_PROFILE_FILE"] = "it-%4m.profraw"
print(
f"NOTE: This is LLVM coverage run, setting LLVM_PROFILE_FILE to [{test_env['LLVM_PROFILE_FILE']}]"
)
# Auto-detect available LLVM profdata tool
for ver in ["21", "20", "18", "19", "17", "16", ""]:
cmd = f"llvm-profdata{'-' + ver if ver else ''}"
if Shell.check(f"command -v {cmd}", verbose=False):
llvm_profdata_cmd = cmd
break
if not llvm_profdata_cmd:
print("ERROR: llvm-profdata not found in PATH")
else:
print(f"Using {llvm_profdata_cmd} to merge coverage files")
test_results = []
failed_tests_files = []
has_error = False
# Set when a pytest run was cut short by a timeout (graceful session-timeout or the
# hard subprocess backstop). Used below to keep an empty flaky/targeted result a
# best-effort SKIPPED only when a timeout actually exhausted the budget.
timed_out = False
session_timeout_parallel = 3600 * 2
session_timeout_sequential = 3600
if is_llvm_coverage:
session_timeout_parallel = 7200
session_timeout_sequential = 7200
if args.session_timeout:
session_timeout_parallel = args.session_timeout * 2
session_timeout_sequential = args.session_timeout
# Flaky-check soft timeout. Mirrors the pattern in `ci/jobs/functional_tests.py`:
# bound the total time spent inside pytest so the job has headroom for cleanup,
# log collection and reporting before the job is cancelled. Without this, a
# flaky-check run over many modified test modules can be hard-killed, producing no
# report at all instead of a best-effort partial one.
#
# The budget must stay well below the external ceiling at which a lone integration
# job is cancelled (observed at ~80-90 min from job start in CI). The previous 90 min
# was above that ceiling: the graceful xdist `--session-timeout` and the subprocess
# hard-kill backstop never fired before the external cancellation, so the whole
# process was killed and 0 results were reported (the job showed a red failure rather
# than a best-effort report). 45 min matches the functional flaky check and leaves
# ample room for the hard-kill backstop (+600s below), cleanup and reporting.
FLAKY_CHECK_TIME_LIMIT = 45 * 60 # 45 min - kept below the external job-cancellation ceiling
if is_flaky_check:
elapsed_for_flaky = int(sw.duration)
flaky_check_remaining_s = max(FLAKY_CHECK_TIME_LIMIT - elapsed_for_flaky, 60)
print(
f"Flaky-check time limit: {FLAKY_CHECK_TIME_LIMIT}s "
f"(elapsed so far: {elapsed_for_flaky}s, remaining: {flaky_check_remaining_s}s)"
)
# Cap per-phase session timeouts so a single phase cannot consume the entire budget.
session_timeout_parallel = min(session_timeout_parallel, flaky_check_remaining_s)
session_timeout_sequential = min(session_timeout_sequential, flaky_check_remaining_s)
error_info = []
failed_test_cases = []
# Clear dmesg to avoid false OOM detection from previous CI jobs on the same host.
# Do this only in CI (non-local runs) and via a non-interactive privileged helper.
if not info.is_local_run:
try:
Utils.clear_dmesg()
except Exception as ex:
print(f"Failed to clear dmesg before integration tests: {ex}")
if is_flaky_check or is_targeted_check:
# Each xdist worker runs all modules independently with its own isolated Docker cluster.
# ClickHouseCluster appends PYTEST_XDIST_WORKER to the project name, so clusters
# from different workers never interfere. --dist=each sends all tests to every worker.
parallel_dist = "--dist=each"
parallel_workers = workers
# Sequential tests cannot run in parallel, so we loop over them instead.
# Run at least 3 times to have meaningful flakiness signal, at most workers times.
sequential_repeat_cnt = max(3, workers)
else:
parallel_dist = "--dist=loadfile"
parallel_workers = workers
sequential_repeat_cnt = 1
if parallel_test_modules:
log_file = f"{temp_path}/pytest_parallel.log"
test_result_parallel, parallel_timed_out = run_pytest_and_collect_results(
command=f"{quote_tests(parallel_test_modules)} --report-log-exclude-logs-on-passed-tests -n {parallel_workers} {parallel_dist} --tb=short {repeat_option} --session-timeout={session_timeout_parallel}",
env=test_env,
report_name="parallel",
timeout=session_timeout_parallel + 600,
)
timed_out = timed_out or parallel_timed_out
test_results.extend(test_result_parallel.results)
_mark_infrastructure_errors(test_result_parallel.results)
failed_test_cases.extend(
[t.name for t in test_result_parallel.results if t.is_failure()]
)
if test_result_parallel.files:
failed_tests_files.extend(test_result_parallel.files)
if test_result_parallel.is_error():
if not is_targeted_check and not is_flaky_check:
# In targeted checks we may overload the run with many heavy tests running
# in parallel; in flaky checks the soft FLAKY_CHECK_TIME_LIMIT may cap the
# pytest session-timeout. In both cases a session-timeout is an expected
# risk rather than an infrastructure problem, so we do not treat such
# errors as job-level failures.
has_error = True
error_info.append(test_result_parallel.info)
fail_num = len([r for r in test_results if not r.is_ok()])
if sequential_test_modules and fail_num < MAX_FAILS_BEFORE_DROP and not has_error:
for attempt in range(sequential_repeat_cnt):
# Recompute remaining budget for flaky-check at every iteration and stop
# scheduling new runs once it is exhausted (soft timeout).
iter_session_timeout_sequential = session_timeout_sequential
if is_flaky_check:
elapsed_for_flaky = int(sw.duration)
flaky_check_remaining_s = max(FLAKY_CHECK_TIME_LIMIT - elapsed_for_flaky, 0)
if flaky_check_remaining_s < 60:
print(
f"Flaky-check time limit reached after [{attempt}/{sequential_repeat_cnt}] sequential attempts "
f"(elapsed: {elapsed_for_flaky}s, limit: {FLAKY_CHECK_TIME_LIMIT}s); stopping"
)
break
iter_session_timeout_sequential = min(
session_timeout_sequential, flaky_check_remaining_s
)
test_result_sequential, sequential_timed_out = run_pytest_and_collect_results(
command=f"{quote_tests(sequential_test_modules)} --report-log-exclude-logs-on-passed-tests --tb=short {repeat_option} -n 1 --dist=loadfile --session-timeout={iter_session_timeout_sequential}",
env=test_env,
report_name="sequential",
timeout=iter_session_timeout_sequential + 600,
)
timed_out = timed_out or sequential_timed_out
test_results.extend(test_result_sequential.results)
_mark_infrastructure_errors(test_result_sequential.results)
failed_test_cases.extend(
[t.name for t in test_result_sequential.results if t.is_failure()]
)
if test_result_sequential.files:
failed_tests_files.extend(test_result_sequential.files)
if test_result_sequential.is_error():
if not is_targeted_check and not is_flaky_check:
# In targeted checks we may overload the run with many heavy tests running
# sequentially; in flaky checks the per-iteration `iter_session_timeout_sequential`
# may be capped by FLAKY_CHECK_TIME_LIMIT. In both cases a session-timeout is
# an expected risk rather than an infrastructure problem, so we do not treat
# such errors as job-level failures.
has_error = True
error_info.append(test_result_sequential.info)
break
if (is_flaky_check or is_targeted_check) and not test_result_sequential.is_ok():
print(
f"Flaky/targeted check: sequential test run fails after attempt [{attempt+1}/{sequential_repeat_cnt}] - break"
)
break
# Run additional build types for bugfix validation.
# Exit early on first failure to avoid duplicate test names and workspace pollution.
if is_bugfix_validation:
build_types = bugfix_build_types(info.job_name)
for r in test_results:
r.set_label(build_types[0])
if all(r.is_ok() for r in test_results):
for bugfix_bt in build_types[1:]:
print(f"\n=== Bugfix validation with {bugfix_bt} ===")
bt_clickhouse_path = f"{temp_path}/clickhouse_{bugfix_bt}"
test_env["CLICKHOUSE_TESTS_SERVER_BIN_PATH"] = bt_clickhouse_path
test_env["CLICKHOUSE_BINARY"] = bt_clickhouse_path
test_env["CLICKHOUSE_TESTS_CLIENT_BIN_PATH"] = bt_clickhouse_path
Shell.check(
f"{bt_clickhouse_path} --version", verbose=True, strict=True
)
bt_test_results = []
if parallel_test_modules:
bt_result_parallel, _ = run_pytest_and_collect_results(
command=f"{quote_tests(parallel_test_modules)} --report-log-exclude-logs-on-passed-tests -n {workers} --dist=loadfile --tb=short {repeat_option} --session-timeout={session_timeout_parallel}",
env=test_env,
report_name=f"parallel_{bugfix_bt}",
timeout=session_timeout_parallel + 600,
)
bt_test_results.extend(bt_result_parallel.results)
_mark_infrastructure_errors(bt_result_parallel.results)
if bt_result_parallel.files:
failed_tests_files.extend(bt_result_parallel.files)
if bt_result_parallel.is_error():
has_error = True
error_info.append(bt_result_parallel.info)
bt_fail_num = len([r for r in bt_test_results if not r.is_ok()])
if sequential_test_modules and bt_fail_num < MAX_FAILS_BEFORE_DROP and not has_error:
bt_result_sequential, _ = run_pytest_and_collect_results(
command=f"{quote_tests(sequential_test_modules)} --report-log-exclude-logs-on-passed-tests --tb=short {repeat_option} -n 1 --dist=loadfile --session-timeout={session_timeout_sequential}",
env=test_env,
report_name=f"sequential_{bugfix_bt}",
timeout=session_timeout_sequential + 600,
)
bt_test_results.extend(bt_result_sequential.results)
_mark_infrastructure_errors(bt_result_sequential.results)
if bt_result_sequential.files:
failed_tests_files.extend(bt_result_sequential.files)
if bt_result_sequential.is_error():
has_error = True
error_info.append(bt_result_sequential.info)
for r in bt_test_results:
r.set_label(bugfix_bt)
test_results = bt_test_results
if any(not r.is_ok() for r in bt_test_results):
break
# Collect logs before re-run
attached_files = []
if not info.is_local_run:
failed_suits = []
# Collect docker compose configs used in tests
config_files = [
str(p)
for p in Path("./tests/integration/").glob("test_*/_instances*/*/configs/")
]
for test_result in test_results:
if not test_result.is_ok() and ".py" in test_result.name:
failed_suits.append(test_result.name.split("/")[0])
failed_suits = list(set(failed_suits))
for failed_suit in failed_suits:
failed_tests_files.append(f"tests/integration/{failed_suit}")
# Add all files matched ./ci/tmp/*.log ./ci/tmp/*.jsonl into failed_tests_files
for pattern in ["*.log", "*.jsonl"]:
for log_file in Path("./ci/tmp/").glob(pattern):
if log_file.is_file():
failed_tests_files.append(str(log_file))
if failed_suits:
attached_files.append(
Utils.compress_files_gz(failed_tests_files, f"{temp_path}/logs.tar.gz")
)
attached_files.append(
Utils.compress_files_gz(config_files, f"{temp_path}/configs.tar.gz")
)
if Path("./ci/tmp/docker-in-docker.log").exists():
attached_files.append("./ci/tmp/docker-in-docker.log")
# Rerun failed tests if any to check if failure is reproducible
if 0 < len(failed_test_cases) < 10 and not (
is_flaky_check or is_bugfix_validation or is_targeted_check or info.is_local_run
):
test_result_retries, _ = run_pytest_and_collect_results(
command=f"{quote_tests(failed_test_cases)} --report-log-exclude-logs-on-passed-tests --tb=short -n 1 --dist=loadfile --session-timeout=1200",
env=test_env,
report_name="retries",
timeout=1200 + 600,
)
successful_retries = [t.name for t in test_result_retries.results if t.is_ok()]
failed_retries = [t.name for t in test_result_retries.results if t.is_failure()]
if successful_retries or failed_retries:
for test_case in test_results:
if test_case.name in successful_retries:
test_case.set_label(Result.Label.OK_ON_RETRY)
elif test_case.name in failed_retries:
test_case.set_label(Result.Label.FAILED_ON_RETRY)
# Remove iptables rule added in tests
Shell.check("sudo iptables -D DOCKER-USER 1 ||:", verbose=True)
if not info.is_local_run:
print("Dumping dmesg")
Shell.check("dmesg -T > ./ci/tmp/dmesg.log", verbose=True, strict=True)
with open("./ci/tmp/dmesg.log", "rb") as dmesg:
dmesg = dmesg.read()
if (
b"Out of memory: Killed process" in dmesg
or b"oom_reaper: reaped process" in dmesg
or b"oom-kill:constraint=CONSTRAINT_NONE" in dmesg
):
if is_bugfix_validation:
# A host OOM is an infrastructure/resource failure, not bug
# reproduction. Report it as `ERROR` and set `has_error` so
# the status inversion below is skipped (same as `Timeout`),
# otherwise the inverted `FAIL` would flip the job to green.
test_results.append(
Result(
name=OOM_IN_DMESG_TEST_NAME, status=Result.Status.ERROR
)
)
has_error = True
error_info.append(
"OOM in dmesg - infrastructure/resource failure, "
"not bug reproduction"
)
else:
test_results.append(
Result(
name=OOM_IN_DMESG_TEST_NAME, status=Result.Status.FAIL
)
)
attached_files.append("./ci/tmp/dmesg.log")
# For targeted, flaky checks, and bugfix validation, the synthetic "Timeout"
# result must not be propagated as a top-level `FAIL`: for targeted checks a
# session-timeout is an expected risk (because of `--count N` overloading), for
# flaky checks because of the soft `FLAKY_CHECK_TIME_LIMIT`, and for bugfix
# validation an inverted `FAIL` would be mistakenly treated as successful bug
# reproduction.
if is_targeted_check or is_flaky_check or is_bugfix_validation:
test_results = [r for r in test_results if r.name != "Timeout"]
# Whether pytest produced any real test results, captured *before* the synthetic
# `skipped_flaky_modules` entries are appended below. The empty-result status decision
# must look only at real pytest output: in a scope-capped flaky run the synthetic
# `SKIPPED` entries would otherwise make `test_results` non-empty even when the selected
# modules produced nothing, masking a timeout-empty run (should be `SKIPPED`) or an empty
# harness failure (should be `ERROR`) as a green top-level result.
pytest_has_results = bool(test_results)
# Make the best-effort scope cap explicit in the report: list the modules that were
# skipped to fit the time budget as SKIPPED entries rather than dropping them silently.
for skipped_module in skipped_flaky_modules:
test_results.append(
Result(
name=skipped_module,
status=Result.Status.SKIPPED,
info="Skipped by flaky-check best-effort scope cap (MAX_FLAKY_CHECK_MODULES)",
)
)
# If a timeout exhausted the time budget before any test produced a result (e.g. a
# single very slow or hanging module consumed the whole budget), report SKIPPED rather
# than letting `create_from` default an empty result set to ERROR, which would block
# the PR. Crucially, this best-effort downgrade applies *only* when a timeout was
# actually observed: an empty result without a timeout means pytest failed to produce
# any output for some other reason (crashed before writing the jsonl report, plugin or
# internal error, ...), which is a real harness failure and must stay ERROR.
#
# Both decisions use `pytest_has_results` (real pytest output) rather than the current
# `test_results`, which may carry only the synthetic `skipped_flaky_modules` entries
# appended above. With a scope cap in effect those synthetic `SKIPPED` rows make
# `test_results` non-empty, so the empty-result status must be forced here: `create_from`
# only defaults an empty result set to `ERROR`, and a list of `SKIPPED` rows would
# otherwise collapse to a green top-level status.
empty_best_effort = is_empty_best_effort_skip(
is_flaky_check, is_targeted_check, pytest_has_results, timed_out
)
empty_harness_failure = (
(is_flaky_check or is_targeted_check) and not pytest_has_results and not timed_out
)
R = Result.create_from(
results=test_results,
status=(
Result.Status.SKIPPED
if empty_best_effort
else Result.Status.ERROR if empty_harness_failure else ""
),
info=(
"No test results collected within the flaky-check time budget (best effort)"
if empty_best_effort
else (
"No test results collected and no timeout was observed - reporting the "
"empty pytest run as a harness ERROR"
if empty_harness_failure
else ""
)
),
stopwatch=sw,
files=attached_files,
)
if is_llvm_coverage:
assert (
is_bugfix_validation is False
), "LLVM coverage with bugfix validation is not supported"
has_failure = False
for r in R.results:
if r.status == Result.Status.FAIL:
if r.has_label(Result.Label.OK_ON_RETRY):
# Remove label and set to OK
r.remove_label(Result.Label.OK_ON_RETRY)
r.status = Result.Status.OK
else:
has_failure = True
if has_failure:
R.set_failed()
R.set_info("Some tests failed during LLVM coverage run")
else:
R.set_success()
has_error = False
# Capture whether this run saw any infrastructure problems BEFORE the
# clearing block below resets `has_error`. If the answer is yes, the
# bugfix-validation inversion path further down must be skipped: we have
# no reliable signal about whether the bug reproduces on this arch, and
# running the inversion would let an infra `FAIL` be flipped to `OK`
# (counted as validation) or rewrite an `ERROR`-only outcome as
# `SKIPPED` via the no-`has_failure` branch. See bot review on
# ClickHouse/ClickHouse#103541 (2026-05-15).
had_infra_or_error = has_error or any(
r.has_label(Result.Label.INFRA) for r in test_results
)
# If all non-OK results are infrastructure errors, do not treat as a real failure
if has_error:
non_ok = [r for r in test_results if not r.is_ok()]
if non_ok and all(r.has_label(Result.Label.INFRA) for r in non_ok):
print(
"All failures are infrastructure errors - clearing error flag"
)
has_error = False
force_ok_exit = True
if has_error:
R.set_error().set_info("\n".join(error_info))
if is_bugfix_validation and (Labels.PR_BUGFIX in info.pr_labels or Labels.PR_CRITICAL_BUGFIX in info.pr_labels):
assert (
is_llvm_coverage is False
), "Bugfix validation with LLVM coverage is not supported"
if had_infra_or_error:
# Infrastructure errors or session-level failures were observed
# during this run. Skip the inversion path so the per-arch job
# cannot be silently counted as a validation. The post-hook in
# `new_tests_check.py` uses strict `is_success` (`OK` / `XFAIL`
# only); leaving the result in a non-success state is enough to
# prevent this arch from contributing a false validation.
#
# If, after all the upstream handling, the result is still in a
# success-equivalent state (e.g. every surviving child is `OK`
# because all infra failures were already relabeled to `SKIPPED`
# by `_mark_infrastructure_errors`), force `ERROR` here so the
# post-hook cannot accidentally treat this arch as validated.
print(
"Bugfix validation: infrastructure error or session-level "
"failure detected - skipping status inversion to avoid "
"leaking an infra outcome into validation success."
)
if R.is_success():
R.set_error().set_info(
"Bugfix validation aborted: infrastructure error during "
"the run - no reliable signal about whether the bug "
"reproduces on this arch"
)
else:
has_failure = False
for r in R.results:
# invert statuses: only `FAIL` is treated as a successful
# reproduction signal. Generic `ERROR` is left untouched
# because in integration tests `ERROR` is also used for
# runner-level problems (for example session-timeout paths
# in `run_pytest_and_collect_results`), and infrastructure
# errors that escape `_mark_infrastructure_errors` could
# otherwise flip the job to green.
r.set_label(Result.Label.XFAIL)
if r.status == Result.Status.FAIL:
r.status = Result.Status.OK
has_failure = True
elif r.status == Result.Status.OK:
r.status = Result.Status.FAIL
if not has_failure:
# See the matching comment in `ci/jobs/functional_tests.py`. The
# bug did not reproduce on this arch, so report SKIPPED instead
# of FAIL: `Result.is_ok` includes SKIPPED so the job exits 0,
# while `is_success` (used by the post-hook) excludes SKIPPED so
# the per-arch job does not count as a validation. Contract:
# at least one per-arch job must end up `OK`/`XFAIL` for the
# post-hook to consider the bug validated.
print("Bug does not reproduce on this arch - bugfix validation N/A")
R.set_status(Result.Status.SKIPPED)
R.set_info("Bug does not reproduce on this arch - bugfix validation N/A")
else:
R.set_success()
force_ok_exit = False
if R:
failures_cnt = len([r for r in R.results if not r.is_ok()])
if failures_cnt > 0 and failures_cnt < 4:
print(
f"NOTE: Failed {failures_cnt} tests - do not block pipeline, exit with 0"
)
force_ok_exit = True
elif failures_cnt > 0 and "ci-non-blocking" in info.pr_labels:
print(
f"NOTE: Failed {failures_cnt} tests, label 'ci-non-blocking' is set - do not block pipeline - exit with 0"
)
force_ok_exit = True
if is_bugfix_validation:
# Per-arch bugfix-validation jobs are advisory: their pass/fail status
# records "did the bug reproduce on this arch?", not whether the PR
# should be blocked. Setting `do_not_block_pipeline_on_failure=True`
# marks the job as non-blocking so downstream jobs are not dropped
# when this job reports FAIL. The process itself still exits with
# the natural status (`Result.complete_job` calls `sys.exit(1)` on
# non-OK results); the non-blocking flag is metadata for the
# pipeline scheduler. The PR-merge-blocking decision lives in the
# `new_tests_check.py` workflow post-hook, which OR's the per-arch
# bugfix-validation job statuses.
print(
"NOTE: Bugfix validation job - marking as non-blocking; "
"failure here will not block downstream pipeline jobs "
"(process exit code still reflects the actual job status)"
)
force_ok_exit = True
if is_llvm_coverage and llvm_profdata_cmd:
print("Collecting and merging LLVM coverage files...")
# Merge all profraw files into final profdata file
merged_profdata = merge_profraw_files(llvm_profdata_cmd, job_params)
# Attach profdata file to the result report so it is uploaded
# unconditionally (even when tests fail) and visible in the CI report.
if merged_profdata and os.path.exists(merged_profdata):
R.files.append(merged_profdata)
force_ok_exit = True
print("NOTE: LLVM coverage job - do not block pipeline - exit with 0")
R.sort().complete_job(do_not_block_pipeline_on_failure=force_ok_exit)
if __name__ == "__main__":
main()