test: add manual S3 metric snapshot benchmark (#9573)

* test: add repeatable metric snapshot S3 benchmark

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test: use only existing CI S3 permissions in the metric snapshot benchmark

The CI S3 user is not allowed GetBucketLocation or ListBucketMultipartUploads,
so the bucket probe and the prefix cleanup failed with AccessDenied. Use only
list, put, get and delete under the run prefix, as the object store WAL
benchmark does, and probe S3 before allocating the EC2 runner.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test: scope metric snapshot inventory to the current round

The full round's S3 inventory listed the whole run prefix, so it also counted
the smoke snapshot. List only the round's snapshot prefix while cleanup still
covers the run, and assert that the teardown's S3 cleanup step runs always()
with the validated run prefix.

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* docs: record the AWS validation run of the metric snapshot benchmark

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
(cherry picked from commit 7df99f946e)
This commit is contained in:
jeremyhi
2026-10-23 14:43:10 +08:00
committed by discord9
parent 069b942518
commit 97e3bfd547
9 changed files with 1592 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Deterministic wide, heterogeneous Metric tables for snapshot benchmarks."""
import argparse
import base64
import concurrent.futures
import hashlib
import http.client
import json
import os
from pathlib import Path
import threading
import time
import urllib.parse
GROUPS = 10
TAG_POOL = 298
ROWS = 32
DATABASE = "metrics"
class Client:
def __init__(self, addr):
self.addr = addr
self.connections = threading.local()
self.headers = {"Content-Type": "application/x-www-form-urlencoded"}
if auth := os.environ.get("GREPTIME_BENCH_AUTH"):
self.headers["Authorization"] = "Basic " + base64.b64encode(
auth.encode()
).decode()
def sql(self, query, database=DATABASE, batch=False):
connection = getattr(self.connections, "connection", None)
if connection is None:
connection = http.client.HTTPConnection(self.addr, timeout=600)
self.connections.connection = connection
body = urllib.parse.urlencode({"db": database, "sql": query})
try:
connection.request(
"POST", "/v1/ddl/logical-tables" if batch else "/v1/sql",
body, self.headers,
)
response = connection.getresponse()
raw = response.read()
except (OSError, http.client.HTTPException):
connection.close()
self.connections.connection = None
raise
data = json.loads(raw)
if response.status != 200 or "error" in data:
raise RuntimeError(f"SQL failed (HTTP {response.status}): {data}")
return data["output"]
def values(self, query, database=DATABASE):
return self.sql(query, database)[0]["records"]["rows"]
def tags(index, per_group):
return sorted((index % per_group * 7 + offset) % TAG_POOL for offset in range(10))
def column_names(tag_ids):
return ["ts", *[f"tag_{tag:03d}" for tag in tag_ids], "val"]
def columns(tag_ids):
names = [f"tag_{tag:03d}" for tag in tag_ids]
return (
"ts TIMESTAMP TIME INDEX, "
+ ", ".join(name + " STRING" for name in names)
+ ", val DOUBLE, PRIMARY KEY(" + ",".join(names) + ")"
)
def table_ddl(index, per_group):
return (
f"CREATE TABLE m{index:06d} ({columns(tags(index, per_group))}) "
f"ENGINE=metric WITH(on_physical_table='phy_{index // per_group:02d}')"
)
def row(index, row_index, per_group):
values = []
for tag in tags(index, per_group):
if (row_index + tag) % 29 == 0:
value = None
elif (row_index + tag) % 17 == 0:
value = ""
elif tag % 10 == 0:
value = f"instance-{index:06d}-{row_index % 4}"
elif tag % 10 == 1:
value = "区域-" + str(row_index % 3)
elif tag % 10 == 2:
value = "label-" + "x" * 48 + "-" + str(row_index % 2)
else:
value = f"tag{tag:03d}-v{row_index % (2 + tag % 7)}"
values.append(value)
return [row_index, *values, None if row_index % 7 == 0 else index % 97 + row_index / 4]
def sql_value(value):
if value is None:
return "NULL"
if isinstance(value, str):
return "'" + value.replace("'", "''") + "'"
return str(value)
def canonical(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def update_digest(digest, value):
digest.update((canonical(value) + "\n").encode())
def progress(phase, completed, total):
if completed == total or completed % 4096 == 0:
print(f"{phase}: {completed}/{total}", flush=True)
def prepare(client, per_group):
stages = {}
started = time.monotonic()
client.sql(f"CREATE DATABASE {DATABASE}", "public")
for group in range(GROUPS):
client.sql(
f"CREATE TABLE phy_{group:02d} ({columns(range(TAG_POOL))}) "
"ENGINE=metric WITH(physical_metric_table='')"
)
total = GROUPS * per_group
for group in range(GROUPS):
for base in range(group * per_group, (group + 1) * per_group, 128):
end = min((group + 1) * per_group, base + 128)
client.sql(";".join(table_ddl(i, per_group) for i in range(base, end)), batch=True)
progress("create", end, total)
stages["create_seconds"] = time.monotonic() - started
def insert(index):
rows = ",".join(
"(" + ",".join(sql_value(v) for v in row(index, j, per_group)) + ")"
for j in range(ROWS)
)
names = ",".join(column_names(tags(index, per_group)))
client.sql(f"INSERT INTO m{index:06d} ({names}) VALUES {rows}")
started = time.monotonic()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
for base in range(0, total, 128):
end = min(total, base + 128)
list(pool.map(insert, range(base, end)))
progress("insert", end, total)
stages["insert_seconds"] = time.monotonic() - started
started = time.monotonic()
for group in range(GROUPS):
client.sql(f"ADMIN FLUSH_TABLE('{DATABASE}.phy_{group:02d}')")
stages["flush_seconds"] = time.monotonic() - started
return stages
def check_records(index, per_group, records):
names = column_names(tags(index, per_group))
types = ["TimestampMillisecond", *["String"] * 10, "Float64"]
expected_columns = [dict(name=name, data_type=kind) for name, kind in zip(names, types)]
actual_columns = records["schema"]["column_schemas"]
if sorted(actual_columns, key=lambda c: c["name"]) != sorted(
expected_columns, key=lambda c: c["name"]
):
raise RuntimeError(f"logical schema mismatch: m{index:06d}")
# Metric tables canonicalize column order; SELECT * still checks every column.
positions = [names.index(column["name"]) for column in actual_columns]
expected_rows = [
[values[position] for position in positions]
for values in (row(index, j, per_group) for j in range(ROWS))
]
# Canonical JSON also distinguishes integers, floats, booleans, and NULL.
if canonical(records["rows"]) != canonical(expected_rows):
raise RuntimeError(f"typed values/NULL mismatch: m{index:06d}")
def validate(client, per_group):
total = GROUPS * per_group
expected_names = {f"m{i:06d}" for i in range(total)} | {
f"phy_{group:02d}" for group in range(GROUPS)
}
table_rows = client.values(
"SELECT table_name, table_id FROM information_schema.tables "
f"WHERE table_schema='{DATABASE}' ORDER BY table_name"
)
table_ids = dict(table_rows)
if len(table_rows) != len(expected_names) or set(table_ids) != expected_names:
raise RuntimeError("table membership mismatch")
if len(set(table_ids.values())) != len(table_ids):
raise RuntimeError("duplicate table IDs")
digests = {name: hashlib.sha256() for name in ("schema", "data", "ddl")}
def check_ddl(names):
outputs = client.sql(";".join(f"SHOW CREATE TABLE {name}" for name in names))
if len(outputs) != len(names):
raise RuntimeError("incomplete SHOW CREATE TABLE response")
for name, output in zip(names, outputs):
records = output["records"]["rows"]
if len(records) != 1 or records[0][0] != name or not records[0][1]:
raise RuntimeError(f"invalid SHOW CREATE TABLE response: {name}")
update_digest(digests["ddl"], records)
for base in range(0, total, 64):
end = min(total, base + 64)
names = [f"m{i:06d}" for i in range(base, end)]
outputs = client.sql(";".join(f"SELECT * FROM {name} ORDER BY ts" for name in names))
if len(outputs) != len(names):
raise RuntimeError("incomplete SELECT response")
for index, output in enumerate(outputs, base):
records = output["records"]
check_records(index, per_group, records)
update_digest(digests["schema"], [f"m{index:06d}", records["schema"]])
update_digest(digests["data"], [f"m{index:06d}", records["rows"]])
check_ddl(names)
progress("validate", end, total)
check_ddl([f"phy_{group:02d}" for group in range(GROUPS)])
return {**{name + "_sha256": digest.hexdigest() for name, digest in digests.items()},
"table_ids": table_ids}
def compare_source(result, source):
for field in ("fixture", "schema_sha256", "data_sha256", "ddl_sha256"):
if result[field] != source[field]:
raise RuntimeError(f"source/target mismatch: {field}")
source_ids, target_ids = source["table_ids"], result["table_ids"]
if source_ids.keys() != target_ids.keys():
raise RuntimeError("source/target table membership mismatch")
changed = sum(source_ids[name] != target_ids[name] for name in source_ids)
if changed != len(source_ids):
raise RuntimeError(f"table IDs not all remapped: {changed}/{len(source_ids)}")
result["remapped_table_ids"] = changed
def placeholders(client):
database = "metric_bench_guard"
client.sql(f"CREATE DATABASE {database}", "public")
for index in range(17):
client.sql(f"CREATE TABLE placeholder_{index} (ts TIMESTAMP TIME INDEX)", database)
return {"placeholder_tables": 17}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=("prepare", "validate", "placeholders"))
parser.add_argument("--addr", default="127.0.0.1:4000")
parser.add_argument("--tables-per-group", type=int, default=10000)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--source", type=Path, help="Source validation JSON, for target comparison")
args = parser.parse_args()
if not 50 <= args.tables_per_group <= 10000:
parser.error("--tables-per-group must be between 50 and 10000")
if args.source is not None and args.action != "validate":
parser.error("--source only applies to validate")
coverage = {tag for i in range(args.tables_per_group) for tag in tags(i, args.tables_per_group)}
if coverage != set(range(TAG_POOL)):
parser.error("fixture does not cover all 298 tags")
client = Client(args.addr)
started = time.monotonic()
result = {"fixture": {"groups": GROUPS, "tables_per_group": args.tables_per_group,
"rows_per_table": ROWS, "tag_pool": TAG_POOL}}
if args.action == "placeholders":
result.update(placeholders(client))
elif args.action == "prepare":
result.update(prepare(client, args.tables_per_group))
else:
result.update(validate(client, args.tables_per_group))
result["wall_seconds"] = time.monotonic() - started
if args.source is not None:
compare_source(result, json.loads(args.source.read_text()))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(json.dumps({key: value for key, value in result.items() if key != "table_ids"}))
if __name__ == "__main__":
main()
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Run one packed Metric snapshot smoke, then one full S3 round trip."""
import argparse
from datetime import datetime
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import threading
import time
import urllib.request
HERE = Path(__file__).resolve().parent
def command(args, **kwargs):
result = subprocess.run(args, text=True, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, **kwargs)
if result.returncode:
# CLI argv and error output can contain storage credentials.
raise RuntimeError(f"{args[0]} exited with status {result.returncode}")
return result.stdout
def write_json(path, value):
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
def metric_values(text):
values = {}
for line in text.splitlines():
if line and not line.startswith("#"):
key, value = line.rsplit(" ", 1)
values[key] = float(value)
return values
def docker_timestamp(value):
# Docker emits nanoseconds; Python 3.9 accepts at most microseconds.
return datetime.fromisoformat(re.sub(r"(\.\d{6})\d+", r"\1", value).replace("Z", "+00:00"))
def host_memory():
if not Path("/proc/vmstat").exists():
return {"available": False}
vm = dict(line.split() for line in Path("/proc/vmstat").read_text().splitlines())
mem = dict(line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines())
return {"available": True, "pswpin_pages": int(vm["pswpin"]),
"pswpout_pages": int(vm["pswpout"]), "oom_kill": int(vm.get("oom_kill", 0)),
**{key + "_kib": int(mem[key].split()[0])
for key in ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree")}}
def process_usage(pid):
try:
stat = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
return {"cpu_seconds": (int(stat[11]) + int(stat[12])) / os.sysconf("SC_CLK_TCK"),
"rss_bytes": int(stat[21]) * os.sysconf("SC_PAGE_SIZE")}
except (OSError, IndexError):
return None
class Benchmark:
def __init__(self, local_smoke):
self.local_smoke = local_smoke
self.image = os.environ["GREPTIMEDB_IMAGE"]
self.commit = os.environ["EXPECTED_COMMIT"]
if not re.fullmatch(r"greptime/greptimedb(?:-nightly)?@sha256:[0-9a-f]{64}", self.image):
raise ValueError("GREPTIMEDB_IMAGE must be an immutable official image digest")
if not re.fullmatch(r"[0-9a-f]{40}", self.commit):
raise ValueError("EXPECTED_COMMIT must be a full commit SHA")
self.per_group = int(os.environ.get("LOGICAL_TABLES_PER_GROUP", "10000"))
if not 50 <= self.per_group <= 10000:
raise ValueError("LOGICAL_TABLES_PER_GROUP must be in 50..10000")
self.prefix = os.environ["RUN_PREFIX"]
if not re.fullmatch(r"metric-snapshot-bench/[0-9]+-[0-9]+/", self.prefix):
raise ValueError("RUN_PREFIX must be metric-snapshot-bench/<run>-<attempt>/")
self.results = Path(os.environ["RESULTS"]).resolve()
self.data = Path(os.environ["DATA_ROOT"]).resolve()
self.results.mkdir(parents=True, exist_ok=False)
self.data.mkdir(parents=True, exist_ok=False)
self.network = "metric-snapshot-" + self.prefix.split("/")[1]
self.server = self.network + "-db"
self.cli_name = self.network + "-cli"
self.port = int(os.environ.get("BENCH_HTTP_PORT", "19410"))
self.base_url = f"http://127.0.0.1:{self.port}"
self.endpoint = os.environ.get("S3_ENDPOINT", "")
if self.endpoint and not local_smoke:
raise ValueError("Custom S3 endpoints are restricted to local smoke")
if not local_smoke and os.environ["S3_REGION"] != "ap-southeast-1":
raise ValueError("Cloud benchmark requires ap-southeast-1")
self.containers = set()
self.network_created = False
self.phases = []
self.server_pid = 0
self.round_dir = None
def docker(self, *args):
return command(["docker", *args])
def metrics(self):
try:
with urllib.request.urlopen(self.base_url + "/metrics", timeout=5) as response:
return response.read().decode()
except OSError:
return ""
def phase(self, name, action):
print(f"Starting {self.round_dir.name}/{name}", flush=True)
before = host_memory()
metrics_before = self.metrics()
samples = []
done = threading.Event()
def sample():
while not done.is_set():
samples.append({"time": time.time(), "server": process_usage(self.server_pid),
"cli": process_usage(getattr(self, "cli_pid", 0)),
"query_memory": {k: v for k, v in metric_values(self.metrics()).items()
if "query_memory" in k} if self.server_pid else {}})
done.wait(0.5)
monitor = threading.Thread(target=sample, daemon=True)
monitor.start()
start = time.monotonic()
outcome = "failed"
result = None
try:
result = action()
outcome = "passed"
return result
finally:
elapsed = time.monotonic() - start
done.set()
monitor.join()
after = host_memory()
interfered = before["available"] and after["available"] and (
after["pswpout_pages"] > before["pswpout_pages"] or after["oom_kill"] > before["oom_kill"])
if interfered:
outcome = "resource-invalid"
record = {"round": self.round_dir.name, "phase": name, "outcome": outcome,
"wall_seconds": elapsed, "host_before": before, "host_after": after,
"process_samples": samples}
if isinstance(result, dict):
record.update(result)
if before["available"] and after["available"]:
record["new_swapout_pages"] = after["pswpout_pages"] - before["pswpout_pages"]
record["new_oom_kills"] = after["oom_kill"] - before["oom_kill"]
metrics_after = self.metrics()
pre, post = metric_values(metrics_before), metric_values(metrics_after)
record["server_counter_deltas"] = {
key: value - pre.get(key, 0) for key, value in post.items()
if ("opendal" in key and ("_count{" in key or "_sum{" in key))
or key.startswith("greptime_servers_http_requests_total{")}
write_json(self.round_dir / f"{name}-resources.json", record)
(self.round_dir / f"{name}-before.prom").write_text(metrics_before)
(self.round_dir / f"{name}-after.prom").write_text(metrics_after)
self.phases.append({k: v for k, v in record.items() if k != "process_samples"})
self.summary()
print(f"Finished {name}: {outcome}, {elapsed:.3f}s", flush=True)
if interfered:
raise RuntimeError(f"Resource interference during {name}; evidence retained")
def fixture(self, operation, per_group, output, source=None):
args = [sys.executable, str(HERE / "metric-snapshot-fixture.py"), operation,
"--addr", f"127.0.0.1:{self.port}", "--tables-per-group", str(per_group),
"--output", str(output)]
if source:
args += ["--source", str(source)]
with (self.round_dir / f"{operation}-{output.stem}.log").open("w") as log:
status = subprocess.run(args, stdout=log, stderr=subprocess.STDOUT).returncode
if status:
raise RuntimeError(f"Fixture {operation} failed; see its artifact log")
def start_server(self, role):
root = self.data / self.round_dir.name / role
root.mkdir(parents=True)
evidence = self.round_dir / role
evidence.mkdir()
config = root / "config.toml"
config.write_text('''enable_telemetry = false
[http]
addr = "0.0.0.0:4000"
[grpc]
bind_addr = "0.0.0.0:4001"
[mysql]
enable = false
[postgres]
enable = false
[storage]
type = "File"
data_home = "/data/db"
[query]
parallelism = 4
memory_pool_size = "2GiB"
[logging]
dir = "/evidence/logs"
level = "info"
''')
self.containers.add(self.server)
self.docker("run", "-d", "--name", self.server, "--network", self.network,
"--network-alias", "metric-db", "--user", f"{os.getuid()}:{os.getgid()}",
"--add-host", "host.docker.internal:host-gateway",
"-p", f"127.0.0.1:{self.port}:4000",
"-v", f"{root}:/data", "-v", f"{evidence}:/evidence",
self.image, "standalone", "start", "-c", "/data/config.toml")
self.server_pid = int(self.docker("inspect", "-f", "{{.State.Pid}}", self.server))
deadline = time.monotonic() + 120
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(self.base_url + "/v1/capabilities", timeout=2) as response:
caps = json.load(response)
if any(caps.get(key) != 1 for key in
("metric_packed_export", "metric_packed_import", "metric_batch_ddl")):
raise RuntimeError("Required packed snapshot capabilities are absent")
write_json(evidence / "capabilities.json", caps)
return
except OSError:
if self.docker("inspect", "-f", "{{.State.Running}}", self.server).strip() != "true":
break
time.sleep(1)
raise RuntimeError("Server did not become ready")
def stop_server(self, role):
self.docker("stop", "-t", "60", self.server)
state = json.loads(self.docker("inspect", "-f", "{{json .State}}", self.server))
write_json(self.round_dir / role / "exit.json", state)
(self.round_dir / role / "stdout.log").write_text(self.docker("logs", self.server))
self.docker("rm", self.server)
self.containers.remove(self.server)
self.server_pid = 0
if state["OOMKilled"] or state["ExitCode"] != 0:
raise RuntimeError(f"{role} server did not shut down cleanly")
def cli(self, args, name):
# Expand credentials only inside the container. Never persist argv or env.
script = 'exec greptime "$@" --s3 --s3-region "$S3_REGION" --s3-access-key-id "$AWS_ACCESS_KEY_ID" --s3-secret-access-key "$AWS_SECRET_ACCESS_KEY"'
if self.endpoint:
args += ["--s3-endpoint", self.endpoint]
self.containers.add(self.cli_name)
self.docker("create", "--name", self.cli_name, "--network", self.network,
"--user", f"{os.getuid()}:{os.getgid()}",
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_ACCESS_KEY_ID", "-e", "AWS_SECRET_ACCESS_KEY", "-e", "S3_REGION",
"-v", f"{self.round_dir}:/evidence", "--entrypoint", "/bin/sh", self.image,
"-c", script, "sh", "--log-dir", f"/evidence/{name}-logs", "cli", "data", *args)
def execute():
self.docker("start", self.cli_name)
self.cli_pid = int(self.docker("inspect", "-f", "{{.State.Pid}}", self.cli_name))
status = int(self.docker("wait", self.cli_name))
state = json.loads(self.docker("inspect", "-f", "{{json .State}}", self.cli_name))
(self.round_dir / f"{name}.log").write_text(self.docker("logs", self.cli_name))
write_json(self.round_dir / f"{name}-exit.json", state)
if status or state["ExitCode"] or state["OOMKilled"]:
raise RuntimeError(f"{name} failed; see artifact log")
return {"cli_wall_seconds": (docker_timestamp(state["FinishedAt"]) -
docker_timestamp(state["StartedAt"])).total_seconds()}
try:
self.phase(name, execute)
finally:
self.cli_pid = 0
if not (self.round_dir / f"{name}-exit.json").exists():
self.save_container(self.cli_name, name + "-failure")
self.docker("rm", "-f", self.cli_name)
self.containers.remove(self.cli_name)
def round(self, label, per_group):
self.round_dir = self.results / label
self.round_dir.mkdir()
self.start_server("source")
source = self.round_dir / "source-validation.json"
self.phase("prepare", lambda: self.fixture("prepare", per_group, self.round_dir / "prepare.json"))
self.phase("source-validation", lambda: self.fixture("validate", per_group, source))
location = f"s3://{os.environ['S3_BUCKET']}/{self.prefix}{label}"
self.cli(["export-v2", "create", "--addr", "metric-db:4000", "--to", location,
"--schemas", "metrics", "--pack-metric-data", "--parallelism", "4",
"--chunk-parallelism", "1", "--start-time", "1970-01-01T00:00:00Z",
"--end-time", "1970-01-01T00:00:01Z", "--timeout", "1h",
"--no-proxy", "--progress", "always"], "export")
if not self.local_smoke:
command(["uv", "run", str(HERE / "metric-snapshot-s3-ops.py"), "inventory", "--round", label,
"--output", str(self.round_dir / "s3-inventory.json")])
self.stop_server("source")
self.cli(["export-v2", "verify", "--snapshot", location], "verify")
self.start_server("target")
self.fixture("placeholders", per_group, self.round_dir / "placeholders.json")
self.cli(["import-v2", "--addr", "metric-db:4000", "--from", location,
"--task-parallelism", "1", "--state-path", "/evidence/import-state.json",
"--timeout", "1h", "--no-proxy", "--progress", "always"], "import")
imported = json.loads((self.round_dir / "import-resources.json").read_text())
batch_requests = sum(value for key, value in imported["server_counter_deltas"].items()
if '/v1/ddl/logical-tables' in key and 'code="200"' in key)
if batch_requests <= 0:
raise RuntimeError("Import did not complete any successful batch DDL HTTP requests")
write_json(self.round_dir / "import-completion.json", {"batch_ddl_requests": batch_requests,
"resume_state_removed": not (self.round_dir / "import-state.json").exists()})
if (self.round_dir / "import-state.json").exists():
raise RuntimeError("Successful import left its resume state file")
self.phase("target-validation", lambda: self.fixture(
"validate", per_group, self.round_dir / "target-validation.json", source))
self.stop_server("target")
def summary(self):
lines = ["# Metric packed snapshot benchmark", "", f"Image: `{self.image}`",
f"Source commit: `{self.commit}`", "",
"Local smoke only; timings are not AWS results." if self.local_smoke else
"AWS S3 in ap-southeast-1; source and target use local File storage.", "",
"| Round | Phase | Seconds | Result |", "| --- | --- | ---: | --- |"]
for phase in self.phases:
lines.append(f"| {phase['round']} | {phase['phase']} | {phase['wall_seconds']:.3f} | {phase['outcome']} |")
lines += ["", "Each logical table has 32 rows, 10 overlapping string tags, a timestamp and a DOUBLE.",
"Phase durations include Docker start, wait and log collection. cli_wall_seconds records Docker StartedAt to FinishedAt separately; setup, loading and full validation are separate.",
"Process CPU/RSS are sampled every 0.5 s on Linux, not exact process peaks. Linux host swap/OOM counters are checked per phase; configured swap is not RAM.",
"Raw server Prometheus snapshots and CLI progress/logs are retained. OpenDAL operation counts are not AWS HTTP request counts: multipart requests, retries and CLI operations are not fully covered. Exact AWS request totals are unavailable."]
(self.results / "summary.md").write_text("\n".join(lines) + "\n")
def run(self):
self.docker("pull", self.image)
version = self.docker("run", "--rm", self.image, "--version")
(self.results / "version.txt").write_text(version)
if self.commit not in version or "clean: true" not in version:
raise RuntimeError("Image version does not match the expected clean source commit")
for args, required in ((["export-v2", "create"], "--pack-metric-data"),
(["import-v2"], "--task-parallelism")):
help_text = self.docker("run", "--rm", self.image, "cli", "data", *args, "--help")
(self.results / (args[0] + "-help.txt")).write_text(help_text)
if required not in help_text:
raise RuntimeError(f"Image CLI lacks {required}")
write_json(self.results / "host.json", {"memory": host_memory(), "local_smoke": self.local_smoke,
"platform": self.docker("image", "inspect", "-f", "{{.Os}}/{{.Architecture}}", self.image).strip()})
self.docker("network", "create", self.network)
self.network_created = True
self.round("smoke", 50)
if not self.local_smoke and self.per_group != 50:
self.round("full", self.per_group)
def save_container(self, name, label):
state = subprocess.run(["docker", "inspect", "-f", "{{json .State}}", name],
capture_output=True, text=True)
(self.round_dir / (label + "-state.json")).write_text(state.stdout)
result = subprocess.run(["docker", "logs", name], capture_output=True, text=True)
(self.round_dir / (label + ".log")).write_text(result.stdout + result.stderr)
def cleanup(self):
for name in self.containers:
if self.round_dir:
self.save_container(name, name + "-failure")
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, check=False)
if self.network_created:
subprocess.run(["docker", "network", "rm", self.network], stdout=subprocess.DEVNULL, check=False)
self.summary()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["run"])
parser.add_argument("--local-smoke", action="store_true")
args = parser.parse_args()
bench = Benchmark(args.local_smoke)
def cancelled(signum, frame):
raise RuntimeError(f"Received signal {signum}")
signal.signal(signal.SIGTERM, cancelled)
try:
bench.run()
finally:
bench.cleanup()
if __name__ == "__main__":
main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# /// script
# requires-python = ">=3.10"
# dependencies = ["boto3>=1.35,<2"]
# ///
"""Budget preflight and strictly scoped S3 operations for the metric snapshot benchmark."""
import argparse
from decimal import Decimal
import json
import os
from pathlib import Path
import re
import time
import urllib.request
REGION = "ap-southeast-1"
HOURLY_USD = Decimal("0.9408")
OTHER_RESERVE_USD = Decimal("1.50")
def owned_prefix(prefix):
if not re.fullmatch(r"metric-snapshot-bench/[0-9]+-[0-9]+/", prefix):
raise ValueError("prefix must be exactly metric-snapshot-bench/<run_id>-<attempt>/")
return prefix
def plan(env):
image = env["GREPTIMEDB_IMAGE"]
if not re.fullmatch(r"greptime/greptimedb(?:-nightly)?@sha256:[0-9a-f]{64}", image):
raise ValueError("image must be an immutable greptime/greptimedb sha256 reference")
if not re.fullmatch(r"[0-9a-f]{40}", env["EXPECTED_COMMIT"]):
raise ValueError("expected commit must be a full lowercase source SHA")
tables, minutes = int(env["LOGICAL_TABLES_PER_GROUP"]), int(env["BENCHMARK_TIMEOUT_MINUTES"])
if not 50 <= tables <= 10_000 or not 30 <= minutes <= 240:
raise ValueError("logical tables per group must be 50..10000; timeout must be 30..240 minutes")
if env["S3_REGION"] != REGION or env["RUNNER_REGION"] != REGION:
raise ValueError("bucket and runner must both use ap-southeast-1")
if not env["S3_BUCKET"]:
raise ValueError("S3 bucket is required")
prior = Decimal(env["PRIOR_SPEND_USD"])
# Reserve 45m provision, 10m queue allowance and 20m cleanup beyond the job timeout.
reservation = HOURLY_USD * Decimal(minutes + 75) / 60 + OTHER_RESERVE_USD
if not prior.is_finite() or prior < 0 or prior >= 15 or prior + reservation > 20:
raise ValueError("campaign budget exhausted: prior spend must be below $15 and leave the full attempt reserve within $20")
return {"image": image, "expected_commit": env["EXPECTED_COMMIT"],
"logical_tables_per_group": tables, "smoke_logical_tables": 500,
"timeout_minutes": minutes, "prefix": owned_prefix(env["RUN_PREFIX"]),
"instance_type": "c6id.4xlarge", "region": REGION,
"prior_estimated_usd": str(prior), "attempt_reserve_usd": str(reservation),
"ec2_hourly_usd": str(HOURLY_USD), "other_reserve_usd": str(OTHER_RESERVE_USD),
"cost_note": "Estimate, not an AWS billing total or a hard spending cap."}
def client(region):
import boto3
from botocore.config import Config
if region != REGION:
raise ValueError("S3 region must be ap-southeast-1")
return boto3.client("s3", region_name=region, config=Config(
connect_timeout=10, read_timeout=30, retries={"mode": "standard", "max_attempts": 3}))
def entries(s3, operation, bucket, prefix, fields, scope=""):
listed = owned_prefix(prefix) + scope
for page in s3.get_paginator(operation).paginate(Bucket=bucket, Prefix=listed):
for field in fields:
for item in page.get(field, []):
if not item["Key"].startswith(listed):
raise ValueError("S3 returned an object outside the owned prefix")
yield item
def inventory(s3, bucket, prefix, round=None):
# A round's snapshot lives under <run prefix><round>/; earlier rounds stay until cleanup.
if round not in (None, "smoke", "full"):
raise ValueError("round must be smoke or full")
scope = round + "/" if round else ""
objects = list(entries(s3, "list_objects_v2", bucket, prefix, ("Contents",), scope))
return {"prefix": prefix + scope, "objects": len(objects), "bytes": sum(item["Size"] for item in objects),
"measurement": "S3 listing inventory; not request counts or transferred bytes."}
def probe(s3, bucket, prefix):
if inventory(s3, bucket, prefix)["objects"]:
raise ValueError("run prefix is already occupied; use a fresh workflow attempt")
key = prefix + "_probe"
s3.put_object(Bucket=bucket, Key=key, Body=b"metric snapshot benchmark")
try:
body = s3.get_object(Bucket=bucket, Key=key)["Body"]
try:
if body.read() != b"metric snapshot benchmark":
raise ValueError("S3 probe returned unexpected content")
finally:
body.close()
finally:
s3.delete_object(Bucket=bucket, Key=key)
return inventory(s3, bucket, prefix)
def cleanup(s3, bucket, prefix):
owned_prefix(prefix)
for item in entries(s3, "list_objects_v2", bucket, prefix, ("Contents",)):
s3.delete_object(Bucket=bucket, Key=item["Key"])
after = inventory(s3, bucket, prefix)
if after["objects"]:
raise RuntimeError("owned S3 prefix is not empty after cleanup")
return after
def scrub(root, env):
secrets = [env[name].encode() for name in ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN")
if env.get(name)]
if not secrets:
raise ValueError("credential scan requires the CI credential environment")
overlap = max(map(len, secrets)) - 1
for result in Path(root).rglob("*"):
if result.is_file():
with result.open("rb") as stream:
previous = b""
while chunk := stream.read(1024 * 1024):
data = previous + chunk
if any(secret in data for secret in secrets):
raise ValueError("credential detected in results; artifact upload is blocked")
previous = data[-overlap:] if overlap else b""
def wait_for_benchmark(env, started_at):
"""Bound runner allocation even when the benchmark job never leaves its queue."""
run_id, attempt = owned_prefix(env["RUN_PREFIX"]).split("/")[1].split("-")
repo = env["GITHUB_REPOSITORY"]
if run_id != env["GITHUB_RUN_ID"] or not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
raise ValueError("workflow identity does not match its owned resource prefix")
minutes = int(env["BENCHMARK_TIMEOUT_MINUTES"])
if not 30 <= minutes <= 240 or started_at <= 0:
raise ValueError("invalid benchmark allocation deadline")
# Use the original allocation timestamp; a delayed or retried watcher cannot extend it.
deadline = started_at + (45 + minutes + 10) * 60
request = urllib.request.Request(
f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/attempts/{attempt}/jobs?per_page=100",
headers={"Authorization": f"Bearer {env['GH_TOKEN']}", "Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"})
while True:
remaining = deadline - time.time()
if remaining <= 0:
raise TimeoutError("benchmark allocation deadline reached; proceeding to resource cleanup")
# API errors deliberately fail this step: subsequent always() steps clean up immediately.
with urllib.request.urlopen(request, timeout=min(15, remaining)) as response:
jobs = json.load(response)["jobs"]
benchmark = next((job for job in jobs if job["name"] == "benchmark"), None)
if benchmark and benchmark["status"] == "completed":
return {"benchmark_conclusion": benchmark["conclusion"], "owned_attempt": attempt}
remaining = deadline - time.time()
if remaining > 0:
time.sleep(min(30, remaining))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("plan", "probe", "inventory", "cleanup", "scrub", "cost", "wait-for-benchmark"))
parser.add_argument("--output")
parser.add_argument("--started-at", type=int)
parser.add_argument("--round")
args = parser.parse_args()
if args.round is not None and args.command != "inventory":
parser.error("--round applies only to inventory")
if args.command == "plan":
result = plan(os.environ)
elif args.command == "scrub":
scrub(os.environ["RESULTS"], os.environ)
return
elif args.command == "wait-for-benchmark":
if args.started_at is None:
parser.error("wait-for-benchmark requires --started-at")
result = wait_for_benchmark(os.environ, args.started_at)
elif args.command == "cost":
if args.started_at is None:
parser.error("cost requires --started-at")
seconds = max(0, int(time.time()) - args.started_at)
estimate = HOURLY_USD * Decimal(seconds) / 3600
result = {"allocation_window_seconds": seconds, "ec2_estimate_usd": str(round(estimate, 4)),
"campaign_estimate_with_other_reserve_usd": str(round(
Decimal(os.environ["PRIOR_SPEND_USD"]) + estimate + OTHER_RESERVE_USD, 4)),
"note": "Allocation wall-time estimate plus $1.50 reserve for S3, disk and IPv4; not an AWS bill."}
else:
prefix = owned_prefix(os.environ["RUN_PREFIX"])
s3, bucket = client(os.environ["S3_REGION"]), os.environ["S3_BUCKET"]
if args.command == "inventory":
result = inventory(s3, bucket, prefix, args.round)
else:
result = globals()[args.command](s3, bucket, prefix)
rendered = json.dumps(result, indent=2) + "\n"
if args.output:
destination = Path(args.output)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(rendered)
print(rendered, end="")
if __name__ == "__main__":
main()
@@ -0,0 +1,101 @@
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
"fixture", Path(__file__).resolve().parents[1] / "metric-snapshot-fixture.py"
)
fixture = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fixture)
class ValidatorTests(unittest.TestCase):
def records(self):
return {
"schema": {"column_schemas": [
{"name": "ts", "data_type": "TimestampMillisecond"},
*[{"name": f"tag_{i:03d}", "data_type": "String"} for i in range(10)],
{"name": "val", "data_type": "Float64"},
]},
"rows": [fixture.row(0, j, 50) for j in range(32)],
}
def test_full_tag_coverage_and_heterogeneous_schemas(self):
for per_group in (50, 10000):
variants = {tuple(fixture.tags(i, per_group)) for i in range(per_group)}
self.assertEqual(set().union(*map(set, variants)), set(range(298)))
self.assertGreater(len(variants), 1)
self.assertTrue(all(len(tags) == 10 for tags in variants))
def test_accepts_engine_column_order_with_matching_values(self):
records = self.records()
order = [*range(1, 11), 0, 11] # The engine returns tags, timestamp, value.
records["schema"]["column_schemas"] = [
records["schema"]["column_schemas"][index] for index in order
]
records["rows"] = [[values[index] for index in order] for values in records["rows"]]
fixture.check_records(0, 50, records)
records["rows"][1][10] = 2
with self.assertRaisesRegex(RuntimeError, "typed values"):
fixture.check_records(0, 50, records)
def test_rejects_column_type_and_membership_changes(self):
fixture.check_records(0, 50, self.records())
for mutation in ("type", "extra", "missing"):
records = self.records()
columns = records["schema"]["column_schemas"]
if mutation == "type":
columns[0]["data_type"] = "TimestampMicrosecond"
elif mutation == "extra":
columns.append({"name": "unexpected", "data_type": "String"})
else:
del columns[1]
with self.subTest(mutation=mutation), self.assertRaisesRegex(RuntimeError, "schema"):
fixture.check_records(0, 50, records)
def test_rejects_null_and_numeric_type_changes(self):
for value in ("", 0, False):
records = self.records()
records["rows"][0][-1] = value # Expected SQL NULL.
with self.subTest(value=value), self.assertRaisesRegex(RuntimeError, "typed values"):
fixture.check_records(0, 50, records)
records = self.records()
records["rows"][4][-1] = 1 # Numeric equality to 1.0 must not hide a type change.
with self.assertRaisesRegex(RuntimeError, "typed values"):
fixture.check_records(0, 50, records)
def test_source_comparison_requires_same_ddl_and_remapped_ids(self):
source = {"fixture": {"tables_per_group": 50}, "schema_sha256": "schema",
"data_sha256": "data", "ddl_sha256": "ddl",
"table_ids": {"m000000": 1024, "phy_00": 1025}}
target = copy.deepcopy(source)
target["table_ids"] = {"m000000": 1041, "phy_00": 1042}
fixture.compare_source(target, source)
self.assertEqual(target["remapped_table_ids"], 2)
for field in ("schema_sha256", "data_sha256", "ddl_sha256"):
changed = copy.deepcopy(target)
changed[field] = "changed"
with self.subTest(field=field), self.assertRaisesRegex(RuntimeError, field):
fixture.compare_source(changed, source)
target["table_ids"]["phy_00"] = 1025
with self.assertRaisesRegex(RuntimeError, "not all remapped"):
fixture.compare_source(target, source)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Failure accounting for the metric snapshot runner."""
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
SPEC = importlib.util.spec_from_file_location(
"metric_snapshot_bench", Path(__file__).resolve().parents[1] / "metric-snapshot-s3-bench.py")
bench = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(bench)
class PhaseTests(unittest.TestCase):
def test_docker_nanosecond_timestamps(self):
start = bench.docker_timestamp("2026-10-14T06:06:44.72000818Z")
end = bench.docker_timestamp("2026-10-14T06:06:46.123456789Z")
self.assertAlmostEqual((end - start).total_seconds(), 1.403448)
def test_resource_interference_invalidates_success_and_preserves_fixture(self):
with tempfile.TemporaryDirectory() as directory:
runner = bench.Benchmark.__new__(bench.Benchmark)
runner.round_dir = Path(directory)
runner.server_pid = 0
runner.phases = []
runner.metrics = lambda: ""
runner.summary = lambda: None
fixture = runner.round_dir / "source-validation.json"
fixture.write_text('{"data_sha256":"fixture"}')
before = {"available": True, "pswpout_pages": 0, "oom_kill": 0}
after = {"available": True, "pswpout_pages": 1, "oom_kill": 0}
with patch.object(bench, "host_memory", side_effect=[before, after]):
with self.assertRaisesRegex(RuntimeError, "Resource interference"):
runner.phase("source-validation", lambda: None)
self.assertEqual(json.loads(fixture.read_text()), {"data_sha256": "fixture"})
evidence = json.loads((runner.round_dir / "source-validation-resources.json").read_text())
self.assertEqual(evidence["outcome"], "resource-invalid")
self.assertEqual(evidence["new_swapout_pages"], 1)
def test_action_failure_keeps_failed_resource_evidence(self):
with tempfile.TemporaryDirectory() as directory:
runner = bench.Benchmark.__new__(bench.Benchmark)
runner.round_dir = Path(directory)
runner.server_pid = 0
runner.phases = []
runner.metrics = lambda: ""
runner.summary = lambda: None
def fail():
raise RuntimeError("export failed")
with patch.object(bench, "host_memory", return_value={"available": False}):
with self.assertRaisesRegex(RuntimeError, "export failed"):
runner.phase("export", fail)
evidence = json.loads((runner.round_dir / "export-resources.json").read_text())
self.assertEqual(evidence["outcome"], "failed")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Offline safety checks for the metric snapshot workflow's budget and cleanup."""
import importlib.util
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
SPEC = importlib.util.spec_from_file_location("snapshot_ops", Path(__file__).parents[1] / "metric-snapshot-s3-ops.py")
ops = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ops)
PREFIX = "metric-snapshot-bench/123-1/"
class FakeS3:
def __init__(self):
self.objects = [{"Key": PREFIX + "current", "Size": 11}, {"Key": PREFIX + "data", "Size": 7}]
self.deleted = []
def get_paginator(self, operation):
assert operation == "list_objects_v2"
parent = self
class Paginator:
def paginate(self, **kwargs):
assert kwargs["Bucket"] == "bucket" and kwargs["Prefix"].startswith(PREFIX)
# Keys outside the run prefix model a misbehaving listing and are always returned.
return iter([{"Contents": [item for item in parent.objects if not item["Key"].startswith(PREFIX)
or item["Key"].startswith(kwargs["Prefix"])]}])
return Paginator()
def put_object(self, **kwargs):
self.objects.append({"Key": kwargs["Key"], "Size": len(kwargs["Body"])})
return {}
def get_object(self, **kwargs):
return {"Body": io.BytesIO(b"metric snapshot benchmark")}
def delete_object(self, **kwargs):
self.deleted.append(kwargs)
self.objects = [item for item in self.objects if item["Key"] != kwargs["Key"]]
class OperationsTest(unittest.TestCase):
def env(self, **changes):
return dict(GREPTIMEDB_IMAGE="greptime/greptimedb-nightly@sha256:" + "a" * 64,
EXPECTED_COMMIT="b" * 40, LOGICAL_TABLES_PER_GROUP="10000",
BENCHMARK_TIMEOUT_MINUTES="240", PRIOR_SPEND_USD="0",
S3_REGION="ap-southeast-1", RUNNER_REGION="ap-southeast-1", S3_BUCKET="bucket",
RUN_PREFIX=PREFIX) | changes
def test_budget_includes_provision_cleanup_and_other_cost_reserve(self):
plan = ops.plan(self.env())
self.assertEqual(plan["attempt_reserve_usd"], "6.4392")
ops.plan(self.env(PRIOR_SPEND_USD="13.5608"))
for changes in ({"PRIOR_SPEND_USD": "13.5609"}, {"PRIOR_SPEND_USD": "15"},
{"PRIOR_SPEND_USD": "NaN"}, {"PRIOR_SPEND_USD": "-1"},
{"BENCHMARK_TIMEOUT_MINUTES": "241"}, {"LOGICAL_TABLES_PER_GROUP": "49"},
{"LOGICAL_TABLES_PER_GROUP": "10001"}, {"S3_REGION": "us-east-1"},
{"GREPTIMEDB_IMAGE": "greptime/greptimedb:latest"},
{"GREPTIMEDB_IMAGE": "docker.io/greptime/greptimedb@sha256:" + "a" * 64},
{"GREPTIMEDB_IMAGE": "greptime/greptimedb:nightly@sha256:" + "a" * 64},
{"EXPECTED_COMMIT": "abcdef"}):
with self.subTest(changes=changes), self.assertRaises(ValueError):
ops.plan(self.env(**changes))
def watcher_env(self):
return self.env(GITHUB_REPOSITORY="GreptimeTeam/greptimedb", GITHUB_RUN_ID="123",
GITHUB_RUN_ATTEMPT="2", GH_TOKEN="test-token", BENCHMARK_TIMEOUT_MINUTES="30")
def test_watcher_completed_job_uses_original_attempt_and_does_not_wait(self):
for conclusion in ("success", "failure", "cancelled"):
response = io.BytesIO(json.dumps({"jobs": [{"name": "benchmark", "status": "completed",
"conclusion": conclusion}]}).encode())
with self.subTest(conclusion=conclusion), \
mock.patch.object(ops.urllib.request, "urlopen", return_value=response) as fetch, \
mock.patch.object(ops.time, "time", return_value=1005), \
mock.patch.object(ops.time, "sleep") as sleep:
self.assertEqual(ops.wait_for_benchmark(self.watcher_env(), 1000),
{"benchmark_conclusion": conclusion, "owned_attempt": "1"})
self.assertIn("/runs/123/attempts/1/jobs?", fetch.call_args.args[0].full_url)
sleep.assert_not_called()
def test_watcher_queue_reaches_durable_deadline(self):
deadline = 1000 + (45 + 30 + 10) * 60
clock = [deadline - 31]
def advance(seconds):
self.assertLessEqual(seconds, 30)
clock[0] += seconds
def queued(*args, **kwargs):
return io.BytesIO(b'{"jobs": [{"name": "benchmark", "status": "queued"}]}')
with mock.patch.object(ops.urllib.request, "urlopen", side_effect=queued) as fetch, \
mock.patch.object(ops.time, "time", side_effect=lambda: clock[0]), \
mock.patch.object(ops.time, "sleep", side_effect=advance), \
self.assertRaisesRegex(TimeoutError, "proceeding to resource cleanup"):
ops.wait_for_benchmark(self.watcher_env(), 1000)
self.assertEqual(clock[0], deadline)
self.assertEqual(fetch.call_count, 2)
def test_watcher_api_error_fails_immediately_for_cleanup(self):
with mock.patch.object(ops.urllib.request, "urlopen", side_effect=OSError("API unavailable")), \
mock.patch.object(ops.time, "time", return_value=1005), \
mock.patch.object(ops.time, "sleep") as sleep, \
self.assertRaises(OSError):
ops.wait_for_benchmark(self.watcher_env(), 1000)
sleep.assert_not_called()
def test_cleanup_deletes_every_listed_object(self):
s3 = FakeS3()
result = ops.cleanup(s3, "bucket", PREFIX)
self.assertEqual(result["objects"], 0)
self.assertEqual(s3.deleted, [{"Bucket": "bucket", "Key": PREFIX + "current"},
{"Bucket": "bucket", "Key": PREFIX + "data"}])
def test_probe_refuses_occupied_prefix_and_removes_its_object(self):
with self.assertRaisesRegex(ValueError, "already occupied"):
ops.probe(FakeS3(), "bucket", PREFIX)
s3 = FakeS3()
s3.objects = []
self.assertEqual(ops.probe(s3, "bucket", PREFIX)["objects"], 0)
self.assertEqual(s3.deleted, [{"Bucket": "bucket", "Key": PREFIX + "_probe"}])
def test_failed_probe_delete_still_has_hosted_cleanup_path(self):
s3 = FakeS3()
s3.objects = []
with mock.patch.object(s3, "delete_object", side_effect=OSError("temporary delete failure")), \
self.assertRaises(OSError):
ops.probe(s3, "bucket", PREFIX)
self.assertEqual([item["Key"] for item in s3.objects], [PREFIX + "_probe"])
workflow = (Path(__file__).parents[2] / "workflows/metric-snapshot-s3-bench.yml").read_text()
teardown = workflow.split(" teardown:\n", 1)[1]
job_if = next(line.strip() for line in teardown.splitlines() if line.startswith(" if:"))
self.assertEqual(job_if, "if: ${{ always() && needs.validate.outputs.run_id != '' }}")
ec2 = teardown.split(" - name: Delete EC2, its temporary disks and runner registration\n", 1)[1]
self.assertEqual(ec2.splitlines()[0].strip(), "if: ${{ always() && needs.provision.result != 'skipped' }}")
self.assertIn(" RUN_PREFIX: ${{ needs.validate.outputs.run_prefix }}\n", teardown.split(" steps:", 1)[0])
s3_cleanup = teardown.split(" - name: Remove and verify only this run's S3 prefix\n", 1)[1].split(" - ", 1)[0]
self.assertEqual(s3_cleanup.splitlines()[0].strip(), "if: always()")
self.assertIn("run: uv run greptimedb/.github/scripts/metric-snapshot-s3-ops.py cleanup\n", s3_cleanup)
self.assertEqual(ops.cleanup(s3, "bucket", PREFIX)["objects"], 0)
def test_round_inventory_excludes_other_rounds_while_cleanup_covers_the_run(self):
s3 = FakeS3()
s3.objects = [{"Key": PREFIX + "smoke/pack-000000.bin", "Size": 3},
{"Key": PREFIX + "full/pack-000000.bin", "Size": 5},
{"Key": PREFIX + "full/index.json", "Size": 2}]
full = ops.inventory(s3, "bucket", PREFIX, "full")
self.assertEqual((full["prefix"], full["objects"], full["bytes"]), (PREFIX + "full/", 2, 7))
self.assertEqual(ops.inventory(s3, "bucket", PREFIX, "smoke")["bytes"], 3)
self.assertEqual(ops.inventory(s3, "bucket", PREFIX)["objects"], 3)
for round in ("", "../full", "full/"):
with self.subTest(round=round), self.assertRaises(ValueError):
ops.inventory(s3, "bucket", PREFIX, round)
self.assertEqual(ops.cleanup(s3, "bucket", PREFIX)["objects"], 0)
self.assertEqual(len(s3.deleted), 3)
def test_bad_prefix_and_out_of_scope_response_cannot_delete(self):
for prefix in ("", "metric-snapshot-bench/", "metric-snapshot-bench/123-1", PREFIX + "../", "oswal-bench/123-1/"):
s3 = FakeS3()
with self.subTest(prefix=prefix), self.assertRaises(ValueError):
ops.cleanup(s3, "bucket", prefix)
self.assertEqual(s3.deleted, [])
s3 = FakeS3()
s3.objects[0]["Key"] = "other-run/current"
with self.assertRaises(ValueError):
ops.inventory(s3, "bucket", PREFIX, "full")
with self.assertRaises(ValueError):
ops.cleanup(s3, "bucket", PREFIX)
self.assertEqual(s3.deleted, [])
def test_credential_crossing_read_boundary_blocks_artifact_upload(self):
with tempfile.TemporaryDirectory() as directory:
logfile = Path(directory) / "server.log"
logfile.write_bytes(b"a" * (1024 * 1024 - 4) + b"private-key" + b"tail")
with self.assertRaisesRegex(ValueError, "upload is blocked"):
ops.scrub(directory, {"AWS_SECRET_ACCESS_KEY": "private-key"})
logfile.write_text("safe log\n")
ops.scrub(directory, {"AWS_SECRET_ACCESS_KEY": "private-key"})
if __name__ == "__main__":
unittest.main()