#!/usr/bin/env python3 """ Local benchmark for ANALYZE TABLE histogram algorithms. The script creates a synthetic FUSE table, runs ANALYZE TABLE WITH HISTOGRAM with the window, KLL fast, and KLL full algorithms, samples databend-query RSS while ANALYZE is running, and compares histogram-based cardinality estimates with exact counts. It is intended for local experiments and is not part of the CI test suite. """ from __future__ import annotations import argparse import json import math import os import re import shlex import subprocess import sys import threading import time from collections.abc import Callable from dataclasses import asdict from dataclasses import dataclass from pathlib import Path from typing import Any sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.bench_common.bendsql import BendSQL, QueryResult, csv_records DEFAULT_LOCAL_DSN = "databend://root:@127.0.0.1:38000?sslmode=disable" HISTOGRAM_RE = re.compile( r'\[bucket id: (?P\d+), min: "(?P.*?)", max: "(?P.*?)", ' r"ndv: (?P[-+0-9.eE]+), count: (?P[-+0-9.eE]+)\]" ) @dataclass(frozen=True) class Bucket: bucket_id: int lower: float upper: float ndv: float count: float @dataclass(frozen=True) class Probe: column: str kind: str lower: int upper: int | None = None @property def label(self) -> str: if self.kind == "eq": return f"{self.column} = {self.lower}" return f"{self.column} BETWEEN {self.lower} AND {self.upper}" @property def sql_predicate(self) -> str: column = quote_ident(self.column) if self.kind == "eq": return f"{column} = {self.lower}" assert self.upper is not None return f"{column} BETWEEN {self.lower} AND {self.upper}" @dataclass class AnalyzeResult: algorithm: str elapsed_sec: float query_pids: list[int] rss_baseline_kb: int | None rss_peak_kb: int | None rss_delta_kb: int | None @dataclass class AccuracyResult: algorithm: str column: str distribution: str predicate_kind: str predicate: str exact_count: float estimated_count: float absolute_error: float q_error: float relative_error: float | None def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Compare ANALYZE histogram algorithm cost and accuracy." ) parser.add_argument("--dsn", default=os.getenv("BENDSQL_DSN"), help="bendsql DSN") parser.add_argument("--bendsql", default=os.getenv("BENDSQL", "bendsql")) parser.add_argument("--database", default="tmp_analyze_histogram_bench") parser.add_argument("--table", default="t") parser.add_argument("--rows", type=int, default=5_000_000) parser.add_argument("--batch-rows", type=int, default=500_000) parser.add_argument("--domain", type=int, default=100_000) parser.add_argument( "--skew-percent", type=int, default=80, help="Percent of rows forced to skew_key = 0.", ) parser.add_argument("--max-threads", type=int, default=8) parser.add_argument("--kll-error-rate", type=float, default=0.01) parser.add_argument( "--algorithms", default="window,kll_fast,kll_full", help="Comma-separated analyze algorithms to run: window,kll_fast,kll_full.", ) parser.add_argument("--repeat", type=int, default=1) parser.add_argument("--sample-interval-sec", type=float, default=0.05) parser.add_argument( "--run-mode", choices=("sequential", "isolated-query"), default="sequential", help=( "sequential reuses the current query process; isolated-query starts a fresh " "databend-query process for setup and for each measured ANALYZE run." ), ) parser.add_argument( "--query-bin", default="target/debug/databend-query", help="databend-query binary used by --run-mode=isolated-query.", ) parser.add_argument( "--query-config", default="_data/local/databend-query.toml", help="databend-query config used by --run-mode=isolated-query.", ) parser.add_argument( "--query-ready-timeout-sec", type=float, default=60.0, help="Timeout while waiting for a managed databend-query process to accept SQL.", ) parser.add_argument( "--query-shutdown-timeout-sec", type=float, default=10.0, help="Grace period before killing a managed databend-query process.", ) parser.add_argument( "--query-log-dir", default="target/bench-results", help="Directory for managed databend-query stdout/stderr logs.", ) parser.add_argument( "--query-pid", action="append", type=int, default=[], help="databend-query PID to sample. Can be passed multiple times.", ) parser.add_argument( "--query-process-substring", default="databend-query", help="Used to auto-discover query PIDs when --query-pid is omitted.", ) parser.add_argument("--reuse-table", action="store_true") parser.add_argument("--keep", action="store_true", help="Do not drop database after run.") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--output", default=None, help="Write JSON result to this path.") return parser.parse_args() def quote_ident(name: str) -> str: return "`" + name.replace("`", "``") + "`" def fq_table(database: str, table: str) -> str: return f"{quote_ident(database)}.{quote_ident(table)}" def build_client(args: argparse.Namespace) -> BendSQL: if args.run_mode == "isolated-query" and not args.dsn: args.dsn = DEFAULT_LOCAL_DSN return BendSQL(args.bendsql, args.dsn, args.dry_run) def run_sql(client: BendSQL, sql: str, label: str, echo_stdout: bool = True) -> QueryResult: return client.run(sql, label, output="csv", echo_stdout=echo_stdout) def session_sql(args: argparse.Namespace, sql: str) -> str: return "\n".join( [ "SET enable_table_snapshot_stats = 1;", "SET enable_analyze_histogram = 1;", f"SET max_threads = {args.max_threads};", sql, ] ) def prepare_table(client: BendSQL, args: argparse.Namespace) -> None: table = fq_table(args.database, args.table) if not args.reuse_table: run_sql(client, f"DROP DATABASE IF EXISTS {quote_ident(args.database)}", "drop database") run_sql(client, f"CREATE DATABASE {quote_ident(args.database)}", "create database") run_sql( client, f""" CREATE TABLE {table} ( id UInt64, uniform_key UInt64, skew_key UInt64, payload String ) """, "create table", ) inserted = 0 while inserted < args.rows: batch_rows = min(args.batch_rows, args.rows - inserted) insert_sql = f""" INSERT INTO {table} SELECT number + {inserted} AS id, (number + {inserted}) % {args.domain} AS uniform_key, IF(((number + {inserted}) % 100) < {args.skew_percent}, 0, (number + {inserted}) % {args.domain}) AS skew_key, concat('payload_', to_string((number + {inserted}) % {args.domain})) AS payload FROM numbers({batch_rows}) """ run_sql(client, session_sql(args, insert_sql), f"insert batch offset={inserted}", False) inserted += batch_rows else: run_sql(client, f"CREATE DATABASE IF NOT EXISTS {quote_ident(args.database)}", "ensure database") run_sql( client, f"SELECT count(), count(DISTINCT uniform_key), count(DISTINCT skew_key) FROM {table}", "table cardinality", ) def discover_query_pids(process_substring: str) -> list[int]: pids: list[int] = [] self_pid = os.getpid() proc_dir = Path("/proc") if not proc_dir.exists(): return pids for entry in proc_dir.iterdir(): if not entry.name.isdigit(): continue pid = int(entry.name) if pid == self_pid: continue try: cmdline = (entry / "cmdline").read_bytes().replace(b"\x00", b" ").decode() except (OSError, UnicodeDecodeError): continue if process_substring in cmdline: pids.append(pid) return sorted(pids) def rss_kb(pid: int) -> int | None: try: for line in Path(f"/proc/{pid}/status").read_text().splitlines(): if line.startswith("VmRSS:"): return int(line.split()[1]) except OSError: return None return None def total_rss_kb(pids: list[int]) -> int | None: values = [value for pid in pids if (value := rss_kb(pid)) is not None] if not values: return None return sum(values) def safe_label(label: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("_") def tail_file(path: Path, max_bytes: int = 8192) -> str: try: with path.open("rb") as file: file.seek(0, os.SEEK_END) size = file.tell() file.seek(max(0, size - max_bytes)) return file.read().decode(errors="replace") except OSError: return "" def start_query_process(args: argparse.Namespace, label: str) -> tuple[subprocess.Popen[Any], Path]: if args.dry_run: raise RuntimeError("managed query process is not available in dry-run mode") log_dir = Path(args.query_log_dir) log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"analyze_histogram_{safe_label(label)}_{int(time.time() * 1000)}.log" cmd = [args.query_bin, "-c", args.query_config] printable = " ".join(shlex.quote(part) for part in cmd) print(f"\n[start query {label}] {printable} > {log_path}", flush=True) env = os.environ.copy() env.setdefault("RUST_BACKTRACE", "1") with log_path.open("ab") as log_file: process = subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT, env=env) return process, log_path def wait_for_query( client: BendSQL, args: argparse.Namespace, process: subprocess.Popen[Any], log_path: Path, label: str, ) -> None: deadline = time.monotonic() + args.query_ready_timeout_sec cmd = build_bendsql_cmd(client, "SELECT 1") last_error = "" while time.monotonic() < deadline: exit_code = process.poll() if exit_code is not None: log_tail = tail_file(log_path) raise RuntimeError( f"databend-query exited while starting {label}, exit={exit_code}\n{log_tail}" ) try: probe = subprocess.run(cmd, text=True, capture_output=True, timeout=5) except subprocess.SubprocessError as err: last_error = str(err) else: if probe.returncode == 0: print(f"[query ready {label}] pid={process.pid}", flush=True) return last_error = (probe.stderr or probe.stdout).strip() time.sleep(0.5) log_tail = tail_file(log_path) raise RuntimeError( f"timed out waiting for databend-query {label}: {last_error}\n{log_tail}" ) def stop_query_process( process: subprocess.Popen[Any], args: argparse.Namespace, label: str, ) -> None: if process.poll() is not None: return print(f"[stop query {label}] pid={process.pid}", flush=True) process.terminate() try: process.wait(timeout=args.query_shutdown_timeout_sec) except subprocess.TimeoutExpired: process.kill() process.wait() def with_managed_query( client: BendSQL, args: argparse.Namespace, label: str, callback: Callable[[list[int]], Any], ) -> Any: if args.dry_run: return callback([]) process, log_path = start_query_process(args, label) try: wait_for_query(client, args, process, log_path, label) return callback([process.pid]) finally: stop_query_process(process, args, label) class RssMonitor: def __init__(self, pids: list[int], interval_sec: float) -> None: self.pids = pids self.interval_sec = interval_sec self.baseline_kb: int | None = total_rss_kb(pids) self.peak_kb: int | None = self.baseline_kb self._stop = threading.Event() self._thread = threading.Thread(target=self._run, daemon=True) def __enter__(self) -> "RssMonitor": self._thread.start() return self def __exit__(self, *_: Any) -> None: self._stop.set() self._thread.join() def _run(self) -> None: while not self._stop.is_set(): current = total_rss_kb(self.pids) if current is not None: if self.baseline_kb is None: self.baseline_kb = current if self.peak_kb is None or current > self.peak_kb: self.peak_kb = current self._stop.wait(self.interval_sec) @property def delta_kb(self) -> int | None: if self.baseline_kb is None or self.peak_kb is None: return None return max(0, self.peak_kb - self.baseline_kb) def build_bendsql_cmd(client: BendSQL, sql: str, output: str = "csv") -> list[str]: cmd = [client.binary, "--output", output, f"--query={sql}"] if client.dsn: cmd[1:1] = ["--dsn", client.dsn] return cmd def run_measured_sql( client: BendSQL, sql: str, label: str, pids: list[int], sample_interval_sec: float, ) -> tuple[QueryResult, int | None, int | None, int | None]: cmd = build_bendsql_cmd(client, sql) printable = " ".join(shlex.quote(part) for part in cmd) print(f"\n[{label}] {printable}", flush=True) if client.dry_run: return QueryResult(label, sql, 0.0, "", ""), None, None, None start = time.monotonic() with RssMonitor(pids, sample_interval_sec) as monitor: proc = subprocess.run(cmd, text=True, capture_output=True) elapsed = time.monotonic() - start if proc.returncode != 0: print(proc.stdout, end="", file=sys.stdout) print(proc.stderr, end="", file=sys.stderr) raise RuntimeError(f"bendsql failed for {label}, exit={proc.returncode}") if proc.stdout.strip(): print(proc.stdout.strip(), flush=True) if proc.stderr.strip(): print(proc.stderr.strip(), file=sys.stderr, flush=True) return ( QueryResult(label, sql, elapsed, proc.stdout, proc.stderr), monitor.baseline_kb, monitor.peak_kb, monitor.delta_kb, ) def analyze_sql(args: argparse.Namespace, algorithm: str) -> str: options = f"ALGORITHM = '{algorithm}'" if algorithm in {"kll_fast", "kll_full"}: options += f", ERROR_RATE = {args.kll_error_rate}" return session_sql(args, f"ANALYZE TABLE {fq_table(args.database, args.table)} WITH HISTOGRAM {options}") def run_analyze( client: BendSQL, args: argparse.Namespace, algorithm: str, iteration: int, pids: list[int], ) -> AnalyzeResult: sql = analyze_sql(args, algorithm) result, baseline_kb, peak_kb, delta_kb = run_measured_sql( client, sql, f"analyze {algorithm} iter={iteration}", pids, args.sample_interval_sec, ) return AnalyzeResult( algorithm=algorithm, elapsed_sec=result.elapsed_sec, query_pids=pids, rss_baseline_kb=baseline_kb, rss_peak_kb=peak_kb, rss_delta_kb=delta_kb, ) def parse_histogram(histogram: str) -> list[Bucket]: buckets: list[Bucket] = [] for match in HISTOGRAM_RE.finditer(histogram): try: buckets.append( Bucket( bucket_id=int(match.group("id")), lower=float(match.group("min")), upper=float(match.group("max")), ndv=float(match.group("ndv")), count=float(match.group("count")), ) ) except ValueError: continue return buckets def load_histograms(client: BendSQL, args: argparse.Namespace) -> dict[str, dict[str, Any]]: result = run_sql( client, f""" SELECT column_name, distinct_count, histogram FROM fuse_statistic('{args.database}', '{args.table}') WHERE column_name IN ('uniform_key', 'skew_key') ORDER BY column_name """, "load histograms", False, ) histograms: dict[str, dict[str, Any]] = {} for row in csv_records(result.stdout): if len(row) < 3: continue column_name, distinct_count, histogram = row[0], row[1], row[2] histograms[column_name] = { "distinct_count": int(distinct_count) if distinct_count else None, "histogram": histogram, "buckets": parse_histogram(histogram), } return histograms def build_probes(domain: int) -> list[Probe]: domain = max(domain, 10) small_width = max(1, domain // 100) medium_width = max(1, domain // 20) return [ Probe("uniform_key", "eq", 0), Probe("uniform_key", "eq", domain // 4), Probe("uniform_key", "eq", domain // 2), Probe("uniform_key", "eq", domain - 1), Probe("uniform_key", "range", 0, small_width), Probe("uniform_key", "range", domain // 3, domain // 3 + small_width), Probe("uniform_key", "range", domain // 2, domain // 2 + medium_width), Probe("skew_key", "eq", 0), Probe("skew_key", "eq", domain // 4), Probe("skew_key", "range", 0, 0), Probe("skew_key", "range", 0, small_width), Probe("skew_key", "range", domain // 2, domain // 2 + medium_width), ] def probe_distribution(probe: Probe) -> str: if probe.column == "uniform_key": return "uniform" if probe.column == "skew_key": return "skew" return probe.column def exact_counts(client: BendSQL, args: argparse.Namespace, probes: list[Probe]) -> dict[str, float]: counts: dict[str, float] = {} table = fq_table(args.database, args.table) for probe in probes: result = run_sql( client, f"SELECT count() FROM {table} WHERE {probe.sql_predicate}", f"exact {probe.label}", False, ) rows = csv_records(result.stdout) counts[probe.label] = float(rows[0][0]) if rows and rows[0] else 0.0 return counts def estimate_probe(buckets: list[Bucket], probe: Probe) -> float: if probe.kind == "eq": estimate = 0.0 value = float(probe.lower) for bucket in buckets: if bucket.lower <= value <= bucket.upper: estimate += bucket.count / max(bucket.ndv, 1.0) return estimate assert probe.upper is not None lower = float(probe.lower) upper = float(probe.upper) estimate = 0.0 for bucket in buckets: overlap_lower = max(lower, bucket.lower) overlap_upper = min(upper, bucket.upper) if overlap_lower > overlap_upper: continue if bucket.lower == bucket.upper: estimate += bucket.count continue bucket_width = bucket.upper - bucket.lower + 1.0 overlap_width = overlap_upper - overlap_lower + 1.0 estimate += bucket.count * max(0.0, min(1.0, overlap_width / bucket_width)) return estimate def evaluate_accuracy( algorithm: str, histograms: dict[str, dict[str, Any]], probes: list[Probe], counts: dict[str, float], ) -> list[AccuracyResult]: results: list[AccuracyResult] = [] for probe in probes: buckets = histograms.get(probe.column, {}).get("buckets", []) estimate = estimate_probe(buckets, probe) exact = counts[probe.label] absolute = abs(estimate - exact) q_error = cardinality_q_error(estimate, exact) relative = None if exact == 0 else absolute / exact results.append( AccuracyResult( algorithm=algorithm, column=probe.column, distribution=probe_distribution(probe), predicate_kind=probe.kind, predicate=probe.label, exact_count=exact, estimated_count=estimate, absolute_error=absolute, q_error=q_error, relative_error=relative, ) ) return results def cardinality_q_error(estimated: float, exact: float) -> float: estimated = max(estimated, 1.0) exact = max(exact, 1.0) return max(estimated / exact, exact / estimated) def summarize_histograms(histograms: dict[str, dict[str, Any]]) -> dict[str, Any]: summary: dict[str, Any] = {} for column, info in histograms.items(): buckets: list[Bucket] = info["buckets"] summary[column] = { "distinct_count": info["distinct_count"], "num_buckets": len(buckets), "bucket_count_sum": sum(bucket.count for bucket in buckets), "bucket_ndv_sum": sum(bucket.ndv for bucket in buckets), "histogram_bytes": len(info["histogram"]), } return summary def summarize_accuracy_values(values: list[AccuracyResult]) -> dict[str, Any]: q_errors = sorted(result.q_error for result in values) relatives = sorted( result.relative_error for result in values if result.relative_error is not None ) absolutes = sorted(result.absolute_error for result in values) return { "probe_count": len(values), "q_error_count": len(q_errors), "max_q_error": max(q_errors) if q_errors else None, "mean_q_error": sum(q_errors) / len(q_errors) if q_errors else None, "geomean_q_error": geometric_mean(q_errors), "p50_q_error": percentile(q_errors, 0.50), "p90_q_error": percentile(q_errors, 0.90), "p95_q_error": percentile(q_errors, 0.95), "relative_probe_count": len(relatives), "max_absolute_error": max(absolutes) if absolutes else None, "mean_absolute_error": sum(absolutes) / len(absolutes) if absolutes else None, "max_relative_error": max(relatives) if relatives else None, "mean_relative_error": sum(relatives) / len(relatives) if relatives else None, } def geometric_mean(values: list[float]) -> float | None: if not values: return None return math.exp(sum(math.log(value) for value in values) / len(values)) def percentile(values: list[float], quantile: float) -> float | None: if not values: return None if len(values) == 1: return values[0] index = (len(values) - 1) * quantile lower_index = int(index) upper_index = min(lower_index + 1, len(values) - 1) fraction = index - lower_index return values[lower_index] * (1.0 - fraction) + values[upper_index] * fraction def summarize_accuracy(results: list[AccuracyResult]) -> dict[str, Any]: by_algorithm: dict[str, list[AccuracyResult]] = {} for result in results: by_algorithm.setdefault(result.algorithm, []).append(result) summary: dict[str, Any] = {} for algorithm, values in by_algorithm.items(): summary[algorithm] = summarize_accuracy_values(values) return summary def summarize_accuracy_by_distribution(results: list[AccuracyResult]) -> dict[str, Any]: by_group: dict[str, dict[str, list[AccuracyResult]]] = {} for result in results: by_group.setdefault(result.algorithm, {}).setdefault(result.distribution, []).append(result) return { algorithm: { distribution: summarize_accuracy_values(values) for distribution, values in sorted(distributions.items()) } for algorithm, distributions in sorted(by_group.items()) } def summarize_accuracy_by_kind(results: list[AccuracyResult]) -> dict[str, Any]: by_group: dict[str, dict[str, list[AccuracyResult]]] = {} for result in results: by_group.setdefault(result.algorithm, {}).setdefault(result.predicate_kind, []).append( result ) return { algorithm: { predicate_kind: summarize_accuracy_values(values) for predicate_kind, values in sorted(kinds.items()) } for algorithm, kinds in sorted(by_group.items()) } def summarize_accuracy_by_distribution_and_kind( results: list[AccuracyResult], ) -> dict[str, Any]: by_group: dict[str, dict[str, dict[str, list[AccuracyResult]]]] = {} for result in results: by_group.setdefault(result.algorithm, {}).setdefault(result.distribution, {}).setdefault( result.predicate_kind, [] ).append(result) return { algorithm: { distribution: { predicate_kind: summarize_accuracy_values(values) for predicate_kind, values in sorted(kinds.items()) } for distribution, kinds in sorted(distributions.items()) } for algorithm, distributions in sorted(by_group.items()) } def main() -> None: args = parse_args() if args.rows <= 0 or args.batch_rows <= 0 or args.domain <= 0: raise ValueError("--rows, --batch-rows, and --domain must be greater than zero") if not 0 <= args.skew_percent <= 100: raise ValueError("--skew-percent must be between 0 and 100") algorithms = [item.strip() for item in args.algorithms.split(",") if item.strip()] for algorithm in algorithms: if algorithm not in {"window", "kll_fast", "kll_full"}: raise ValueError(f"unsupported algorithm: {algorithm}") probes = build_probes(args.domain) client = build_client(args) analyze_results: list[AnalyzeResult] = [] accuracy_results: list[AccuracyResult] = [] histogram_summaries: dict[str, Any] = {} sampled_pids: list[int] = [] if args.run_mode == "sequential": sampled_pids = args.query_pid or discover_query_pids(args.query_process_substring) if sampled_pids: print(f"Sampling databend-query RSS from pids: {sampled_pids}", flush=True) else: print( "No databend-query PID discovered; RSS fields will be null. " "Pass --query-pid to enable memory sampling.", file=sys.stderr, flush=True, ) prepare_table(client, args) counts = exact_counts(client, args, probes) for iteration in range(1, args.repeat + 1): for algorithm in algorithms: analyze_result = run_analyze(client, args, algorithm, iteration, sampled_pids) analyze_results.append(analyze_result) histograms = load_histograms(client, args) key = f"{algorithm}_iter_{iteration}" histogram_summaries[key] = summarize_histograms(histograms) accuracy_results.extend(evaluate_accuracy(algorithm, histograms, probes, counts)) else: if args.query_pid: print( "--query-pid is ignored in --run-mode=isolated-query; managed query " "processes are sampled by their own PIDs.", file=sys.stderr, flush=True, ) def setup(_: list[int]) -> dict[str, float]: prepare_table(client, args) return exact_counts(client, args, probes) counts = with_managed_query(client, args, "setup", setup) for iteration in range(1, args.repeat + 1): for algorithm in algorithms: def run_once(pids: list[int]) -> None: analyze_result = run_analyze(client, args, algorithm, iteration, pids) analyze_results.append(analyze_result) histograms = load_histograms(client, args) key = f"{algorithm}_iter_{iteration}" histogram_summaries[key] = summarize_histograms(histograms) accuracy_results.extend( evaluate_accuracy(algorithm, histograms, probes, counts) ) with_managed_query(client, args, f"{algorithm}_iter_{iteration}", run_once) result = { "config": { "database": args.database, "table": args.table, "rows": args.rows, "batch_rows": args.batch_rows, "domain": args.domain, "skew_percent": args.skew_percent, "max_threads": args.max_threads, "kll_error_rate": args.kll_error_rate, "algorithms": algorithms, "repeat": args.repeat, "run_mode": args.run_mode, "query_bin": args.query_bin if args.run_mode == "isolated-query" else None, "query_config": args.query_config if args.run_mode == "isolated-query" else None, "query_pids": sampled_pids, }, "analyze_results": [asdict(result) for result in analyze_results], "histogram_summaries": histogram_summaries, "accuracy_results": [asdict(result) for result in accuracy_results], "accuracy_summary": summarize_accuracy(accuracy_results), "accuracy_summary_by_distribution": summarize_accuracy_by_distribution(accuracy_results), "accuracy_summary_by_kind": summarize_accuracy_by_kind(accuracy_results), "accuracy_summary_by_distribution_and_kind": summarize_accuracy_by_distribution_and_kind( accuracy_results ), } print(json.dumps(result, indent=2, sort_keys=True), flush=True) if args.output: Path(args.output).parent.mkdir(parents=True, exist_ok=True) Path(args.output).write_text(json.dumps(result, indent=2, sort_keys=True) + "\n") print(f"Wrote {args.output}", flush=True) if not args.keep: if args.run_mode == "sequential": run_sql(client, f"DROP DATABASE IF EXISTS {quote_ident(args.database)}", "drop database") else: def cleanup(_: list[int]) -> None: run_sql( client, f"DROP DATABASE IF EXISTS {quote_ident(args.database)}", "drop database", ) with_managed_query(client, args, "cleanup", cleanup) if __name__ == "__main__": main()