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

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

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

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

569 lines
20 KiB
Python

#!/usr/bin/env python3
import html
import json
import os
from pathlib import Path
from praktika import Secret
from praktika.info import Info
from praktika.result import Result
from praktika.utils import Shell, Utils
from ci.jobs.scripts.server_cleanup import kill_leftover_server_processes
temp_dir = f"{Utils.cwd()}/ci/tmp/"
# Thresholds based on baseline run with Datastore 26.3 and Datastore-dialect queries.
# The DBA StackOverflow dataset with ~18K SQLStorm queries.
# With the PostgreSQL -> Datastore query rewriter, ~60% of queries succeed.
MIN_TOTAL_QUERIES = 18_000
MIN_SUCCESS_RATE = 0.50 # at least 50% queries should succeed
# Export of the server's `system.*_log` tables to the central CI logs cluster,
# matching the setup used by other checks (e.g. `clickbench`). Defined here to
# keep the values in sync with `ci/jobs/scripts/datastore_proc.py`.
LOG_EXPORT_CONFIG_TEMPLATE = """
remote_servers:
{CLICKHOUSE_CI_LOGS_CLUSTER}:
shard:
replica:
secure: 1
user: '{CLICKHOUSE_CI_LOGS_USER}'
host: '{CLICKHOUSE_CI_LOGS_HOST}'
port: 9440
password: '{CLICKHOUSE_CI_LOGS_PASSWORD}'
"""
CLICKHOUSE_CI_LOGS_CLUSTER = "system_logs_export"
CLICKHOUSE_CI_LOGS_USER = "ci"
class ClickHouseBinary:
def __init__(self):
self.path = temp_dir
self.config_path = f"{temp_dir}/config"
self.start_cmd = (
f"{self.path}/clickhouse-server --config-file={self.config_path}/config.xml"
)
self.log_file = f"{temp_dir}/server.log"
self.port = 9000
self.log_export_host, self.log_export_password = None, None
def install(self):
Utils.add_to_PATH(self.path)
commands = [
f"mkdir -p {self.config_path}/users.d",
f"cp ./programs/server/config.xml ./programs/server/users.xml {self.config_path}",
f"cp -r --dereference ./programs/server/config.d {self.config_path}",
f"chmod +x {self.path}/clickhouse",
f"ln -sf {self.path}/clickhouse {self.path}/clickhouse-server",
f"ln -sf {self.path}/clickhouse {self.path}/clickhouse-client",
]
res = True
for command in commands:
res = res and Shell.check(command, verbose=True)
return res
def start(self):
import subprocess
import time
print("Starting Datastore server")
print("Command: ", self.start_cmd)
kill_leftover_server_processes()
self.log_fd = open(self.log_file, "w")
self.proc = subprocess.Popen(
self.start_cmd, stderr=subprocess.STDOUT, stdout=self.log_fd, shell=True
)
time.sleep(2)
retcode = self.proc.poll()
if retcode is not None:
# `Popen` was started with `stdout=self.log_fd` and
# `stderr=subprocess.STDOUT`, so `self.proc.stdout/stderr` are always
# `None`. Read the tail of the log file instead so startup failures
# report actionable diagnostics.
log_tail = ""
try:
self.log_fd.flush()
with open(self.log_file, "r", errors="replace") as f:
log_tail = "".join(f.readlines()[-100:])
except OSError as e:
log_tail = f"(could not read {self.log_file}: {e})"
Utils.print_formatted_error(
f"Failed to start Datastore (exit code {retcode})",
log_tail,
"",
)
return False
print("Datastore server process started -> wait ready")
res = self.wait_ready()
if res:
print("Datastore server ready")
else:
print("Datastore server NOT ready")
return res
def wait_ready(self):
res, out, err = 0, "", ""
attempts = 30
delay = 2
for attempt in range(attempts):
res, out, err = Shell.get_res_stdout_stderr(
f'clickhouse-client --port {self.port} --query "select 1"', verbose=True
)
if out.strip() == "1":
print("Server ready")
break
else:
print("Server not ready, wait")
Utils.sleep(delay)
else:
Utils.print_formatted_error(
f"Server not ready after [{attempts*delay}s]", out, err
)
return False
return True
def create_log_export_config(self):
# Write the remote cluster definition into the server's own `config.d`
# so the `Distributed` engine used by the log senders can resolve the
# central CI logs cluster. Must run before the server is started.
print("Create log export config")
config_file = Path(self.config_path) / "config.d" / "system_logs_export.yaml"
config_file.parent.mkdir(parents=True, exist_ok=True)
# The log-replication materialized views are created with
# `DEFINER = ci_logs_sender`, so that user must exist on the server.
Shell.check(
f"mkdir -p {self.config_path}/users.d"
f" && cp ./tests/config/users.d/ci_logs_sender.yaml {self.config_path}/users.d/",
verbose=True,
strict=True,
)
self.log_export_host, self.log_export_password = (
Secret.Config(
name="clickhouse_ci_logs_host",
type=Secret.Type.AWS_SSM_PARAMETER,
region="us-east-1",
)
.join_with(
Secret.Config(
name="clickhouse_ci_logs_password",
type=Secret.Type.AWS_SSM_PARAMETER,
region="us-east-1",
)
)
.get_value()
)
config_content = LOG_EXPORT_CONFIG_TEMPLATE.format(
CLICKHOUSE_CI_LOGS_CLUSTER=CLICKHOUSE_CI_LOGS_CLUSTER,
CLICKHOUSE_CI_LOGS_HOST=self.log_export_host,
CLICKHOUSE_CI_LOGS_USER=CLICKHOUSE_CI_LOGS_USER,
CLICKHOUSE_CI_LOGS_PASSWORD=self.log_export_password,
)
with open(config_file, "w") as f:
f.write(config_content)
return True
def start_log_exports(self, check_start_time):
# Create the remote `system.*_log` tables and the materialized views
# that replicate freshly inserted rows to them. Must run after the
# server is ready and before the benchmark queries, so their
# `query_log` (and other) records are captured.
print("Start log export")
if self.log_export_host:
os.environ["CLICKHOUSE_CI_LOGS_CLUSTER"] = CLICKHOUSE_CI_LOGS_CLUSTER
os.environ["CLICKHOUSE_CI_LOGS_HOST"] = self.log_export_host
os.environ["CLICKHOUSE_CI_LOGS_USER"] = CLICKHOUSE_CI_LOGS_USER
os.environ["CLICKHOUSE_CI_LOGS_PASSWORD"] = self.log_export_password
info = Info()
os.environ["EXTRA_COLUMNS_EXPRESSION"] = (
f"toLowCardinality('{info.repo_name}') AS repo, CAST({info.pr_number} AS UInt32) AS pull_request_number, '{info.sha}' AS commit_sha, toDateTime('{Utils.timestamp_to_str(check_start_time)}', 'UTC') AS check_start_time, toLowCardinality('{info.job_name}') AS check_name, toLowCardinality('{info.instance_type}') AS instance_type, '{info.instance_id}' AS instance_id"
)
return Shell.check(
"./ci/jobs/scripts/functional_tests/setup_log_cluster.sh --setup-logs-replication",
verbose=True,
)
@staticmethod
def stop_log_exports():
# Flush any buffered system-log records so the final benchmark queries
# are exported, then detach the replication views.
Shell.check(
'clickhouse-client --query "SYSTEM FLUSH LOGS"',
verbose=True,
)
return Shell.check(
"./ci/jobs/scripts/functional_tests/setup_log_cluster.sh --stop-log-replication",
verbose=True,
)
def check_thresholds(stats):
"""Check that the benchmark results meet minimum thresholds."""
lines = []
passed = True
total = stats.get("total", 0)
success_rate = stats.get("success_rate", 0)
if total < MIN_TOTAL_QUERIES:
lines.append(
f"FAILED: total queries {total:,} < minimum {MIN_TOTAL_QUERIES:,}"
)
passed = False
else:
lines.append(f"OK: total queries {total:,} >= minimum {MIN_TOTAL_QUERIES:,}")
if success_rate < MIN_SUCCESS_RATE:
lines.append(
f"FAILED: success rate {success_rate:.1%} < minimum {MIN_SUCCESS_RATE:.1%}"
)
passed = False
else:
lines.append(
f"OK: success rate {success_rate:.1%} >= minimum {MIN_SUCCESS_RATE:.1%}"
)
info = "; ".join(lines)
return passed, info
def generate_html_report(stats, results, report_path, threshold_passed, threshold_info):
"""Generate an HTML report with benchmark results."""
reset_color = "</b>"
def color_tag(ratio):
if ratio == 0:
return "<b style='color: red;'>"
elif ratio < 0.5:
return "<b style='color: orange;'>"
elif ratio < 1:
return "<b style='color: gray;'>"
else:
return "<b style='color: green;'>"
with open(report_path, "w", encoding="utf-8") as f:
f.write(
"<html><body><pre style='font-size: 16pt; padding: 1em; line-height: 1.25;'>\n"
)
# Threshold status
if threshold_passed:
f.write(f"<b style='color: green;'>Thresholds: PASSED{reset_color}\n")
else:
f.write(f"<b style='color: red;'>Thresholds: FAILED{reset_color}\n")
f.write(f" {html.escape(threshold_info)}\n\n")
# Summary
total = stats["total"]
success = stats["success"]
timeout = stats["timeout"]
oom = stats["oom"]
errors = stats["errors"]
success_rate = stats["success_rate"]
f.write("<b>Summary</b>\n")
f.write(
f" Queries: {color_tag(success_rate)}"
f"{success:,} / {total:,} ({success_rate:.1%}){reset_color}\n"
)
f.write(f" Timeouts: {timeout:,}\n")
f.write(f" OOM: {oom:,}\n")
f.write(f" Errors: {errors:,}\n")
f.write(f" Median query time: {stats['median_ms']:.1f}ms\n")
f.write(f" Total query time: {stats['sum_ms']:.0f}ms\n\n")
# Error categories breakdown
error_categories = stats.get("error_categories", {})
if error_categories:
f.write("<b>Failure categories</b>\n")
for cat, count in sorted(error_categories.items(), key=lambda x: -x[1]):
pct = 100.0 * count / (total - success) if (total - success) > 0 else 0
f.write(
f" <b style='color: red;'>{count:>6,}</b>"
f" {pct:5.1f}% {html.escape(cat)}\n"
)
f.write("\n")
# Slowest successful queries
success_results = [r for r in results if r["state"] == "success"]
if success_results:
slowest = sorted(success_results, key=lambda r: -r["client_total_ms"])[:20]
f.write("<b>Slowest successful queries (top 20)</b>\n")
for r in slowest:
f.write(
f" {r['client_total_ms']:>10.1f}ms query {html.escape(r['query'])}\n"
)
f.write("\n")
# Sample errors (up to 5 per category)
if error_categories:
f.write("<b>Sample errors</b>\n")
shown_per_cat = {}
for r in results:
if r["state"] == "success":
continue
cat = r["state"]
shown_per_cat.setdefault(cat, 0)
if shown_per_cat[cat] >= 5:
continue
shown_per_cat[cat] += 1
msg = r["message"][:200].replace("\n", " ")
if len(r["message"]) > 200:
msg += "..."
f.write(
f" [{html.escape(cat)}] query {html.escape(r['query'])}: "
f"<span style='color: gray;'>{html.escape(msg)}</span>\n"
)
f.write("\n")
f.write("</pre></body></html>\n")
print(f"HTML report written to {report_path}")
def main():
results = []
stop_watch = Utils.Stopwatch()
ch = ClickHouseBinary()
info = Info()
sqlstorm_dir = os.path.join(Utils.cwd(), "tests", "sqlstorm")
sqlstorm_repo = os.path.join(temp_dir, "sqlstorm")
olapbench_repo = os.path.join(temp_dir, "olapbench")
data_dir = os.path.join(temp_dir, "sqlstorm_data")
out_dir = os.path.join(temp_dir, "sqlstorm_output")
os.makedirs(out_dir, exist_ok=True)
os.makedirs(data_dir, exist_ok=True)
# Step 0: Run the query-rewriter unit tests so they gate this PR and any
# future edits to the rewriter (the benchmark below would otherwise pass
# without ever exercising the focused regression tests).
results.append(
Result.from_commands_run(
name="Rewriter unit tests",
command=f"python3 {sqlstorm_dir}/test_rewrite_queries.py",
)
)
# Step 1: Start Datastore
print("Start Datastore")
def start():
if not ch.install():
return False
# Configure export of system log tables to the central CI logs cluster
# (skipped for local runs, where the credentials are not available).
if not info.is_local_run:
ch.create_log_export_config()
if not ch.start():
return False
if not info.is_local_run:
if not ch.start_log_exports(check_start_time=stop_watch.start_time):
print("WARNING: Failed to start log export")
return True
results.append(
Result.from_commands_run(
name="Start Datastore",
command=start,
)
)
# Step 2: Clone SQLStorm repo (for queries)
if results[-1].is_ok():
print("Clone SQLStorm repo")
# Use the ClickHouse fork with queries rewritten for Datastore dialect.
# Pin to an immutable commit SHA so reruns are reproducible and historical
# failures remain debuggable, matching the pattern used for `olapbench_commit`.
sqlstorm_commit = "31952876e628817190c8a787a21afea08ca2bf87"
def clone_sqlstorm():
if not Path(sqlstorm_repo).is_dir():
if not Shell.check(
f"git clone https://github.com/ClickHouse/SQLStorm.git {sqlstorm_repo}",
verbose=True,
):
return False
else:
if not Shell.check(
f"git -C {sqlstorm_repo} fetch origin",
verbose=True,
):
return False
return Shell.check(
f"git -C {sqlstorm_repo} checkout {sqlstorm_commit}",
verbose=True,
)
results.append(
Result.from_commands_run(
name="Clone SQLStorm repo",
command=clone_sqlstorm,
)
)
# Step 3: Clone OLAPBench repo (for schema definition)
if results[-1].is_ok():
print("Clone OLAPBench repo")
olapbench_commit = "0e23df49491e17b1f38b95da6bc803812d5b5a97"
def clone_olapbench():
if not Path(olapbench_repo).is_dir():
if not Shell.check(
f"git clone https://github.com/SQL-Storm/OLAPBench.git {olapbench_repo}",
verbose=True,
):
return False
else:
if not Shell.check(
f"git -C {olapbench_repo} fetch origin",
verbose=True,
):
return False
return Shell.check(
f"git -C {olapbench_repo} checkout {olapbench_commit}",
verbose=True,
)
results.append(
Result.from_commands_run(
name="Clone OLAPBench repo",
command=clone_olapbench,
)
)
# Step 4: Download StackOverflow DBA dataset
if results[-1].is_ok():
print("Download StackOverflow DBA dataset")
data_url = "https://db.in.tum.de/~schmidt/data/stackoverflow_dba.tar.gz"
data_sha256 = "a80c23a6ddf7699641d89554114d0125ee47e83f5be80ad81ac5ea5c7682acf2"
data_archive = os.path.join(data_dir, "stackoverflow_dba.tar.gz")
extracted_dir = os.path.join(data_dir, "stackoverflow_dba")
def download_data():
if os.path.isdir(extracted_dir) and os.listdir(extracted_dir):
print("Data already downloaded, skipping")
return True
if not Shell.check(
f"wget -q -O {data_archive} {data_url}",
verbose=True,
):
return False
if not Shell.check(
f"echo '{data_sha256} {data_archive}' | sha256sum -c",
verbose=True,
):
print("Checksum verification failed!")
return False
if not Shell.check(
f"tar -xzf {data_archive} -C {data_dir}",
verbose=True,
):
return False
# Clean up archive to save disk space
os.remove(data_archive)
return True
results.append(
Result.from_commands_run(
name="Download dataset",
command=download_data,
)
)
# Step 5: Run benchmark
query_results = []
stats = {}
if results[-1].is_ok():
print("Run SQLStorm benchmark")
schema_path = os.path.join(
olapbench_repo, "benchmarks", "stackoverflow", "stackoverflow.dbschema.json"
)
query_dir = os.path.join(sqlstorm_repo, "v1.0", "stackoverflow", "queries")
def run_benchmark():
nonlocal query_results, stats
# Run the benchmark; its exit code is ignored on purpose, the
# thresholds are evaluated separately from the loaded results.
Shell.check(
f"python3 {sqlstorm_dir}/runner.py"
f" --query-dir {query_dir}"
f" --data-dir {extracted_dir}"
f" --schema {schema_path}"
f" --out-dir {out_dir}"
f" --timeout 10"
f" --port {ch.port}",
verbose=True,
)
# Load results regardless of exit code
results_path = os.path.join(out_dir, "results.json")
stats_path = os.path.join(out_dir, "stats.json")
if os.path.isfile(results_path):
with open(results_path) as f:
query_results = json.load(f)
if os.path.isfile(stats_path):
with open(stats_path) as f:
stats = json.load(f)
return True # always succeed - thresholds are checked separately
results.append(
Result.from_commands_run(
name="Run benchmark",
command=run_benchmark,
)
)
# Step 6: Generate report and check thresholds
report_html_path = os.path.join(out_dir, "report.html")
threshold_info = ""
if results[-1].is_ok():
print("Generate report and check thresholds")
def report_and_thresholds():
nonlocal threshold_info
effective_stats = stats if stats else {
"total": 0, "success": 0, "timeout": 0, "oom": 0,
"errors": 0, "success_rate": 0, "median_ms": 0, "sum_ms": 0,
"error_categories": {},
}
threshold_ok, threshold_info = check_thresholds(effective_stats)
generate_html_report(
effective_stats, query_results, report_html_path,
threshold_ok, threshold_info,
)
return threshold_ok
results.append(
Result.from_commands_run(
name="Report & Thresholds",
command=report_and_thresholds,
)
)
# Detach log replication (flushes the remaining records first).
if not info.is_local_run:
ch.stop_log_exports()
Result.create_from(
results=results,
stopwatch=stop_watch,
files=[report_html_path] if os.path.isfile(report_html_path) else [],
info=threshold_info,
).complete_job()
if __name__ == "__main__":
main()