Files
zeekay 9a90943b8f merge: ClickHouse 26.7.1.1315-stable into datastore — preserve debrand
13,838 upstream commits, forward from the 26.6.1.1 fork point. 129 conflicts:
37 resolved mechanically (take upstream's file, re-apply the brand sweep) and
92 by hand where our fork and upstream had both changed the same code.

The rule throughout: upstream owns the engine, we own the naming and a small set
of deliberate deviations. Never resolve by discarding an upstream change — a
dropped rename or a missed new parameter becomes a compile error a thousand
files away.

Deviations verified intact after the merge:
  ENABLE_XRAY still defaults OFF        (CMakeLists.txt:105)
  X-Datastore-User read by the server   (authenticateUserByHTTP.cpp)
  X-ClickHouse-* in src/                0
  hanzo/research                        present
  system.*_log retention bound          present
  ArrowFlight rip                       held against upstream re-adding it

Two things worth naming, because neither shows up as a conflict:

  src/CMakeLists.txt gained an UNGUARDED
  `add_object_library(datastore_storages_arrowflight Storages/ArrowFlight)` from
  upstream, for a directory our gRPC rip deleted. It sat outside every conflict
  hunk, so a resolver reading only the markers would sail straight past it into
  a CMake configure error. Removed; the Server/ArrowFlight sibling at line 474
  stays because it is inside `if (TARGET ch_contrib::arrow_flight)`.

  The brand sweep had rewritten github.com/ClickHouse/ClickHouse issue links to
  github.com/Datastore/Datastore — a dead host. Those point at upstream's own
  tracker and are restored, alongside the contributors list, sql.clickhouse.com
  and the ClickHouse Inc copyright: attribution is theirs, not ours to rename.

base/glibc-compatibility/memcpy/memcpy.h is deleted, not kept: upstream dropped
it for LLVM-libc's, and our only change to it was a comment rebrand.
2026-07-26 14:47:43 -07:00

6884 lines
272 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# pylint: disable=too-many-return-statements
# pylint: disable=global-variable-not-assigned
# pylint: disable=global-statement
# pylint: disable=too-many-lines
# pylint: disable=anomalous-backslash-in-string
# pylint: disable=protected-access
import copy
import enum
import glob
# Not requests, to avoid requiring extra dependency.
import http.client
import io
import itertools
import json
import math
import multiprocessing
import multiprocessing.managers
import multiprocessing.sharedctypes
import multiprocessing.synchronize
import os
import os.path
import platform
import random
import re
import shutil
import signal
import socket
import string
import subprocess
import sys
import shlex
import traceback
import urllib.parse
# for crc32
import zlib
from argparse import ArgumentParser, Namespace
from ast import literal_eval as make_tuple
from contextlib import contextmanager, redirect_stdout
from datetime import datetime, timedelta
from errno import EPERM, ESRCH
from pathlib import Path
from ssl import SSLEOFError
from subprocess import PIPE, Popen
from time import sleep, time
from typing import Dict, List, Optional, Set, Tuple, Union
try:
import termcolor # type: ignore
except ImportError:
termcolor = None
# Base path for per-process PGID files used by `--cleanup` to locate orphaned
# test process groups. Each worker process writes its own file named
# ``_GROUP_PID_NAME.<pid>`` so no cross-process locking is needed.
# The CI hook can invoke `datastore-test --cleanup` even if the parent was
# killed with SIGKILL before its cleanup code ran.
# `__file__` is `{repo}/tests/datastore-test`, so this resolves to
# `{repo}/ci/tmp/clickhouse_test_group_pid`.
_GROUP_PID_PATH = Path(__file__).resolve().parent.parent / "ci" / "tmp"
_GROUP_PID_NAME = "clickhouse_test_group_pid"
def write_text_atomic(path: Path, text: str) -> None:
"""Write `text` to `path` atomically via a sibling `.tmp` file and `os.replace`."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text)
os.replace(tmp, path)
except Exception as e:
print(f"Warning: could not write {path}: {e}", file=sys.stderr)
# Exit code returned by `datastore-test` when the run was aborted early via
# `stop_testing` (typically: server died / health check failed). Distinct from
# the generic failure code 1 so the job side can populate the synthetic
# "Server died" leaf without fragile log-line matching. Kept in sync with
# `STOP_TESTING_EXIT_CODE` in `ci/jobs/scripts/functional_tests_results.py`.
STOP_TESTING_EXIT_CODE = 2
# Exit code returned when the run stopped *gracefully* because the global time
# limit (`--global_time_limit` / `--stop-time`) was reached. This is an
# expected, benign stop condition - the flaky and targeted checks deliberately
# rerun the selected tests until the time budget is exhausted - so it must NOT
# be conflated with `STOP_TESTING_EXIT_CODE` (which the job side reports as
# "Server died"). Kept in sync with `GLOBAL_TIME_LIMIT_EXIT_CODE` in
# `ci/jobs/scripts/functional_tests_results.py`.
GLOBAL_TIME_LIMIT_EXIT_CODE = 3
# Exit code returned when the run stopped early because the `--max-failures` /
# `--max-failures-chain` limit was reached. The server is alive and the failing
# tests are real and already attributed - so this must NOT be conflated with
# `STOP_TESTING_EXIT_CODE` (reported as "Server died", which also blanks out the
# per-test attribution). Kept in sync with `MAX_FAILURES_EXIT_CODE` in
# `ci/jobs/scripts/functional_tests_results.py`.
MAX_FAILURES_EXIT_CODE = 4
USE_JINJA = True
try:
import jinja2
except ImportError:
USE_JINJA = False
print("WARNING: jinja2 not installed! Template tests will be skipped.")
MESSAGES_TO_RETRY = [
"ConnectionPoolWithFailover: Connection failed at try",
"DB::Exception: New table appeared in database being dropped or detached. Try again",
"is already started to be removing by another replica right now",
# This is from LSan, and it indicates its own internal problem:
"Unable to get registers from thread",
# Next two can be caused by a flaky tmp FS; normally fine upon retry; details at #93451
"No such file or directory",
"Permission denied",
"Net Exception: Connection reset by peer",
# Transient Keeper errors that happen under heavy load (e.g. ARM ASan builds)
"Coordination::Exception: Connection loss",
"Coordination::Exception: Session expired",
# Server-global (total) memory tracker firing under heavy parallel co-scheduling
# on ASan shards: the OvercommitTracker kills queries across all co-scheduled
# tests once total RSS nears the shared server cap. Emitted only by the global
# tracker (programs/server/Server.cpp: setDescription("(total)")), never by a
# per-query/per-user limit, so retrying cannot mask a real per-query assertion.
"(total) memory limit exceeded",
]
IGNORED_SANITIZER_ERRORS = [
"ASan doesn't fully support makecontext/swapcontext functions and may produce false positives in some cases!",
# These warnings accompany the above message and are part of the same known limitation
# (https://github.com/google/sanitizers/issues/189). They are not real errors.
"False positive error reports may follow",
"For details see https://github.com/google/sanitizers/issues/189",
# This warning appears when the virtual stack size is too large for ASan to track __asan_handle_no_return.
# It is a known limitation (https://github.com/google/sanitizers/issues/189) and not a real error.
"ASan is ignoring requested __asan_handle_no_return",
# Note, usually such error means that sanitizer has found something but failed to print, so it should not be ignored
# "LLVM ERROR: IO failure on output stream: Broken pipe",
]
MAX_RETRIES = 3
TEST_FILE_EXTENSIONS = [".sql", ".sql.j2", ".sh", ".py", ".expect"]
VERSION_PATTERN = r"^((\d+\.)?(\d+\.)?(\d+\.)?\d+)$"
TEST_MAX_RUN_TIME_IN_SECONDS = 180
def detect_cgroup_version():
if os.path.exists("/sys/fs/cgroup/cgroup.controllers"):
return 2
elif os.path.exists("/sys/fs/cgroup/memory"):
return 1
return None
CGROUP_VERSION = detect_cgroup_version()
BASH = "/bin/bash"
def shell_get_output(cmd: str, timeout: Optional[float] = None) -> str:
try:
res = subprocess.run(
cmd,
shell=True,
executable=BASH,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=timeout,
)
return res.stdout.strip()
except Exception as e:
print(f"Error running command {cmd!r}: {e}", file=sys.stderr)
return ""
class SharedEngineReplacer:
# Infix marker embedded into the transient per-PID test and reference copies this
# class creates next to the originals. It contains a dot-delimited token that no
# real test filename uses, so suite discovery (`is_transient_test_copy`) can
# recognize and skip these copies unambiguously, without constraining the names of
# genuine tests.
TEMP_FILE_MARKER = "shared_engine_replacer_tmp"
SPECIALIZED_ENGINES = "Collapsing|VersionedCollapsing|Summing|Replacing|Aggregating|Coalescing"
ENGINES_NON_REPLICATED_REGEXP = (
rf"(?i)ENGINE[ =]{{0,5}}(({SPECIALIZED_ENGINES}|)MergeTree\(?\)?)"
)
ENGINES_MAPPING_REPLICATED = [
("ReplicatedMergeTree", "SharedMergeTree"),
("ReplicatedCollapsingMergeTree", "SharedCollapsingMergeTree"),
(
"ReplicatedVersionedCollapsingMergeTree",
"SharedVersionedCollapsingMergeTree",
),
("ReplicatedSummingMergeTree", "SharedSummingMergeTree"),
("ReplicatedReplacingMergeTree", "SharedReplacingMergeTree"),
("ReplicatedAggregatingMergeTree", "SharedAggregatingMergeTree"),
("ReplicatedCoalescingMergeTree", "SharedCoalescingMergeTree"),
]
NEW_SYNTAX_REPLICATED_MERGE_TREE_RE = (
r"Replicated[a-zA-Z]*MergeTree\((\\?'.*\\?')?,?(\\?'.*\\?')?[a-zA-Z, _}{]*\)"
)
SHARED_MERGE_TREE_RE = (
r"Shared[a-zA-Z]*MergeTree\((\\?'.*\\?')?,?(\\?'.*\\?')?[a-zA-Z, _}{]*\)"
)
OLD_SYNTAX_OR_ARGUMENTS_RE = r"Tree\(.*[0-9]+.*\)"
CLUSTER_KEYWORDS = [
"test_shard_localhost",
"test_shard_localhost_secure",
"test_cluster_two_shards_localhost",
"test_cluster_two_shards",
"test_cluster_1_shard_3_replicas_1_unavailable",
"test_cluster_database_replicated",
"test_cluster_one_shard_two_replicas",
"test_cluster_one_shard_three_replicas_localhost",
"test_cluster_two_shards_different_databases",
"test_cluster_two_replicas_different_databases_internal_replication",
"test_cluster_two_shards_different_databases_with_local",
"test_cluster_two_shard_three_replicas_localhost",
]
DISKS_MAP = {
"s3_disk": "s3disk",
"s3_plain_disk": "s3diskForSampleData",
"s3_cache": "s3WithKeeperDiskWithCache",
"local_disk": "default",
"s3_no_cache": "s3WithKeeperDisk",
}
REGEX_REPLACE = {
r"cluster_for_parallel_replicas\s?=\s?'parallel_replicas'": "cluster_for_parallel_replicas='default'",
r"--cluster_for_parallel_replicas \"parallel_replicas\"": '--cluster_for_parallel_replicas "default"',
r"(SET|set) log_queries = \d;": "",
r"cluster\(parallel_replicas": r"cluster(default",
}
def _check_replicated_new_syntax(self, line):
return re.search(self.NEW_SYNTAX_REPLICATED_MERGE_TREE_RE, line) is not None
def _check_old_syntax_or_arguments(self, line):
return re.search(self.OLD_SYNTAX_OR_ARGUMENTS_RE, line) is not None
@classmethod
def has_non_replicated(cls, filename):
with open(filename, "r", newline="", encoding="utf-8", errors="ignore") as f:
for line in f:
if not ("cloud_mode" in line) and re.search(
cls.ENGINES_NON_REPLICATED_REGEXP, line
):
return True
return False
@classmethod
def has_replicated_or_shared_or_cloud(cls, filename):
with open(filename, "r", newline="", encoding="utf-8", errors="ignore") as f:
for line in f:
if (
"cloud_mode" in line
or re.search(cls.NEW_SYNTAX_REPLICATED_MERGE_TREE_RE, line)
is not None
or re.search(cls.SHARED_MERGE_TREE_RE, line) is not None
):
return True
return False
@staticmethod
def _is_select_line(line):
return re.match(r"^(SELECT).*", line, flags=re.IGNORECASE)
@staticmethod
def _is_create_query(line):
return re.match(r"^(CREATE|ENGINE).*", line, flags=re.IGNORECASE)
def _replace_non_replicated(
self, line, escape_quotes, use_random_path, add_path=True, replace_with="Shared"
):
groups = re.search(self.ENGINES_NON_REPLICATED_REGEXP, line)
if groups is not None and not self._check_old_syntax_or_arguments(line):
non_replicated_engine = groups.groups()[0]
basename_no_ext = os.path.splitext(os.path.basename(self.file_name))[0]
if use_random_path:
shared_path = "/" + os.path.join(
basename_no_ext.replace("_", "/"),
str(os.getpid()),
str(random.randint(1, 1000)),
)
else:
shared_path = "/" + os.path.join(
basename_no_ext.replace("_", "/"), str(os.getpid())
)
shared_engine = replace_with + non_replicated_engine.replace("()", "")
if escape_quotes:
# in cloud run we remove SMT parameters to use default ZK path and replica
engine_path = f"(\\'{shared_path}\\', \\'1\\')"
else:
engine_path = f"('{shared_path}', '1')"
if add_path:
shared_engine += engine_path
return line.replace(non_replicated_engine, shared_engine)
return line
def _need_to_replace_something(self):
return (
(
(self.replace_replicated or self.replace_non_replicated)
and "shared_merge_tree" not in self.file_name
)
or self.cloud
or self.args.replace_log_memory_with_mergetree
)
def _has_show_create_table(self):
with open(self.file_name, "r", encoding="utf-8") as f:
return re.search("show create table", f.read(), re.IGNORECASE)
def _remove_rmt_parameters(self, line):
# Regexp for removing zookeeper path and replica name:
# SharedMergeTree('/clickhouse/tables/{database}/t_lightweight_mut_6', '1')
smt_with_parameters = rf"((Shared|Replicated)(?:{self.SPECIALIZED_ENGINES})?MergeTree)\('.*?',\s*'.*?'(?:,\s*(.*?))?\)"
if re.search(smt_with_parameters, line):
modified_statement = re.sub(
smt_with_parameters,
lambda m: f"{m.group(1)}({m.group(3) or ''})",
line,
)
return modified_statement
return line
def _replace_data_engines(self, line, replace_engine="SharedMergeTree"):
# These table engines are not supported
data_engines = r"(Engine\s*=?\s*)(Log|TinyLog|StripeLog|Memory)\(?\)?"
# Replace them with SMT
replace_with = f"{replace_engine}() ORDER BY tuple()"
if self.reference_file and not self.with_echo:
replace_with = f"{replace_engine}\\nORDER BY tuple()\\nSETTINGS index_granularity = 8192"
if (
re.search(data_engines, line, flags=re.IGNORECASE)
and "CREATE DATABASE" not in line.upper()
):
modified_statement = re.sub(
data_engines,
lambda m: f"{m.group(1)}{replace_with}",
line,
flags=re.IGNORECASE,
)
return modified_statement
return line
def _replace_temporary_tables(self, line):
# These table engines are not supported
create_stmt = r"CREATE\s*TEMPORARY\s*TABLE"
if re.search(create_stmt, line, flags=re.IGNORECASE):
modified_statement = re.sub(
create_stmt,
"CREATE TABLE",
line,
flags=re.IGNORECASE,
)
return modified_statement
return line
def _replace_database_engines(self, line):
pattern = r"(CREATE DATABASE\s+(?:IF NOT EXISTS\s+)?(?:`[^`]*`|\S+))(\s*ENGINE\s*=\s*(Shared|Replicated|Atomic|Ordinary|Memory)(\(.*\))?)"
if re.search(pattern, line, flags=re.IGNORECASE):
modified_statement = re.sub(
pattern,
lambda m: m.group(1),
line,
flags=re.IGNORECASE,
)
return modified_statement
return line
def __init__(
self,
args,
file_name,
replace_replicated,
replace_non_replicated,
reference_file,
cloud,
):
self.args = args
self.file_name = file_name
self.replace_replicated = replace_replicated
self.replace_non_replicated = replace_non_replicated
self.reference_file = reference_file
self.cloud = cloud
self.with_echo = False
use_random_path = not reference_file and not self._has_show_create_table()
if not self._need_to_replace_something():
return
self.temp_file_path = "{0}.{2}.{3}{1}".format(
*os.path.splitext(file_name),
SharedEngineReplacer.TEMP_FILE_MARKER,
os.getpid(),
)
shutil.copy2(self.file_name, self.temp_file_path)
with (
open(self.file_name, "r", newline="", encoding="utf-8") as source,
open(self.temp_file_path, "w", newline="", encoding="utf-8") as modified,
):
for line in source:
# Replace table engines if echo enabled to match changed reference
if (
self.cloud or self.args.replace_log_memory_with_mergetree
) and re.match("-- ?{ ?echo.*", line):
self.with_echo = True
if self.cloud and self.with_echo:
self.replace_replicated = True
self.replace_non_replicated = True
if self.args.replace_log_memory_with_mergetree:
line = self._replace_data_engines(
line,
replace_engine="SharedMergeTree" if self.cloud else "MergeTree",
)
if (
self._is_select_line(line)
or (reference_file and not self._is_create_query(line))
) and not self.cloud:
modified.write(line)
continue
if self.replace_replicated:
for (
engine_from,
engine_to,
) in self.ENGINES_MAPPING_REPLICATED:
if engine_from in line and (
self._check_replicated_new_syntax(line)
or engine_from + " " in line
or engine_from + ";" in line
):
line = line.replace(engine_from, engine_to)
break
if args.shared_catalog_stress:
line = self._replace_data_engines(line)
line = self._replace_database_engines(line)
# Remove parameters (zk path and replica name) from SMT tables
# because they are not supported in cloud
if self.cloud:
line = self._remove_rmt_parameters(line)
line = self._replace_data_engines(line)
line = self._replace_database_engines(line)
# line = self._replace_temporary_tables(line)
# In cloud, we have only default cluster and cluster for each database. Test only default now
for keyword in self.CLUSTER_KEYWORDS:
line = line.replace(keyword, "default")
for prev, new in self.DISKS_MAP.items():
line = line.replace(prev, new)
for pattern, replacement in self.REGEX_REPLACE.items():
line = re.sub(pattern, replacement, line)
if self.replace_non_replicated:
line = self._replace_non_replicated(
line, reference_file, use_random_path, add_path=not self.cloud
)
modified.write(line)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_tb):
if not self._need_to_replace_something():
return
os.unlink(self.temp_file_path)
def get_path(self):
if not self._need_to_replace_something():
return self.file_name
return self.temp_file_path
def stringhash(s: str) -> int:
# default hash() function consistent
# only during process invocation https://stackoverflow.com/a/42089311
return zlib.crc32(s.encode("utf-8"))
def read_file_as_binary_string(file_path):
with open(file_path, "rb") as file:
binary_data = file.read()
return binary_data
# First and last lines of the log
def trim_for_log(s, limit=10000):
if not s:
return s
lines = s.splitlines()
if len(lines) > limit:
separator = "-" * 40 + str(len(lines) - limit) + " lines are hidden" + "-" * 40
return "\n".join(lines[:limit//2] + [] + [separator] + [] + lines[-limit//2:])
return "\n".join(lines)
def is_valid_utf_8(fname):
try:
with open(fname, "rb") as f:
contents = f.read()
contents.decode("utf-8")
return True
except UnicodeDecodeError:
return False
class TestException(Exception):
pass
class ConfigException(Exception):
pass
class HTTPError(Exception):
def __init__(self, message=None, code=None):
self.message = message
self.code = code
super().__init__(message)
def __str__(self):
return f"Code: {self.code}. {self.message}"
# Helpers to execute queries via HTTP interface.
def clickhouse_execute_http(
base_args,
query,
body=None,
timeout=30,
settings=None,
default_format=None,
max_http_retries=5,
retry_error_codes=False,
):
if base_args.secure:
client = http.client.HTTPSConnection(
host=base_args.tcp_host, port=base_args.http_port, timeout=timeout
)
else:
client = http.client.HTTPConnection(
host=base_args.tcp_host, port=base_args.http_port, timeout=timeout
)
timeout = int(timeout)
params = {
"query": query,
# hung check in stress tests may remove the database,
# hence we should use 'system'.
"database": "system",
"connect_timeout": timeout,
"receive_timeout": timeout,
"send_timeout": timeout,
"http_connection_timeout": timeout,
"http_receive_timeout": timeout,
"http_send_timeout": timeout,
"output_format_parallel_formatting": 0,
"max_rows_to_read": 0, # Some queries read from system.text_log which might get too big
# Disable server-side AST fuzzer for infrastructure queries (hung
# check, log analysis, etc.). The stress test profile sets
# ast_fuzzer_runs=5, and without this override the fuzzer mutates
# the hung-check processlist query, spawning new queries that then
# appear in the processlist — causing a false-positive hung check.
"ast_fuzzer_runs": 0,
}
if settings is not None:
params.update(settings)
if default_format is not None:
params["default_format"] = default_format
for i in range(max_http_retries):
try:
client.request(
"POST",
f"/?{base_args.client_options_query_str}{urllib.parse.urlencode(params)}",
body=body,
)
res = client.getresponse()
data = res.read()
if res.status == 200 or (not retry_error_codes):
break
except Exception as ex:
if i == max_http_retries - 1:
raise ex
client.close()
# Cap sleep time to prevent unbounded delays (max 3 seconds per retry)
sleep(min(i + 1, 3))
if res.status != 200:
raise HTTPError(data.decode(), res.status)
return data
def clickhouse_execute(
base_args,
query,
body=None,
timeout=30,
settings=None,
max_http_retries=5,
retry_error_codes=False,
):
return clickhouse_execute_http(
base_args,
query,
body,
timeout,
settings,
max_http_retries=max_http_retries,
retry_error_codes=retry_error_codes,
).strip()
def clickhouse_execute_json(
base_args, query, timeout=60, settings=None, max_http_retries=5
):
data = clickhouse_execute_http(
base_args,
query,
None,
timeout,
settings,
"JSONEachRow",
max_http_retries=max_http_retries,
)
if not data:
return None
rows = []
for row in data.strip().splitlines():
rows.append(json.loads(row))
return rows
# Should we capture client's stacktraces via SIGTSTP
CAPTURE_CLIENT_STACKTRACE = False
def kill_process_group(pgid, fatal_log):
try:
if SANITIZED:
print(f"Processes left in process group {pgid}:")
print(
'\n'.join(f"{p[0]} {p[3]}" for p in pgrep(pgid=pgid)),
)
print("Wait 60 seconds to let sanitizers print the report...")
sleep(60)
print(f"Killing process group {pgid}")
print(f"Processes in process group {pgid}:")
print(
'\n'.join(f"{p[0]} {p[3]}" for p in pgrep(pgid=pgid)),
)
if CAPTURE_CLIENT_STACKTRACE:
# Let's try to dump stacktrace in client (useful to catch issues there)
os.killpg(pgid, signal.SIGTSTP)
stacktrace_delay = 0.5 if RELEASE_NON_SANITIZED else 10
print(f"Wait {stacktrace_delay} seconds for clickhouse utilities to handle SIGTSTP...")
sleep(stacktrace_delay)
# Let's check maybe clickhouse already printed the stacktrace, then no need to do the same with lldb
client_printed_stacktrace = False
try:
if fatal_log and os.path.exists(fatal_log):
with open(fatal_log, "rb") as f:
if (
b"This is a signal used for debugging purposes by the user"
in f.read()
):
client_printed_stacktrace = True
except:
print(f"Cannot read {fatal_log}. Ignoring.")
if not client_printed_stacktrace:
processes = [p[0] for p in pgrep(pgid=pgid, command="clickhouse")]
for child in processes:
print(get_stacktraces_from_lldb(child))
# NOTE: this still may leave some processes, that had been
# created by timeout(1), since it also creates new process
# group. But this should not be a problem with default
# options, since the default time for each test is 10min,
# and this is way bigger than the timeout for each
# timeout(1) invocation.
#
# But as a workaround we are sending SIGTERM first, and
# only after SIGKILL, that way timeout(1) will have an
# ability to terminate children (though not always since
# signals are asynchronous).
os.killpg(pgid, signal.SIGTERM)
# We need minimal delay to let processes handle SIGTERM - 0.1 (this may
# not be enough, but at least something)
sleep(0.1)
try:
os.killpg(pgid, signal.SIGKILL)
except OSError as e:
# SIGTERM above succeeded, so we have permission to signal this group.
# EPERM here is a macOS-specific behavior: killpg returns EPERM when
# the group contains zombie processes (already exited after SIGTERM
# but not yet reaped). Linux ignores zombies in killpg instead.
if e.errno in (ESRCH, EPERM):
print(f"Got {e.strerror} while sending SIGKILL to {pgid}. Ignoring.")
else:
raise
except OSError as e:
if e.errno == ESRCH:
print(f"Got ESRCH while killing {pgid}. Ignoring.")
else:
raise
print(f"Process group {pgid} should be killed")
def cleanup_child_processes(pid):
print(f"Child processes of {pid}:")
try:
pgid = os.getpgid(os.getpid())
except OSError as e:
if e.errno == ESRCH:
print(f"Process {pid} does not exist. Ignoring.")
return
raise
print(
'\n'.join(f"{p[0]} {p[3]}" for p in pgrep(ppid=pid)),
)
# Due to start_new_session=True, it is not enough to kill by PGID, we need
# to look at children processes as well.
# But we are hoping that nobody creates session in the tests (though it is
# possible via timeout(), but we are assuming that they will be killed by
# timeout).
processes = [p[0] for p in pgrep(ppid=pid)]
for child in processes:
try:
child_pgid = os.getpgid(child)
except ProcessLookupError as e:
print(e, f"No such process: {child}")
continue
if child_pgid != pgid:
kill_process_group(child_pgid, None)
# SIGKILL should not be sent, since this will kill the script itself
os.killpg(pgid, signal.SIGTERM)
# send signal to all processes in group to avoid hung check triggering
# (to avoid terminating datastore-test itself, the signal should be ignored)
def stop_tests():
signal.signal(signal.SIGTERM, signal.SIG_IGN)
cleanup_child_processes(os.getpid())
signal.signal(signal.SIGTERM, signal_handler)
def get_db_engine(args, database_name):
if args.cloud or args.shared_catalog_stress:
# use default database: shared catalog / replicated
return ""
if args.replicated_database:
return f" ON CLUSTER test_cluster_database_replicated \
ENGINE=Replicated('/test/clickhouse/db/{database_name}', \
'{{shard}}', '{{replica}}')"
if args.db_engine:
return " ENGINE=" + args.db_engine
if args.shared_catalog:
return " ENGINE=Shared"
return "" # Will use default engine
def get_create_database_settings(args, testcase_args):
create_database_settings = {}
if testcase_args:
create_database_settings["log_comment"] = testcase_args.testcase_basename
if args.db_engine == "Ordinary":
create_database_settings["allow_deprecated_database_ordinary"] = 1
return create_database_settings
def get_zookeeper_session_uptime(args):
try:
if args.replicated_database:
return int(
clickhouse_execute(
args,
"""
SELECT min(materialize(zookeeperSessionUptime()))
FROM clusterAllReplicas('test_cluster_database_replicated', system.one)
""",
)
)
if args.shared_catalog:
return int(
clickhouse_execute(
args,
"""
SELECT min(materialize(zookeeperSessionUptime()))
FROM clusterAllReplicas('test_cluster_shared_catalog', system.one)
""",
)
)
return int(clickhouse_execute(args, "SELECT zookeeperSessionUptime()"))
except Exception:
return None
def need_retry(args, stdout, stderr, total_time):
if args.check_zookeeper_session:
# Sometimes we may get unexpected exception like "Replica is readonly" or "Shutdown is called for table"
# instead of "Session expired" or "Connection loss"
# Retry if session was expired during test execution.
# If ZooKeeper is configured, then it's more reliable than checking stderr,
# but the following condition is always true if ZooKeeper is not configured.
session_uptime = get_zookeeper_session_uptime(args)
if session_uptime is not None and session_uptime < math.ceil(total_time):
return True
return any(msg in stdout for msg in MESSAGES_TO_RETRY) or any(
msg in stderr for msg in MESSAGES_TO_RETRY
)
def get_processlist_size(args):
if args.replicated_database:
return int(
clickhouse_execute(
args,
"""
SELECT
count()
FROM clusterAllReplicas('test_cluster_database_replicated', system.processes)
WHERE query NOT LIKE '%system.processes%'
AND query NOT LIKE '%system.minio_%_logs%'
""",
).strip()
)
if args.shared_catalog:
return int(
clickhouse_execute(
args,
"""
SELECT
count()
FROM clusterAllReplicas('test_cluster_shared_catalog', system.processes)
WHERE query NOT LIKE '%system.processes%'
AND query NOT LIKE '%system.minio_%_logs%'
""",
).strip()
)
return int(
clickhouse_execute(
args,
"""
SELECT
count()
FROM system.processes
WHERE query NOT LIKE '%system.processes%'
AND query NOT LIKE '%system.minio_%_logs%'
""",
).strip()
)
def get_processlist_with_stacktraces(args):
if args.replicated_database:
return clickhouse_execute(
args,
"""
SELECT materialize(hostName() || '::' || tcpPort()::String) as host_port, *
-- NOTE: view() here to do JOIN on shards, instead of initiator
FROM clusterAllReplicas('test_cluster_database_replicated', view(
SELECT
p.*,
arrayStringConcat(groupArray('Thread ID ' || toString(s.thread_id) || '\n' || arrayStringConcat(arrayMap(
x -> concat(addressToLine(x), '::', demangle(addressToSymbol(x))),
s.trace), '\n') AS stacktrace
), '\n') AS stacktraces
FROM system.processes p
JOIN system.stack_trace s USING (query_id)
WHERE query NOT LIKE '%system.processes%'
AND query NOT LIKE '%system.minio_%_logs%'
GROUP BY p.*
))
ORDER BY elapsed DESC FORMAT Vertical
""",
settings={
"allow_introspection_functions": 1,
},
timeout=120,
)
if args.shared_catalog:
return clickhouse_execute(
args,
"""
SELECT materialize(hostName() || '::' || tcpPort()::String) as host_port, *
-- NOTE: view() here to do JOIN on shards, instead of initiator
FROM clusterAllReplicas('test_cluster_shared_catalog', view(
SELECT
p.*,
arrayStringConcat(groupArray('Thread ID ' || toString(s.thread_id) || '\n' || arrayStringConcat(arrayMap(
x -> concat(addressToLine(x), '::', demangle(addressToSymbol(x))),
s.trace), '\n') AS stacktrace
), '\n') AS stacktraces
FROM system.processes p
JOIN system.stack_trace s USING (query_id)
WHERE query NOT LIKE '%system.processes%'
AND query NOT LIKE '%system.minio_%_logs%'
GROUP BY p.*
))
ORDER BY elapsed DESC FORMAT Vertical
""",
settings={
"allow_introspection_functions": 1,
},
timeout=120,
)
return clickhouse_execute(
args,
"""
SELECT
p.*,
arrayStringConcat(groupArray('Thread ID ' || toString(s.thread_id) || '\n' || arrayStringConcat(arrayMap(
x -> concat(addressToLine(x), '::', demangle(addressToSymbol(x))),
s.trace), '\n') AS stacktrace
), '\n') AS stacktraces
FROM system.processes p
JOIN system.stack_trace s USING (query_id)
WHERE query NOT LIKE '%system.processes%'
AND query NOT LIKE '%system.minio_%_logs%'
GROUP BY p.*
ORDER BY elapsed DESC FORMAT Vertical
""",
settings={
"allow_introspection_functions": 1,
},
timeout=120,
)
def get_transactions_list(args):
try:
if args.replicated_database:
return clickhouse_execute_json(
args,
"SELECT materialize((hostName(), tcpPort())) as host, * FROM "
"clusterAllReplicas('test_cluster_database_replicated', system.transactions)",
)
if args.shared_catalog:
return clickhouse_execute_json(
args,
"SELECT materialize((hostName(), tcpPort())) as host, * FROM "
"clusterAllReplicas('test_cluster_shared_catalog', system.transactions)",
)
return clickhouse_execute_json(args, "select * from system.transactions")
except Exception as e:
return f"Cannot get list of transactions: {e}"
_LLDB_INSTALL_ATTEMPTED = False
def _ensure_lldb_installed():
"""Make sure `lldb` is on PATH, installing it on the fly if not.
TEMPORARY scaffolding: the CI base images currently ship `gdb` (from the
`clickhouse/cctools` layer), not `lldb`. While the switch to `lldb` is in
flight, install it at runtime so `print_c_stacktraces` keeps working.
Once `ci/docker/cctools/Dockerfile` (and the images that copy from it)
install `lldb` permanently, this helper and its single call site below
should be removed.
Never raise: `print_c_stacktraces` is a best-effort diagnostic. If the
install fails (no sudo, no network, no `brew`, ...), let
`shell_get_output` return "" so the surrounding diagnostic flow keeps
going instead of crashing during an already-broken run.
Bounded and idempotent: each subprocess call has a hard timeout so a
stalled package mirror cannot wedge stacktrace collection for minutes,
and a process-wide flag ensures only the first call attempts the
install — `print_c_stacktraces` iterates over every server PID, and we
must not pay the install cost (or timeout) for each one if the first
attempt already failed.
"""
global _LLDB_INSTALL_ATTEMPTED
if shutil.which("lldb"):
return
if _LLDB_INSTALL_ATTEMPTED:
return
_LLDB_INSTALL_ATTEMPTED = True
try:
if platform.system() == "Darwin":
subprocess.run(["brew", "install", "llvm"], check=True, timeout=180)
else:
env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
subprocess.run(["apt-get", "update"], check=True, env=env, timeout=60)
subprocess.run(
["apt-get", "install", "-y", "lldb"],
check=True, env=env, timeout=120,
)
except Exception as e:
print(f"Could not install lldb on the fly: {e}", file=sys.stderr)
# collect server stacktraces using lldb (used uniformly on Linux and macOS)
def get_stacktraces_from_lldb(pid):
# 30s ceiling: `thread backtrace all` should finish in seconds even on a
# debug build with many threads; anything beyond that means lldb itself
# is wedged (e.g. signal-handler deadlock on the inferior). Killing lldb
# leaves the server process untouched.
_ensure_lldb_installed() # TEMPORARY: see _ensure_lldb_installed docstring.
return shell_get_output(
f"lldb --batch -p {pid} -o 'thread backtrace all' -o 'detach' -o 'quit'",
timeout=30,
)
# collect server stacktraces from system.stack_trace table
def get_stacktraces_from_clickhouse(args, timeout: float = 30):
settings_str = " ".join(
[
get_additional_client_options(args),
"--allow_introspection_functions=1",
"--skip_unavailable_shards=1",
]
)
if args.replicated_database:
msg = (
f"{args.client} {settings_str} --query "
'"SELECT materialize((hostName(), tcpPort())) as host, thread_name, thread_id, query_id, trace, '
"arrayStringConcat(arrayMap(x, y -> concat(x, ': ', y), "
"arrayMap(x -> addressToLine(x), trace), "
"arrayMap(x -> demangle(addressToSymbol(x)), trace)), '\n') as trace_str "
"FROM clusterAllReplicas('test_cluster_database_replicated', 'system.stack_trace') "
'ORDER BY host, thread_id FORMAT Vertical"'
)
elif args.shared_catalog:
msg = (
f"{args.client} {settings_str} --query "
'"SELECT materialize((hostName(), tcpPort())) as host, thread_name, thread_id, query_id, trace, '
"arrayStringConcat(arrayMap(x, y -> concat(x, ': ', y), "
"arrayMap(x -> addressToLine(x), trace), "
"arrayMap(x -> demangle(addressToSymbol(x)), trace)), '\n') as trace_str "
"FROM clusterAllReplicas('test_cluster_shared_catalog', 'system.stack_trace') "
'ORDER BY host, thread_id FORMAT Vertical"'
)
else:
msg = (
f"{args.client} {settings_str} --query "
"\"SELECT thread_name, thread_id, query_id, trace, arrayStringConcat(arrayMap(x, y -> concat(x, ': ', y), "
"arrayMap(x -> addressToLine(x), trace), "
"arrayMap(x -> demangle(addressToSymbol(x)), trace)), '\n') as trace_str "
'FROM system.stack_trace FORMAT Vertical"'
)
# Bound the dump so timeout_handler / hung-check callers cannot wedge
# forever if clickhouse-client itself blocks (e.g. the server accepted
# the connection but is not making progress on system.stack_trace).
return shell_get_output(msg, timeout=timeout)
def pgrep(ppid: int = None, pgid: int = None, command: str = None) -> List[List[str]]:
# -ww disables line-width truncation of the command column. Without it,
# ps cuts the cmdline at 80 chars when stdout is not a TTY, which can hide
# the "clickhouse-server" substring when the binary lives deep in the
# filesystem (e.g. /home/ubuntu/actions-runner/_work/.../ci/tmp/...).
ps = shell_get_output("ps -ww -eo pid,ppid,pgid,command").splitlines()[1:]
ps = [p.split(None, 3) for p in ps]
ps = [[int(p[0]), int(p[1]), int(p[2]), p[3]] for p in ps]
if ppid is not None:
ps = [p for p in ps if p[1] == ppid]
if pgid is not None:
ps = [p for p in ps if p[2] == pgid]
if command is not None:
ps = [p for p in ps if command in p[3]]
return ps
def get_all_server_pids():
"""Get PIDs of all clickhouse-server processes visible to the current user.
No pgid scoping: in every real scenario where this helper is useful the
server lives outside `datastore-test`'s process group (CI harnesses,
Docker, systemd, and the `ClickHouseService` test fixture all spawn the
server with `start_new_session=True` or equivalent). A pgid filter
silently filters out everything we are meant to find.
"""
return [p[0] for p in pgrep(command="clickhouse-server")]
def is_asan_build(args):
"""Check if the server is an ASan build — attaching a debugger disables LeakSanitizer detections."""
try:
result = clickhouse_execute(
args,
"SELECT count() FROM system.build_options WHERE name = 'CXX_FLAGS' AND position('sanitize=address' IN value)",
)
return result != b"0"
except Exception:
# If we can't query the server, check environment variable as fallback.
return bool(os.environ.get("ASAN_OPTIONS"))
def print_c_stacktraces(args) -> None:
"""C++-side backtraces via lldb. Works whenever the server process
exists and is debuggable, independent of whether SQL is reachable.
Every call site is one of two situations: we're aborting the whole
run (SERVER_DIED, hung-check, check_server_started) or terminating
the one wedged test (timeout_handler). lldb's "thread backtrace all"
pause is on the order of seconds — acceptable in both cases against
the diagnostic value.
Skipped under ASan, where attaching a debugger disables
LeakSanitizer detections.
"""
if is_asan_build(args):
print(
colored(
"\nCannot collect C stacktraces under ASan: debugger attach is disabled.",
args,
"red",
attrs=["bold"],
)
)
return
server_pid = get_server_pid()
all_pids = get_all_server_pids()
if not all_pids:
print(
colored(
"\nUnable to locate any ClickHouse server process. "
"It must have crashed or exited prematurely!",
args,
"red",
attrs=["bold"],
)
)
return
for pid in all_pids:
label = "main server" if pid == server_pid else "replica"
print(f"\nCollecting C stacktraces from {label} process {pid} with lldb:")
bt = get_stacktraces_from_lldb(pid)
if bt and len(bt) >= 1000:
print(f"=== PID {pid} ({label}) ===\n{bt}")
else:
print(f"Got suspiciously small stacktraces from {pid}: {bt}")
def print_sql_stacktraces(args) -> None:
"""ClickHouse-internal stack view from system.stack_trace.
Adds thread_name and query_id to the C-side picture, which is the
only way to map a stuck thread to the query it was running.
Self-skips when the server is not answering queries — the SQL dump
would hit the same dead socket as the liveness check. This keeps
callers honest: they can invoke this from any context (per-test
timeout, end-of-run hung-check) without first checking whether the
server still responds.
"""
if not check_server_liveness(args, max_retries=1):
print("Skipping system.stack_trace query: server is unreachable.")
return
print("\nCollecting stacktraces from system.stack_trace table:")
bt = get_stacktraces_from_clickhouse(args)
if bt:
print(bt)
def get_server_pid():
# Use the portable pgrep helper (built on `ps -eo pid,ppid,pgid,command`)
# so this works on both Linux and macOS — `pidof` does not exist on
# macOS, and `lsof -i tcp:PORT` would require knowing the port (only
# available on `args`, which is undefined in multiprocessing children
# spawned with the `spawn` start method).
procs = pgrep(command="clickhouse-server")
if not procs:
return None # most likely server is dead
return procs[0][0]
def colored(text, args, color=None, on_color=None, attrs=None):
if termcolor and (sys.stdout.isatty() or args.force_color):
return termcolor.colored(text, color, on_color, attrs)
return text
class TestStatus(enum.Enum):
FAIL = "FAIL"
UNKNOWN = "UNKNOWN"
OK = "OK"
SKIPPED = "SKIPPED"
NOT_FAILED = "NOT_FAILED"
class FailureReason(enum.Enum):
# FAIL reasons
TIMEOUT = "Timeout!"
SERVER_DIED = "server died"
EXIT_CODE = "return code: "
STDERR = "having stderror: "
EXCEPTION = "having exception in stdout: "
RESULT_DIFF = "result differs with reference: "
TOO_LONG = (
f"Test runs too long (> {TEST_MAX_RUN_TIME_IN_SECONDS}s). Make it faster."
)
INTERNAL_QUERY_FAIL = "Internal query (CREATE/DROP DATABASE) failed:"
CONNECTION_ERROR = "Connection error: "
# SKIPPED reasons
NOT_SUPPORTED = "not supported"
NOT_SUPPORTED_IN_CLOUD = "not supported in cloud"
NOT_SUPPORTED_IN_CLOUD_PREFILTER = "not supported in cloud. prefilter"
NOT_SUPPORTED_IN_CLOUD_POSTFILTER = "not supported in cloud. postfilter"
NOT_SUPPORTED_IN_PRIVATE = "not supported in private build"
DISABLED = "disabled"
SKIP = "skip"
NO_JINJA = "no jinja"
NO_ZOOKEEPER = "no zookeeper"
NO_SHARD = "no shard"
FAST_ONLY = "running fast tests only"
NOT_FAST_ONLY = "running not only fast tests"
NO_LONG = "not running long tests"
REPLICATED_DB = "replicated-database"
NON_ATOMIC_DB = "database engine not Atomic"
OBJECT_STORAGE = "object-storage"
COVERAGE = "coverage"
S3_STORAGE = "s3-storage"
AZURE_BLOB_STORAGE = "azure-blob-storage"
BUILD = "not running for current build"
NO_PARALLEL_REPLICAS = "smth is not supported with parallel replicas"
NO_ASYNC_INSERT = "test is not supported with async inserts enabled"
NO_LLVM_COVERAGE = "test is not supported with llvm coverage enabled"
SHARED_MERGE_TREE = "no-shared-merge-tree"
REQUIRE_REPLICATED_TO_SHARED_MERGE_TREE_REPLACEMENT = "Test with shared_merge_tree in name require option --replace-replicated-with-shared for datastore-test"
SHARED_CATALOG = "no-shared-catalog"
SHARED_CATALOG_ONLY = "shared-catalog"
DISTRIBUTED_CACHE = "distributed-cache"
NO_SHARED_OR_REPLICATED_MERGE_TREE = "no-shared-or-replicated-merge-tree"
NO_STATELESS = "no-stateless"
NO_STATEFUL = "no-stateful"
NO_OPENSSL_FIPS = "no-openssl-fips"
ENCRYPTED_STORAGE = "encrypted-storage"
OLD_ANALYZER = "old-analyzer"
NOT_SUPPORTED_ON_DARWIN = "not supported on Darwin"
LINUX_ONLY = "supported only on Linux"
# UNKNOWN reasons
NO_REFERENCE = "no reference file"
INTERNAL_ERROR = "Test internal error: "
NOT_FAILED = (
"The test succeeded, but it is listed in parallel_replicas_blacklist.txt or async_insert_blacklist.txt. "
"Please remove it from the list"
)
def threshold_generator(always_on_prob, always_off_prob, min_val, max_val):
def gen():
tmp = random.random()
if tmp <= always_on_prob:
return min_val
if tmp <= always_on_prob + always_off_prob:
return max_val
if isinstance(min_val, int) and isinstance(max_val, int):
return random.randint(min_val, max_val)
return random.uniform(min_val, max_val)
return gen
def auto_statistics_generator():
def gen():
if random.random() < 0.3:
return ""
all_statistic_types = ["uniq", "uniq_v2", "tdigest", 'countmin', 'basic']
return ",".join(random.sample(all_statistic_types, random.randint(0, len(all_statistic_types))))
return gen
def randomize_external_sort_group_by():
"""Returns a dict of 4 settings for external sort/group by.
The Bytes-vs-Ratio choice is made per call (not per process),
so each test run gets an independent choice."""
if random.choice(["Bytes", "Ratio"]) == "Bytes":
generator = threshold_generator(0.3, 0.5, 0, 10 * 1024 * 1024 * 1024)
return {
"max_bytes_before_external_sort": generator(),
"max_bytes_before_external_group_by": generator(),
"max_bytes_ratio_before_external_sort": 0,
"max_bytes_ratio_before_external_group_by": 0,
}
generator = threshold_generator(0.3, 0.5, 0.0, 0.20)
return {
"max_bytes_before_external_sort": 0,
"max_bytes_before_external_group_by": 0,
"max_bytes_ratio_before_external_sort": round(generator(), 2),
"max_bytes_ratio_before_external_group_by": round(generator(), 2),
}
# To keep dependency list as short as possible, tzdata is not used here (to
# avoid try/except block for import)
def get_localzone():
return os.getenv("TZ", "/".join(os.readlink("/etc/localtime").split("/")[-2:]))
# Refer to `tests/integration/helpers/random_settings.py` for integration test random settings
class SettingsRandomizer:
settings = {
"max_insert_threads": lambda: (
12 if random.random() < 0.03 else random.randint(1, 3)
),
# Exercise both the legacy "client converts inline data to native blocks" path
# and the new "server parses inline data" path (added in PR #101034).
"send_table_structure_on_insert_with_inline_data": lambda: random.randint(0, 1),
"group_by_two_level_threshold": threshold_generator(0.2, 0.2, 1, 1000000),
"group_by_two_level_threshold_bytes": threshold_generator(
0.2, 0.2, 1, 50000000
),
"distributed_aggregation_memory_efficient": lambda: random.randint(0, 1),
"fsync_metadata": lambda: random.randint(0, 1),
"output_format_parallel_formatting": lambda: random.randint(0, 1),
"input_format_parallel_parsing": lambda: random.randint(0, 1),
"min_chunk_bytes_for_parallel_parsing": lambda: max(
1024, int(random.gauss(10 * 1024 * 1024, 5 * 1000 * 1000))
),
"max_read_buffer_size": lambda: random.randint(500000, 1048576),
"prefer_localhost_replica": lambda: random.randint(0, 1),
"max_block_size": lambda: random.randint(8000, 100000),
"max_joined_block_size_rows": lambda: random.randint(8000, 100000),
"joined_block_split_single_row": lambda: random.randint(0, 1),
"join_output_by_rowlist_perkey_rows_threshold": lambda: random.choices([0, 1, 1_000_000, random.randint(0, 1000)], weights=[1, 1, 1, 10])[0],
"max_threads": lambda: 32 if random.random() < 0.03 else random.randint(1, 3),
"optimize_append_index": lambda: random.randint(0, 1),
"use_hedged_requests": lambda: random.randint(0, 1),
"optimize_if_chain_to_multiif": lambda: random.randint(0, 1),
"optimize_if_transform_strings_to_enum": lambda: random.randint(0, 1),
"optimize_read_in_order": lambda: random.randint(0, 1),
"optimize_or_like_chain": lambda: random.randint(0, 1),
"optimize_substitute_columns": lambda: random.randint(0, 1),
"enable_multiple_prewhere_read_steps": lambda: random.randint(0, 1),
"use_lightweight_primary_key_index_analysis": lambda: random.random() > 0.40,
"read_in_order_two_level_merge_threshold": lambda: random.randint(0, 100),
"optimize_aggregation_in_order": lambda: random.randint(0, 1),
"aggregation_in_order_max_block_bytes": lambda: random.randint(0, 50000000),
"use_uncompressed_cache": lambda: random.randint(0, 1),
"min_bytes_to_use_direct_io": threshold_generator(
0.2, 0.5, 1, 10 * 1024 * 1024 * 1024
),
"min_bytes_to_use_mmap_io": threshold_generator(
0.2, 0.5, 1, 10 * 1024 * 1024 * 1024
),
"local_filesystem_read_method": lambda: random.choice(
["read", "pread", "mmap", "pread_threadpool"]
),
"remote_filesystem_read_method": lambda: random.choice(["read", "threadpool"]),
"local_filesystem_read_prefetch": lambda: random.randint(0, 1),
"filesystem_cache_segments_batch_size": lambda: random.choice(
[0, 1, 2, 3, 5, 10, 50, 100]
),
"read_from_filesystem_cache_if_exists_otherwise_bypass_cache": lambda: random.randint(
0, 1
),
"throw_on_error_from_cache_on_write_operations": lambda: random.randint(0, 1),
"remote_filesystem_read_prefetch": lambda: random.randint(0, 1),
"distributed_cache_discard_connection_if_unread_data": lambda: random.randint(
0, 1
),
"distributed_cache_use_clients_cache_for_read": lambda: random.randint(
0, 1
),
"allow_prefetched_read_pool_for_remote_filesystem": lambda: random.randint(
0, 1
),
"filesystem_prefetch_max_memory_usage": lambda: random.choice(
["32Mi", "64Mi", "128Mi"]
),
"filesystem_prefetches_limit": lambda: random.choice(
[0, 10]
), # 0 means unlimited (but anyway limited by prefetch_max_memory_usage)
"filesystem_prefetch_min_bytes_for_single_read_task": lambda: random.choice(
["1Mi", "8Mi", "16Mi"]
),
"filesystem_prefetch_step_marks": lambda: random.choice(
[0, 50]
), # 0 means 'auto'
"filesystem_prefetch_step_bytes": lambda: random.choice(
[0, "100Mi"]
), # 0 means 'auto'
"enable_filesystem_cache": lambda: random.randint(0, 1),
"enable_filesystem_cache_on_write_operations": lambda: random.randint(0, 1),
"compile_expressions": lambda: random.randint(0, 1),
"compile_aggregate_expressions": lambda: random.randint(0, 1),
"compile_sort_description": lambda: random.randint(0, 1),
"merge_tree_coarse_index_granularity": lambda: random.randint(2, 32),
"optimize_distinct_in_order": lambda: random.randint(0, 1),
"max_bytes_before_remerge_sort": lambda: random.randint(1, 3000000000),
"min_compress_block_size": lambda: random.randint(1, 1048576 * 3),
"max_compress_block_size": lambda: random.randint(1, 1048576 * 3),
"merge_tree_compact_parts_min_granules_to_multibuffer_read": lambda: random.randint(
1, 128
),
"optimize_sorting_by_input_stream_properties": lambda: random.randint(0, 1),
"http_response_buffer_size": lambda: random.randint(0, 10 * 1048576),
"http_wait_end_of_query": lambda: random.random() > 0.5,
"enable_memory_bound_merging_of_aggregation_results": lambda: random.randint(
0, 1
),
"min_count_to_compile_expression": lambda: random.choice([0, 3]),
"min_count_to_compile_aggregate_expression": lambda: random.choice([0, 3]),
"min_count_to_compile_sort_description": lambda: random.choice([0, 3]),
"session_timezone": lambda: random.choice(
[
# special non-deterministic around 1970 timezone, see [1].
#
# [1]: https://github.com/ClickHouse/ClickHouse/issues/42653
"America/Mazatlan",
"America/Hermosillo",
"Mexico/BajaSur",
# These timezones had DST transitions on some unusual dates (e.g. 2000-01-15 12:00:00).
"Africa/Khartoum",
"Africa/Juba",
# server default that is randomized across all timezones
# NOTE: due to lots of trickery we cannot use empty timezone here, but this should be the same.
get_localzone(),
]
),
# This setting affects part names and their content which can be read from tables in tests.
# We have a lot of tests which relies on part names, so it's very unsafe to enable randomization
# of this setting
# "prefer_warmed_unmerged_parts_seconds": lambda: random.randint(0, 10),
"use_page_cache_for_disks_without_file_cache": lambda: random.random() < 0.7,
"use_page_cache_for_local_disks": lambda: random.random() < 0.3,
"use_page_cache_for_object_storage": lambda: random.random() < 0.5,
"page_cache_inject_eviction": lambda: random.random() < 0.5,
"merge_tree_read_split_ranges_into_intersecting_and_non_intersecting_injection_probability": lambda: round(
random.random(), 2
),
"prefer_external_sort_block_bytes": lambda: random.choice([0, 1, 100000000]),
"cross_join_min_rows_to_compress": lambda: random.choice([0, 1, 100000000]),
"cross_join_min_bytes_to_compress": lambda: random.choice([0, 1, 100000000]),
"min_external_table_block_size_bytes": lambda: random.choice([0, 1, 100000000]),
"max_parsing_threads": lambda: random.choice([0, 1, 10]),
"optimize_functions_to_subcolumns": lambda: random.randint(0, 1),
"parallel_replicas_local_plan": lambda: random.randint(0, 1),
"query_plan_join_swap_table": lambda: random.choice(["auto", "false"]), # true forces swap, which is harmful for some tests
"enable_vertical_final": lambda: random.randint(0, 1),
"optimize_extract_common_expressions": lambda: random.randint(0, 1),
# Default-false optimize_* settings: enable with 0.5 probability
# NOTE: optimize_move_to_prewhere_if_final is excluded because it triggers
# "Logical error: Input-output header mismatch after removing unused columns
# after pushing down filters to prewhere" (a real code bug).
"optimize_rewrite_array_exists_to_has": lambda: random.random() < 0.5,
"optimize_skip_unused_shards": lambda: random.random() < 0.5,
"optimize_trivial_approximate_count_query": lambda: random.random() < 0.5,
"optimize_trivial_insert_select": lambda: random.random() < 0.5,
"optimize_using_constraints": lambda: random.random() < 0.5,
"optimize_syntax_fuse_functions": lambda: random.random() < 0.5,
# Default-true optimize_* settings: disable with 0.05 probability
"optimize_aggregators_of_group_by_keys": lambda: random.random() > 0.05,
"optimize_and_compare_chain": lambda: random.random() > 0.05,
"optimize_arithmetic_operations_in_aggregate_functions": lambda: random.random() > 0.05,
"optimize_count_from_files": lambda: random.random() > 0.05,
"optimize_distributed_group_by_sharding_key": lambda: random.random() > 0.05,
"optimize_empty_string_comparisons": lambda: random.random() > 0.05,
"optimize_group_by_constant_keys": lambda: random.random() > 0.05,
"optimize_group_by_function_keys": lambda: random.random() > 0.05,
"optimize_injective_functions_in_group_by": lambda: random.random() > 0.05,
"optimize_injective_functions_inside_uniq": lambda: random.random() > 0.05,
"optimize_inverse_dictionary_lookup": lambda: random.random() > 0.05,
"optimize_move_to_prewhere": lambda: random.random() > 0.05,
"optimize_multiif_to_if": lambda: random.random() > 0.05,
"optimize_normalize_count_variants": lambda: random.random() > 0.05,
"optimize_on_insert": lambda: random.random() > 0.05,
"optimize_redundant_functions_in_order_by": lambda: random.random() > 0.05,
"optimize_respect_aliases": lambda: random.random() > 0.05,
"optimize_rewrite_aggregate_function_with_if": lambda: random.random() > 0.05,
"optimize_rewrite_regexp_functions": lambda: random.random() > 0.05,
"optimize_rewrite_sum_if_to_count_if": lambda: random.random() > 0.05,
"optimize_skip_unused_shards_rewrite_in": lambda: random.random() > 0.05,
"optimize_time_filter_with_preimage": lambda: random.random() > 0.05,
"optimize_trivial_count_query": lambda: random.random() > 0.05,
"optimize_uniq_to_count": lambda: random.random() > 0.05,
"optimize_use_projections": lambda: random.random() > 0.05,
"optimize_use_implicit_projections": lambda: random.random() > 0.05,
"optimize_use_projection_filtering": lambda: random.random() > 0.05,
"query_plan_optimize_lazy_materialization": lambda: random.random() > 0.05,
"query_plan_optimize_prewhere": lambda: random.random() > 0.05,
"use_async_executor_for_materialized_views": lambda: random.randint(0, 1),
"use_query_condition_cache": lambda: random.randint(0, 1),
"secondary_indices_enable_bulk_filtering": lambda: random.randint(0, 1),
"use_skip_indexes_if_final": lambda: random.randint(0, 1),
"use_skip_indexes_on_data_read": lambda: random.randint(0, 1),
"enable_join_runtime_filters_index_analysis": lambda: random.randint(0, 1),
"optimize_trivial_count_with_sparsity_filter": lambda: random.randint(0, 1),
"optimize_rewrite_like_perfect_affix": lambda: random.randint(0, 1),
"enable_lazy_columns_replication": lambda: random.randint(0, 1),
"allow_special_serialization_kinds_in_output_formats": lambda: random.randint(0, 1),
"use_skip_indexes_for_top_k": lambda: random.randint(0, 1),
"use_top_k_dynamic_filtering": lambda: random.randint(0, 1),
"query_plan_max_limit_for_top_k_optimization": lambda: random.choice([0, 1, 10, 100, 1000, 100000]),
"query_plan_read_in_order_through_join": lambda: random.randint(0, 1),
"read_in_order_use_virtual_row": lambda: random.randint(0, 1),
"short_circuit_function_evaluation_for_nulls_threshold": lambda: random.random() * 1.1,
"automatic_parallel_replicas_mode": lambda: 2 if random.random() < 0.25 else 0,
"temporary_files_buffer_size": lambda: random.randint(800 * 1024, 1200 * 1024),
# dpsize' - implements DPsize algorithm currently only for Inner joins. So it may not work in some tests.
# That is why we use it with fallback to 'greedy'.
"query_plan_optimize_join_order_algorithm": lambda: random.choice(['greedy', 'dpsize,greedy', 'greedy,dpsize']),
"max_bytes_before_external_join": lambda: random.choice([0, 1000000000]),
"grace_hash_join_initial_buckets": lambda: random.choice([1, 2, 4, 8]),
"query_plan_optimize_join_order_randomize": lambda: 1 if random.random() < 0.1 else random.randint(2, 1_000_000),
"use_top_k_dynamic_filtering_for_variable_length_types": lambda: random.randint(0, 1),
# Default 16 MiB; randomize across small/large to exercise both the coalescing
# path and the cap that splits long miss runs into multiple reads.
"page_cache_max_coalesced_bytes": lambda: random.choice([65536, 1048576, 16777216, 67108864]),
# Default-true optimize/enable settings: disable with 0.05 probability
"optimize_dictget_tuple_element": lambda: random.random() > 0.05,
"execute_exists_as_scalar_subquery": lambda: random.random() > 0.05,
"normalize_function_names": lambda: random.random() > 0.05,
"enable_join_runtime_filters": lambda: random.random() > 0.05,
"enable_parallel_blocks_marshalling": lambda: random.random() > 0.05,
# Default-false query_plan_* toggles: enable with 0.3 probability
"query_plan_reuse_storage_ordering_for_window_functions": lambda: random.random() < 0.3,
"query_plan_join_shard_by_pk_ranges": lambda: random.random() < 0.3,
"count_distinct_optimization": lambda: random.random() < 0.3,
"enable_add_distinct_to_in_subqueries": lambda: random.random() < 0.3,
# Statistics-based optimization
"use_statistics": lambda: random.random() > 0.05,
"use_statistics_cache": lambda: random.random() > 0.05,
# More query plan optimizations
"enable_optimize_predicate_expression": lambda: random.random() > 0.05,
"enable_optimize_predicate_expression_to_final_subquery": lambda: random.random() > 0.05,
"enable_software_prefetch_in_aggregation": lambda: random.random() > 0.05,
"allow_reorder_prewhere_conditions": lambda: random.random() > 0.05,
"materialize_statistics_on_insert": lambda: random.random() > 0.05,
"query_plan_merge_filters": lambda: random.random() > 0.05,
"query_plan_remove_unused_columns": lambda: random.random() > 0.05,
"query_plan_convert_outer_join_to_inner_join": lambda: random.random() > 0.05,
"query_plan_convert_any_join_to_semi_or_anti_join": lambda: random.random() > 0.05,
"query_plan_merge_filter_into_join_condition": lambda: random.random() > 0.05,
"query_plan_enable_multithreading_after_window_functions": lambda: random.random() > 0.05,
"query_plan_max_limit_for_lazy_materialization": lambda: random.choice([0, 1, 10, 100, 1000, 10000, 100000]),
"query_plan_optimize_join_order_limit": lambda: 0 if random.random() < 0.05 else random.choice([1, 5, 10, 20, 64]),
"query_plan_direct_read_from_text_index": lambda: random.random() > 0.05,
"query_plan_text_index_add_hint": lambda: random.random() > 0.05,
"optimize_truncate_order_by_after_group_by_keys": lambda: random.random() > 0.05,
"enable_automatic_decision_for_merging_across_partitions_for_final": lambda: random.random() > 0.05,
"parallel_non_joined_rows_processing": lambda: random.random() > 0.05,
"use_skip_indexes_for_disjunctions": lambda: random.random() > 0.05,
"use_hash_table_stats_for_join_reordering": lambda: random.random() > 0.05,
"allow_calculating_subcolumns_sizes_for_merge_tree_reading": lambda: random.random() > 0.05,
"use_join_disjunctions_push_down": lambda: random.random() > 0.05,
"enable_join_transitive_predicates": lambda: random.random() < 0.3,
"analyzer_compatibility_prefer_alias_over_subcolumn": lambda: random.randint(0, 1),
"use_streaming_marks_compression": lambda: random.randint(0, 1),
"text_index_posting_list_apply_mode": lambda: random.choice(["materialize", "lazy"]),
"text_index_lazy_intersection_density_threshold": threshold_generator(0.2, 0.2, 0.0, 1.0),
}
dependent_settings = {
"use_skip_indexes_if_final_exact_mode": "use_skip_indexes_if_final",
}
# Settings that require another setting to be enabled;
# when the parent is disabled, the child is forced to disabled too.
conditional_settings = {
"optimize_use_implicit_projections": "optimize_use_projections",
"optimize_use_projection_filtering": "optimize_use_projections",
# top-k dynamic filtering requires the top-k skip index path to be active
"use_top_k_dynamic_filtering": "use_skip_indexes_for_top_k",
# variable-length opt-in only matters when the parent dynamic filter is on
"use_top_k_dynamic_filtering_for_variable_length_types": "use_top_k_dynamic_filtering",
# statistics cache is pointless when statistics are disabled
"use_statistics_cache": "use_statistics",
}
stress_test_only_settings = {
"analyzer_compatibility_prefer_alias_over_subcolumn",
}
@staticmethod
def adjust_settings_for_autopr(args, tags, random_settings):
# no_parallel_replicas is set for runs with enabled parallel replicas. In this case we should not reset `cluster_for_parallel_replicas`
if (
not args.no_parallel_replicas
and "no-parallel-replicas" not in tags
and random_settings["automatic_parallel_replicas_mode"] == 2
):
# parallel replicas won't actually be used: since `automatic_parallel_replicas_mode` is 2 we will only collect statistics
random_settings["enable_parallel_replicas"] = 1
# seem to throw us over the memory limit in flaky checks with sanitizers
random_settings["parallel_distributed_insert_select"] = 0
random_settings["cluster_for_parallel_replicas"] = "parallel_replicas"
random_settings["parallel_replicas_local_plan"] = 1
random_settings["parallel_replicas_for_non_replicated_merge_tree"] = 1
@staticmethod
def get_random_settings(args, tags):
random_settings = {}
is_debug = BuildFlags.DEBUG in args.build_flags
for setting, generator in SettingsRandomizer.settings.items():
if setting in SettingsRandomizer.stress_test_only_settings and not args.stress_tests:
continue
if (
is_debug
and setting == "allow_prefetched_read_pool_for_remote_filesystem"
):
random_settings[setting] = 0
else:
random_settings[setting] = generator()
# Bytes-vs-Ratio choice is made per test, not per process
random_settings.update(randomize_external_sort_group_by())
for setting, other_setting in SettingsRandomizer.dependent_settings.items():
random_settings[setting] = random_settings[other_setting]
for setting, parent_setting in SettingsRandomizer.conditional_settings.items():
if not random_settings[parent_setting]:
random_settings[setting] = False
# Re-apply dependent_settings so that dependents reflect any values changed by
# conditional pruning above (e.g. use_skip_indexes_if_final_exact_mode must stay
# in sync with use_skip_indexes_if_final even after it was forced to False).
for setting, other_setting in SettingsRandomizer.dependent_settings.items():
random_settings[setting] = random_settings[other_setting]
SettingsRandomizer.adjust_settings_for_autopr(args, tags, random_settings)
return random_settings
class MergeTreeSettingsRandomizer:
settings = {
"ratio_of_defaults_for_sparse_serialization": threshold_generator(
0.3, 0.5, 0.0, 1.0
),
"compute_exact_num_defaults_for_sparse_columns": lambda: random.randint(0, 1),
"prefer_fetch_merged_part_size_threshold": threshold_generator(
0.2, 0.5, 1, 10 * 1024 * 1024 * 1024
),
"vertical_merge_algorithm_min_rows_to_activate": threshold_generator(
0.4, 0.4, 1, 1000000
),
"vertical_merge_algorithm_min_columns_to_activate": threshold_generator(
0.4, 0.4, 1, 100
),
"allow_vertical_merges_from_compact_to_wide_parts": lambda: random.randint(
0, 1
),
"min_merge_bytes_to_use_direct_io": threshold_generator(
0.25, 0.25, 1, 10 * 1024 * 1024 * 1024
),
"index_granularity_bytes": lambda: random.randint(1024, 30 * 1024 * 1024),
"merge_max_block_size": lambda: random.randint(1, 8192 * 3),
"index_granularity": lambda: random.randint(1, 65536),
"min_bytes_for_wide_part": threshold_generator(0.3, 0.3, 0, 1024 * 1024 * 1024),
"compress_marks": lambda: random.randint(0, 1),
"compress_primary_key": lambda: random.randint(0, 1),
"marks_compress_block_size": lambda: random.randint(8000, 100000),
"primary_key_compress_block_size": lambda: random.randint(8000, 100000),
"replace_long_file_name_to_hash": lambda: random.randint(0, 1),
"max_file_name_length": threshold_generator(0.3, 0.3, 0, 128),
"min_bytes_for_full_part_storage": threshold_generator(
0.3, 0.3, 0, 512 * 1024 * 1024
),
"compact_parts_max_bytes_to_buffer": lambda: random.randint(
1024, 512 * 1024 * 1024
),
"compact_parts_max_granules_to_buffer": threshold_generator(0.15, 0.15, 1, 256),
"compact_parts_merge_max_bytes_to_prefetch_part": lambda: random.randint(
1, 32 * 1024 * 1024
),
"cache_populated_by_fetch": lambda: random.randint(0, 1),
"concurrent_part_removal_threshold": threshold_generator(0.2, 0.3, 0, 100),
"old_parts_lifetime": threshold_generator(0.2, 0.3, 10, 8 * 60),
"prewarm_mark_cache": lambda: random.randint(0, 1),
"use_const_adaptive_granularity": lambda: random.randint(0, 1),
"enable_index_granularity_compression": lambda: random.randint(0, 1),
"enable_block_number_column": lambda: random.randint(0, 1),
"enable_block_offset_column": lambda: random.randint(0, 1),
"use_primary_key_cache": lambda: random.randint(0, 1),
"prewarm_primary_key_cache": lambda: random.randint(0, 1),
"object_serialization_version": lambda: random.choice(["v2", "v3"]),
"object_shared_data_serialization_version": lambda: random.choice(
["map", "map_with_buckets", "advanced"]),
"object_shared_data_serialization_version_for_zero_level_parts": lambda: random.choice(
["map", "map_with_buckets", "advanced"]),
"object_shared_data_buckets_for_compact_part": lambda: random.randint(1, 32),
"object_shared_data_buckets_for_wide_part": lambda: random.randint(1, 32),
"dynamic_serialization_version": lambda: random.choice(["v2", "v3"]),
"auto_statistics_types": auto_statistics_generator(),
"serialization_info_version": lambda: random.choice(["basic", "with_types"]),
"string_serialization_version": lambda: random.choice(["single_stream", "with_size_stream"]),
"nullable_serialization_version": lambda: random.choice(["basic", "allow_sparse"]),
"enable_shared_storage_snapshot_in_query": lambda: random.randint(0, 1),
"min_columns_to_activate_adaptive_write_buffer": lambda: random.randint(0, 1000),
"reduce_blocking_parts_sleep_ms": lambda: random.randint(200, 2000),
"shared_merge_tree_outdated_parts_group_size": lambda: random.randint(1, 2),
"shared_merge_tree_max_outdated_parts_to_process_at_once": lambda: random.randint(1, 10),
"shared_merge_tree_try_fetch_part_in_memory_data_from_replicas": lambda: random.randint(0, 1),
"shared_merge_tree_try_fetch_part_in_memory_data_from_replicas_on_startup": lambda: random.randint(0, 1),
"text_index_dictionary_block_size": lambda: random.choice([16, 64, 128, 256, 512, 1024, 8192]),
"text_index_dictionary_block_frontcoding_compression": lambda: random.randint(0, 1),
"text_index_posting_list_block_size": lambda: random.choice([16, 64, 256, 1024, 65536, 1048576]),
"text_index_posting_list_codec": lambda: random.choice(["none", "bitpacking"]),
"map_serialization_version": lambda: random.choice(["basic", "with_buckets"]),
"map_serialization_version_for_zero_level_parts": lambda: random.choice(["basic", "with_buckets"]),
"max_buckets_in_map": lambda: random.randint(1, 100),
"map_buckets_strategy": lambda: random.choice(["constant", "sqrt", "linear"]),
"map_buckets_min_avg_size": lambda: random.randint(1, 1000),
"map_buckets_coefficient": lambda: round(random.uniform(0.5, 2.0), 2),
"propagate_types_serialization_versions_to_nested_types": lambda: random.randint(0, 1),
"compress_per_column_in_compact_parts": lambda: random.randint(0, 1),
"packed_skip_index_max_bytes": lambda: random.choice(
[0, 100, 1024, 1024 * 1024, 10 * 1024 * 1024]
),
}
@staticmethod
def get_random_settings(args):
random_settings = {}
# Tiny granules multiply the number of marks per part (marks ~ rows /
# index_granularity), which is disproportionately expensive on slow
# builds and can turn a few-second test into minutes. Raise the lower
# bounds for index_granularity / index_granularity_bytes there.
slow_build = (
any(flag in args.build_flags for flag in BuildFlags.SANITIZERS)
or BuildFlags.DEBUG in args.build_flags
or BuildFlags.WITH_COVERAGE in args.build_flags
)
for setting, generator in MergeTreeSettingsRandomizer.settings.items():
if setting in args.changed_merge_tree_settings:
continue
if slow_build and setting == "index_granularity":
value = random.randint(100, 65536)
elif slow_build and setting == "index_granularity_bytes":
value = random.randint(100 * 1024, 30 * 1024 * 1024)
else:
value = generator()
if type(value) == str and value == "":
continue
random_settings[setting] = value
return random_settings
def replace_in_file(filename, what, with_what):
with open(filename, "rb") as f:
data = f.read()
with open(filename, "wb") as f:
f.write(data.replace(what.encode(), with_what.encode()))
def replace_in_file_re(filename, what, with_what):
os.system(f"LC_ALL=C sed -i -e 's/{what}/{with_what}/' {filename}")
def str_to_numeric(v: str):
vl = v.lower()
if vl in ("true", "false"):
return vl == "true"
try:
if "." in v:
return float(v)
return int(v)
except ValueError:
return v
def str_to_bool(v):
if isinstance(v, str):
return bool(str_to_numeric(v))
return bool(v)
def parse_settings_cli_blob(blob: str) -> Dict[str, object]:
"""
Accepts strings like:
"--max_threads 1 --fsync_metadata 1 --http_wait_end_of_query True"
Returns dict: {"max_threads": 1, "fsync_metadata": 1, "http_wait_end_of_query": True}
"""
if not blob:
return {}
tokens = shlex.split(blob)
out: Dict[str, object] = {}
i = 0
while i < len(tokens):
key = tokens[i].lstrip("-")
if not key:
i += 1
continue
# value may be missing → treat as boolean flag
if i + 1 >= len(tokens) or tokens[i + 1].startswith("--"):
out[key] = True
i += 1
continue
out[key] = str_to_numeric(tokens[i + 1])
i += 2
return out
def load_settings_from_file(path: str, *, kind: str) -> Dict[str, object]:
"""
File must contain ONLY the inner blob (space-separated `--k v` pairs),
NOT the outer `--settings "..."` or `--merge-tree-settings "..."`.
"""
with open(path, "r", encoding="utf-8") as f:
content = f.read().strip()
bad_prefixes = ("--settings", "--merge-tree-settings")
for p in bad_prefixes:
if content.startswith(p):
raise ValueError(
f"{kind} file '{path}' must NOT include the leading '{p}'; "
"put only the inner space-separated --key value pairs."
)
return parse_settings_cli_blob(content)
RANDOM_SETTINGS_DIAGNOSTICS_DEFAULT_DIR = "/tmp/clickhouse-random-settings-diagnostics"
RANDOM_SETTINGS_DIAGNOSTICS_FILE = "random_settings_failures.jsonl"
RANDOM_SETTINGS_DIAGNOSTICS_RESULTS_FILE = "random_settings_diagnostics_results.jsonl"
def get_random_settings_diagnostics_path(args):
return os.path.join(
args.random_settings_diagnostics_dir, RANDOM_SETTINGS_DIAGNOSTICS_FILE
)
def get_random_settings_diagnostics_results_path(args):
return os.path.join(
args.random_settings_diagnostics_dir, RANDOM_SETTINGS_DIAGNOSTICS_RESULTS_FILE
)
def save_random_settings_artifact(args, test_name, settings, mt_settings):
"""Append a failed test's random settings to the diagnostics JSONL file."""
entry = {
"test_name": test_name,
"settings": {k: str(v) for k, v in settings.items()},
"merge_tree_settings": {k: str(v) for k, v in mt_settings.items()},
}
path = get_random_settings_diagnostics_path(args)
line = json.dumps(entry, ensure_ascii=False)
with open(path, "a", encoding="utf-8") as f:
f.write(line + "\n")
def load_random_settings_artifacts(args):
"""Read all entries from the diagnostics JSONL file, keeping only the first per test."""
path = get_random_settings_diagnostics_path(args)
if not os.path.isfile(path):
return []
seen = set()
entries = []
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
entry = json.loads(line)
if entry["test_name"] not in seen:
seen.add(entry["test_name"])
entries.append(entry)
return entries
def load_diagnostics_results(diagnostics_dir):
"""Read diagnosis results JSONL. Returns dict: test_name -> diagnosis text."""
path = os.path.join(diagnostics_dir, RANDOM_SETTINGS_DIAGNOSTICS_RESULTS_FILE)
if not os.path.isfile(path):
return {}
results = {}
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
entry = json.loads(line)
results[entry["test_name"]] = entry.get("diagnosis", "")
return results
# unlike functools.cache() ignore args
# (since args can be non-hashable)
def cache_ignore_args(func):
cached_result = None
has_result = False
def wrapper(*args, **kwargs):
nonlocal cached_result, has_result
if not has_result:
cached_result = func(*args, **kwargs)
has_result = True
return cached_result
return wrapper
@cache_ignore_args
def is_old_analyzer_used(args):
return (
clickhouse_execute(args, "SELECT getSetting('enable_analyzer')").strip()
== b"false"
)
def setup_cgroup_with_memory_limit_cb(cgroup_name: str, memory_limit_bytes: int):
def preexec_fn():
if CGROUP_VERSION is None:
print(f"Cgroups are not available, memory limit will not be applied", file=sys.stderr)
return
try:
# NOTE: will not work locally, since I did not find a way to propagate cgroup.subtree_control into the user unit
if CGROUP_VERSION == 2:
CGROUP_ROOT = "/sys/fs/cgroup"
cgroup_path = os.path.join(CGROUP_ROOT, cgroup_name)
os.makedirs(cgroup_path, exist_ok=True)
with open(os.path.join(cgroup_path, "memory.max"), "w") as f:
f.write(str(memory_limit_bytes))
with open(os.path.join(cgroup_path, "cgroup.procs"), "w") as f:
f.write(str(os.getpid()))
else: # v1
CGROUP_ROOT = "/sys/fs/cgroup/memory"
cgroup_path = os.path.join(CGROUP_ROOT, cgroup_name)
os.makedirs(cgroup_path, exist_ok=True)
with open(os.path.join(cgroup_path, "memory.limit_in_bytes"), "w") as f:
f.write(str(memory_limit_bytes))
with open(os.path.join(cgroup_path, "tasks"), "w") as f:
f.write(str(os.getpid()))
except Exception as e:
print(f"Failed to configure cgroup {cgroup_name}: {e}", file=sys.stderr)
# Exit child safely without running exec
os._exit(1)
return preexec_fn
def cleanup_cgroup(cgroup_name: str):
if CGROUP_VERSION is None:
return
try:
if CGROUP_VERSION == 2:
# Read current process cgroup to find user slice
with open("/proc/self/cgroup", "r") as f:
cgroup_line = f.read().strip()
current_cgroup = cgroup_line.split("::")[1] if "::" in cgroup_line else ""
if not current_cgroup:
return
CGROUP_ROOT = "/sys/fs/cgroup"
cgroup_path = os.path.join(CGROUP_ROOT, current_cgroup.lstrip("/"), cgroup_name)
if os.path.exists(cgroup_path):
os.rmdir(cgroup_path)
else: # v1
CGROUP_ROOT = "/sys/fs/cgroup/memory"
cgroup_path = os.path.join(CGROUP_ROOT, cgroup_name)
if os.path.exists(cgroup_path):
os.rmdir(cgroup_path)
except Exception as e:
print(f"Failed to cleanup cgroup {cgroup_name}: {e}", file=sys.stderr)
class TestResult:
def __init__(
self,
case_name: str,
status: TestStatus,
reason: Optional[FailureReason],
total_time: float,
description: str,
):
self.case_name: str = case_name
self.status: TestStatus = status
self.reason: Optional[FailureReason] = reason
self.total_time: float = total_time
self.description: str = description
self.need_retry: bool = False
def check_if_need_retry(self, args, stdout, stderr, runs_count):
if (
self.status != TestStatus.FAIL
or not need_retry(args, stdout, stderr, self.total_time)
or MAX_RETRIES < runs_count
or args.dont_retry_failures
):
return
self.need_retry = True
class TestCase:
@staticmethod
def get_description_from_exception_info(exc_info):
exc_type, exc_value, tb = exc_info
exc_name = exc_type.__name__
traceback_str = "\n".join(traceback.format_tb(tb, 10))
description = f"\n{exc_name}\n{exc_value}\n{traceback_str}"
return description
@staticmethod
def get_reference_file(args, suite_dir, name):
"""
Returns reference file name for specified test
"""
name = removesuffix(name, ".gen")
file_extentions = [".reference", ".gen.reference"]
if is_old_analyzer_used(args):
file_extentions = [".oldanalyzer.reference", *file_extentions]
for ext in file_extentions:
reference_file = os.path.join(suite_dir, name) + ext
if os.path.isfile(reference_file):
return reference_file
return None
def configure_testcase_args(self, args, case_file, suite_tmp_dir):
testcase_args = copy.deepcopy(args)
testcase_args.testcase_start_time = datetime.now()
testcase_basename = os.path.basename(case_file)
if args.cloud:
# Sticky host is used by default
# For additional coverage, queries can be run on different replicas if the test is designed for this
if "cloud-no-sticky" in self.tags:
os.environ["CLICKHOUSE_HOST"] = os.environ["CLICKHOUSE_HOST_NON_STICKY"]
else:
os.environ["CLICKHOUSE_HOST"] = os.environ["CLICKHOUSE_HOST_STICKY"]
tcp_host = os.environ["CLICKHOUSE_HOST"]
testcase_args.tcp_host = tcp_host
testcase_args.client = re.sub(
r"--host=[^\s]*", f"--host={tcp_host}", testcase_args.client, count=1
)
testcase_args.testcase_basename = testcase_basename
if testcase_args.database:
database = testcase_args.database
os.environ.setdefault("CLICKHOUSE_DATABASE", database)
os.environ.setdefault("CLICKHOUSE_TMP", suite_tmp_dir)
testcase_args.test_tmp_dir = suite_tmp_dir
else:
# If --database is not specified, we will create temporary database with
# unique name and we will recreate and drop it for each test
def random_str(length=8):
alphabet = string.ascii_lowercase + string.digits
# NOTE: it is important not to use default random generator, since it shares state.
return "".join(
random.SystemRandom().choice(alphabet) for _ in range(length)
)
database = f"test_{random_str()}"
clickhouse_execute(
args,
"CREATE DATABASE IF NOT EXISTS "
+ database
+ get_db_engine(testcase_args, database),
settings=get_create_database_settings(args, testcase_args),
)
os.environ["CLICKHOUSE_DATABASE"] = database
# Set temporary directory to match the randomly generated database,
# because .sh tests also use it for temporary files and we want to avoid
# collisions.
testcase_args.test_tmp_dir = os.path.join(suite_tmp_dir, database)
os.mkdir(testcase_args.test_tmp_dir)
os.environ["CLICKHOUSE_TMP"] = testcase_args.test_tmp_dir
testcase_args.testcase_database = database
# Printed only in case of failures
#
# NOTE: here we use "CLICKHOUSE_TMP" instead of "file_suffix",
# so it is installed in configure_testcase_args() unlike other files
# (stdout_file, stderr_file) in TestCase::__init__().
# Since using CLICKHOUSE_TMP is easier to use in expect.
testcase_args.debug_log_file = (
os.path.join(testcase_args.test_tmp_dir, testcase_basename) + ".debuglog"
)
testcase_args.testcase_client = (
f"{testcase_args.client} --log_comment '{testcase_args.testcase_basename}-{testcase_args.testcase_database}'"
)
return testcase_args
@staticmethod
def cli_format_settings(settings_list) -> str:
out = []
for k, v in settings_list.items():
out.extend([f"--{k}", str(v)])
return " ".join(out)
@staticmethod
def http_format_settings(settings_list) -> str:
return urllib.parse.urlencode(settings_list)
def has_show_create_table_in_test(self):
return not subprocess.call(["grep", "-iq", "show create", self.case_file])
def test_observes_create_query_text(self):
# True if the test would be perturbed by the client rewriting CREATE ... MergeTree
# queries (which the codec injection enables via --allow_merge_tree_settings):
# it inspects the stored/normalized query text, or its CREATE reparse is
# semantics-sensitive (AUTO_INCREMENT compatibility mode).
return not subprocess.call(
[
"grep",
"-iqE",
"show create|create_table_query|normalized_query|auto_increment",
self.case_file,
]
)
def add_effective_settings(self, client_options):
new_options = ""
if self.effective_settings:
http_params = self.http_format_settings(self.effective_settings)
if len(self.base_url_params) == 0:
os.environ["CLICKHOUSE_URL_PARAMS"] = http_params
else:
os.environ["CLICKHOUSE_URL_PARAMS"] = (
self.base_url_params + "&" + http_params
)
new_options += f" {self.cli_format_settings(self.effective_settings)}"
if self.effective_merge_tree_settings:
new_options += f" --allow_merge_tree_settings {self.cli_format_settings(self.effective_merge_tree_settings)}"
# Put randomized settings BEFORE base_client_options / client_options so
# that user-supplied --client-option values win over them: with
# --allow_repeated_settings, the last occurrence is what takes effect.
if new_options != "":
new_options += " --allow_repeated_settings"
os.environ["CLICKHOUSE_CLIENT_OPT"] = (
new_options + " " + self.base_client_options + " "
)
elif client_options:
client_options += " --allow_repeated_settings"
os.environ["CLICKHOUSE_CLIENT_OPT"] = (
self.base_client_options + client_options + " "
)
current_value = os.environ.get("CLICKHOUSE_CLIENT_OPT", "")
os.environ["CLICKHOUSE_CLIENT_OPT"] = f"{current_value} --allow_repeated_settings".strip()
return new_options + " " + client_options
def remove_settings_from_env(self):
os.environ["CLICKHOUSE_URL_PARAMS"] = self.base_url_params
os.environ["CLICKHOUSE_CLIENT_OPT"] = self.base_client_options
def add_info_about_settings(self, description):
if self.effective_settings:
description += f"\nSettings used in the test: {self.cli_format_settings(self.effective_settings)}"
if self.effective_merge_tree_settings:
description += f"\n\nMergeTree settings used in test: {self.cli_format_settings(self.effective_merge_tree_settings)}"
return description + "\n"
def _run_with_settings(self, args, suite, client_options, settings, mt_settings):
"""Re-run this test with specific effective settings, returning the TestResult."""
saved_settings = self.effective_settings
saved_mt_settings = self.effective_merge_tree_settings
saved_runs_count = self.runs_count
self.effective_settings = settings
self.effective_merge_tree_settings = mt_settings
try:
result = self.run(args, suite, client_options)
# Don't let diagnosis reruns trigger further retries
result.need_retry = False
return result
finally:
self.effective_settings = saved_settings
self.effective_merge_tree_settings = saved_mt_settings
self.runs_count = saved_runs_count
def _run_within_budget(self, args, suite, client_options, settings, mt_settings):
"""Run the test repeatedly within the step time budget.
Respects both the per-step budget and the global stop_time limit.
Returns (fail_count, ok_count, other_count, total_runs, timed_out).
timed_out is True if the global stop_time was reached.
"""
budget = args.diagnose_random_settings_step_time_budget
deadline = time() + budget
if args.stop_time:
deadline = min(deadline, args.stop_time)
fail_count = 0
ok_count = 0
other_count = 0
timed_out = False
while time() < deadline:
result = self._run_with_settings(
args, suite, client_options, settings, mt_settings
)
if result.status == TestStatus.FAIL:
fail_count += 1
elif result.status == TestStatus.OK:
ok_count += 1
else:
other_count += 1
if args.stop_time and time() > args.stop_time:
timed_out = True
break
total = fail_count + ok_count + other_count
return fail_count, ok_count, other_count, total, timed_out
def _is_global_deadline_reached(self, args):
return args.stop_time and time() > args.stop_time
def diagnose_random_settings(self, args, suite, client_options):
"""Diagnose which randomized settings caused a test failure.
Uses a time-budget approach: each step runs the test as many times as
possible within a configurable time budget to handle sporadic failures
reliably. Respects the global stop_time limit.
Algorithm:
1. Re-run with the same settings to confirm reproducibility.
2. Re-run without any randomization to confirm the failure is settings-related.
3. If step 1 has failures and step 2 does not, minimize the settings via
ddmin (delta debugging) to find the minimal set that triggers the failure.
Returns a diagnosis string to append to the test description.
"""
failed_settings = dict(self.effective_settings)
failed_mt_settings = dict(self.effective_merge_tree_settings)
step_budget = args.diagnose_random_settings_step_time_budget
# No randomized settings — rerun up to 3 times to check reproducibility.
if not failed_settings and not failed_mt_settings:
max_reruns = 3
fail_count = 0
for i in range(max_reruns):
result = self._run_with_settings(args, suite, client_options, {}, {})
if result.status == TestStatus.FAIL:
fail_count += 1
return (
f"--- Failure reproducibility check ---\n"
f"Failed {fail_count} out of {max_reruns} reruns.\n"
)
diagnosis_lines = ["--- Randomized settings diagnosis ---"]
# Step 1: Re-run with the same settings within the time budget
diagnosis_lines.append(
f"Step 1: Re-running with the same randomized settings (budget: {step_budget}s)..."
)
fail1, ok1, other1, total1, timed_out = self._run_within_budget(
args, suite, client_options, failed_settings, failed_mt_settings
)
diagnosis_lines.append(
f" Runs: {total1}, Failed: {fail1}, Passed: {ok1}, Other: {other1}"
)
if timed_out:
diagnosis_lines.append("Result: Inconclusive (global time limit reached).")
return "\n".join(diagnosis_lines) + "\n"
if fail1 == 0:
if ok1 > 0:
diagnosis_lines.append(
"Result: All reruns passed. The failure is not reproducible (likely a transient issue)."
)
else:
diagnosis_lines.append(
"Result: No failures, but no clean passes either. Inconclusive."
)
return "\n".join(diagnosis_lines) + "\n"
if ok1 > 0:
diagnosis_lines.append(
f"Result: Failure is flaky ({fail1}/{total1} reruns failed)."
)
return "\n".join(diagnosis_lines) + "\n"
diagnosis_lines.append("Result: Failure is reproducible.")
if self._is_global_deadline_reached(args):
diagnosis_lines.append("Stopping diagnosis: global time limit reached.")
return "\n".join(diagnosis_lines) + "\n"
# Step 2: Re-run without any randomization within the time budget
diagnosis_lines.append(
f"Step 2: Re-running without randomized settings (budget: {step_budget}s)..."
)
fail2, ok2, other2, total2, timed_out = self._run_within_budget(
args, suite, client_options, {}, {}
)
diagnosis_lines.append(
f" Runs: {total2}, Failed: {fail2}, Passed: {ok2}, Other: {other2}"
)
if timed_out:
diagnosis_lines.append("Result: Inconclusive (global time limit reached).")
return "\n".join(diagnosis_lines) + "\n"
if fail2 > 0:
diagnosis_lines.append(
"Result: Test also fails without randomized settings (not a randomization issue)."
)
return "\n".join(diagnosis_lines) + "\n"
if ok2 == 0:
diagnosis_lines.append(
"Result: No failures, but no clean passes either. Inconclusive."
)
return "\n".join(diagnosis_lines) + "\n"
diagnosis_lines.append(
"Result: Passes without randomization. Confirmed: the failure is caused by randomized settings."
)
if self._is_global_deadline_reached(args):
diagnosis_lines.append("Stopping diagnosis: global time limit reached.")
return "\n".join(diagnosis_lines) + "\n"
# Step 3: Minimize settings via ddmin (delta debugging)
minimize_budget = args.diagnose_random_settings_minimize_budget
diagnosis_lines.append(
f"Step 3: Minimizing settings to find the culprit(s) (budget: {minimize_budget}s)..."
)
minimize_deadline = time() + minimize_budget
if args.stop_time:
minimize_deadline = min(minimize_deadline, args.stop_time)
culprit_settings = {}
culprit_mt_settings = {}
minimize_hit_limit = False
# Determine which category(ies) actually cause the failure before
# minimizing, to avoid reporting false culprits from the other category.
minimize_regular = bool(failed_settings)
minimize_mt = bool(failed_mt_settings)
if failed_settings and failed_mt_settings:
# Test regular settings alone (no MergeTree settings)
result_regular_only = self._run_with_settings(
args, suite, client_options, failed_settings, {}
)
# Test MergeTree settings alone (no regular settings)
result_mt_only = self._run_with_settings(
args, suite, client_options, {}, failed_mt_settings
)
regular_alone_fails = result_regular_only.status == TestStatus.FAIL
mt_alone_fails = result_mt_only.status == TestStatus.FAIL
if regular_alone_fails and not mt_alone_fails:
diagnosis_lines.append(
" Regular settings alone reproduce the failure; skipping MergeTree minimization."
)
minimize_mt = False
elif mt_alone_fails and not regular_alone_fails:
diagnosis_lines.append(
" MergeTree settings alone reproduce the failure; skipping regular minimization."
)
minimize_regular = False
# else: neither alone or both — minimize both categories
# Minimize regular settings (keep all MT settings active during minimization)
if minimize_regular:
culprit_settings, hit_limit = self._minimize_settings(
args, suite, client_options,
failed_settings, failed_mt_settings if minimize_mt else {},
is_merge_tree=False,
deadline=minimize_deadline,
)
minimize_hit_limit = minimize_hit_limit or hit_limit
# Minimize MergeTree settings (keep full regular settings to preserve
# cross-category interactions)
if minimize_mt and not minimize_hit_limit:
culprit_mt_settings, hit_limit = self._minimize_settings(
args, suite, client_options,
failed_settings if minimize_regular else {}, failed_mt_settings,
is_merge_tree=True,
deadline=minimize_deadline,
)
minimize_hit_limit = minimize_hit_limit or hit_limit
if minimize_hit_limit:
diagnosis_lines.append(
"Note: minimization time limit reached, result may not be fully minimized."
)
if culprit_settings or culprit_mt_settings:
if culprit_settings:
diagnosis_lines.append(
f"Culprit settings: {self.cli_format_settings(culprit_settings)}"
)
if culprit_mt_settings:
diagnosis_lines.append(
f"Culprit MergeTree settings: {self.cli_format_settings(culprit_mt_settings)}"
)
else:
# Could not isolate — the failure requires the full combination
diagnosis_lines.append(
"Could not isolate individual culprit settings — the failure may require "
"the full combination of randomized settings."
)
if failed_settings:
diagnosis_lines.append(
f"All randomized settings: {self.cli_format_settings(failed_settings)}"
)
if failed_mt_settings:
diagnosis_lines.append(
f"All randomized MergeTree settings: {self.cli_format_settings(failed_mt_settings)}"
)
return "\n".join(diagnosis_lines) + "\n"
def _minimize_settings(
self, args, suite, client_options,
all_settings, all_mt_settings, is_merge_tree,
deadline,
):
"""Delta-debugging (ddmin) minimization for one settings category.
Implements the ddmin algorithm by Andreas Zeller: tests subsets and their
complements at increasing granularity to find the minimal failing
combination — including cases where multiple settings interact.
The other category's settings are kept intact during minimization so that
cross-category interactions are preserved.
Returns (culprit_dict, hit_limit). If deadline is reached, returns the
last known failing combination and hit_limit=True.
"""
target = dict(all_mt_settings if is_merge_tree else all_settings)
other_settings = all_settings if is_merge_tree else {}
other_mt_settings = all_mt_settings if not is_merge_tree else {}
if not target:
return {}, False
def run_with_subset(subset_dict):
if is_merge_tree:
return self._run_with_settings(
args, suite, client_options, other_settings, subset_dict
)
else:
return self._run_with_settings(
args, suite, client_options, subset_dict, other_mt_settings
)
def is_deadline_reached():
return time() > deadline
# ddmin: start with the full failing set, reduce it
failing_keys = list(target.keys())
n = 2 # initial granularity
while len(failing_keys) >= 2:
if is_deadline_reached():
return {k: target[k] for k in failing_keys}, True
chunk_size = max(1, len(failing_keys) // n)
chunks = []
for i in range(0, len(failing_keys), chunk_size):
chunks.append(failing_keys[i : i + chunk_size])
found_reduction = False
# Test each chunk (subset): does this small piece alone cause failure?
for chunk in chunks:
if is_deadline_reached():
return {k: target[k] for k in failing_keys}, True
subset = {k: target[k] for k in chunk}
result = run_with_subset(subset)
if result.status == TestStatus.FAIL:
# This chunk alone fails — recurse into it
failing_keys = chunk
n = 2
found_reduction = True
break
if found_reduction:
continue
# Test each complement: does removing this chunk still fail?
for i, chunk in enumerate(chunks):
if is_deadline_reached():
return {k: target[k] for k in failing_keys}, True
complement = [k for j, c in enumerate(chunks) for k in c if j != i]
subset = {k: target[k] for k in complement}
result = run_with_subset(subset)
if result.status == TestStatus.FAIL:
# Failing without this chunk — the chunk is not needed
failing_keys = complement
n = max(n - 1, 2)
found_reduction = True
break
if not found_reduction:
if n >= len(failing_keys):
# Already at maximum granularity — result is 1-minimal
break
n = min(n * 2, len(failing_keys))
return {k: target[k] for k in failing_keys}, False
def apply_random_settings_limits(self, random_settings):
for setting in random_settings:
if setting in self.random_settings_limits:
min_value = self.random_settings_limits[setting][0]
# min_value can be 0
if min_value != None and random_settings[setting] < min_value:
random_settings[setting] = min_value
max_value = self.random_settings_limits[setting][1]
# max_value can be 0
if max_value != None and random_settings[setting] > max_value:
random_settings[setting] = max_value
def __init__(self, suite: "TestSuite", case: str, args, is_concurrent: bool):
self.suite = suite
self.case: str = case # case file name
self.args: Namespace = args
self.tags: Set[str] = suite.all_tags[case]
self.random_settings_limits = (
suite.all_random_settings_limits[case]
if case in suite.all_random_settings_limits
else {}
)
self.memory_limit = suite.all_memory_limits.get(case, args.memory_limit) or args.memory_limit
for tag in os.getenv("GLOBAL_TAGS", "").split(","):
self.tags.add(tag.strip())
self.case_file: str = os.path.join(suite.suite_path, case)
(self.name, self.ext) = os.path.splitext(case)
file_suffix = f".{os.getpid()}" if is_concurrent else ""
self.reference_file = self.get_reference_file(args, suite.suite_path, self.name)
self.stdout_file = (
os.path.join(suite.suite_tmp_path, self.name) + file_suffix + ".stdout"
)
self.stderr_file = (
os.path.join(suite.suite_tmp_path, self.name) + file_suffix + ".stderr"
)
self.testcase_args = None
self.runs_count = 0
has_no_random_settings_tag = self.tags and "no-random-settings" in self.tags
has_no_random_merge_tree_settings_tag = (
self.tags and "no-random-merge-tree-settings" in self.tags
)
allow_random_settings = not (args.no_random_settings or has_no_random_settings_tag)
# If test contains SHOW CREATE TABLE do not
# randomize merge tree settings, because
# they will be added to table definition and test will fail
allow_random_mt_settings = not (
args.no_random_merge_tree_settings
or has_no_random_settings_tag
or has_no_random_merge_tree_settings_tag
or self.has_show_create_table_in_test()
)
# Base randomized dicts (or empty if not allowed)
base_settings = {}
if allow_random_settings:
base_settings = SettingsRandomizer.get_random_settings(args, self.tags)
self.apply_random_settings_limits(base_settings)
base_mt_settings = {}
if allow_random_mt_settings:
base_mt_settings = MergeTreeSettingsRandomizer.get_random_settings(args)
self.apply_random_settings_limits(base_mt_settings)
# User-specified fixed dicts
fixed_settings = dict(args.fixed_settings or {})
fixed_mt_settings = dict(args.fixed_merge_tree_settings or {})
# - If user passed fixed settings, use them exclusively.
# - Otherwise, use randomized (if allowed), else empty.
self.effective_settings = fixed_settings if fixed_settings else base_settings
self.effective_merge_tree_settings = fixed_mt_settings if fixed_mt_settings else base_mt_settings
# The default compression codec is randomized at the server level (see the
# <compression> override in tests/config/install.sh). Tests that opt out of
# settings randomization expect the stable historical default, so pin the codec
# back to LZ4 for them, overriding the server default (ClientBase injects it
# into CREATE ... MergeTree queries, and only if not already set). LZ4 has no
# shell metacharacters, so it is safe in CLICKHOUSE_CLIENT_OPT for .sh tests.
# The injection relies on --allow_merge_tree_settings, which makes the client
# rewrite CREATE ... MergeTree queries; skip tests that would observe the
# rewrite (SHOW CREATE / system.query_log / system.tables.create_table_query)
# or whose CREATE reparse changes semantics (AUTO_INCREMENT compatibility) —
# those are not codec-sensitive and pass under the server default unchanged.
if (
(has_no_random_settings_tag or has_no_random_merge_tree_settings_tag)
and not self.test_observes_create_query_text()
and "default_compression_codec" not in self.effective_merge_tree_settings
):
self.effective_merge_tree_settings["default_compression_codec"] = "LZ4"
self.base_url_params = (
os.environ["CLICKHOUSE_URL_PARAMS"]
if "CLICKHOUSE_URL_PARAMS" in os.environ
else ""
)
self.base_client_options = (
os.environ["CLICKHOUSE_CLIENT_OPT"]
if "CLICKHOUSE_CLIENT_OPT" in os.environ
else ""
)
self.show_whitespaces_in_diff = args.show_whitespaces_in_diff
# should skip test, should increment skipped_total, skip reason
def should_skip_test(self, suite) -> Optional[FailureReason]:
tags = self.tags
args = self.args
if tags and ("disabled" in tags) and not args.disabled:
return FailureReason.DISABLED
if self.name in suite.skip_list:
return FailureReason.NOT_SUPPORTED
if args.private and self.name in suite.private_skip_list:
return FailureReason.NOT_SUPPORTED_IN_PRIVATE
if args.cloud:
if "no-replicated-database" in tags:
return FailureReason.REPLICATED_DB
if "no-shared-merge-tree" in tags:
return FailureReason.SHARED_MERGE_TREE
if "zookeeper" in tags:
return FailureReason.NO_ZOOKEEPER
if "replica" in tags:
return FailureReason.REPLICATED_DB
if "use-hdfs" in tags:
return FailureReason.NOT_SUPPORTED_IN_CLOUD
if "test-type-expect" in tags:
return FailureReason.NOT_SUPPORTED_IN_CLOUD
if "no-object-storage" in tags:
return FailureReason.OBJECT_STORAGE
if self.name in suite.cloud_skip_list:
return FailureReason.NOT_SUPPORTED_IN_CLOUD
if not args.cloud and "cloud" in tags:
return FailureReason.SKIP
if (
os.path.exists(os.path.join(suite.suite_path, self.name) + ".disabled")
and not args.disabled
):
return FailureReason.DISABLED
if "no-parallel-replicas" in tags and args.no_parallel_replicas:
return FailureReason.NO_PARALLEL_REPLICAS
if "no-old-analyzer" in tags and is_old_analyzer_used(args):
return FailureReason.OLD_ANALYZER
if "no-async-insert" in tags and args.no_async_insert:
return FailureReason.NO_ASYNC_INSERT
if "no-darwin" in tags and platform.system() == "Darwin":
return FailureReason.NOT_SUPPORTED_ON_DARWIN
if "linux" in tags and platform.system() != "Linux":
return FailureReason.LINUX_ONLY
# Do not run *Log and Memory engine-specific tests
if (
"memory-engine" in tags or "log-engine" in tags
) and args.replace_log_memory_with_mergetree:
return FailureReason.NOT_SUPPORTED
if args.skip and any(s in self.name for s in args.skip):
return FailureReason.SKIP
if not USE_JINJA and self.ext.endswith("j2"):
return FailureReason.NO_JINJA
if (
tags
and (("zookeeper" in tags) or ("replica" in tags))
and not args.zookeeper
):
return FailureReason.NO_ZOOKEEPER
if (
tags
and (("shard" in tags) or ("distributed" in tags) or ("global" in tags))
and not args.shard
):
return FailureReason.NO_SHARD
if tags and ("no-fasttest" in tags) and args.fast_tests_only:
return FailureReason.FAST_ONLY
if tags and ("fasttest-only" in tags) and not args.fast_tests_only:
return FailureReason.NOT_FAST_ONLY
if (
tags
and (("long" in tags) or ("deadlock" in tags) or ("race" in tags))
and args.no_long
):
# Tests for races and deadlocks usually are run in a loop for a significant amount of time
return FailureReason.NO_LONG
if (
tags
and ("no-llvm-coverage" in tags)
and args.llvm_coverage
and not args.excluded_from_llvm
):
# Skip tests that are incompatible with LLVM coverage
return FailureReason.NO_LLVM_COVERAGE
if args.excluded_from_llvm and (
not tags or "no-llvm-coverage" not in tags
):
# Run only tests that are normally disabled under LLVM coverage
return FailureReason.NO_LLVM_COVERAGE
if tags and ("no-replicated-database" in tags) and args.replicated_database:
return FailureReason.REPLICATED_DB
# RocksDB tests cannot be used with replicated database as, in our CI, the databases share the same user_files
if tags and ("use-rocksdb" in tags) and args.replicated_database:
return FailureReason.REPLICATED_DB
if tags and ("no-distributed-cache" in tags) and args.distributed_cache:
return FailureReason.DISTRIBUTED_CACHE
if (
tags
and ("atomic-database" in tags)
and (
args.replicated_database
or args.shared_catalog
or args.db_engine not in (None, "Atomic")
)
):
return FailureReason.NON_ATOMIC_DB
if (
tags
and ("no-shared-merge-tree" in tags)
and args.replace_replicated_with_shared
):
return FailureReason.SHARED_MERGE_TREE
if (
tags
and ("shared-catalog" in tags)
and not (args.shared_catalog or args.shared_catalog_stress)
):
return FailureReason.SHARED_CATALOG_ONLY
if (
args.shared_catalog
and not args.cloud
and not args.shared_catalog_stress
and not SharedEngineReplacer.has_replicated_or_shared_or_cloud(
self.case_file
)
):
return FailureReason.NO_SHARED_OR_REPLICATED_MERGE_TREE
if args.shared_catalog and not args.shared_catalog_stress and not args.cloud:
# shared catalog specific tests
if tags and ("shared-catalog" in tags):
return None
if tags and ("no-shared-catalog" in tags):
return FailureReason.SHARED_CATALOG
if tags and ("no-replicated-database" in tags):
return FailureReason.SHARED_CATALOG
if SharedEngineReplacer.has_non_replicated(self.case_file):
return FailureReason.SHARED_CATALOG
if tags and ("no-s3-storage" in tags) and args.s3_storage:
return FailureReason.S3_STORAGE
if tags and ("no-azure-blob-storage" in tags) and args.azure_blob_storage:
return FailureReason.AZURE_BLOB_STORAGE
if (
tags
and ("no-object-storage" in tags)
and (args.azure_blob_storage or args.s3_storage)
):
return FailureReason.OBJECT_STORAGE
if (
tags
and "no-object-storage-with-slow-build" in tags
and (args.s3_storage or args.azure_blob_storage)
and BuildFlags.RELEASE not in args.build_flags
):
return FailureReason.OBJECT_STORAGE
if tags and ("no-coverage" in tags) and (args.collect_per_test_coverage):
return FailureReason.COVERAGE
if "no-batch" in tags and (
args.run_by_hash_num is not None or args.run_by_hash_total is not None
):
return FailureReason.SKIP
if "no-flaky-check" in tags and args.flaky_check:
return FailureReason.SKIP
if "stateful" in tags and args.no_stateful:
return FailureReason.NO_STATEFUL
if "stateful" not in tags and args.no_stateless:
return FailureReason.NO_STATELESS
if tags and ("no-openssl-fips" in tags) and args.openssl_fips:
return FailureReason.NO_OPENSSL_FIPS
if tags and ("no-encrypted-storage" in tags) and args.encrypted_storage:
return FailureReason.ENCRYPTED_STORAGE
# Ignore no-{build} tags in stress-tests job
if tags and not args.stress_tests:
for build_flag in args.build_flags:
if "no-" + build_flag in tags:
return FailureReason.BUILD
if "no-sanitizers" in tags:
if any(
flag in args.build_flags
for flag in BuildFlags.SANITIZERS
):
return FailureReason.BUILD
for tag in tags:
tag = tag.replace("-", "_")
if tag.startswith("use_") and tag not in args.build_flags:
return FailureReason.BUILD
return None
def should_skip_cloud_test(self) -> tuple[Optional[FailureReason], str]:
# Try to find restricted keywords in test case file.
# These features are not supported in the cloud env.
# Simple regexp grep
restricted_functionalities = [
# These table/database engines are not supported in the cloud,
# and it's not possible to rewrite them automatically
r"ENGINE\s*=?\s*(Distributed|File|EmbeddedRocksDB|Lazy|MySQL|Dictionary)",
# CREATE DATABASE with ENGINE clause is restricted to create.
# Replicated is the default and the only.
# r"CREATE DATABASE.*ENGINE",
r"CREATE DATABASE.*ENGINE\s*=?\s*(Memory)",
# Remote localhost nodes are not available
r"remote\('127",
r"remoteSecure\('127",
r"url\('http://",
r"localhost:8123",
# All engines are rewritten to SMT and this old syntax isn't supported in SMT
r" MergeTree\(.*[0-9]+\)",
# Transactions are not supported for SMT
r"BEGIN TRANSACTION",
# Temporary SMT is not supported:
# Temporary tables cannot be created with Replicated, Shared or KeeperMap table engines. (INCORRECT_QUERY)
r"CREATE TEMPORARY",
# Dictionaries from configs
r"(flat_ints|hashed_ints|hashed_sparse_ints|cache_ints|complex_hashed_ints|"
r"complex_cache_ints|one_cell_cache_ints|one_cell_cache_ints_overflow)",
# Only tables with a Replicated engine...
r"CREATE\s*WINDOW\s*VIEW",
# Backups
r"BACKUP\s*TABLE",
r"BACKUP\S*DATABASE",
# Local files are not present
r"FROM\s*file\(",
r"FROM\s*fileCluster\(",
r"FROM\s*mysql\(",
r"FROM\s*s3\(",
r" file\(",
# cloud tests connect to remote nodes
r"CLICKHOUSE_LOCAL",
r"CLICKHOUSE_KEEPER_CLIENT",
r"CLICKHOUSE_CLIENT_BINARY",
r"CLICKHOUSE_BENCHMARK",
r"\${?CLICKHOUSE_COMPRESSOR",
r"CLICKHOUSE_FORMAT",
"clickhouse-format",
# This works incorrectly
r"CLICKHOUSE_URL}&.*ENGINE",
# Mysql instance is not exist
r"MYSQL_CLIENT",
# No postgres
"psql",
"postgres",
"ENGINE ?= ?SQLite",
"object_storage_type ?= ?azure_blob_storage",
"clickhouse-disks",
r"disk\(name",
r"FROM Disk\('backups",
"SETTINGS disk",
"FROM infile",
r"log_queries_min_type\s*=",
r"log_queries=",
# named collections:
"url_with_headers",
"s3_conn",
"s3_conn_db",
"cache_collection",
"url_override1",
"url_override2",
"remote1",
"remote2",
# No local files
"RESTORE DATABASE ",
# Doesn't work with replicated
"non_replicated_deduplication_window",
# TODO: support python tests
"from pure_http_client",
"DB 'test_db'",
# SMT should have `shared_merge_tree_disable_merges_and_mutations_assignment=1` to
# fully stop merges, but enabling this setting for all tests will break everything
"SYSTEM STOP MERGES",
"SYSTEM START MERGES",
# https://github.com/ClickHouse/clickhouse-private/issues/10955
"ATTACH DATABASE",
r"usr/bin/expect",
"SYSTEM ENABLE FAILPOINT",
"SYSTEM FLUSH ASYNC INSERT QUEUE",
"python3 "
]
with open(self.case_file, "r", encoding="utf-8") as f:
try:
test_content = f.read()
except UnicodeDecodeError:
# some tests are not possible to read
# skip restriction check
return None, ""
for line in test_content.splitlines():
for pattern in restricted_functionalities:
if re.match(f".*{pattern}.*", line, flags=re.IGNORECASE):
return (
FailureReason.NOT_SUPPORTED_IN_CLOUD_PREFILTER,
f"Restriction on '{pattern}' in line '{line}'",
)
return None, ""
def should_skip_stress_test(self) -> tuple[Optional[FailureReason], str]:
restricted_functionalities = [
# Temporary disable join and set engines
r"ENGINE\s*=?\s*(Set|Join)",
]
with open(self.case_file, "r", encoding="utf-8") as f:
try:
test_content = f.read()
except UnicodeDecodeError:
# some tests are not possible to read
# skip restriction check
return None, ""
for line in test_content.splitlines():
for pattern in restricted_functionalities:
if re.match(f".*{pattern}.*", line, flags=re.IGNORECASE):
return (
FailureReason.NOT_SUPPORTED_IN_CLOUD_PREFILTER,
f"Restriction on '{pattern}' in line '{line}'",
)
return None, ""
def process_result_impl(self, proc, total_time: float) -> TestResult:
args = self.args
kill_output = ""
if proc:
if proc.returncode is None:
f = io.StringIO()
with redirect_stdout(f):
kill_process_group(
os.getpgid(proc.pid), self.fatal_sanitizer_prefix
)
kill_output = f.getvalue()
description = ""
debug_log = ""
if os.path.exists(self.testcase_args.debug_log_file):
with open(self.testcase_args.debug_log_file, "rb") as stream:
debug_log += self.testcase_args.debug_log_file + ":\n"
debug_log += str(stream.read(), errors="replace", encoding="utf-8")
debug_log += "\n"
stdout = ""
if os.path.exists(self.stdout_file):
with open(self.stdout_file, "rb") as stdfd:
stdout = str(stdfd.read(), errors="replace", encoding="utf-8")
stderr = kill_output
for path in glob.glob(self.fatal_sanitizer_prefix + "*"):
with open(path, "rb") as stream:
content = str(stream.read(), errors="replace", encoding="utf-8")
def ignore_sanitizer_line(line):
return any(
filter(
lambda exception: exception in line,
IGNORED_SANITIZER_ERRORS,
)
)
content = "\n".join(
filter(
lambda line: not ignore_sanitizer_line(line),
content.splitlines(),
)
).strip()
if content:
stderr += f"Path: {path}\n"
stderr += content
stderr += "\n"
if os.path.exists(self.stderr_file):
with open(self.stderr_file, "rb") as stdfd:
stderr += str(stdfd.read(), errors="replace", encoding="utf-8")
# Filter out LLVM profiling runtime noise from stderr.
# In coverage builds, short-lived clickhouse-client processes contend over
# profraw merge pool files, producing "LLVM Profile Error:" messages that
# are not real test failures — the test output is correct. See #97020.
if stderr:
stderr = "\n".join(
line
for line in stderr.splitlines()
if not line.startswith("LLVM Profile Error:")
).strip()
if debug_log:
debug_log = trim_for_log(debug_log, 100)
if proc:
if proc.returncode is None:
if stderr:
description += stderr
if debug_log:
description += "\n"
description += debug_log
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.TIMEOUT,
total_time,
description,
)
if proc.returncode != 0:
reason = FailureReason.EXIT_CODE
description += str(proc.returncode)
if stderr:
description += "\n"
description += stderr
if debug_log:
description += "\n"
description += debug_log
# Stop on fatal errors like segmentation fault. They are sent to client via logs.
if " <Fatal> " in stderr:
reason = FailureReason.SERVER_DIED
if (
self.testcase_args.stop
and (
"Connection refused" in stderr
or "Attempt to read after eof" in stderr
)
and "Received exception from server" not in stderr
and not check_server_liveness(self.args)
):
reason = FailureReason.SERVER_DIED
if os.path.isfile(self.stdout_file):
description += ", result:\n\n"
with open(self.stdout_file, "rb") as f:
description += trim_for_log(
f.read().decode("utf-8", errors="ignore")
)
description += "\n"
description += f"\nstdout:\n{stdout}\n"
return TestResult(
self.name, TestStatus.FAIL, reason, total_time, description
)
if stderr:
description += "\n"
description += trim_for_log(stderr)
description += "\n"
description += "\nstdout:\n"
description += trim_for_log(stdout)
description += "\n"
if debug_log:
description += "\n"
description += debug_log
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.STDERR,
total_time,
description,
)
# "X-Datastore-Exception-Code" included in "Access-Control-Expose-Headers"
# "Exception" may be included into i.e. metric name
if (
re.search(r"(^|\W)(Net|Errno|)Exception(\W|$)", stdout)
and "X-Datastore-Exception-Code" not in stdout
and "X-Datastore-Exception-Tag" not in stdout
):
description += "\n{}\n".format(trim_for_log(stdout, 100))
if debug_log:
description += "\n"
description += debug_log
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.EXCEPTION,
total_time,
description,
)
if "@@SKIP@@" in stdout:
skip_reason = stdout.replace("@@SKIP@@", "").rstrip("\n")
description += " - "
description += skip_reason
return TestResult(
self.name,
TestStatus.SKIPPED,
FailureReason.SKIP,
total_time,
description,
)
if self.reference_file is None:
# If --record is enabled, create a new reference file
if self.testcase_args.record:
import shutil
# Use the same logic as get_reference_file to determine the correct path
name = removesuffix(self.name, ".gen")
# Determine the appropriate extension based on analyzer mode
if is_old_analyzer_used(self.testcase_args):
ext = ".oldanalyzer.reference"
else:
ext = ".reference"
self.reference_file = os.path.join(self.suite.suite_path, name) + ext
shutil.copy2(self.stdout_file, self.reference_file)
description += "\tReference file created\n"
return TestResult(
self.name,
TestStatus.OK,
None,
total_time,
description,
)
return TestResult(
self.name,
TestStatus.UNKNOWN,
FailureReason.NO_REFERENCE,
total_time,
description,
)
result_is_different = subprocess.call(
["diff", "-q", self.reference_file, self.stdout_file], stdout=PIPE
)
if result_is_different:
# If --record is enabled, copy stdout to reference file
if self.testcase_args.record:
import shutil
shutil.copy2(self.stdout_file, self.reference_file)
description += "\tReference file updated\n"
return TestResult(
self.name,
TestStatus.OK,
None,
total_time,
description,
)
with Popen(
[
"diff",
"-U",
str(self.testcase_args.unified),
self.reference_file,
self.stdout_file,
],
stdout=PIPE,
) as diff_proc:
if self.show_whitespaces_in_diff:
with Popen(
["sed", "-e", "s/[ \t]\\+$/&$/g"],
stdin=diff_proc.stdout,
stdout=PIPE,
) as sed_proc:
diff = sed_proc.communicate()[0]
else:
diff = diff_proc.communicate()[0]
diff = diff.decode("utf-8", errors="ignore")
if diff.startswith("Binary files "):
diff += "Content of stdout:\n===================\n"
with open(self.stdout_file, "rb") as file:
diff += str(file.read())
diff += "==================="
description += f"\n{diff}\n"
if debug_log:
description += "\n"
description += debug_log
# Skip test in case these lines are in result
# Because we do rewrite all possible tables to SMT
# and expected result is not correct
if args.cloud and (
"+CREATE TABLE" in description
or "+CREATE TEMPORARY TABLE" in description
or "+CREATE MATERIALIZED" in description
# Rewritten to SMT/Replicated tables have a different part naming scheme,
# starting from all_0_0_0 instead of all_1_1_0
or "+all_0_0_0" in description
):
return TestResult(
self.name,
TestStatus.SKIPPED,
FailureReason.RESULT_DIFF,
total_time,
description,
)
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.RESULT_DIFF,
total_time,
description,
)
if (
self.testcase_args.test_runs > 1
and self.testcase_args.flaky_check
and total_time > TEST_MAX_RUN_TIME_IN_SECONDS
and "long" not in self.tags
):
if debug_log:
description += "\n"
description += debug_log
# We're in Flaky Check mode, check the run time as well while we're at it.
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.TOO_LONG,
total_time,
description,
)
if os.path.exists(self.stdout_file):
os.remove(self.stdout_file)
if os.path.exists(self.stderr_file):
os.remove(self.stderr_file)
for path in glob.glob(self.fatal_sanitizer_prefix + "*"):
os.remove(path)
if os.path.exists(self.testcase_args.debug_log_file):
os.remove(self.testcase_args.debug_log_file)
return TestResult(self.name, TestStatus.OK, None, total_time, description)
def print_test_time(self, test_time) -> str:
return f" {test_time:.2f} sec."
def process_result(self, result: TestResult, messages):
args = self.args
description_full = messages[result.status]
description_full += self.print_test_time(result.total_time)
if result.reason is not None:
description_full += f"\nReason: {result.reason.value} "
description_full += result.description
description_full += "\n"
if result.status == TestStatus.FAIL and self.testcase_args:
description_full += "Database: " + self.testcase_args.testcase_database
result.description = description_full
return result
def send_test_name_failed(self, suite: str, case: str):
pid = os.getpid()
clickhouse_execute(
self.args,
f"SELECT 'Running test {suite}/{case} from pid={pid}'",
retry_error_codes=True,
)
def run_single_test(
self, server_logs_level, client_options
) -> Tuple[Optional[Popen], float]:
args = self.testcase_args
client = args.testcase_client
start_time = args.testcase_start_time
database = args.testcase_database
# NOTE:
# - client writes to fatal_sanitizer_prefix
# - sanitizers write to fatal_sanitizer_prefix.PID
self.fatal_sanitizer_prefix = f"{self.stderr_file}-fatal"
# Ensure that we start with empty files
for path in glob.glob(self.fatal_sanitizer_prefix + "*"):
os.remove(path)
log_opt = " --client_logs_file=" + self.fatal_sanitizer_prefix + " "
client_options += log_opt
for env_name in [
"TSAN_OPTIONS",
"ASAN_OPTIONS",
"MSAN_OPTIONS",
"UBSAN_OPTIONS",
]:
current_options = os.environ.get(env_name, None)
if current_options is None:
os.environ[env_name] = f"log_path={self.fatal_sanitizer_prefix}"
elif "log_path=" not in current_options:
os.environ[env_name] += f":log_path={self.fatal_sanitizer_prefix}"
os.environ["CLICKHOUSE_CLIENT_OPT"] = (
os.environ["CLICKHOUSE_CLIENT_OPT"]
if "CLICKHOUSE_CLIENT_OPT" in os.environ
else ""
) + log_opt
# This is for .sh tests
os.environ["CLICKHOUSE_LOG_COMMENT"] = f'{args.testcase_basename}-{args.testcase_database}'
query_params = ""
if "need-query-parameters" in self.tags:
query_params = (
" --param_CLICKHOUSE_DATABASE="
+ database
+ " --param_CLICKHOUSE_DATABASE_1="
+ database
+ "_1"
+ " --param_CLICKHOUSE_DATABASE_2="
+ database
+ "_2"
)
for env_to_param in [
"CLICKHOUSE_USER_FILES",
"CLICKHOUSE_USER_FILES_UNIQUE",
]:
value = os.environ.get(env_to_param, None)
if value:
query_params += f" --param_{env_to_param}={value}"
params = {
"client": client + " --database=" + database + query_params,
"logs_level": server_logs_level,
"options": client_options,
"test": self.case_file,
"stdout": self.stdout_file,
"stderr": self.stderr_file,
"secure": (
"--secure" if args.secure and "--secure" not in client_options else ""
),
}
# >> append to stderr (but not stdout since it is not used there),
# because there are also output of per test database creation
pattern = "{test} > {stdout} 2> {stderr}"
tests_env = os.environ.copy()
if args.trace:
tests_env["CLICKHOUSE_BASH_TRACING_FILE"] = args.debug_log_file
if self.ext == ".sql":
pattern = (
"{client} --send_logs_level={logs_level} {secure} --multiquery {options} < "
+ pattern
)
command = pattern.format(**params)
preexec_fn = None
cgroup_name = None
if args.memory_limit:
cgroup_name = f"datastore-test-{os.getpid()}"
preexec_fn = setup_cgroup_with_memory_limit_cb(cgroup_name, self.memory_limit)
# pylint:disable-next=consider-using-with; TODO: fix
proc = Popen(command, shell=True, executable=BASH, env=tests_env, start_new_session=True, preexec_fn=preexec_fn)
# proc.pid == PGID after start_new_session=True; write it atomically so
# `datastore-test --cleanup` can find it even if we are killed with SIGKILL.
_gpid_file = _GROUP_PID_PATH / f"{_GROUP_PID_NAME}.{os.getpid()}"
write_text_atomic(_gpid_file, f"{proc.pid}\n")
try:
proc.wait(args.timeout)
except subprocess.TimeoutExpired:
# Whether the test timed out will be decided later
pass
finally:
if cgroup_name:
cleanup_cgroup(cgroup_name)
_gpid_file.unlink(missing_ok=True)
total_time = (datetime.now() - start_time).total_seconds()
# Normalize randomized database names in stdout, stderr files.
replace_in_file(self.stdout_file, database, "default")
if args.hide_db_name:
replace_in_file(self.stderr_file, database, "default")
if args.replicated_database:
replace_in_file(self.stdout_file, "/auto_{shard}", "")
replace_in_file(self.stdout_file, "auto_{replica}", "")
if args.shared_catalog:
replace_in_file(self.stdout_file, "auto_{replica}", "")
replace_in_file(self.stdout_file, "auto_r1", "")
# Normalize hostname in stdout file.
replace_in_file(self.stdout_file, socket.gethostname(), "localhost")
if os.environ.get("CLICKHOUSE_PORT_TCP"):
replace_in_file(
self.stdout_file,
f"PORT {os.environ['CLICKHOUSE_PORT_TCP']}",
"PORT 9000",
)
replace_in_file(
self.stdout_file,
f"localhost {os.environ['CLICKHOUSE_PORT_TCP']}",
"localhost 9000",
)
if os.environ.get("CLICKHOUSE_PORT_TCP_SECURE"):
replace_in_file(
self.stdout_file,
f"PORT {os.environ['CLICKHOUSE_PORT_TCP_SECURE']}",
"PORT 9440",
)
replace_in_file(
self.stdout_file,
f"localhost {os.environ['CLICKHOUSE_PORT_TCP_SECURE']}",
"localhost 9440",
)
if os.environ.get("CLICKHOUSE_PATH"):
replace_in_file(
self.stdout_file,
os.environ["CLICKHOUSE_PATH"],
"/var/lib/clickhouse",
)
if os.environ.get("CLICKHOUSE_PORT_HTTPS"):
replace_in_file(
self.stdout_file,
f"https://localhost:{os.environ['CLICKHOUSE_PORT_HTTPS']}/",
"https://localhost:8443/",
)
if args.cloud:
replace_in_file(
self.stdout_file,
r"ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')",
"ENGINE = SharedMergeTree",
)
replace_in_file(
self.stdout_file,
r"ENGINE = SharedMergeTree(\'/clickhouse/tables/{uuid}/{shard}\', \'{replica}\')",
"ENGINE = SharedMergeTree",
)
replace_in_file(
self.stdout_file,
r"ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')".replace(
"'", r"'\''"
),
"ENGINE = SharedMergeTree",
)
replace_in_file(
self.stdout_file,
r"ENGINE = SharedMergeTree(\\'/clickhouse/tables/{uuid}/{shard}\\', \\'{replica}\\')".replace(
"'", r"'\''"
),
"ENGINE = SharedMergeTree",
)
replace_in_file_re(
self.stdout_file,
r"\(ENGINE = Shared.*MergeTree\)(\([^,.]*\), \([^,.]*\), \([^,.]*\), \([^,.]*\))",
r"\1(\4, \5)",
)
replace_in_file_re(
self.stdout_file,
r"\(ENGINE = Shared.*MergeTree\)(\([^,.]*\), \([^,.]*\), \([^,.]*\))",
r"\1(\4)",
)
return proc, total_time
def run(self, args, suite, client_options):
start_time = datetime.now()
try:
description = ""
skip_reason = self.should_skip_test(suite)
if args.cloud and skip_reason is None:
skip_reason, description = self.should_skip_cloud_test()
# if args.shared_catalog_stress and skip_reason is None:
# skip_reason, description = self.should_skip_stress_test()
if skip_reason is not None:
return TestResult(
self.name, TestStatus.SKIPPED, skip_reason, 0.0, description
)
if args.testname:
try:
self.send_test_name_failed(suite.suite, self.case)
except Exception:
# If health-check failed, double-check server liveness. A single
# check may fail because of memory exhaustion, for example.
if not check_server_liveness(self.args):
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.SERVER_DIED,
0.0,
"\nServer does not respond to health check\n",
)
self.runs_count += 1
self.testcase_args = self.configure_testcase_args(
args, self.case_file, suite.suite_tmp_path
)
client_options = self.add_effective_settings(client_options)
if args.collect_per_test_coverage and BuildFlags.WITH_COVERAGE_DEPTH in args.build_flags:
try:
safe_case = self.case.replace("'", "\\'")
clickhouse_execute(args, f"SYSTEM SET COVERAGE TEST '{safe_case}'")
except Exception as e:
return TestResult(
self.name,
TestStatus.FAIL,
None,
0.0,
f"\nCannot arm per-test coverage: {e}\n",
)
if not is_valid_utf_8(self.case_file) or (
self.reference_file and not is_valid_utf_8(self.reference_file)
):
proc, total_time = self.run_single_test(
args.server_logs_level, client_options
)
result = self.process_result_impl(proc, total_time)
result.check_if_need_retry(
args, result.description, result.description, self.runs_count
)
# to avoid breaking CSV parser
result.description = result.description.replace("\0", "")
else:
@contextmanager
def switch_to_smt():
with (
SharedEngineReplacer(
args,
self.case_file,
args.replace_replicated_with_shared,
args.replace_non_replicated_with_shared,
False,
args.cloud,
) as test_replacer,
SharedEngineReplacer(
args,
self.reference_file,
# cloud_mode_engine=2 in cloud, expect all engines to be Shared*
args.replace_replicated_with_shared or args.cloud,
args.replace_non_replicated_with_shared or args.cloud,
True,
args.cloud,
) as reference_replacer,
):
old_case_file = self.case_file
old_reference_file = self.reference_file
self.case_file = test_replacer.get_path()
self.reference_file = reference_replacer.get_path()
try:
yield
finally:
self.case_file = old_case_file
self.reference_file = old_reference_file
with switch_to_smt():
(
proc,
total_time,
) = self.run_single_test(args.server_logs_level, client_options)
result = self.process_result_impl(proc, total_time)
result.check_if_need_retry(
args,
result.description,
result.description,
self.runs_count,
)
# to avoid breaking CSV parser
result.description = result.description.replace("\0", "")
if result.status == TestStatus.FAIL:
result.description = self.add_info_about_settings(result.description)
if args.cloud and result.status == TestStatus.FAIL:
# If error is in list of skipped errors, mark this test as SKIPPED
for skip_error in self.suite.cloud_skip_errors_list:
if skip_error in result.description:
result.status = TestStatus.SKIPPED
result.reason = FailureReason.NOT_SUPPORTED_IN_CLOUD_POSTFILTER
break
if self.name in suite.blacklist_check:
if result.status == TestStatus.OK:
result.status = TestStatus.NOT_FAILED
result.reason = FailureReason.NOT_FAILED
if result.status == TestStatus.FAIL:
result.status = TestStatus.SKIPPED
self._cleanup(result.status == TestStatus.OK)
return result
except KeyboardInterrupt as e:
raise e
except HTTPError:
total_time = (datetime.now() - start_time).total_seconds()
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.INTERNAL_QUERY_FAIL,
total_time,
self.add_info_about_settings(
self.get_description_from_exception_info(sys.exc_info())
),
)
except socket.timeout:
total_time = (datetime.now() - start_time).total_seconds()
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.TIMEOUT,
total_time,
self.add_info_about_settings(
self.get_description_from_exception_info(sys.exc_info())
),
)
except (ConnectionError, http.client.ImproperConnectionState):
total_time = (datetime.now() - start_time).total_seconds()
exc_description = self.add_info_about_settings(
self.get_description_from_exception_info(sys.exc_info())
)
if not check_server_liveness(self.args):
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.SERVER_DIED,
total_time,
exc_description,
)
# Server is alive — this was a transient network error.
return TestResult(
self.name,
TestStatus.FAIL,
FailureReason.CONNECTION_ERROR,
total_time,
exc_description,
)
except Exception:
total_time = (datetime.now() - start_time).total_seconds()
return TestResult(
self.name,
TestStatus.UNKNOWN,
FailureReason.INTERNAL_ERROR,
total_time,
self.get_description_from_exception_info(sys.exc_info()),
)
finally:
self.remove_settings_from_env()
def _cleanup(self, passed):
args = self.testcase_args
need_cleanup = not args.database
if need_cleanup and args.no_drop_if_fail:
need_cleanup = passed
if not need_cleanup or args.no_drop:
return
time_passed = (datetime.now() - args.testcase_start_time).total_seconds()
timeout = max(args.timeout - time_passed, 20)
try:
self._cleanup_database(args, timeout)
except (ConnectionError, http.client.ImproperConnectionState) as e:
# Cleanup is best-effort only when the server is actually dead.
# If the server is still alive, this was a real cleanup failure
# (leftover state, unfinished DROP) and must propagate so the
# outer handler in run() classifies it as CONNECTION_ERROR — a
# silent swallow would let a test stay OK while leaving its
# database behind, hiding the bug.
if check_server_liveness(args):
raise
# Server died between the test finishing and cleanup running.
# The test's database is moot; the hung-check or the next test
# will detect the dead server and stop the run.
print(
f"WARNING: cleanup skipped for {self.name}: "
f"{type(e).__name__}: {e}",
file=sys.stderr,
)
shutil.rmtree(args.test_tmp_dir)
def _cleanup_database(self, args, timeout):
database = args.testcase_database
# Check if the test does not cleanup its tables.
# Only for newly added tests. Please extend this check to the old tests as well.
if self.case_file >= "02800":
leftover_tables = (
clickhouse_execute(
args,
f"SHOW TABLES FROM {database}",
timeout=timeout,
settings={
"log_comment": f'{args.testcase_basename}-{args.testcase_database}',
},
)
.decode()
.replace("\n", ", ")
)
if len(leftover_tables) != 0:
raise TestException(
f"The test should cleanup its tables ({leftover_tables}), otherwise it is inconvenient for running it locally."
)
drop_database_query = f"DROP DATABASE IF EXISTS {database}"
if args.replicated_database:
drop_database_query += " ON CLUSTER test_cluster_database_replicated"
drop_database_query += " SYNC"
# It's possible to get an error "New table appeared in database being dropped or detached. Try again."
for _ in range(1, 60):
try:
clickhouse_execute(
args,
drop_database_query,
timeout=timeout,
settings={
"log_comment": f'{args.testcase_basename}-{args.testcase_database}',
},
)
except HTTPError as e:
if need_retry(args, e.message, e.message, 0):
continue
raise
break
def parse_to_bytes(size_str):
units = {
'B': 1, 'KB': 10**3, 'MB': 10**6, 'GB': 10**9, 'TB': 10**12,
'KIB': 2**10, 'MIB': 2**20, 'GIB': 2**30, 'TIB': 2**40
}
# Use regex to split the number from the unit
match = re.match(r"^([\d.]+)\s*([a-zA-Z]{1,3})$", size_str.strip())
if not match:
raise ValueError("Invalid format. Use '10GB' or '5 MiB'.")
number, unit = match.groups()
return int(float(number) * units[unit.upper()])
class TestSuite:
@staticmethod
def tests_in_suite_key_func(item: str) -> float:
if args.order == "random":
return random.random()
reverse = 1 if args.order == "asc" else -1
if -1 == item.find("_"):
return 99998
prefix, _ = item.split("_", 1)
try:
return reverse * int(prefix)
except ValueError:
return 99997
@staticmethod
def render_test_template(j2env, suite_dir, test_name):
"""
Render template for test and reference file if needed
"""
if j2env is None:
return test_name
test_base_name = removesuffix(test_name, ".sql.j2", ".sql")
reference_file_name = test_base_name + ".reference.j2"
reference_file_path = os.path.join(suite_dir, reference_file_name)
if os.path.isfile(reference_file_path):
tpl = j2env.get_template(reference_file_name)
tpl.stream().dump(
os.path.join(suite_dir, test_base_name) + ".gen.reference"
)
if test_name.endswith(".sql.j2"):
tpl = j2env.get_template(test_name)
generated_test_name = test_base_name + ".gen.sql"
tpl.stream().dump(os.path.join(suite_dir, generated_test_name))
return generated_test_name
return test_name
@staticmethod
def read_test_tags_and_random_settings_limits(
suite_dir: str, all_tests: List[str]
) -> (Dict[str, Set[str]], Dict[str, Dict[str, Tuple[int, int]]], Dict[str, int]):
def get_comment_sign(filename):
if filename.endswith(".sql") or filename.endswith(".sql.j2"):
return "--"
if (
filename.endswith(".sh")
or filename.endswith(".py")
or filename.endswith(".expect")
):
return "#"
raise TestException(f"Unknown file_extension: {filename}")
def parse_tags_from_line(line, comment_sign) -> Set[str]:
if not line.startswith(comment_sign):
return set()
tags_str = line[len(comment_sign) :].lstrip() # noqa: ignore E203
tags_prefix = "Tags:"
if not tags_str.startswith(tags_prefix):
return set()
tags_str = tags_str[len(tags_prefix) :] # noqa: ignore E203
tags = tags_str.split(",")
tags = {tag.strip() for tag in tags}
if "no-stress" in tags:
raise ValueError(
"The 'no-stress' tag is not supported: stress tests must be "
"able to run every test. Remove it from the test."
)
return tags
def parse_memory_limits_from_line(
line, comment_sign
) -> Dict[str, Tuple[int, int]]:
if not line.startswith(comment_sign):
return None
memory_limits_str = line[len(comment_sign) :].lstrip()
memory_limits_prefix = "Memory limits:"
if not memory_limits_str.startswith(memory_limits_prefix):
return None
memory_limits_str = memory_limits_str[len(memory_limits_prefix) : ]
return parse_to_bytes(memory_limits_str)
def parse_random_settings_limits_from_line(
line, comment_sign
) -> Dict[str, Tuple[int, int]]:
if not line.startswith(comment_sign):
return {}
random_settings_limits_str = line[len(comment_sign) :].lstrip()
random_settings_limits_prefix = "Random settings limits:"
if not random_settings_limits_str.startswith(random_settings_limits_prefix):
return {}
random_settings_limits_str = random_settings_limits_str[
len(random_settings_limits_prefix) :
]
# limits are specified in a form 'setting1=(min, max); setting2=(min,max); ...'
random_settings_limits = {}
for setting_and_limit in random_settings_limits_str.split(";"):
setting_and_limit = setting_and_limit.split("=")
random_settings_limits[setting_and_limit[0].strip()] = make_tuple(
setting_and_limit[1]
)
return random_settings_limits
def find_tag_line(lines, comment_sign):
for line in lines:
if line.startswith(comment_sign) and line[
len(comment_sign) :
].lstrip().startswith("Tags:"):
return line
return ""
def find_random_settings_limits_line(lines, comment_sign):
for line in lines:
if line.startswith(comment_sign) and line[
len(comment_sign) :
].lstrip().startswith("Random settings limits:"):
return line
return ""
def find_memory_limits_line(lines, comment_sign):
for line in lines:
if line.startswith(comment_sign) and line[
len(comment_sign) :
].lstrip().startswith("Memory limits:"):
return line
return ""
def load_tags_and_random_settings_limits_from_file(filepath):
comment_sign = get_comment_sign(filepath)
need_query_params = False
with open(filepath, "r", encoding="utf-8") as file:
try:
lines = file.readlines()
tag_line = find_tag_line(lines, comment_sign)
random_settings_limits_line = find_random_settings_limits_line(lines, comment_sign)
memory_limits_line = find_memory_limits_line(lines, comment_sign)
except UnicodeDecodeError:
return [], {}, None
try:
if filepath.endswith(".sql"):
for line in lines:
if "{CLICKHOUSE_" in line:
need_query_params = True
except UnicodeDecodeError:
pass
parsed_tags = parse_tags_from_line(tag_line, comment_sign)
if need_query_params:
parsed_tags.add("need-query-parameters")
if filepath.endswith(".expect"):
parsed_tags.add("test-type-expect")
if ".cloud." in filepath:
parsed_tags.add("cloud")
random_settings_limits = parse_random_settings_limits_from_line(random_settings_limits_line, comment_sign)
memory_limits = parse_memory_limits_from_line(memory_limits_line, comment_sign)
return parsed_tags, random_settings_limits, memory_limits
all_tags = {}
all_random_settings_limits = {}
all_memory_limits = {}
start_time = datetime.now()
for test_name in all_tests:
(
tags,
random_settings_limits,
memory_limits,
) = load_tags_and_random_settings_limits_from_file(
os.path.join(suite_dir, test_name)
) # noqa: ignore E203
all_tags[test_name] = tags or set()
if random_settings_limits:
all_random_settings_limits[test_name] = random_settings_limits
if memory_limits:
all_memory_limits[test_name] = memory_limits
elapsed = (datetime.now() - start_time).total_seconds()
if elapsed > 1:
print(
f"Tags and random settings limits for suite {suite_dir} read in {elapsed:.2f} seconds"
)
return all_tags, all_random_settings_limits, all_memory_limits
def __init__(self, args, suite_path: str, suite_tmp_path: str, suite: str):
self.args = args
self.suite_path: str = suite_path
self.suite_tmp_path: str = suite_tmp_path
self.suite: str = suite
self.skip_list: List[str] = []
self.cloud_skip_list: List[str] = []
self.private_skip_list: List[str] = []
self.cloud_skip_errors_list: List[str] = []
# List of tests included in a blacklist.
# They will be run, and if they succeed, the test status will be changed to FAIL
# to notify PR author that the changes in PR fixes this test.
self.blacklist_check: List[str] = []
if args.run_by_hash_num is not None and args.run_by_hash_total is not None:
if args.run_by_hash_num > args.run_by_hash_total:
raise TestException(
f"Incorrect run by hash, value {args.run_by_hash_num} bigger than total {args.run_by_hash_total}"
)
def filter_func(x: str) -> bool:
return bool(
stringhash(x) % args.run_by_hash_total == args.run_by_hash_num
)
else:
def filter_func(x: str) -> bool:
_ = x
return True
all_tests = list(self.get_selected_tests(filter_func))
all_tags_and_random_settings_limits = (
self.read_test_tags_and_random_settings_limits(self.suite_path, all_tests)
)
self.all_tags: Dict[str, Set[str]] = all_tags_and_random_settings_limits[0]
self.all_random_settings_limits: Dict[str, Dict[str, (int, int)]] = (all_tags_and_random_settings_limits[1])
self.all_memory_limits: Dict[str, int] = (all_tags_and_random_settings_limits[2])
self.sequential_tests = []
self.parallel_tests = []
# Sort unique test names first, then expand to repeated runs.
# Sorting after expansion would re-group equal names together
# (for asc/desc order), destroying the round-robin interleaving.
all_tests.sort(key=self.tests_in_suite_key_func)
self.all_tests = self.apply_test_runs(all_tests)
post_run_check_tests = []
for test_name in self.all_tests:
if "post-run-check" in self.all_tags[test_name]:
# post_run_check_tests are tests that supposed to run after all normal tests finished
post_run_check_tests.append(test_name)
continue
if self.is_sequential_test(test_name):
if not args.no_sequential:
self.sequential_tests.append(test_name)
else:
if not args.no_parallel:
self.parallel_tests.append(test_name)
self.sequential_tests.extend(post_run_check_tests)
def apply_test_runs(self, all_tests):
test_runs = self.args.test_runs
long_test_runs = max(
int(self.args.test_runs * self.args.long_test_runs_ratio), 1
)
sequential_runs = self.args.sequential_test_runs if self.args.sequential_test_runs is not None else test_runs
newly_modified_runs = {}
if (
self.args.repeat_newly_modified_tests
and not self.args.no_random_settings
):
newly_modified_runs = self.get_newly_modified_test_runs(all_tests)
if newly_modified_runs:
print(
f"Will repeat {len(newly_modified_runs)} recently modified tests "
f"with different randomized settings"
)
def get_runs(test):
if self.is_long_test(test):
base = long_test_runs
elif self.is_sequential_test(test):
base = sequential_runs
else:
base = test_runs
extra = newly_modified_runs.get(test, 0)
return max(base, extra)
# Build a round-robin list so each run-slot interleaves all tests before
# starting the next slot. E.g. [t1,t2,t3, t1,t2,t3, t1,t2] instead of
# [t1,t1,t1, t2,t2,t2, t3,t3], so tests with different run counts still
# start at the same time. This guarantees no test monopolizes consecutive
# runs regardless of --order.
runs_per_test = {test: get_runs(test) for test in all_tests}
max_runs = max(runs_per_test.values()) if runs_per_test else 0
result = []
for slot in range(max_runs):
result.extend(test for test in all_tests if slot < runs_per_test[test])
return result
def get_newly_modified_test_runs(self, all_tests):
"""Get repetition counts for recently added tests based on test name order.
Tests are sorted by name in descending order (newest first, since test
names have numeric prefixes). Each test at rank r gets
int(sqrt(100 / r)) repetitions. Tests with 1 or fewer computed
repetitions are skipped.
"""
sorted_tests = sorted(all_tests, reverse=True)
runs = {}
for rank, test in enumerate(sorted_tests, 1):
count = int(math.sqrt(100.0 / rank))
if count <= 1:
break
runs[test] = count
for test, count in sorted(runs.items(), reverse=True):
print(f" {test}: {count} runs")
return runs
def is_long_test(self, test_name):
if test_name not in self.all_tags:
return False
return "long" in self.all_tags[test_name]
def is_sequential_test(self, test_name):
if args.sequential:
if any(s in test_name for s in args.sequential):
return True
if test_name not in self.all_tags:
return False
tags = self.all_tags[test_name]
if "no-parallel" in tags or "sequential" in tags:
return True
return False
def get_selected_tests(self, filter_func):
"""
Find all files with tests, filter, render templates
"""
j2env = (
jinja2.Environment(
loader=jinja2.FileSystemLoader(self.suite_path),
keep_trailing_newline=True,
)
if USE_JINJA
else None
)
if j2env is not None:
j2env.globals.update(product=itertools.product)
for test_name in os.listdir(self.suite_path):
if not is_test_from_dir(self.suite_path, test_name):
continue
if self.args.test and not any(
re.search(pattern, test_name) for pattern in self.args.test
):
continue
if USE_JINJA and test_name.endswith(".gen.sql"):
continue
if not filter_func(test_name):
continue
test_name = self.render_test_template(j2env, self.suite_path, test_name)
yield test_name
@staticmethod
def read_test_suite(args, suite_dir_name: str):
def is_data_present():
try:
return int(clickhouse_execute(args, "EXISTS TABLE test.hits"))
except Exception as e:
print(
"Cannot check if dataset is available, assuming it's not: ", str(e)
)
return False
base_dir = os.path.abspath(args.queries)
tmp_dir = os.path.abspath(args.tmp)
suite_path = os.path.join(base_dir, suite_dir_name)
suite_re_obj = re.search("^[0-9]+_(.*)$", suite_dir_name)
if not suite_re_obj: # skip .gitignore and so on
return None
suite_tmp_path = os.path.join(tmp_dir, suite_dir_name)
if not os.path.exists(suite_tmp_path):
os.makedirs(suite_tmp_path)
suite = suite_re_obj.group(1)
if not os.path.isdir(suite_path):
return None
if "stateful" in suite and not args.no_stateful and not is_data_present():
print("Won't run stateful tests because test data wasn't loaded.")
return None
if "stateless" in suite and args.no_stateless:
print("Won't run stateless tests because they were manually disabled.")
return None
if "stateful" in suite and args.no_stateful:
print("Won't run stateful tests because they were manually disabled.")
return None
return TestSuite(args, suite_path, suite_tmp_path, suite)
class StopTesting(Exception):
"""Raised to abort the whole test run (server crash, hung-check
timeout, --max-failures limit, global time limit, etc.). Mirrors
`StopIteration` in role: a control-flow exception unwound to a
known outer handler.
The reason is in the message — the catch handler prefixes it with
"Test run aborted:" so it stands out in the log, and exits with
`exit_code` so the job side can distinguish the cause: the default
`STOP_TESTING_EXIT_CODE` means the run was catastrophically interrupted
(reported as "Server died"), `GLOBAL_TIME_LIMIT_EXIT_CODE` means a
benign, expected stop after the time budget was reached, and
`MAX_FAILURES_EXIT_CODE` means too many tests failed (real failures, server
still alive) so the run stopped early.
"""
def __init__(self, message, exit_code=STOP_TESTING_EXIT_CODE):
super().__init__(message)
self.exit_code = exit_code
def run_diagnose_from_artifacts(args, stop_testing):
"""Read saved randomized settings artifacts and run diagnosis for each entry."""
entries = load_random_settings_artifacts(args)
if not entries:
path = get_random_settings_diagnostics_path(args)
print(f"No randomized settings artifacts found in {path}")
return 0
# Filter by test selector if provided
if args.test:
entries = [
e
for e in entries
if any(re.search(pattern, e["test_name"]) for pattern in args.test)
]
if not entries:
print(
f"No artifacts match the test selector: {args.test}"
)
return 0
print(f"Found {len(entries)} test(s) to diagnose.")
base_dir = os.path.abspath(args.queries)
results_path = get_random_settings_diagnostics_results_path(args)
# Non-zero exit code is reserved for unexpected script termination
# (e.g. uncaught exception in the diagnosis machinery). Environmental
# conditions like stop_testing are not script failures — they are
# already surfaced by the main test stage, so the diagnostics stage
# should exit cleanly when interrupted by them.
exit_code = 0
with open(results_path, "w", encoding="utf-8") as results_file:
for entry in entries:
test_name = entry["test_name"]
settings = entry.get("settings", {})
mt_settings = entry.get("merge_tree_settings", {})
# Testing stopped — stop diagnosing further tests but exit cleanly:
# the main test stage has already reported the failure.
if stop_testing.is_set():
break
# Find the test case file across suites
case = None
suite_dir_name = None
for suite_candidate in sorted(os.listdir(base_dir)):
suite_path = os.path.join(base_dir, suite_candidate)
if not os.path.isdir(suite_path):
continue
for fname in os.listdir(suite_path):
name_without_ext = os.path.splitext(fname)[0]
if name_without_ext == test_name and is_test_from_dir(suite_path, fname):
case = fname
suite_dir_name = suite_candidate
break
if case:
break
if not case:
print(f"WARNING: test file for '{test_name}' not found, skipping.")
continue
test_suite = TestSuite.read_test_suite(args, suite_dir_name)
if test_suite is None:
print(f"WARNING: could not load suite for '{test_name}', skipping.")
continue
test_case = TestCase(test_suite, case, args, False)
# Override settings with the saved artifact values (disable randomization)
test_case.effective_settings = settings
test_case.effective_merge_tree_settings = mt_settings
client_options = get_additional_client_options(args)
print(f"\n{'='*70}")
print(f"Diagnosing: {test_name}")
if settings:
print(f" Settings: {TestCase.cli_format_settings(settings)}")
if mt_settings:
print(f" MergeTree settings: {TestCase.cli_format_settings(mt_settings)}")
print(f"{'='*70}")
try:
diagnosis = test_case.diagnose_random_settings(args, test_suite, client_options)
except Exception as e:
print(f"ERROR: Diagnosis failed for {test_name}: {e}")
traceback.print_exc()
exit_code = 1
continue
if diagnosis:
print(diagnosis)
else:
diagnosis = ""
# Classify the diagnosis for CI labeling. Leave label empty for
# inconclusive/unrecognized outcomes so they don't get mislabeled.
label = ""
if "Inconclusive" in diagnosis:
label = ""
elif "Culprit settings:" in diagnosis or "Culprit MergeTree settings:" in diagnosis:
label = "setting"
elif "Failure is flaky" in diagnosis or "not reproducible" in diagnosis.lower():
label = "flaky"
elif (
"not a randomization issue" in diagnosis
or "Could not isolate" in diagnosis
):
label = "reproducible"
else:
# Match "Failed N out of M reruns." for the no-random-settings path
m = re.search(r"Failed (\d+) out of (\d+) reruns", diagnosis)
if m:
fail_n, total_n = int(m.group(1)), int(m.group(2))
if fail_n < total_n:
label = "flaky"
else:
label = "reproducible"
result_entry = {"test_name": test_name, "diagnosis": diagnosis, "label": label}
results_file.write(json.dumps(result_entry, ensure_ascii=False) + "\n")
results_file.flush()
return exit_code
def run_tests_array(
all_tests_with_params: Tuple[
List[str],
int,
TestSuite,
bool,
Namespace,
multiprocessing.sharedctypes.Synchronized,
multiprocessing.synchronize.Event,
multiprocessing.sharedctypes.Synchronized,
multiprocessing.managers.ListProxy,
int,
multiprocessing.sharedctypes.Synchronized,
multiprocessing.sharedctypes.Synchronized,
int,
multiprocessing.sharedctypes.Synchronized,
],
):
(
all_tests,
num_tests,
test_suite,
is_concurrent,
args,
exit_code,
stop_testing,
workers_to_shed,
restarted_tests,
process_index,
total_failures_shared,
progress_counter,
total_tests,
stop_exit_code,
) = all_tests_with_params
os.environ["LLVM_PROFILE_FILE"] = f"clickhouse-client-proc{process_index}-%2m.profraw"
OP_SQUARE_BRACKET = colored("[", args, attrs=["bold"])
CL_SQUARE_BRACKET = colored("]", args, attrs=["bold"])
MSG_FAIL = (
OP_SQUARE_BRACKET
+ colored(" FAIL ", args, "red", attrs=["bold"])
+ CL_SQUARE_BRACKET
)
MSG_UNKNOWN = (
OP_SQUARE_BRACKET
+ colored(" UNKNOWN ", args, "yellow", attrs=["bold"])
+ CL_SQUARE_BRACKET
)
MSG_OK = (
OP_SQUARE_BRACKET
+ colored(" OK ", args, "green", attrs=["bold"])
+ CL_SQUARE_BRACKET
)
MSG_SKIPPED = (
OP_SQUARE_BRACKET
+ colored(" SKIPPED ", args, "cyan", attrs=["bold"])
+ CL_SQUARE_BRACKET
)
MSG_NOT_FAILED = (
OP_SQUARE_BRACKET
+ colored(" NOT_FAILED ", args, "red", attrs=["bold"])
+ CL_SQUARE_BRACKET
)
MESSAGES = {
TestStatus.FAIL: MSG_FAIL,
TestStatus.UNKNOWN: MSG_UNKNOWN,
TestStatus.OK: MSG_OK,
TestStatus.SKIPPED: MSG_SKIPPED,
TestStatus.NOT_FAILED: MSG_NOT_FAILED,
}
passed_total = 0
skipped_total = 0
failures_total = 0
failures_chain = 0
start_time = datetime.now()
client_options = get_additional_client_options(args)
if num_tests > 0:
about = "about " if is_concurrent else ""
proc_name = multiprocessing.current_process().name
print(f"Running {about}{num_tests} {test_suite.suite} tests ({proc_name}).")
while True:
# Check whether this worker has been asked to shed (stop after finishing
# its previous test). The check happens before popping the next test so
# no work is lost — the remaining workers will pick up the queue.
with workers_to_shed.get_lock():
if workers_to_shed.value > 0:
workers_to_shed.value -= 1
break
if all_tests:
try:
case = all_tests.pop(0)
except IndexError:
break
else:
break
if stop_testing.is_set():
if is_concurrent:
# Another worker (or the parent) requested the stop. Break
# quietly and let the parent orchestrate the shutdown and the
# exit code; do NOT call `stop_tests()` here - its `killpg`
# broadcasts SIGTERM to the whole group, including the parent,
# which would die with 143 (reported as "Server died") before it
# could re-raise with the real cause's exit code.
break
stop_tests()
raise StopTesting(
"another component requested stop (see earlier output for the cause)"
)
if args.stop_time and time() > args.stop_time:
if is_concurrent:
# Parallel worker: stop quietly and let the parent orchestrate
# the graceful shutdown and the exit code. Do NOT call
# `stop_tests()` here — its `killpg` broadcasts SIGTERM to the
# whole group, including the parent, which would clobber the
# graceful `GLOBAL_TIME_LIMIT_EXIT_CODE` into 143 (reported as
# "Server died").
break
# Sequential runner (runs in the main process): raise so the
# graceful exit code propagates to the outer handler.
stop_tests()
raise StopTesting(
"global time limit exceeded (--stop-time)",
exit_code=GLOBAL_TIME_LIMIT_EXIT_CODE,
)
test_case = TestCase(test_suite, case, args, is_concurrent)
try:
description = ""
test_case_name = removesuffix(test_case.name, ".gen", ".sql") + ": "
# A value to store the cleaning children output to not mess up with the description
cleanup_output = io.StringIO()
if is_concurrent:
description = f"{test_case_name:72}"
else:
sys.stdout.flush()
progress = get_next_test_progress(progress_counter, total_tests)
sys.stdout.write(f"{progress}{test_case_name:72}")
# This flush is needed so you can see the test name of the long
# running test before it will finish. But don't do it in parallel
# mode, so that the lines don't mix.
sys.stdout.flush()
while True:
# This is the upper level timeout
# It helps with completely frozen processes, like in case of lldb errors
def timeout_handler(_signum, _frame):
with redirect_stdout(cleanup_output):
# Dump stacktraces *before* stop_tests() kills the
# client connections. This is the only moment
# the offending user query is still wedged on the
# server with its query_id — once the TCP
# connection drops, the server typically cancels
# the query and its threads go away. BG threads
# stay around either way and can be captured at
# end-of-run.
print_sql_stacktraces(args)
print_c_stacktraces(args)
stop_tests()
raise TimeoutError("Test execution timed out")
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(int(args.timeout * 1.1) + 60)
test_result = None
try:
test_result = test_case.run(args, test_suite, client_options)
test_result = test_case.process_result(test_result, MESSAGES)
except TimeoutError:
break
finally:
signal.alarm(0)
if not test_result or not test_result.need_retry:
break
restarted_tests.append(test_result)
# Save failed test info so that diagnostics can be run later
# via --diagnose-random-settings.
if (
test_result
and test_result.status == TestStatus.FAIL
and test_result.reason != FailureReason.SERVER_DIED
):
save_random_settings_artifact(
args,
test_case.name,
test_case.effective_settings,
test_case.effective_merge_tree_settings,
)
# First print the description, then invoke the check result logic
description += test_result.description
if description and not description.endswith("\n"):
description += "\n"
if cleanup_output.getvalue():
description += cleanup_output.getvalue()
if not cleanup_output.getvalue().endswith("\n"):
description += "\n"
if is_concurrent:
description = (
get_next_test_progress(progress_counter, total_tests) + description
)
sys.stdout.write(description)
sys.stdout.flush()
if test_result.status == TestStatus.OK:
passed_total += 1
failures_chain = 0
elif test_result.status == TestStatus.FAIL:
failures_total += 1
failures_chain += 1
if test_result.reason == FailureReason.SERVER_DIED:
print_c_stacktraces(args)
stop_tests()
stop_testing.set()
raise StopTesting(
f"ClickHouse server died during test '{test_case.name}'"
)
if args.max_failures > 0:
with total_failures_shared.get_lock():
total_failures_shared.value += 1
if total_failures_shared.value >= args.max_failures:
# Unlike the server-died case above, do NOT call
# `stop_tests()` here: its `killpg` broadcasts SIGTERM
# to the whole group, including the parent, which would
# die with 143 (reported as "Server died") before it
# could re-raise with `MAX_FAILURES_EXIT_CODE`. Just
# signal the stop; the parent masks SIGTERM and then
# terminates the workers in an orderly way.
stop_exit_code.value = MAX_FAILURES_EXIT_CODE
stop_testing.set()
raise StopTesting(
f"reached --max-failures limit ({args.max_failures})",
exit_code=MAX_FAILURES_EXIT_CODE,
)
elif test_result.status == TestStatus.SKIPPED:
skipped_total += 1
except KeyboardInterrupt as e:
print(colored("Break tests execution", args, "red"))
stop_tests()
raise e
if failures_chain >= args.max_failures_chain:
# See the --max-failures branch above: skip `stop_tests()` so the
# worker's SIGTERM broadcast does not kill the parent before it can
# re-raise with `MAX_FAILURES_EXIT_CODE`.
stop_exit_code.value = MAX_FAILURES_EXIT_CODE
stop_testing.set()
raise StopTesting(
f"reached --max-failures-chain limit ({args.max_failures_chain})",
exit_code=MAX_FAILURES_EXIT_CODE,
)
if failures_total > 0:
print(
colored(
f"\nHaving {failures_total} errors! {passed_total} tests passed."
f" {skipped_total} tests skipped."
f" {(datetime.now() - start_time).total_seconds():.2f} s elapsed"
f" ({multiprocessing.current_process().name}).",
args,
"red",
attrs=["bold"],
)
)
exit_code.value = 1
else:
print(
colored(
f"\n{passed_total} tests passed. {skipped_total} tests skipped."
f" {(datetime.now() - start_time).total_seconds():.2f} s elapsed"
f" ({multiprocessing.current_process().name}).",
args,
"green",
attrs=["bold"],
)
)
sys.stdout.flush()
def check_server_liveness(args, max_retries=10) -> bool:
"""Check if server is responding, with retries and exponential back-off.
Used to distinguish a real server death from a transient network failure
(e.g. the server being briefly unresponsive due to memory pressure).
Returns True if the server responds within the retry window, False otherwise.
"""
for attempt in range(max_retries):
try:
clickhouse_execute(
args, "SELECT 1 /*liveness check*/", max_http_retries=1, timeout=10
)
return True
except Exception:
if attempt < max_retries - 1:
sleep(min(2**attempt, 10))
return False
def get_server_memory_fraction(args) -> Optional[float]:
"""Return the fraction of max_server_memory_usage currently in use (0.0–1.0).
Reads the server process RSS from /proc/<pid>/statm, which is more reliable
than querying MemoryTracking from system.metrics — the latter can be far
below actual RSS under sanitizer builds where jemalloc is disabled and the
memory tracker is not corrected to match RSS.
The max_server_memory_usage value is fetched once from the server and cached.
Returns None when the server PID is unavailable, the limit is unconfigured
(0), or the query/read fails.
"""
# Cache the hard limit — it does not change at runtime.
if not hasattr(get_server_memory_fraction, "_max_memory"):
try:
get_server_memory_fraction._max_memory = int(
clickhouse_execute(
args,
"SELECT value FROM system.server_settings WHERE name = 'max_server_memory_usage'",
max_http_retries=1,
timeout=5,
)
.decode()
.strip()
)
except Exception:
return None
maximum = get_server_memory_fraction._max_memory
if maximum == 0:
return None
# Cache the server PID — it does not change during a test run.
if not hasattr(get_server_memory_fraction, "_server_pid"):
get_server_memory_fraction._server_pid = get_server_pid()
pid = get_server_memory_fraction._server_pid
if pid is None:
return None
try:
# /proc/<pid>/statm fields: size resident shared text lib data dt (in pages)
with open(f"/proc/{pid}/statm", "r") as f:
parts = f.read().split()
rss_pages = int(parts[1])
rss_bytes = rss_pages * os.sysconf("SC_PAGE_SIZE")
return rss_bytes / maximum
except Exception:
return None
def check_server_started(args):
print("Connecting to ClickHouse server...", end="")
sys.stdout.flush()
retry_count = args.server_check_retries
query = "SELECT version(), arrayStringConcat(groupArray(value), ' ') FROM system.build_options WHERE name IN ('GIT_HASH', 'GIT_BRANCH')"
while retry_count > 0:
try:
res = (
str(clickhouse_execute(args, query).decode())
.strip()
.replace("\t", " @ ")
)
print(" OK")
print(f"Connected to server {res}")
sys.stdout.flush()
return True
except (
ConnectionError,
http.client.ImproperConnectionState,
SSLEOFError,
HTTPError,
) as e:
# When connecting to cloud sticky-host:
# SSLEOFError : [SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol (_ssl.c:1007)
# HTTPError:
# Code: 503. Code: 439. DB::Exception: Cannot schedule a task: failed to start the thread (threads=2, jobs=2). (CANNOT_SCHEDULE_TASK)
if args.hung_check:
print("Connection error, will retry: ", str(e))
else:
print(".", end="")
sys.stdout.flush()
retry_count -= 1
sleep(0.5)
continue
except TimeoutError:
print("\nConnection timeout, will not retry")
break
except Exception as e:
print(
"\nUnexpected exception, will not retry: ",
type(e).__name__,
": ",
str(e),
)
break
print("\nAll connection tries failed")
sys.stdout.flush()
return False
class BuildFlags:
THREAD = "tsan"
ADDRESS = "asan"
UNDEFINED = "ubsan"
MEMORY = "msan"
WITH_COVERAGE = "with-coverage"
WITH_COVERAGE_DEPTH = "with-coverage-depth"
SANITIZERS = [THREAD, ADDRESS, UNDEFINED, MEMORY]
DEBUG = "debug"
RELEASE = "release"
ORDINARY_DATABASE = "ordinary-database"
POLYMORPHIC_PARTS = "polymorphic-parts"
# Release and non-sanitizer build
RELEASE_NON_SANITIZED = False
# Build with any sanitizer
SANITIZED = False
def collect_build_flags(args):
global RELEASE_NON_SANITIZED
global SANITIZED
result = []
value = clickhouse_execute(
args, "SELECT value FROM system.build_options WHERE name = 'CXX_FLAGS'"
)
sanitize_flag_map = {
b"thread": BuildFlags.THREAD,
b"address": BuildFlags.ADDRESS,
b"undefined": BuildFlags.UNDEFINED,
b"memory": BuildFlags.MEMORY,
}
import re as _re
m = _re.search(rb"-fsanitize=([\w,]+)", value)
if m:
for san in m.group(1).split(b","):
flag = sanitize_flag_map.get(san)
if flag is not None:
result.append(flag)
SANITIZED = bool(result)
# WITH_COVERAGE / WITH_COVERAGE_DEPTH have dedicated rows in system.build_options;
# do not rely on CXX_FLAGS which uses FULL_CXX_FLAGS_NORMALIZED and may not include them.
with_coverage = clickhouse_execute(
args, "SELECT value FROM system.build_options WHERE name = 'WITH_COVERAGE'"
)
if with_coverage.strip() in (b"ON", b"1"):
result.append(BuildFlags.WITH_COVERAGE)
with_coverage_depth = clickhouse_execute(
args, "SELECT value FROM system.build_options WHERE name = 'WITH_COVERAGE_DEPTH'"
)
if with_coverage_depth.strip() in (b"ON", b"1"):
result.append(BuildFlags.WITH_COVERAGE_DEPTH)
value = clickhouse_execute(
args, "SELECT value FROM system.build_options WHERE name = 'BUILD_TYPE'"
)
if b"Debug" in value:
result.append(BuildFlags.DEBUG)
elif b"RelWithDebInfo" in value or b"Release" in value:
result.append(BuildFlags.RELEASE)
RELEASE_NON_SANITIZED = result == [BuildFlags.RELEASE]
value = clickhouse_execute(
args,
"SELECT value FROM system.settings WHERE name = 'allow_deprecated_database_ordinary'",
)
if value == b"1" or args.db_engine == "Ordinary":
result.append(BuildFlags.ORDINARY_DATABASE)
value = int(
clickhouse_execute(
args,
"SELECT value FROM system.merge_tree_settings WHERE name = 'min_bytes_for_wide_part'",
)
)
if value == 0:
result.append(BuildFlags.POLYMORPHIC_PARTS)
use_flags = clickhouse_execute(
args,
"SELECT name FROM system.build_options WHERE name like 'USE_%' AND value in ('ON', '1')",
)
for use_flag in use_flags.strip().splitlines():
use_flag = use_flag.decode().lower()
result.append(use_flag)
system_processor = clickhouse_execute(
args,
"SELECT value FROM system.build_options WHERE name = 'SYSTEM_PROCESSOR' LIMIT 1",
).strip()
if system_processor:
result.append(f"cpu-{system_processor.decode().lower()}")
return result
def collect_changed_merge_tree_settings(args):
changed_settings = (
clickhouse_execute(
args,
"SELECT name FROM system.merge_tree_settings WHERE changed",
)
.strip()
.splitlines()
)
return list(map(lambda s: s.decode(), changed_settings))
def check_table_column(args, database, table, column):
return (
int(
clickhouse_execute(
args,
f"""
SELECT count()
FROM system.columns
WHERE database = '{database}' AND table = '{table}' AND name = '{column}'
""",
)
)
> 0
)
def get_hostname(args):
return clickhouse_execute(args, "SELECT hostname()").decode("utf-8")
def suite_key_func(item: str) -> Union[float, Tuple[int, str]]:
if args.order == "random":
return random.random()
if -1 == item.find("_"):
return 99998, ""
prefix, suffix = item.split("_", 1)
try:
return int(prefix), suffix
except ValueError:
return 99997, ""
def extract_key(key: str) -> str:
return subprocess.getstatusoutput(
args.extract_from_config + " --try --config " + args.configserver + key
)[1]
def run_tests_process(*args_, **kwargs):
return run_tests_array(*args_, **kwargs)
def get_next_test_progress(progress_counter, total_tests: int) -> str:
width = len(str(total_tests))
with progress_counter.get_lock():
progress_counter.value += 1
current_test_number = progress_counter.value
return f"[{current_test_number:>{width}} / {total_tests}] "
def do_run_tests(
jobs,
test_suite: TestSuite,
args,
exit_code,
restarted_tests,
stop_testing,
runner_process_killed,
):
print(
"Found",
len(test_suite.parallel_tests),
"parallel tests and",
len(test_suite.sequential_tests),
"sequential tests",
)
total_tests = len(test_suite.sequential_tests) + len(test_suite.parallel_tests)
progress_counter = multiprocessing.Value('i', 0)
# Exit code a worker wants the whole run to stop with. A worker raises
# `StopTesting` inside its own process, so that exception never reaches the
# parent; the parent only sees `stop_testing`. The worker records its exit
# code here (before setting `stop_testing`) so the parent can re-raise with
# the same code instead of defaulting to "Server died". 0 means unset.
stop_exit_code = multiprocessing.Value('i', 0)
if test_suite.parallel_tests:
tests_n = len(test_suite.parallel_tests)
jobs = min(jobs, tests_n)
# If we don't do random shuffling then there will be always
# nearly the same groups of test suites running concurrently.
# Thus, if there is a test within group which appears to be broken
# then it will affect all other tests in a non-random form.
# So each time a bad test fails - other tests from the group will also fail
# and this process will be more or less stable.
# It makes it more difficult to detect real flaky tests,
# because the distribution and the amount
# of failures will be nearly the same for all tests from the group.
# TODO: add shuffle for sequential tests
if jobs > 1:
random.shuffle(test_suite.parallel_tests)
workers_to_shed = multiprocessing.Value('i', 0)
total_failures_shared = multiprocessing.Value('i', 0)
if args.no_self_parallel:
# Partition tests so that all copies of the same test go to the
# same worker. This prevents the same test from running
# concurrently on multiple workers, avoiding conflicts when tests
# use shared resources (tables with fixed names, files, etc.).
worker_queues: List[List[str]] = [[] for _ in range(jobs)]
for test in test_suite.parallel_tests:
worker_queues[stringhash(test) % jobs].append(test)
else:
manager = multiprocessing.Manager()
shared_queue = manager.list()
shared_queue.extend(test_suite.parallel_tests)
batch_size = len(test_suite.parallel_tests) // jobs
processes = []
for i in range(jobs):
if args.no_self_parallel:
queue = worker_queues[i]
queue_size = len(queue)
else:
queue = shared_queue
queue_size = batch_size
process = multiprocessing.Process(
target=run_tests_process,
args=(
(
queue,
queue_size,
test_suite,
True,
args,
exit_code,
stop_testing,
workers_to_shed,
restarted_tests,
i, # process index
total_failures_shared,
progress_counter,
total_tests,
stop_exit_code,
),
),
)
processes.append(process)
process.start()
# How often (seconds) to query server memory usage.
MEMORY_CHECK_INTERVAL = 5.0
# After shedding a worker, wait this long before the next check to give
# memory time to drop before potentially shedding another worker.
MEMORY_SHED_COOLDOWN = 30.0
# Fraction of max_server_memory_usage at which we start shedding workers.
MEMORY_PRESSURE_THRESHOLD = 0.8
last_memory_check = time()
# Set when the parent stops the run because the global time limit was
# reached, so the final `StopTesting` is raised with the benign
# `GLOBAL_TIME_LIMIT_EXIT_CODE` rather than the "Server died" code.
global_time_limit_reached = False
while processes:
sleep(0.1)
sys.stdout.flush()
# Periodically check memory pressure and reduce concurrency if needed.
# In normal mode workers share a single queue, so shedding one
# does not lose work. In --flaky-check mode each worker has its
# own queue, so its remaining tests will not run — acceptable under
# memory pressure.
now = time()
if now - last_memory_check >= MEMORY_CHECK_INTERVAL and len(processes) > 1:
last_memory_check = now
memory_fraction = get_server_memory_fraction(args)
if memory_fraction is not None and memory_fraction > MEMORY_PRESSURE_THRESHOLD:
# Cap outstanding shed requests so we never drain the pool
# to zero: only increment if the number of workers that will
# still be running after all pending sheds is greater than 1.
with workers_to_shed.get_lock():
if len(processes) - workers_to_shed.value > 1:
workers_to_shed.value += 1
print(
f"Server memory at {memory_fraction:.0%} of limit — "
f"signalling one worker to stop after its current test "
f"({len(processes)} tracked, {workers_to_shed.value} pending shed)"
)
# Use a longer cooldown so memory has time to drop before
# we consider shedding another worker.
last_memory_check = time() + MEMORY_SHED_COOLDOWN - MEMORY_CHECK_INTERVAL
# Stop the whole run once the global time limit is reached. This is
# the *expected* stop condition for the flaky and targeted checks,
# which rerun the selected tests until the time budget is exhausted -
# distinct from a server death or a hung check below. Only claim the
# time limit if nothing else has already requested a stop, so a real
# server death or `--max-failures` abort keeps precedence.
if (
args.stop_time
and time() > args.stop_time
and not stop_testing.is_set()
):
print("Global time limit reached - stopping all tests gracefully.")
# Ignore SIGTERM in the parent for the rest of the run: when we
# terminate the workers below, each worker's KeyboardInterrupt
# handler re-broadcasts SIGTERM to the whole process group via
# `stop_tests()` -> `killpg`. Without this guard that signal
# would hit the parent and exit it with 143 instead of the
# graceful `GLOBAL_TIME_LIMIT_EXIT_CODE` (turning an expected
# timeout back into a "Server died" report).
signal.signal(signal.SIGTERM, signal.SIG_IGN)
global_time_limit_reached = True
stop_testing.set()
# Periodically check the server for hangs
# and stop all processes in this case
if args.hung_check:
if not check_server_liveness(args):
print("Hung check failed: server is not responding")
print_c_stacktraces(args)
stop_testing.set()
if stop_testing.is_set():
# Mask SIGTERM during the cleanup: when we terminate a worker,
# its `except KeyboardInterrupt` handler in `run_tests_array`
# calls `stop_tests()`, which broadcasts SIGTERM to the whole
# process group via `cleanup_child_processes`. Without this
# mask the signal would reach us mid-join, `signal_handler`
# would raise `Terminated`, and we would exit with 128+15=143
# instead of `STOP_TESTING_EXIT_CODE` - causing the job-side
# parser to misclassify the run as "terminated unexpectedly"
# (`Result.Status.ERROR`) rather than synthesizing the
# `Server died` FAIL leaf that bugfix validation needs.
old_sigterm_handler = signal.signal(signal.SIGTERM, signal.SIG_IGN)
try:
print("Stopping tests, terminating all processes...")
# Wait briefly for test results to be written
sleep(5)
for p in processes:
if p.is_alive():
p.terminate()
# Wait for processes to exit, then force-kill stragglers
for p in processes:
p.join(timeout=10)
if p.is_alive():
print(f"Force killing process {p.name} (pid {p.pid})")
p.kill()
for p in processes:
p.join(timeout=5)
finally:
signal.signal(signal.SIGTERM, old_sigterm_handler)
if global_time_limit_reached:
raise StopTesting(
"global time limit reached",
exit_code=GLOBAL_TIME_LIMIT_EXIT_CODE,
)
# A worker that hit `--max-failures` or `--max-failures-chain`
# recorded its exit code in `stop_exit_code` before setting
# `stop_testing`. The server is alive and the failing tests are
# real (and already attributed), so report this with the worker's
# code, not "Server died".
if stop_exit_code.value != 0:
raise StopTesting(
"reached the --max-failures / --max-failures-chain limit",
exit_code=stop_exit_code.value,
)
raise StopTesting(
"test run was stopped (see earlier output for the cause)"
)
for p in processes[:]:
if not p.is_alive():
# Check if process was killed with exception
if p.exitcode is not None and p.exitcode != 0:
print(
f"ERROR: Process {p.name} was killed with exit code {p.exitcode}"
)
runner_process_killed.set()
processes.remove(p)
if test_suite.sequential_tests:
# Sequential runner shares the total_failures_shared counter with parallel workers
# so that --max-failures counts failures from both groups combined.
seq_total_failures = total_failures_shared if test_suite.parallel_tests else multiprocessing.Value('i', 0)
run_tests_array(
(
list(test_suite.sequential_tests),
len(test_suite.sequential_tests),
test_suite,
False,
args,
exit_code,
stop_testing,
multiprocessing.Value('i', 0), # sequential runner is never shed
restarted_tests,
jobs + 1,
seq_total_failures,
progress_counter,
total_tests,
stop_exit_code,
)
)
return total_tests
def is_transient_test_copy(case):
# `SharedEngineReplacer` writes a transient per-PID copy of a test next to the
# original while the test runs (see `SharedEngineReplacer.temp_file_path`), and
# removes it afterwards. Concurrent `clickhouse-test` invocations share one
# queries directory, so another one's suite scan (`os.listdir` in
# `get_selected_tests`) can observe such a copy and would otherwise schedule it as
# a real test — which then fails either as `FileNotFoundError` once the copy is
# gone or as `NO_REFERENCE` for the bogus name. These copies carry a dedicated
# marker (`SharedEngineReplacer.TEMP_FILE_MARKER`) that no real test name uses, so
# we recognize and skip them at discovery unambiguously, without inferring
# "transient copy" from the basename shape and thus without constraining the names
# of genuine tests.
return f".{SharedEngineReplacer.TEMP_FILE_MARKER}." in case
def is_test_from_dir(suite_dir, case):
case_file = os.path.join(suite_dir, case)
# We could also test for executable files (os.access(case_file, os.X_OK),
# but it interferes with 01610_client_spawn_editor.editor, which is invoked
# as a query editor in the test, and must be marked as executable.
return (
os.path.isfile(case_file)
and any(case_file.endswith(suppotred_ext) for suppotred_ext in TEST_FILE_EXTENSIONS)
and not is_transient_test_copy(case)
)
def removesuffix(text, *suffixes):
"""
Added in python 3.9
https://www.python.org/dev/peps/pep-0616/
This version can work with several possible suffixes
"""
for suffix in suffixes:
if suffix and text.endswith(suffix):
return text[: -len(suffix)]
return text
def reportCoverageFor(args, what, query, permissive=False):
value = clickhouse_execute(args, query).decode()
if value != "":
print(f"\nThe following {what} were not covered by tests:\n")
print(value)
print("\n")
return permissive
return True
# This is high-level coverage on per-component basis (functions, data types, etc.)
# Don't be confused with the code coverage.
def reportCoverage(args):
clickhouse_execute(args, "SYSTEM FLUSH LOGS")
return (
reportCoverageFor(
args,
"functions",
"""
SELECT name
FROM system.functions
WHERE NOT is_aggregate AND origin = 'System' AND alias_to = ''
AND name NOT IN
(
SELECT arrayJoin(used_functions) FROM system.query_log WHERE event_date >= yesterday()
)
ORDER BY name
""",
True,
)
and reportCoverageFor(
args,
"aggregate functions",
"""
SELECT name
FROM system.functions
WHERE is_aggregate AND origin = 'System' AND alias_to = ''
AND name NOT IN
(
SELECT arrayJoin(used_aggregate_functions) FROM system.query_log WHERE event_date >= yesterday()
)
ORDER BY name
""",
)
and reportCoverageFor(
args,
"aggregate function combinators",
"""
SELECT name
FROM system.aggregate_function_combinators
WHERE NOT is_internal
AND name NOT IN
(
SELECT arrayJoin(used_aggregate_function_combinators) FROM system.query_log WHERE event_date >= yesterday()
)
ORDER BY name
""",
)
and reportCoverageFor(
args,
"data type families",
"""
SELECT name
FROM system.data_type_families
WHERE alias_to = '' AND name NOT LIKE 'Interval%'
AND name NOT IN
(
SELECT arrayJoin(used_data_type_families) FROM system.query_log WHERE event_date >= yesterday()
)
ORDER BY name
""",
)
)
def reportLogStats(args):
clickhouse_execute(args, "SYSTEM FLUSH LOGS")
query = """
WITH
240 AS mins,
(
SELECT (count(), sum(length(toValidUTF8(message))))
FROM system.text_log
WHERE (now() - toIntervalMinute(mins)) < event_time
) AS total
SELECT
count() AS count,
round(count / (total.1), 3) AS `count_%`,
formatReadableSize(sum(length(toValidUTF8(message)))) AS size,
round(sum(length(toValidUTF8(message))) / (total.2), 3) AS `size_%`,
countDistinct(logger_name) AS uniq_loggers,
countDistinct(thread_id) AS uniq_threads,
groupArrayDistinct(toString(level)) AS levels,
round(sum(query_id = '') / count, 3) AS `background_%`,
message_format_string
FROM system.text_log
WHERE (now() - toIntervalMinute(mins)) < event_time
GROUP BY message_format_string
ORDER BY count DESC
LIMIT 100
FORMAT PrettySpaceNoEscapes
"""
value = clickhouse_execute(args, query).decode(errors="replace")
print("\nTop patterns of log messages:\n")
print(value)
print("\n")
query = """
WITH
240 AS mins
SELECT
count() AS count,
substr(replaceRegexpAll(toValidUTF8(message), '[^A-Za-z]+', ''), 1, 32) AS pattern,
substr(any(toValidUTF8(message)), 1, 256) as runtime_message,
any((extract(source_file, '/[a-zA-Z0-9_]+\\.[a-z]+'), source_line)) as line
FROM system.text_log
WHERE (now() - toIntervalMinute(mins)) < event_time AND message_format_string = ''
GROUP BY pattern
ORDER BY count DESC
LIMIT 30
FORMAT PrettySpaceNoEscapes
"""
value = clickhouse_execute(args, query).decode(errors="replace")
print("\nTop messages without format string (fmt::runtime):\n")
print(value)
print("\n")
query = """
SELECT message_format_string, count(), any(toValidUTF8(message)) AS any_message
FROM system.text_log
WHERE (now() - toIntervalMinute(240)) < event_time
AND (message NOT LIKE (replaceRegexpAll(message_format_string, '{[:.0-9dfx]*}', '%') AS s))
AND (message NOT LIKE concat('%Exception: ', s, '%'))
GROUP BY message_format_string ORDER BY count() DESC LIMIT 20 FORMAT PrettySpaceNoEscapes
"""
value = clickhouse_execute(args, query).decode(errors="replace")
print("\nTop messages not matching their format strings:\n")
print(value)
print("\n")
query = """
WITH ('', '({}) Keys: {}', '({}) {}', 'Aggregating', 'Became leader', 'Cleaning queue',
'Creating set.', 'Cyclic aliases', 'Detaching {}', 'Executing {}', 'Fire events: {}',
'Found part {}', 'Loaded queue', 'No sharding key', 'No tables', 'Query: {}',
'Removed', 'Removed part {}', 'Removing parts.', 'Request URI: {}', 'Sending part {}',
'Sent handshake', 'Starting {}', 'Will mimic {}', 'Writing to {}', 'dropIfEmpty',
'loadAll {}', '{} ({}:{})', '{} -> {}', '{} {}', '{}: {}', '{}%', 'Read object: {}',
'New segment: {}', 'Convert overflow', 'Division by zero', 'Files set to {}',
'Bytes set to {}', 'Numeric overflow', 'Invalid mode: {}',
'Write file: {}', 'Unable to parse JSONPath', 'Host is empty in S3 URI.', 'Expected end of line',
'inflate failed: {}{}', 'Center is not valid', 'Column ''{}'' is ambiguous', 'Cannot parse object', 'Invalid date: {}',
'There is no cache by name: {}', 'No part {} in table', '`{}` should be a String', 'There are duplicate id {}',
'Invalid replica name: {}', 'Unexpected value {} in enum', 'Unknown BSON type: {}', 'Point is not valid',
'Invalid qualified name: {}', 'INTO OUTFILE is not allowed', 'Arguments must not be NaN', 'Cell is not valid',
'brotli decode error{}', 'Invalid H3 index: {}', 'Too large node state size', 'No additional keys found.',
'Attempt to read after EOF.', 'Replication was stopped', '{} building file infos', 'Cannot parse uuid {}',
'Query was cancelled', 'Cancelled merging parts', 'Cancelled mutating parts', 'Log pulling is cancelled',
'Transaction was cancelled', 'Could not find table: {}', 'Table {} doesn''t exist',
'Database {} doesn''t exist', 'Dictionary ({}) not found', 'Unknown table function {}',
'Unknown format {}', 'Unknown explain kind ''{}''', 'Unknown setting {}', 'Unknown input format {}',
'Unknown identifier: ''{}''', 'User name is empty', 'Expected function, got: {}',
'Attempt to read after eof', 'String size is too big ({}), maximum: {}'
) AS known_short_messages
SELECT count() AS c, message_format_string, substr(any(toValidUTF8(message)), 1, 120),
min(if(length(regexpExtract(toValidUTF8(message), '(.*)\\([A-Z0-9_]+\\)')) as prefix_len > 0, prefix_len, length(toValidUTF8(message))) - 26 AS length_without_exception_boilerplate) AS min_length_without_exception_boilerplate
FROM system.text_log
WHERE (now() - toIntervalMinute(240)) < event_time
AND (length(message_format_string) < 16
OR (message ILIKE '%DB::Exception%' AND length_without_exception_boilerplate < 30))
AND message_format_string NOT IN known_short_messages
GROUP BY message_format_string ORDER BY c DESC LIMIT 50 FORMAT PrettySpaceNoEscapes
"""
value = clickhouse_execute(args, query).decode(errors="replace")
print("\nTop short messages:\n")
print(value)
print("\n")
query = """
SELECT max((freq, message_format_string)), level
FROM (SELECT count() / (SELECT count() FROM system.text_log
WHERE (now() - toIntervalMinute(240)) < event_time) AS freq,
min(level) AS level, message_format_string FROM system.text_log
WHERE (now() - toIntervalMinute(120)) < event_time
GROUP BY message_format_string ORDER BY freq DESC)
GROUP BY level
"""
value = clickhouse_execute(args, query).decode(errors="replace")
print("\nTop messages by level:\n")
print(value)
print("\n")
def try_get_skip_list(base_dir, name, remove_comment=True):
test_names_to_skip = []
skip_list_path = os.path.join(base_dir, name)
if not os.path.exists(skip_list_path):
return test_names_to_skip
with open(skip_list_path, "r", encoding="utf-8") as fd:
for line in fd.read().split("\n"):
if line == "" or line[0] == " ":
continue
if remove_comment:
line = line.split()[0].strip()
if line == "":
continue
test_names_to_skip.append(line.strip())
return test_names_to_skip
def main(args):
exit_code = multiprocessing.Value("i", 0)
stop_testing = multiprocessing.Event()
runner_process_killed = multiprocessing.Event()
multiprocessing_manager = multiprocessing.Manager()
restarted_tests = multiprocessing_manager.list()
tests_start_time = time()
if not check_server_started(args):
msg = "Server is not responding. Cannot execute 'SELECT 1' query."
if args.hung_check:
print(msg)
print_c_stacktraces(args)
raise TestException(msg)
if args.cloud:
# When client connects to cloud instance it connects to random node
# But some .sh tests do multiple connections and depend on executing queries on single node
# Special annotation should be enabled for sticky-routing
# https://docs.dp.clickhouse-dev.com/infrastructure/sticky-routing.html
# "annotations": {"clickhouse.com/sticky-routing-enabled": "true"}
hostname = get_hostname(args)
non_sticky_host = os.getenv("CLICKHOUSE_HOST")
host = hostname + ".sticky." + os.getenv("CLICKHOUSE_HOST")
os.environ["CLICKHOUSE_HOST"] = host
os.environ["CLICKHOUSE_HOST_STICKY"] = host
os.environ["CLICKHOUSE_HOST_NON_STICKY"] = non_sticky_host
print("Sticky host:", host)
user = os.getenv("CLICKHOUSE_CLIENT_USER")
if user is None:
raise ConfigException("CLICKHOUSE_CLIENT_USER env is not set")
password = os.getenv("CLICKHOUSE_CLIENT_PASSWORD")
if password is None:
raise ConfigException("CLICKHOUSE_CLIENT_PASSWORD env is not set")
config = f"""<config>
<user>{user}</user>
<password>{password}</password>
<secure>true</secure>
</config>
"""
client_config_path = os.path.abspath("clickhouse-client.xml")
with open(client_config_path, "w", encoding="utf-8") as f:
f.write(config)
os.environ["CLICKHOUSE_CONFIG_CLIENT"] = client_config_path
args.configclient = client_config_path
# Wait until the sticky host is accessible.
args_sticky = copy.deepcopy(args)
args_sticky.tcp_host = host
args_sticky.client += f" --host={host}"
# Recommended time to wait - 10 minutes
args_sticky.server_check_retries = 1500
if not check_server_started(args_sticky):
raise TestException(f"Sticky host '{host}' not responding")
args.build_flags = collect_build_flags(args)
args.changed_merge_tree_settings = collect_changed_merge_tree_settings(args)
print(
f"build_flags={args.build_flags} "
f"collect_per_test_coverage={args.collect_per_test_coverage} "
f"coverage_active={args.collect_per_test_coverage and BuildFlags.WITH_COVERAGE_DEPTH in args.build_flags}"
)
if args.collect_per_test_coverage and BuildFlags.WITH_COVERAGE_DEPTH not in args.build_flags:
raise RuntimeError(
"--collect-per-test-coverage is enabled but the binary was not built with "
"WITH_COVERAGE_DEPTH=ON. Per-test coverage collection will not work. "
"Make sure the binary is built with -DWITH_COVERAGE_DEPTH=ON."
)
if BuildFlags.WITH_COVERAGE_DEPTH in args.build_flags and not args.collect_per_test_coverage:
raise RuntimeError(
"Binary is built with WITH_COVERAGE_DEPTH=ON but --collect-per-test-coverage "
"is disabled. Per-test coverage data will not be collected. "
"Pass --collect-per-test-coverage to enable collection."
)
if args.s3_storage and (BuildFlags.RELEASE not in args.build_flags):
args.no_random_settings = True
if args.skip:
args.skip = set(args.skip)
if args.replace_replicated_with_shared:
if not args.skip:
args.skip = set([])
args.skip = set(args.skip)
base_dir = os.path.abspath(args.queries)
# Keep same default values as in queries/shell_config.sh
os.environ.setdefault("CLICKHOUSE_BINARY", args.binary)
# os.environ.setdefault("CLICKHOUSE_CLIENT", args.client)
os.environ.setdefault("CLICKHOUSE_CONFIG", args.configserver)
if args.configclient:
os.environ.setdefault("CLICKHOUSE_CONFIG_CLIENT", args.configclient)
# Force to print server warnings in stderr
# Shell scripts could change logging level
os.environ.setdefault("CLICKHOUSE_CLIENT_SERVER_LOGS_LEVEL", args.server_logs_level)
# This code is bad as the time is not monotonic
args.stop_time = None
if args.global_time_limit:
args.stop_time = time() + args.global_time_limit
if args.zookeeper is None:
args.zookeeper = True
if args.shard is None:
args.shard = bool(extract_key(' --key listen_host | grep -E "127.0.0.2|::"'))
def create_common_database(args, db_name):
create_database_retries = 0
last_err = None
while create_database_retries < MAX_RETRIES:
start_time = datetime.now()
try:
clickhouse_execute(
args,
f"CREATE DATABASE IF NOT EXISTS {db_name} "
f"{get_db_engine(args, db_name)}",
settings=get_create_database_settings(args, None),
)
return
except HTTPError as e:
last_err = e
total_time = (datetime.now() - start_time).total_seconds()
if not need_retry(args, e.message, e.message, total_time):
break
create_database_retries += 1
if last_err:
raise RuntimeError(f"Failed to create database {db_name} after {MAX_RETRIES} attempts") from last_err
try:
if args.database and args.database != "test":
create_common_database(args, args.database)
else:
create_common_database(args, "test")
except Exception as e:
print(f"Failed to create databases for tests: {e}")
stop_testing.set()
raise StopTesting(f"Failed to create databases for tests: {e}") from e
if (
args.collect_per_test_coverage
and BuildFlags.WITH_COVERAGE_DEPTH in args.build_flags
):
clickhouse_execute(
args,
"""CREATE TABLE IF NOT EXISTS system.coverage_log
(
time DateTime,
test_name LowCardinality(String),
file LowCardinality(String),
line_start UInt32,
line_end UInt32,
min_depth UInt8 DEFAULT 255,
branch_flag UInt8 DEFAULT 0,
INDEX file_bloom file TYPE bloom_filter GRANULARITY 1,
INDEX line_minmax line_start TYPE minmax GRANULARITY 1
)
ENGINE = ReplacingMergeTree(time)
ORDER BY (test_name, file, line_start, line_end)
COMMENT 'Contains information about per-test coverage from the CI, but used only for exporting to the CI cluster'""",
)
clickhouse_execute(
args,
"""CREATE TABLE IF NOT EXISTS system.coverage_indirect_calls
(
test_name LowCardinality(String),
callee_offset UInt64,
caller_name_hash UInt64,
caller_func_hash UInt64,
call_count UInt64
)
ENGINE = ReplacingMergeTree(call_count)
ORDER BY (test_name, callee_offset)
COMMENT 'Indirect (virtual/function-pointer) call observations per test for coverage-based test selection'""",
)
# Self-test: verify the coverage collection pipeline works end-to-end.
clickhouse_execute(args, "SYSTEM SET COVERAGE TEST '_selftest'")
# Diagnostic: check coverage map size and covered NameRefs via SQL
try:
diag = clickhouse_execute(
args, "SELECT coverageDiag()"
).decode(errors="replace").strip()
print(f"Coverage diagnostic (before self-test): coverageDiag={diag} "
f"(name_refs, map_size, matches, non_empty_file_regions, zero_line_regions, first_file_len_and_line)")
except Exception as e:
print(f"Coverage diagnostic: coverageDiag() failed: {e}")
clickhouse_execute(args, "SELECT 1")
clickhouse_execute(args, "SYSTEM SET COVERAGE TEST ''")
selftest_rows = int(
clickhouse_execute(
args,
"SELECT count() FROM system.coverage_log FINAL WHERE test_name = '_selftest'",
)
or 0
)
try:
diag2 = clickhouse_execute(
args, "SELECT coverageDiag()"
).decode(errors="replace").strip()
print(f"Coverage diagnostic (after self-test): coverageDiag={diag2} "
f"(name_refs, map_size, matches, non_empty_file_regions, zero_line_regions, first_file_len_and_line)")
except Exception:
pass
if selftest_rows == 0:
raise Exception(
"Coverage self-test FAILED: coverage_log has 0 rows for _selftest — "
"coverage mapping is not working (binary built without WITH_COVERAGE_DEPTH?)"
)
print(f"Coverage self-test: coverage_log rows for _selftest = {selftest_rows} (OK)")
# Validate that the coverage mapping parser produced no bogus line numbers.
# line_end > 50000 or line_end < line_start indicate cursor-misalignment bugs
# in the LLVM coverage region decoder (e.g. wrong kKindShift for BranchRegion).
try:
bogus_rows = int(
clickhouse_execute(
args,
"SELECT count() FROM system.coverage_log FINAL"
" WHERE test_name = '_selftest'"
" AND (line_end > 50000 OR line_end < line_start)",
)
or 0
)
if bogus_rows > 0:
sample = clickhouse_execute(
args,
"SELECT file, line_start, line_end FROM system.coverage_log FINAL"
" WHERE test_name = '_selftest'"
" AND (line_end > 50000 OR line_end < line_start)"
" LIMIT 5",
).decode(errors="replace").strip()
raise Exception(
f"Coverage region quality FAILED: {bogus_rows} bogus line numbers "
f"(line_end > 50000 or line_end < line_start) — "
f"coverage mapping parser has bugs.\nSample:\n{sample}"
)
print(f"Coverage region quality: bogus line numbers = 0 (OK)")
except Exception as e:
print(f"Coverage region quality check failed: {e}")
clickhouse_execute(
args, "DELETE FROM system.coverage_log WHERE test_name = '_selftest'"
)
total_tests_run = 0
cloud_skip_list = try_get_skip_list(base_dir, "../queries-no-cloud-tests.txt")
private_skip_list = try_get_skip_list(base_dir, "../queries-no-private-tests.txt")
skip_list = []
blacklist_check = []
blacklist_check.extend(try_get_skip_list(base_dir, "../analyzer_tech_debt.txt"))
if args.no_parallel_replicas is True:
blacklist = try_get_skip_list(base_dir, "../parallel_replicas_blacklist.txt")
blacklist_check.extend(blacklist)
if args.no_async_insert is True:
skip_list_async_inserts = try_get_skip_list(
base_dir, "../async_insert_blacklist.txt"
)
blacklist_check.extend(skip_list_async_inserts)
if os.getenv("CLOUD_ENABLE_PARALLEL_REPLICAS", "0") == "1":
private_skip_list_parallel_replicas = try_get_skip_list(
base_dir, "../queries-no-cloud-no-pr-tests.txt"
)
cloud_skip_list.extend(private_skip_list_parallel_replicas)
# Some unsupported SQL patterns hard to parse from test case file
# So test case is executed at first and if its exception is in the list,
# then skip this test
cloud_skip_errors_list = [
# SMT arguments restricted
"Explicit replica_name is specified in SharedMergeTree arguments",
# old engine syntax
"Expected two string literal arguments: zookeeper_path and replica_name",
# executable dicts are not supported
"Dictionary source of type `executable` is disabled",
# disable after statistics tests
# "Create table with statistics is now disabled",
"Moving tables between databases is not supported for Replicated engine. (NOT_IMPLEMENTED)",
"Incorrect ATTACH TABLE query for Atomic database engine.",
"Setting max_partitions_per_insert_block should not be changed.",
# cloud_mode=1 restricts index_granularity=0
"table cannot have index granularity (bytes) equals zero",
# make_distributed_plan add to constraints because it doesn't work yet
"Setting make_distributed_plan should not be changed"
]
if not args.shared_catalog:
cloud_skip_errors_list += [
# disable for SharedCatalog
"CREATE AS SELECT is not supported with Replicated databases",
# new message
"CREATE AS SELECT and POPULATE is not supported with Replicated databases",
]
if args.shared_catalog:
# In rare cases detach table is not finished and it is expected
# https://github.com/ClickHouse/clickhouse-private/issues/23651
cloud_skip_errors_list += ["Cannot attach table with UUID"]
if args.diagnose_random_settings:
diag_exit_code = run_diagnose_from_artifacts(args, stop_testing)
# Exit immediately: post-run checks below (hung check, coverage export,
# log stats, etc.) assume a completed test run and would surface spurious
# failures in diagnose mode. Any real diagnostics error is already in
# diag_exit_code.
sys.exit(diag_exit_code)
else:
diagnostics_dir = args.random_settings_diagnostics_dir
os.makedirs(diagnostics_dir, exist_ok=True)
diagnostics_path = get_random_settings_diagnostics_path(args)
with open(diagnostics_path, "w", encoding="utf-8"):
pass
for suite in sorted(os.listdir(base_dir), key=suite_key_func):
if stop_testing.is_set():
break
test_suite = TestSuite.read_test_suite(args, suite)
if test_suite is None:
continue
test_suite.skip_list = skip_list
test_suite.cloud_skip_list = cloud_skip_list
test_suite.private_skip_list = private_skip_list
test_suite.cloud_skip_errors_list = cloud_skip_errors_list
test_suite.blacklist_check = blacklist_check
total_tests_run += do_run_tests(
args.jobs,
test_suite,
args,
exit_code,
restarted_tests,
stop_testing,
runner_process_killed,
)
if runner_process_killed.is_set():
exit_code.value = 1
if args.hung_check:
# Some queries may execute in background for some time after test was finished. This is normal.
print("Checking the hung queries: ", end="")
hung_count = 0
try:
deadline = datetime.now() + timedelta(seconds=90)
while datetime.now() < deadline:
hung_count = get_processlist_size(args)
if hung_count == 0:
print(" done")
break
print(". ", end="")
except Exception as e:
print(
colored(
"\nHung check failed. Failed to get processlist size: " + str(e),
args,
"red",
attrs=["bold"],
)
)
exit_code.value = 1
processlist = ""
hung_check_failed = False
if hung_count > 0:
try:
processlist = get_processlist_with_stacktraces(args)
except Exception as e:
# The stacktrace query is expensive (JOIN + symbolization) and
# can time out under sanitizers. Re-check whether the queries
# are still running — if they finished in the meantime, it was
# not a real hang.
try:
hung_count = get_processlist_size(args)
except Exception:
hung_count = -1
if hung_count != 0:
print(
colored(
"\nHung check failed. Failed to get processlist with stacktraces: "
+ str(e),
args,
"red",
attrs=["bold"],
)
)
exit_code.value = 1
hung_check_failed = True
else:
print(
" queries finished while collecting stacktraces, not a hang"
)
if processlist:
print(
colored(
"\nFound hung queries in processlist:", args, "red", attrs=["bold"]
)
)
print(processlist.decode())
print(get_transactions_list(args))
# `processlist` above already shows per-thread stacks for
# every user query still running (system.processes JOIN
# system.stack_trace by query_id). print_sql_stacktraces
# here dumps the rest of system.stack_trace — BG threads
# with no query_id (merges, replication, schedule pool),
# which are the typical lock-holders when a user query is
# wedged but looks idle.
print_sql_stacktraces(args)
print_c_stacktraces(args)
exit_code.value = 1
elif not hung_check_failed:
print(colored("\nNo queries hung.", args, "green", attrs=["bold"]))
if len(restarted_tests) > 0:
print("\nSome tests were restarted:\n")
for test_result in restarted_tests:
print(f"\n{test_result.case_name:72}: ")
# replace it with lowercase to avoid parsing retried tests as failed
for status in TestStatus:
test_result.description = test_result.description.replace(
status.value, status.value.lower()
)
print(test_result.description)
if total_tests_run == 0:
print("No tests were run.")
sys.exit(1)
else:
print("All tests have finished.")
if args.collect_per_test_coverage and BuildFlags.WITH_COVERAGE_DEPTH in args.build_flags:
try:
# Flush the last test's coverage into system.coverage_log.
clickhouse_execute(args, "SYSTEM SET COVERAGE TEST ''")
except Exception as e:
print("Cannot flush final coverage: ", str(e))
cov_rows = int(clickhouse_execute(args, "SELECT count() FROM system.coverage_log FINAL") or 0)
cov_tests = int(clickhouse_execute(args, "SELECT uniq(test_name) FROM system.coverage_log FINAL") or 0)
print(f"system.coverage_log: {cov_rows} rows, {cov_tests} distinct tests")
try:
ic_rows = int(
clickhouse_execute(
args,
"SELECT count() FROM system.coverage_indirect_calls FINAL",
)
or 0
)
ic_tests = int(
clickhouse_execute(
args,
"SELECT uniq(test_name) FROM system.coverage_indirect_calls FINAL",
)
or 0
)
ic_callees = int(
clickhouse_execute(
args,
"SELECT uniqExact(callee_offset) FROM system.coverage_indirect_calls FINAL",
)
or 0
)
ic_status = "OK" if ic_rows > 0 else "EMPTY - indirect call value profiling inactive"
print(
f"system.coverage_indirect_calls: {ic_rows} rows, {ic_tests} tests, "
f"{ic_callees} unique callees ({ic_status})"
)
except Exception as e:
print(f"Coverage indirect calls stats check failed: {e}")
try:
has_trace_log = int(clickhouse_execute(args, "EXISTS system.trace_log"))
except Exception as e:
print(f"Failed to check trace_log existence: {e}")
has_trace_log = 0
if has_trace_log:
try:
clickhouse_execute(args, "SYSTEM FLUSH LOGS system.trace_log")
unchecked_memory = int(clickhouse_execute(args, f"""
SELECT sum(size) FROM system.trace_log
WHERE
trace_type = 'MemoryAllocatedWithoutCheck'
AND event_date >= toDate(fromUnixTimestamp({int(tests_start_time)}))
AND event_time >= fromUnixTimestamp({int(tests_start_time)})
"""))
if unchecked_memory > 1e9:
print(f"""
Too much memory has been allocated without checking for memory limits: {unchecked_memory/1024/1024/1024} GiB.
Take a look at:
SELECT arrayStringConcat(symbols, '\\n'), sum(size)
FROM system.trace_log
WHERE trace_type = 'MemoryAllocatedWithoutCheck'
AND event_date >= toDate(fromUnixTimestamp({int(tests_start_time)}))
AND event_time >= fromUnixTimestamp({int(tests_start_time)})
GROUP BY 1
ORDER BY 2
LIMIT -10
FORMAT Vertical
See also at
- MemoryTrackerUntrackedAllocationsBlockerInThread
- AllocatorWithMemoryTracking
""")
except Exception as e:
print(f"Failed to get MemoryAllocatedWithoutCheck events from trace_log: {e}")
if args.report_logs_stats:
try:
reportLogStats(args)
except Exception as e:
print(f"Failed to get stats about log messages: {e}")
if args.report_coverage and not reportCoverage(args):
exit_code.value = 1
sys.exit(exit_code.value)
def find_binary(name):
def is_executable(path):
return os.access(path, os.X_OK) and os.path.isfile(path)
if is_executable(name):
return name
paths = os.environ.get("PATH").split(":")
for path in paths:
bin_path = os.path.join(path, name)
if is_executable(bin_path):
return bin_path
# maybe it wasn't in PATH
bin_path = os.path.join("/usr/local/bin", name)
if is_executable(bin_path):
return bin_path
bin_path = os.path.join("/usr/bin", name)
if is_executable(bin_path):
return bin_path
# Default binary path if source folder contains build
bin_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), f"../build/programs/{name}")
if is_executable(bin_path):
return bin_path
raise TestException(f"{name} was not found in PATH")
def find_clickhouse_command(binary, command):
symlink = binary + "-" + command
if os.access(symlink, os.X_OK):
return symlink
# To avoid requiring symlinks (in case you download binary from CI)
return binary + " " + command
def get_additional_client_options(args):
if args.client_option:
client_options = " ".join("--" + option for option in args.client_option)
if "CLICKHOUSE_CLIENT_OPT" in os.environ:
return os.environ["CLICKHOUSE_CLIENT_OPT"] + " " + client_options
return client_options
if "CLICKHOUSE_CLIENT_OPT" in os.environ:
return os.environ["CLICKHOUSE_CLIENT_OPT"]
return ""
def get_additional_client_options_url(args):
if args.client_option:
return "&".join(args.client_option)
return ""
def parse_args():
parser = ArgumentParser(description="ClickHouse functional tests")
parser.add_argument("-q", "--queries", help="Path to queries dir")
parser.add_argument("--tmp", help="Path to tmp dir")
parser.add_argument(
"-b",
"--binary",
default="clickhouse",
type=find_binary,
help="Path to clickhouse binary or name of binary in PATH",
)
parser.add_argument("--extract_from_config", help="extract-from-config program")
parser.add_argument(
"--configclient", help="Client config (if you do not use default ports)"
)
parser.add_argument(
"--configserver",
default="/etc/clickhouse-server/config.xml",
help="Preprocessed server config",
)
parser.add_argument(
"-o", "--output", help="Output xUnit compliant test report directory"
)
parser.add_argument(
"--memory-limit",
type=int,
default=0,
help="Memory limit for each test (in bytes)",
)
parser.add_argument(
"-t",
"--timeout",
type=int,
default=600,
help="Timeout for each test case in seconds",
)
parser.add_argument(
"--global_time_limit",
type=int,
help="Stop if executing more than specified time (after current test is finished)",
)
parser.add_argument("test", nargs="*", help="Optional test case name regex")
parser.add_argument(
"-d",
"--disabled",
action="store_true",
default=False,
help="Also run disabled tests",
)
parser.add_argument(
"--stop",
action="store_true",
default=None,
dest="stop",
help="Stop on network errors",
)
parser.add_argument(
"--order", default="desc", choices=["asc", "desc", "random"], help="Run order"
)
parser.add_argument(
"--testname",
action="store_true",
default=None,
dest="testname",
help="Make query with test name before test run",
)
parser.add_argument("--hung-check", action="store_true", default=False)
parser.add_argument("--no-left-queries-check", action="store_true", default=False)
parser.add_argument("--force-color", action="store_true", default=False)
parser.add_argument(
"--database", help="Database for tests (random name test_XXXXXX by default)"
)
parser.add_argument(
"--no-drop-if-fail",
action="store_true",
help="Do not drop database for test if test has failed",
)
parser.add_argument(
"--no-drop",
action="store_true",
help="Do not drop database",
)
parser.add_argument(
"--hide-db-name",
action="store_true",
help='Replace random database name with "default" in stderr',
)
parser.add_argument(
"--no-sequential", action="store_true", help="Not run no-parallel"
)
parser.add_argument(
"--no-parallel", action="store_true", help="Run only no-parallel"
)
parser.add_argument(
"-j", "--jobs", default=1, nargs="?", type=int, help="Run all tests in parallel"
)
parser.add_argument(
"--test-runs",
default=1,
nargs="?",
type=int,
help="Run each test many times (run is considered as flaky check)",
)
parser.add_argument(
"--long-test-runs-ratio",
default=0.1,
nargs="?",
type=float,
help="Ratio from --test-runs for long tests",
)
parser.add_argument(
"--sequential-test-runs",
default=None,
nargs="?",
type=int,
help="Number of runs for sequential (no-parallel) and no-flaky-check tests. "
"Defaults to half of --test-runs.",
)
parser.add_argument(
"--repeat-newly-modified-tests",
action="store_true",
default=False,
help="Repeat recently added tests multiple times with different randomized settings. "
"Tests are sorted by name descending; repetitions = int(sqrt(100 / rank)). "
"Only effective when randomized settings are enabled.",
)
parser.add_argument(
"-U",
"--unified",
default=10,
type=int,
help="output NUM lines of unified context",
)
parser.add_argument(
"-r",
"--server-check-retries",
default=180,
type=int,
help="Num of tries to execute SELECT 1 before tests started",
)
parser.add_argument("--db-engine", help="Database engine name")
parser.add_argument(
"--replicated-database",
action="store_true",
default=False,
help="Run tests with Replicated database engine",
)
parser.add_argument(
"--fast-tests-only",
action="store_true",
default=False,
help='Run only fast tests (the tests without the "no-fasttest" tag)',
)
parser.add_argument(
"--no-stateless", action="store_true", help="Disable all stateless tests"
)
parser.add_argument(
"--no-stateful", action="store_true", help="Disable all stateful tests"
)
parser.add_argument("--skip", nargs="+", help="Skip these tests")
parser.add_argument(
"--sequential",
nargs="+",
help="Run all tests sequentially",
)
parser.add_argument(
"--no-long", action="store_true", dest="no_long", help="Do not run long tests"
)
parser.add_argument(
"--record",
action="store_true",
default=False,
help="Automatically update reference files when stdout differs from reference",
)
parser.add_argument(
"--client-option", nargs="+", help="Specify additional client argument"
)
parser.add_argument(
"--check-zookeeper-session",
action="store_true",
help="Check ZooKeeper session uptime to determine if failed test should be retried",
)
parser.add_argument(
"--s3-storage",
action="store_true",
default=False,
help="Run tests over s3 storage",
)
parser.add_argument(
"--distributed-cache",
action="store_true",
default=False,
help="Run tests with enabled distributed cache",
)
parser.add_argument(
"--azure-blob-storage",
action="store_true",
default=False,
help="Run tests over azure blob storage",
)
parser.add_argument(
"--no-random-settings",
action="store_true",
default=False,
help="Disable settings randomization",
)
parser.add_argument(
"--no-random-merge-tree-settings",
action="store_true",
default=False,
help="Disable MergeTree settings randomization",
)
parser.add_argument(
"--random-settings-diagnostics-dir",
type=str,
default=RANDOM_SETTINGS_DIAGNOSTICS_DEFAULT_DIR,
help="Directory for saving/reading randomized settings diagnostics artifacts "
f"(default: {RANDOM_SETTINGS_DIAGNOSTICS_DEFAULT_DIR})",
)
parser.add_argument(
"--diagnose-random-settings",
action="store_true",
default=False,
help="Read saved randomized settings artifacts and diagnose which specific settings caused each failure",
)
parser.add_argument(
"--diagnose-random-settings-step-time-budget",
type=int,
default=60,
help="Time budget in seconds for each diagnosis step (default: 60)",
)
parser.add_argument(
"--diagnose-random-settings-minimize-budget",
type=int,
default=300,
help="Time budget in seconds for the settings minimization phase (default: 300)",
)
parser.add_argument(
"--llvm-coverage",
action="store_true",
default=False,
help="This run will be used to collect code coverage with llvm-cov",
)
parser.add_argument(
"--excluded-from-llvm",
action="store_true",
default=False,
help="Run only tests that are normally disabled under LLVM coverage (tagged no-llvm-coverage)",
)
# Explicit, non-random settings (copy-paste friendly)
parser.add_argument(
"--settings",
type=str,
help='Explicit ClickHouse settings to use. Paste the CI blob inside quotes, e.g.: '
'--settings "--max_threads 1 --fsync_metadata 1 ..."',
)
parser.add_argument(
"--settings-file",
type=str,
help="Path to a file containing ONLY the inner space-separated settings blob "
'(e.g. "--max_threads 1 --fsync_metadata 1 ...").',
)
parser.add_argument(
"--merge-tree-settings",
type=str,
help='Explicit MergeTree settings to use. Paste the CI blob inside quotes, e.g.: '
'--merge-tree-settings "--index_granularity 8192 ..."',
)
parser.add_argument(
"--merge-tree-settings-file",
type=str,
help="Path to a file containing ONLY the inner space-separated MergeTree settings blob."
)
parser.add_argument(
"--run-by-hash-num",
type=int,
help="Run tests matching crc32(test_name) % run_by_hash_total == run_by_hash_num",
)
parser.add_argument(
"--run-by-hash-total",
type=int,
help="Total test groups for crc32(test_name) % run_by_hash_total == run_by_hash_num",
)
parser.add_argument(
"--show-whitespaces-in-diff",
action="store_true",
help="Display $ characters after line with trailing whitespaces in diff output",
)
parser.add_argument(
"--encrypted-storage",
action="store_true",
default=False,
help="Run tests over encrypted storage",
)
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"--cloud",
action="store_true",
default=None,
dest="cloud",
help="Run only tests that are supported in ClickHouse Cloud environment",
)
group.add_argument(
"--no-cloud",
action="store_false",
default=None,
dest="cloud",
help="Run all the tests, including the ones not supported in ClickHouse Cloud environment",
)
parser.set_defaults(cloud=False)
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"--private",
action="store_true",
default=None,
dest="private",
help="Run only tests that are supported in the private build",
)
group.add_argument(
"--no-private",
action="store_false",
default=None,
dest="private",
help="Run all the tests, including the ones not supported in the private build",
)
# Only used to skip tests via "../queries-no-private-tests.txt", so it's fine to keep it enabled by default
parser.set_defaults(private=True)
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"--zookeeper",
action="store_true",
default=None,
dest="zookeeper",
help="Run zookeeper related tests",
)
group.add_argument(
"--no-zookeeper",
action="store_false",
default=None,
dest="zookeeper",
help="Do not run zookeeper related tests",
)
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"--shard",
action="store_true",
default=None,
dest="shard",
help="Run sharding related tests "
"(required to clickhouse-server listen 127.0.0.2 127.0.0.3)",
)
group.add_argument(
"--no-shard",
action="store_false",
default=None,
dest="shard",
help="Do not run shard related tests",
)
# TODO: Remove upgrade-check option after release 24.3 and use
# ignore_drop_queries_probability option in stress.py as in stress tests
group.add_argument(
"--upgrade-check",
action="store_true",
help="Run tests for further server upgrade testing by ignoring all "
"drop queries in tests for collecting data from new version of server",
)
parser.add_argument(
"--flaky-check",
action="store_true",
help="Enables CI flakiness detection by running new tests multiple times. This mode adds specific checks for the flaky-check job and requires the --test-runs argument.",
)
parser.add_argument(
"--no-self-parallel",
action="store_true",
help="Partition tests by name so the same test never runs concurrently on multiple workers. Use with --test-runs to avoid conflicts on shared resources.",
)
parser.add_argument(
"--stress-tests",
action="store_true",
help="A CI test configuration that runs all possible test combinations without validating results. This is used to bypass specific test restrictions.",
)
parser.add_argument(
"--secure",
action="store_true",
default=False,
help="Use secure connection to connect to clickhouse-server",
)
parser.add_argument(
"--max-failures-chain",
default=20,
type=int,
help="Max number of failed tests in a row (stop tests if higher)",
)
parser.add_argument(
"--max-failures",
default=0,
type=int,
help="Stop after this many total failures across all workers (0 = no limit)",
)
parser.add_argument(
"--report-coverage",
action="store_true",
default=False,
help="Check what high-level server components were covered by tests",
)
parser.add_argument(
"--collect-per-test-coverage",
action="store_true",
default=False,
help="Create `system.coverage_log` table on the server and collect information about low-level code coverage on a per test basis there",
)
parser.add_argument(
"--reset-coverage-before-every-test",
action="store_true",
default=True,
help="Collect isolated test coverage for every test instead of a cumulative. Useful only when tests are run sequentially.",
)
parser.add_argument(
"--report-logs-stats",
action="store_true",
default=False,
help="Report statistics about log messages",
)
parser.add_argument(
"--no-parallel-replicas",
action="store_true",
default=False,
help="Do not include tests that are not supported with parallel replicas feature",
)
parser.add_argument(
"--no-async-insert",
action="store_true",
default=False,
help="Do not include tests that are not supported with async insert feature",
)
parser.add_argument(
"--replace-replicated-with-shared",
action="store_true",
default=str_to_bool(os.environ.get("REPLACE_RMT_WITH_SMT", False)),
help="Replace ReplicatedMergeTree engine with SharedMergeTree",
)
parser.add_argument(
"--replace-non-replicated-with-shared",
action="store_true",
default=str_to_bool(os.environ.get("REPLACE_MT_WITH_SMT", False)),
help="Replace ordinary MergeTree engine with SharedMergeTree",
)
parser.add_argument(
"--replace-log-memory-with-mergetree",
action="store_true",
default=str_to_bool(os.environ.get("REPLACE_LOG_MEM_WITH_MT", False)),
help="Replace *Log and Memory engines with MergeTree",
)
parser.add_argument(
"--openssl-fips",
action="store_true",
default=False,
help="Do not include tests that fail in OpenSSL FIPS mode",
)
parser.add_argument(
"--shared-catalog",
action="store_true",
default=str_to_bool(os.environ.get("USE_SHARED_CATALOG", False)),
help="Run tests with Shared Catalog",
)
parser.add_argument(
"--shared-catalog-stress",
action="store_true",
default=False,
help="Run tests stress tests with Shared Catalog",
)
parser.add_argument(
"--capture-client-stacktrace",
action="store_true",
help="Capture stacktraces from clickhouse-client/local on errors",
)
parser.add_argument(
"--trace", action="store_true", help="Capture various tracing info"
)
parser.add_argument(
"--server-logs-level",
# warning by default, error in cloud
default="error" if parser.parse_known_args()[0].cloud else "warning",
choices=[
"none",
"fatal",
"critical",
"error",
"warning",
"notice",
"info",
"debug",
"trace",
"test",
],
help="Changes the CH setting 'send_logs_level'",
)
parser.add_argument(
"--dont_retry_failures",
action="store_true",
help="Do not retry failed tests",
)
parser.add_argument(
"--cleanup",
action="store_true",
help="Kill all processes from the group pid file and exit (used by CI hooks for orphan cleanup)",
)
return parser.parse_args()
def cleanup_test_groups():
"""Kill all process groups recorded in per-worker PGID files.
"""
for _pgid_file in _GROUP_PID_PATH.glob(f"{_GROUP_PID_NAME}.*"):
if _pgid_file.name.endswith(".tmp"):
continue
try:
pgid = int(_pgid_file.read_text())
kill_process_group(pgid, None)
except Exception as e:
print(f"Warning: could not read {_pgid_file}: {e}", file=sys.stderr)
continue
finally:
_pgid_file.unlink(missing_ok=True)
class Terminated(KeyboardInterrupt):
def __init__(self, signal):
self.signal = signal
def signal_handler(signal, frame):
raise Terminated(signal)
if __name__ == "__main__":
try:
args = parse_args()
except Exception as e:
print(e, file=sys.stderr)
sys.exit(1)
if args.cleanup:
cleanup_test_groups()
sys.exit(0)
# Move to a new process group so that signals from the controlling terminal
# (e.g. Ctrl+C) do not accidentally reach our caller. If the caller already
# used start_new_session=True (as run_test() does), we are already a process
# group leader and setpgid would raise PermissionError — that is fine.
if os.getpid() != os.getpgid(0):
try:
os.setpgid(0, 0)
except PermissionError:
pass # already a session/process-group leader
check_name = os.environ.get("CHECK_NAME")
if check_name and "aarch" in check_name:
multiprocessing.set_start_method("spawn")
print(
'The multiprocessing start method is set to "spawn". '
"It is the default start method on macOS and should be tested in CI as well."
)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGHUP, signal_handler)
# When using --record, disable engine replacement to ensure we record
# the original test's output, not a modified version
if args.record:
if args.replace_replicated_with_shared or args.replace_non_replicated_with_shared:
print("INFO: Disabling engine replacement (--replace-replicated-with-shared, --replace-non-replicated-with-shared)", file=sys.stderr)
print(" when using --record to ensure reference files are updated with original test output.", file=sys.stderr)
args.replace_replicated_with_shared = False
args.replace_non_replicated_with_shared = False
# Collect explicit settings (if any)
args.fixed_settings = {}
args.fixed_merge_tree_settings = {}
try:
if getattr(args, "settings_file", None):
args.fixed_settings.update(
load_settings_from_file(args.settings_file, kind="settings")
)
if getattr(args, "settings", None):
args.fixed_settings.update(parse_settings_cli_blob(args.settings))
if getattr(args, "merge_tree_settings_file", None):
args.fixed_merge_tree_settings.update(
load_settings_from_file(args.merge_tree_settings_file, kind="merge-tree settings")
)
if getattr(args, "merge_tree_settings", None):
args.fixed_merge_tree_settings.update(
parse_settings_cli_blob(args.merge_tree_settings)
)
except Exception as _e:
print(f"Failed to parse provided settings: {_e}", file=sys.stderr)
sys.exit(1)
if args.queries and not os.path.isdir(args.queries):
print(
f"Cannot access the specified directory with queries ({args.queries})",
file=sys.stderr,
)
assert False, "No --queries provided"
CAPTURE_CLIENT_STACKTRACE = args.capture_client_stacktrace
# Autodetect the directory with queries if not specified
if args.queries is None:
args.queries = "queries"
if not os.path.isdir(args.queries):
# If we're running from the repo
args.queries = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "queries"
)
if not os.path.isdir(args.queries):
# Next we're going to try some system directories, don't write 'stdout' files into them.
if args.tmp is None:
args.tmp = "/tmp/datastore-test"
args.queries = "/usr/local/share/datastore-test/queries"
if not os.path.isdir(args.queries):
args.queries = "/usr/share/datastore-test/queries"
if not os.path.isdir(args.queries):
args.queries = "/repo/tests/queries"
if not os.path.isdir(args.queries):
print(
"Failed to detect path to the queries directory. Please specify it with "
"'--queries' option.",
file=sys.stderr,
)
sys.exit(1)
print("Using queries from '" + args.queries + "' directory")
if args.tmp is None:
args.tmp = args.queries
args.client = find_clickhouse_command(args.binary, "client")
if args.extract_from_config:
print(
"WARNING: --extract_from_config option is deprecated and will be removed in the future",
file=sys.stderr,
)
args.extract_from_config = find_clickhouse_command(
args.binary, "extract-from-config"
)
if args.configclient:
args.client += " --config-file=" + args.configclient
tcp_host = os.getenv("CLICKHOUSE_HOST")
if tcp_host is not None:
args.tcp_host = tcp_host
args.client += f" --host={tcp_host}"
else:
args.tcp_host = "localhost"
if args.cloud:
args.secure = True
tcp_port = os.getenv("CLICKHOUSE_PORT_TCP")
if tcp_port is not None:
args.tcp_port = int(tcp_port)
args.client += f" --port={tcp_port}"
else:
args.tcp_port = 9440 if args.secure else 9000
if args.secure:
os.environ["CLICKHOUSE_PORT_TCP"] = str(args.tcp_port)
http_port = os.getenv("CLICKHOUSE_PORT_HTTP")
if http_port is not None:
args.http_port = int(http_port)
else:
args.http_port = 8443 if args.secure else 8123
os.environ["CLICKHOUSE_PORT_HTTP"] = str(args.http_port)
if args.secure and os.getenv("CLICKHOUSE_PORT_HTTP_PROTO") is None:
os.environ["CLICKHOUSE_PORT_HTTP_PROTO"] = "https"
client_database = os.getenv("CLICKHOUSE_DATABASE")
if client_database is not None:
args.client += f" --database={client_database}"
args.client_database = client_database
else:
args.client_database = "default"
if args.upgrade_check:
args.client += " --fake-drop"
if args.client_option or args.secure:
# Set options for client
if "CLICKHOUSE_CLIENT_OPT" in os.environ:
os.environ["CLICKHOUSE_CLIENT_OPT"] += " "
else:
os.environ["CLICKHOUSE_CLIENT_OPT"] = ""
print('---------')
print(os.environ["CLICKHOUSE_CLIENT_OPT"])
print(get_additional_client_options(args))
os.environ["CLICKHOUSE_CLIENT_OPT"] += get_additional_client_options(args)
print('---------')
if args.secure:
os.environ["CLICKHOUSE_CLIENT_OPT"] += " --secure "
if args.cloud:
enable_analyzer = os.getenv("CLOUD_ENABLE_ANALYZER", "1")
enable_pr = os.getenv("CLOUD_ENABLE_PARALLEL_REPLICAS", "0")
print("analyzer:", enable_analyzer)
print("pr:", enable_pr)
query_params = {
# These two are enabled by default in dev env
"allow_experimental_analyzer": enable_analyzer,
"allow_experimental_parallel_reading_from_replicas": enable_pr,
"allow_suspicious_types_in_order_by": 1,
"distributed_ddl_output_mode": "none",
# DETACH TABLE is not allowed for Replicated databases.
# Use DETACH TABLE PERMANENTLY or SYSTEM RESTART REPLICA or
# set database_replicated_always_detach_permanently to 1.
"database_replicated_always_detach_permanently": 1,
# Break some tests
"async_insert_deduplicate": 0,
"insert_deduplicate": 0,
"alter_sync": 2,
"mutations_sync": 2,
# Test 00765_locate.sql
"function_locate_has_mysql_compatible_argument_order": 1,
"input_format_try_infer_datetimes_only_datetime64": "false",
# check setting if analyzer enabled, so we will test 2 outcomes
# it's behaviour doesn't depend on analyzer
"database_replicated_allow_replicated_engine_arguments": 0,
}
cloud_settings = " ".join([f"--{k}={v}" for k, v in query_params.items()])
os.environ["CLICKHOUSE_CLIENT_OPT"] = (
cloud_settings + os.environ["CLICKHOUSE_CLIENT_OPT"] + " "
)
args.replace_replicated_with_shared = False
args.replace_non_replicated_with_shared = False
args.server_logs_level = "error"
# Set options for curl
if "CLICKHOUSE_URL_PARAMS" in os.environ:
os.environ["CLICKHOUSE_URL_PARAMS"] += "&"
else:
os.environ["CLICKHOUSE_URL_PARAMS"] = ""
cloud_settings_query = "&".join([f"{k}={v}" for k, v in query_params.items()])
os.environ["CLICKHOUSE_URL_PARAMS"] += cloud_settings_query + "&"
client_options_query_str = get_additional_client_options_url(args)
args.client_options_query_str = client_options_query_str + "&"
args.client_options_query_str += os.environ["CLICKHOUSE_URL_PARAMS"]
os.environ["CLICKHOUSE_URL_PARAMS"] += client_options_query_str
else:
args.client_options_query_str = ""
# NOTE: The seeds of random-by-default mechanisms (keeper fault injection,
# etc.) are pinned under LLVM coverage so the set of branches exercised
# inside the server is stable run-to-run. They are pinned server-side, in
# the `default` profile (see `tests/config/users.d/coverage_fault_injection_seeds.xml`,
# installed by `tests/config/install.sh --llvm-coverage`), rather than as
# per-query client/URL settings. Injecting them per query made every
# statement re-apply them, which broke tests that switch to readonly mode
# mid-session (e.g. `01187_set_profile_as_setting`, `00543_...`): the
# follow-up statement failed with "Cannot modify '...' setting in readonly
# mode" instead of the expected behaviour. As session defaults they are not
# re-applied per statement, so readonly tests are unaffected.
if args.jobs is None:
args.jobs = multiprocessing.cpu_count()
if args.db_engine and args.db_engine == "Ordinary":
MESSAGES_TO_RETRY.append(" locking attempt on ")
if args.replace_replicated_with_shared:
args.s3_storage = True
if args.encrypted_storage and not args.s3_storage and not args.azure_blob_storage:
print(
"Disabling encrypted storage. To run tests with encrypted storage, either s3_storage or azure_blob_storage should be enabled."
)
args.encrypted_storage = False
try:
main(args)
except StopTesting as e:
print(f"Test run aborted: {e}", file=sys.stderr)
traceback.print_exc()
sys.exit(e.exit_code)
except Terminated as e:
print(f"Terminated with {e.signal} signal", file=sys.stderr)
sys.exit(128 + e.signal)
except KeyboardInterrupt:
print("Interrupted")
sys.exit(128 + signal.SIGINT)