chore: merge narthex into feat/narthex-range-modifiers

This commit is contained in:
Dennis Zhuang
2026-10-23 14:56:31 +08:00
48 changed files with 11932 additions and 509 deletions
@@ -57,6 +57,7 @@ runs:
# Without GT_S3_*, every remote object-store case is skipped and the
# release only ever exercises the local file backend.
env:
RUST_MIN_STACK: 8388608 # 8 MiB, aligned with Rust CI tests.
GT_S3_BUCKET: ${{ inputs.aws-ci-test-bucket }}
GT_S3_REGION: ${{ inputs.aws-region }}
GT_S3_ACCESS_KEY_ID: ${{ inputs.aws-access-key-id }}
@@ -64,6 +64,7 @@ runs:
shell: bash
env:
CARGO_BUILD_RUSTFLAGS: "-Clink-arg=-Wl,-ld_classic"
RUST_MIN_STACK: 8388608 # 8 MiB, aligned with Rust CI and release tests.
SQLNESS_OPTS: "--preserve-state"
run: |
make test sqlness-test
@@ -22,7 +22,9 @@ inputs:
runs:
using: composite
steps:
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
tool: protoc@3.23.4
- name: Install rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -48,6 +50,7 @@ runs:
run: make test sqlness-test
env:
RUST_BACKTRACE: 1
RUST_MIN_STACK: 8388608 # 8 MiB, aligned with Rust CI tests.
SQLNESS_OPTS: "--preserve-state"
- name: Upload sqlness logs
@@ -1,3 +1,7 @@
datanode:
storage:
storageClassName: standard
tracing:
enabled: true
endpoint: "http://my-greptimedb-monitor-standalone.my-greptimedb:4000/v1/otlp/v1/traces"
@@ -15,9 +19,9 @@ monitoring:
limits:
cpu: 500m
memory: 256Mi
datanodeStorage:
fs:
storageSize: 5Gi
datanodeStorage:
fs:
storageSize: 5Gi
vector:
resources:
requests:
+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()
+229
View File
@@ -0,0 +1,229 @@
#!/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.
"""Writer and oracle of the benchmark's crash cells.
Every writer owns the stripe `first + index, first + index + writers, ...` of
batch sequence numbers, inserts `rows` rows per batch and stops at the first
statement that is not acknowledged, so a stripe has at most one attempted but
unacknowledged batch: the one in flight at the kill. Every request is logged
with its status and the time it ended, on the same clock as the kill stamps.
With `--seconds` the writers also stop once that long has passed, and the
command fails unless every writer wrote until then without a failure.
"""
from __future__ import annotations
import argparse
import collections
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def insert(url: str, table: str, seq: int, rows: int) -> str:
values = ", ".join(f"({seq}, {seq * rows + i})" for i in range(rows))
body = urllib.parse.urlencode({"sql": f"INSERT INTO {table} (seq, ts) VALUES {values}"}).encode()
try:
with urllib.request.urlopen(urllib.request.Request(url, data=body), timeout=60) as response:
return str(response.status)
except urllib.error.HTTPError as error:
return str(error.code)
except (OSError, urllib.error.URLError):
# The connection dropped: the request may or may not have been applied.
return "000"
def writer(args, index: int, deadline: float, failures: list) -> None:
directory, seq = Path(args.dir), args.first_seq + index
with open(directory / f"attempted-{index}.log", "a") as attempted, \
open(directory / f"requests-{index}.log", "a") as requests, \
open(directory / f"acked-{index}.log", "a") as acked:
while time.monotonic() < deadline:
attempted.write(f"{seq}\n")
attempted.flush()
code = insert(args.url, args.table, seq, args.rows)
requests.write(f"{seq} {code} {time.time_ns()}\n")
requests.flush()
if code != "200":
failures.append(f"writer {index}: batch {seq} ended with {code}")
break
acked.write(f"{seq}\n")
acked.flush()
seq += args.writers
(directory / f"exit-{index}.log").write_text(f"{time.time_ns()}\n")
def write(args) -> int:
os.makedirs(args.dir, exist_ok=True)
deadline = time.monotonic() + args.seconds if args.seconds else float("inf")
failures = []
def run(index):
try:
writer(args, index, deadline, failures)
except Exception as error:
failures.append(f"writer {index}: {error!r}")
raise
threads = [threading.Thread(target=run, args=(i,)) for i in range(args.writers)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# Crash cells end their writers with a kill; timed writers must have run to the deadline.
if args.seconds and (failures or not any(lines(Path(args.dir) / f"acked-{i}.log") for i in range(args.writers))):
print("; ".join(failures) or "no batch was acknowledged", file=sys.stderr)
return 1
return 0
def lines(path: Path) -> list:
return [line.split() for line in path.read_text().splitlines() if line.strip()] if path.exists() else []
def found_batches(path: Path) -> dict:
"""GreptimeDB's JSON answer to SELECT seq, count(*) ... GROUP BY seq, as batch -> row count."""
return {int(seq): int(count) for seq, count in json.loads(path.read_text())["output"][0]["records"]["rows"]}
def check_kill(directory: Path, writers: int, before_ns: int, after_ns: int, window_max_ms: int) -> dict:
"""A dropped connection is in flight when it ended at or after the stamp taken before the kill."""
errors, in_flight, running = [], 0, 0
if after_ns - before_ns > window_max_ms * 1_000_000:
errors.append(f"kill window {after_ns - before_ns}ns exceeds {window_max_ms}ms")
for index in range(writers):
exit_path = directory / f"exit-{index}.log"
if not exit_path.exists():
errors.append(f"writer {index} left no exit time")
continue
running += int(exit_path.read_text()) >= before_ns
for seq, code, end_ns in lines(directory / f"requests-{index}.log"):
if code == "200":
continue
if code != "000":
errors.append(f"writer {index}: batch {seq} rejected with HTTP {code}")
elif int(end_ns) < before_ns:
errors.append(f"writer {index}: batch {seq} lost its connection before the kill")
else:
in_flight += 1
if running == 0:
errors.append("no writer was running at the kill")
return {"kill_window_ns": after_ns - before_ns, "in_flight": in_flight, "writers_at_kill": running,
"kill_errors": errors}
def verify(run_dir: Path, found: dict, rows: int, enqueued: bool, cycle: int) -> dict:
"""Checks the table, read as batch -> row count, against every cycle's writers so far and
against what every earlier restart recovered, and counts what the given cycle's writers
acknowledged, left and lost on their own."""
acked, stripes, cycle_attempted, cycle_acked = [], collections.OrderedDict(), set(), set()
recovered_before = set()
for cycle_dir in sorted(run_dir.glob("cycle-*"), key=lambda p: int(p.name.split("-")[1])):
current = cycle_dir.name == f"cycle-{cycle}"
if int(cycle_dir.name.split("-")[1]) < cycle and (cycle_dir / "found.json").exists():
recovered_before.update(found_batches(cycle_dir / "found.json"))
for path in sorted(cycle_dir.glob("attempted-*.log")):
stripes[(cycle_dir.name, path.name)] = [int(seq) for (seq,) in lines(path)]
if current:
cycle_attempted.update(stripes[(cycle_dir.name, path.name)])
for path in sorted(cycle_dir.glob("acked-*.log")):
acked += [int(seq) for (seq,) in lines(path)]
if current:
cycle_acked.update(int(seq) for (seq,) in lines(path))
acked_set = set(acked)
attempted = {seq for stripe in stripes.values() for seq in stripe}
errors = []
duplicated = sorted(seq for seq, count in found.items() if count > rows)
partial = sorted(seq for seq, count in found.items() if count < rows)
if duplicated:
errors.append(f"batches present more than once: {duplicated[:20]}")
if partial:
errors.append(f"batches present in part: {partial[:20]}")
missing = sorted(acked_set - set(found))
if missing and not enqueued:
errors.append(f"acknowledged but absent: {missing[:20]}")
# A batch an earlier restart recovered was durable then, so in either mode only the
# current cycle's backlog that was not durable at its kill may be missing.
vanished = sorted(recovered_before - set(found))
if vanished:
errors.append(f"recovered by an earlier restart but absent: {vanished[:20]}")
unattempted = sorted(set(found) - attempted)
if unattempted:
errors.append(f"present but never attempted: {unattempted[:20]}")
# The unacknowledged tail of each stripe was in flight at a kill.
in_flight = set()
for key, stripe in stripes.items():
prefix = 0
while prefix < len(stripe) and stripe[prefix] in acked_set:
prefix += 1
if any(seq in acked_set for seq in stripe[prefix:]):
errors.append(f"{key}: acknowledged after a failure")
in_flight.update(stripe[prefix:])
extra = sorted(set(found) - acked_set)
stray = [seq for seq in extra if seq not in in_flight]
if stray and not enqueued:
errors.append(f"present, unacknowledged and not in flight at a kill: {stray[:20]}")
if len(acked) != len(acked_set):
errors.append("a batch was acknowledged twice")
present = set(found) & cycle_attempted
return {"cycle_acked_batches": len(cycle_acked), "cycle_present_batches": len(present),
"cycle_lost_batches": len(cycle_acked - present), "cycle_lost_rows": len(cycle_acked - present) * rows,
"cycle_extra_batches": len(present - cycle_acked),
"total_found_batches": len(found), "total_acked_batches": len(acked_set),
"total_lost_batches": len(missing), "total_extra_batches": len(extra), "verify_errors": errors}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
w = commands.add_parser("write")
w.add_argument("--dir", required=True)
w.add_argument("--url", required=True)
w.add_argument("--table", default="crash")
for name in ("writers", "first-seq", "rows"):
w.add_argument(f"--{name}", type=int, required=True)
w.add_argument("--seconds", type=float, default=0, help="stop after this long; 0 writes until a failure")
c = commands.add_parser("check")
c.add_argument("--run-dir", type=Path, required=True)
c.add_argument("--cycle", type=int, required=True)
c.add_argument("--found", type=Path, required=True, help="GreptimeDB JSON output of SELECT seq, count(*)")
c.add_argument("--mode", choices=("durable", "enqueued"), required=True)
for name in ("writers", "rows", "before-ns", "after-ns", "window-max-ms"):
c.add_argument(f"--{name}", type=int, required=True)
c.add_argument("--pin-rows", type=int, help="rows of the pin table, whose one row was acknowledged first")
args = parser.parse_args()
if args.command == "write":
return write(args)
result = {"cycle": args.cycle}
result |= check_kill(args.run_dir / f"cycle-{args.cycle}", args.writers, args.before_ns, args.after_ns,
args.window_max_ms)
result |= verify(args.run_dir, found_batches(args.found), args.rows,
args.mode == "enqueued", args.cycle)
if args.pin_rows is not None and (args.pin_rows > 1 or args.mode == "durable" and args.pin_rows != 1):
result["verify_errors"].append(f"the pin table holds {args.pin_rows} rows, not 1")
result["passed"] = not result["kill_errors"] and not result["verify_errors"]
print(json.dumps(result))
return 0 if result["passed"] else 1
if __name__ == "__main__":
sys.exit(main())
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Runs inside the o11ybench runtime image: the load of scripts/agent-observability/greptimedb.sh
# with the same arguments plus the loader's --skip-create-table, for a table the cell created.
set -euo pipefail
mkdir "/results/$RESULT_NAME"
mkdir -p /results/corpus
cp /work/corpus/summary.json /work/corpus/evidence.json /results/corpus/
w="$PWD/workloads/logbench-agent-observability"
r="/results/$RESULT_NAME"
prepare_workers=${PREPARE_WORKERS:-$(python3 -c 'import sys; sys.path.insert(0, "scripts"); from benchmark_workers import effective_cpus; print(effective_cpus())')}
o11ybench-greptimedb-loader --workload logbench-agent-observability --prepare-workers "$prepare_workers" \
--skip-create-table \
--grpc-endpoint "$GRPC_ENDPOINT" --sql-endpoint "$ENDPOINT" \
--database public --table agent_observations --batch-size "${GREPTIMEDB_BATCH_SIZE:-10000}" --write-mode stream --max-in-flight "${MAX_IN_FLIGHT:-1}" \
--flush-table --request-timeout-seconds 900 \
--schema "$w/schema.json" --physical-index-profiles "$w/physical-index-profiles.json" \
--data /work/corpus/agent_observations.jsonl \
--profile-summary /work/corpus/summary.json --evidence /work/corpus/evidence.json \
--transcript-output "$r/load-transcript.jsonl" \
--target-metadata-output "$r/target-metadata.json" \
--greptimedb-image "$TARGET_IMAGE" --greptimedb-version "$TARGET_VERSION" \
--greptimedb-digest "$TARGET_DIGEST" \
--startup-input=standalone --startup-input=start \
--startup-input=--http-addr=0.0.0.0:4000 \
--startup-input=--grpc-bind-addr=0.0.0.0:4001 \
> "$r/load-result.json"
+359 -51
View File
@@ -10,24 +10,64 @@ runtime() {
--security-opt no-new-privileges --cap-drop ALL "$@"
}
# Pin the runtime image to a digest without pulling it: ECR Public's anonymous data limit can refuse a
# hosted runner even a manifest read. A reference that names its digest is pinned as given; only a tag
# is resolved from the registry's manifest. The benchmark host pulls the image and checks the contents.
check_runtime() {
local image=$1 resolved
local image=$1 repository digest attempt component='[a-z0-9]+(([._]|__|-+)[a-z0-9]+)*'
[[ "$image" == public.ecr.aws/* ]] || { echo 'The runtime image must come from AWS ECR Public' >&2; return 1; }
bash "$here/pull-benchmark-image.sh" "$image"
resolved=$(docker image inspect "$image" | jq -er '.[0].RepoDigests[0]')
docker run --rm --entrypoint sh "$resolved" -c \
'test -f scripts/agent-observability/greptimedb.sh && test -f scripts/generate_agent_observations.py' \
|| { echo 'The runtime image lacks the agent observability loader or generator' >&2; return 1; }
printf 'runtime_image=%s\n' "$resolved" >> "${GITHUB_OUTPUT:-/dev/stdout}"
# Docker's reference grammar, checked here because a named digest never reaches the registry.
local grammar="^public\.ecr\.aws(/$component)+(:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(@sha256:[0-9a-f]{64})?\$"
[[ "$image" =~ $grammar ]] || { echo "$image is not a full ECR Public image reference" >&2; return 1; }
if [[ "$image" == *@* ]]; then
digest=${image##*@}
else
for attempt in 1 2 3; do
digest=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$image" | jq -er .digest) && break
((attempt < 3)) || { echo "Cannot read the manifest of $image" >&2; return 1; }
sleep $((attempt * 10))
done
fi
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "$image has no sha256 digest" >&2; return 1; }
repository=${image%@*}
[[ "${repository##*/}" != *:* ]] || repository=${repository%:*}
printf 'runtime_image=%s@%s\n' "$repository" "$digest" >> "${GITHUB_OUTPUT:-/dev/stdout}"
}
# Pull every GreptimeDB image of the run, pin it to its digest and record the version and
# commit it reports, before any instance is allocated.
check_images() {
local entry image resolved info version commit checked=()
while IFS= read -r entry; do
image=$(jq -r .image <<< "$entry")
bash "$here/pull-benchmark-image.sh" "$image"
resolved=$(docker image inspect "$image" | jq -er '[.[0].RepoDigests[]? | select(test("@sha256:[0-9a-f]{64}$"))][0]') \
|| { echo "$image has no sha256 repository digest" >&2; return 1; }
info=$(docker run --rm --entrypoint greptime "$resolved" --version)
version=$(sed -n 's/^version: //p' <<< "$info") commit=$(sed -n 's/^commit: //p' <<< "$info")
[[ -n "$version" && -n "$commit" ]] || { echo "$image reports no version or commit" >&2; return 1; }
echo "${image}: $resolved, version $version, commit $commit"
checked+=("$(jq -c --arg image "$resolved" --arg version "$version" --arg commit "$commit" \
'{label, image: $image, requested: .image, version: $version, commit: $commit}' <<< "$entry")")
done < <(jq -c '.[]' <<< "$1")
((${#checked[@]})) || { echo 'No GreptimeDB image to check' >&2; return 1; }
printf 'greptimedb_images=%s\n' "$(jq -sc . <<< "${checked[*]}")" >> "${GITHUB_OUTPUT:-/dev/stdout}"
}
pull_images() {
: "${RESULTS:?}" "${RUNTIME_IMAGE:?}" "${GREPTIMEDB_TAG:?}"
: "${RESULTS:?}" "${RUNTIME_IMAGE:?}" "${GREPTIMEDB_IMAGES:?}"
local image images=()
mkdir -p "$RESULTS/images"
bash "$here/pull-benchmark-image.sh" "$RUNTIME_IMAGE"
bash "$here/pull-benchmark-image.sh" "greptime/greptimedb:$GREPTIMEDB_TAG"
docker run --rm --entrypoint sh "$RUNTIME_IMAGE" -c \
'test -f scripts/agent-observability/greptimedb.sh && test -f scripts/generate_agent_observations.py' \
|| { echo 'The runtime image lacks the agent observability loader or generator' >&2; return 1; }
while IFS= read -r image; do
bash "$here/pull-benchmark-image.sh" "$image"
images+=("$image")
done < <(jq -r '.[].image' <<< "$GREPTIMEDB_IMAGES")
docker image inspect "$RUNTIME_IMAGE" > "$RESULTS/images/runtime.json"
docker image inspect "greptime/greptimedb:$GREPTIMEDB_TAG" > "$RESULTS/images/greptimedb.json"
docker image inspect "${images[@]}" > "$RESULTS/images/greptimedb.json"
}
wal_disk() {
@@ -46,25 +86,242 @@ wal_disk() {
findmnt -no SOURCE,FSTYPE -T "$root" | tee "$RESULTS/wal-disk.txt"
}
# One corpus per row count the load cells use; crash cells write their own rows.
generate() {
: "${PLAN:?}" "${RESULTS:?}" "${CORPUS_DIR:?}" "${OWNER:?}" "${RUNTIME_IMAGE:?}"
local args=() arg
while IFS= read -r arg; do args+=("$arg"); done < <(jq -r '.generator_args[]' "$PLAN")
mkdir -p "$CORPUS_DIR"
runtime --name "$OWNER-generator" --mount "type=bind,src=$CORPUS_DIR,dst=/work/corpus" \
"$RUNTIME_IMAGE" scripts/generate_agent_observations.py "${args[@]}" \
--output /work/corpus/agent_observations.jsonl \
--summary-output /work/corpus/summary.json --evidence-output /work/corpus/evidence.json \
> "$RESULTS/generator.log" 2>&1
jq -e --argjson rows "$(jq .rows "$PLAN")" '.line_count == $rows' "$CORPUS_DIR/summary.json" >/dev/null
local corpus args arg dir rows
while IFS= read -r corpus; do
args=() dir="$CORPUS_DIR/$(jq -r .dir <<< "$corpus")" rows=$(jq .rows <<< "$corpus")
while IFS= read -r arg; do args+=("$arg"); done < <(jq -r '.generator_args[]' <<< "$corpus")
mkdir -p "$dir"
runtime --name "$OWNER-generator-$rows" --mount "type=bind,src=$dir,dst=/work/corpus" \
"$RUNTIME_IMAGE" scripts/generate_agent_observations.py "${args[@]}" \
--output /work/corpus/agent_observations.jsonl \
--summary-output /work/corpus/summary.json --evidence-output /work/corpus/evidence.json \
>> "$RESULTS/generator.log" 2>&1
jq -e --argjson rows "$rows" '.line_count == $rows' "$dir/summary.json" >/dev/null
done < <(jq -c '.corpora[]' "$PLAN")
}
# Scrape once, after the load succeeded or failed, while the server still runs.
# Scrape once per server, after the load succeeded or failed, while it still runs:
# the loaded server into metrics.prom, a restarted one into metrics-restart.prom.
scrape_metrics() {
curl --noproxy '*' -fsS --max-time 30 http://127.0.0.1:4000/metrics > "$dir/metrics.all" || { rm -f "$dir/metrics.all"; return 1; }
grep -E '^(# (HELP|TYPE) )?greptime_logstore_object_store_wal_' "$dir/metrics.all" > "$dir/metrics.prom" || true
grep -E '^(# (HELP|TYPE) )?(greptime_logstore_object_store_wal_|opendal_operation_(duration_seconds_count|errors_total))' \
"$dir/metrics.all" > "$dir/metrics$phase.prom" || true
rm -f "$dir/metrics.all"
metrics_scraped=true
case "$phase" in "") metrics_scraped=true ;; -restart) restart_scraped=true ;; esac
}
# A crash cycle cannot repeat a scrape once its server is killed, so it retries.
# The named WAL sample must be in the scrape, or it does not count.
scrape_retry() {
local required=$1 attempt
for attempt in 1 2 3; do
scrape_metrics && grep -q "^greptime_logstore_object_store_wal_$required " "$dir/metrics$phase.prom" && return 0
sleep 1
done
return 1
}
sql() { curl --noproxy '*' -fsS --max-time 30 --data-urlencode "sql=$1" 'http://127.0.0.1:4000/v1/sql?db=public'; }
now_ns() { python3 -c 'import time; print(time.time_ns())'; }
now() { date +%s.%N; }
elapsed() { awk -v a="$1" -v b="$(now)" 'BEGIN {printf "%.3f", b - a}'; }
# CPU seconds the server's cgroup used, and busy and total jiffies of the host.
server_cpu() { docker exec "$cid" cat /sys/fs/cgroup/cpu.stat 2>/dev/null | awk '/^usage_usec / {printf "%.3f", $2 / 1e6}' || true; }
host_cpu() { awk '/^cpu / {print $2 + $3 + $4 + $7 + $8 + $9, $2 + $3 + $4 + $5 + $6 + $7 + $8 + $9}' /proc/stat 2>/dev/null || true; }
# The host's 1, 5 and 15 minute load averages as a JSON list.
host_load() { awk '{printf "[%s, %s, %s]", $1, $2, $3}' "${OSWAL_LOADAVG:-/proc/loadavg}" 2>/dev/null || true; }
# A server's log is named after the phase it started in, whatever phase stops it.
start_server() {
server_phase=$phase
cid=$(docker run -d --init --name "$name$phase" --label "o11ybench.owner=$OWNER" \
--add-host host.docker.internal:host-gateway \
-p 127.0.0.1:4000:4000 -p 127.0.0.1:4001:4001 "${docker_env[@]}" ${mounts[@]+"${mounts[@]}"} \
"$GREPTIMEDB_IMAGE" standalone start --http-addr=0.0.0.0:4000 --grpc-bind-addr=0.0.0.0:4001)
local deadline=$((SECONDS + 300))
until curl --noproxy '*' -fsS --max-time 5 http://127.0.0.1:4000/health >/dev/null 2>&1; do
[[ $(docker inspect --format '{{.State.Running}}' "$cid") == true ]] || { echo "$id: server$phase exited" >&2; exit 1; }
((SECONDS < deadline)) || { echo "$id: server$phase not healthy in 300s" >&2; exit 1; }
sleep 0.2
done
}
# kill sends SIGKILL, killed collects a server the caller already killed, and
# no argument stops it gracefully.
stop_server() {
case "${1:-}" in
kill) docker kill --signal KILL "$cid" >/dev/null ;;
killed) docker wait "$cid" >/dev/null ;;
*) docker stop -t 120 "$cid" >/dev/null ;;
esac
docker logs "$cid" > "$dir/server$server_phase.log" 2>&1
docker rm -fv "$cid" >/dev/null
cid=
}
# Kill the loaded server, start the same configuration on the same prefix and
# data home, and time it until /health and until the loaded table answers.
restart() {
local table count started deadline
table=$(jq -er .table "$dir/greptimedb-1/load-result.json")
scrape_metrics || { echo "$id: metrics scrape failed" >&2; exit 4; }
# The objects recovery has to read are those still indexed at the kill.
indexed_before_kill=$(awk '/^greptime_logstore_object_store_wal_indexed_objects / {print $2}' "$dir/metrics.prom")
stop_server kill
phase=-restart started=$(now)
start_server
restart_seconds=$(elapsed "$started")
deadline=$((SECONDS + 300))
# Regions may still be opening after /health.
until count=$(curl --noproxy '*' -fsS --max-time 30 --data-urlencode "sql=SELECT count(*) FROM $table" \
'http://127.0.0.1:4000/v1/sql?db=public' | jq -er '.output[0].records.rows[0][0]'); do
((SECONDS < deadline)) || { echo "$id: $table not readable in 300s after restart" >&2; exit 5; }
sleep 0.2
done
restart_query_seconds=$(elapsed "$started") restart_count=$count
[[ "$count" == "$(jq .rows <<< "$spec")" ]] || { echo "$id: $count rows after restart" >&2; exit 5; }
# The restart was measured, but on fewer objects than the cell asked for.
awk -v n="${indexed_before_kill:-0}" -v min="$(jq -r '.min_objects // 0' <<< "$spec")" 'BEGIN {exit !(n >= min)}' \
|| { echo "$id: ${indexed_before_kill:-no} objects indexed at the kill, fewer than min_objects" >&2; exit 7; }
}
# Pins the WAL with one row in a second table before the writers start, if asked,
# and samples the object counts every 30 s while they run.
sample_objects() {
set +e
local body
while sleep "${OSWAL_SAMPLE_SECONDS:-30}"; do
# A failed scrape is a missing sample, never zero objects.
if ! body=$(curl --noproxy '*' -fsS --max-time 10 http://127.0.0.1:4000/metrics 2>/dev/null) \
|| ! grep -q '^greptime_logstore_object_store_wal_indexed_objects ' <<< "$body"; then
echo "$(date +%s) missing" >> "$dir/objects.tsv"
continue
fi
awk -v t="$(date +%s)" '
/^greptime_logstore_object_store_wal_indexed_objects / {i = $2}
/^greptime_logstore_object_store_wal_deleted_objects_total / {d = $2}
/^greptime_logstore_object_store_wal_created_objects_total / {c = $2}
END {print t, i + 0, d + 0, c + 0}' <<< "$body" >> "$dir/objects.tsv"
done
}
# Writers insert striped batches until a SIGKILL at a random point stops the
# server; it restarts on the same prefix and data home and the table is checked
# against what was acknowledged. Nothing is cleaned between cycles.
crash_cycles() {
local writers rows cycles kill_min kill_max cycle cdir wpid spid= delay before after started restart_s query_s next=0 pin_count pin_args
local load_start complete pin_left pin_base deleted sql_url=${OSWAL_SQL_URL:-http://127.0.0.1:4000/v1/sql?db=public}
writers=$(jq -r .concurrency <<< "$spec") rows=$(jq -r .batch_rows "$PLAN") cycles=$(jq -r .cycles <<< "$spec")
kill_min=$(jq -r '.kill_after[0]' <<< "$spec") kill_max=$(jq -r '.kill_after[1]' <<< "$spec")
if jq -e .pin <<< "$spec" >/dev/null; then
# Only the objects indexed before the pin row lie below it, such as the empty start object that
# the row's write lets the store collect at any later point; the servers may collect no more.
phase=-pin; scrape_retry indexed_objects || { echo "$id: metrics scrape failed" >&2; exit 4; }
read -r pin_left pin_base < <(awk '/^greptime_logstore_object_store_wal_indexed_objects / {i = $2}
/^greptime_logstore_object_store_wal_deleted_objects_total / {d = $2} END {printf "%d %d\n", i, d}' "$dir/metrics-pin.prom")
# The pin table's own auto flush outlasts every cycle a crash group allows, so its row holds the WAL.
sql "CREATE TABLE pin (v BIGINT, ts TIMESTAMP TIME INDEX) WITH ('auto_flush_interval' = '1d')" >/dev/null
sql "INSERT INTO pin VALUES (1, 0)" >/dev/null
fi
sql "CREATE TABLE crash (seq BIGINT, ts TIMESTAMP TIME INDEX) WITH (append_mode = 'true')" >/dev/null
for ((cycle = 1; cycle <= cycles; cycle++)); do
cdir="$dir/cycle-$cycle" complete=true; mkdir -p "$cdir"
# The counters of this server process before its writers start are the cycle's baseline.
phase=-start-$cycle; scrape_retry indexed_objects || complete=false
load_start=$(now_ns)
python3 "$here/oswal-s3-bench-crash.py" write --dir "$cdir" --url "$sql_url" \
--writers "$writers" --first-seq "$next" --rows "$rows" &
wpid=$!
if jq -e .pin <<< "$spec" >/dev/null; then sample_objects & spid=$!; fi
delay=$(awk -v a="$kill_min" -v b="$kill_max" 'BEGIN {srand(); printf "%.3f", a + rand() * (b - a)}')
sleep "$delay"
phase=-cycle-$cycle; scrape_retry indexed_objects || complete=false
before=$(now_ns); docker kill --signal KILL "$cid" >/dev/null; after=$(now_ns)
wait "$wpid"
if [[ -n "$spid" ]]; then kill "$spid" 2>/dev/null || true; wait "$spid" 2>/dev/null || true; spid=; fi
stop_server killed
phase=-restart-$cycle started=$(now)
start_server
restart_s=$(elapsed "$started")
until sql "SELECT seq, count(*) FROM crash GROUP BY seq" > "$cdir/found.json" 2>/dev/null; do
(($(elapsed "$started" | cut -d. -f1) < 300)) || { echo "$id: crash not readable after restart $cycle" >&2; exit 5; }
sleep 0.2
done
query_s=$(elapsed "$started")
scrape_retry recovery_seconds_count || complete=false
pin_count=null pin_args=()
if jq -e .pin <<< "$spec" >/dev/null; then
pin_count=$(sql "SELECT count(*) FROM pin" | jq -er '.output[0].records.rows[0][0]') pin_args=(--pin-rows "$pin_count")
fi
local verdict=0
python3 "$here/oswal-s3-bench-crash.py" check --run-dir "$dir" --cycle "$cycle" --found "$cdir/found.json" \
--mode "$(jq -r .wal.ACK_MODE <<< "$spec")" --writers "$writers" --rows "$rows" \
--before-ns "$before" --after-ns "$after" --window-max-ms 2000 ${pin_args[@]+"${pin_args[@]}"} \
> "$cdir/check.json" || verdict=$?
jq -c --arg delay "$delay" --arg restart "$restart_s" --arg query "$query_s" --argjson pin "$pin_count" \
--argjson load "$(awk -v a="$load_start" -v b="$before" 'BEGIN {printf "%.3f", (b - a) / 1e9}')" \
--argjson complete "$complete" \
'. + {kill_after_seconds: ($delay | tonumber), load_seconds: $load, restart_seconds: ($restart | tonumber),
restart_query_seconds: ($query | tonumber), pin_rows: $pin, metrics_complete: $complete}' \
"$cdir/check.json" >> "$dir/cycles.jsonl"
((verdict == 0)) || { echo "$id: cycle $cycle failed its check" >&2; exit 6; }
# A cycle without its scrapes is not a measurement, whatever the table held.
[[ "$complete" == true ]] || { echo "$id: cycle $cycle lost a metrics scrape" >&2; exit 4; }
# Each server counts its collections from its own start: the first one from its pin scrape,
# every restarted one from zero, and an absent counter has not moved.
if jq -e .pin <<< "$spec" >/dev/null; then
deleted=$(awk '/^greptime_logstore_object_store_wal_deleted_objects_total / {d = $2} END {printf "%d", d}' \
"$dir/metrics-cycle-$cycle.prom")
pin_left=$((pin_left - (deleted - pin_base))) pin_base=0
((pin_left >= 0)) || { echo "$id: cycle $cycle collected WAL objects the pin held" >&2; exit 11; }
fi
next=$(cat "$cdir"/attempted-*.log | sort -n | tail -1); next=$((next + 1))
done
}
# Writes the table `dropped` for write_seconds, drops it and kills the server right
# away, while most of its objects are still indexed; restarts on the same prefix and
# data home, then writes and flushes the table `kept` and samples the indexed objects
# until the dropped table's objects are collected.
drop_restart() {
local writers rows seconds started deadline indexed recovered sql_url=${OSWAL_SQL_URL:-http://127.0.0.1:4000/v1/sql?db=public}
writers=$(jq -r .concurrency <<< "$spec") rows=$(jq -r .batch_rows "$PLAN") seconds=$(jq -r .write_seconds <<< "$spec")
sql "CREATE TABLE dropped (seq BIGINT, ts TIMESTAMP TIME INDEX) WITH (append_mode = 'true')" >/dev/null
sql "CREATE TABLE kept (seq BIGINT, ts TIMESTAMP TIME INDEX) WITH (append_mode = 'true')" >/dev/null
python3 "$here/oswal-s3-bench-crash.py" write --dir "$dir/dropped" --url "$sql_url" --table dropped \
--writers "$writers" --first-seq 0 --rows "$rows" --seconds "$seconds" \
|| { echo "$id: the writers of dropped stopped before $seconds s" >&2; exit 10; }
phase=-written; scrape_retry indexed_objects || { echo "$id: metrics scrape failed" >&2; exit 4; }
sql "DROP TABLE dropped" >/dev/null
stop_server kill
phase=-restart started=$(now)
start_server
restart_seconds=$(elapsed "$started")
scrape_retry recovery_seconds_count || { echo "$id: metrics scrape failed" >&2; exit 4; }
recovered=$(awk '/^greptime_logstore_object_store_wal_recovered_objects_total / {print $2}' "$dir/metrics-restart.prom")
python3 "$here/oswal-s3-bench-crash.py" write --dir "$dir/kept" --url "$sql_url" --table kept \
--writers 1 --first-seq 0 --rows "$rows" --seconds 2 \
|| { echo "$id: the writer of kept stopped before 2 s" >&2; exit 10; }
sql "ADMIN FLUSH_TABLE('kept')" >/dev/null
# The flushed watermark becomes durable with the next object, so every sample writes one row.
deadline=$((SECONDS + ${OSWAL_COLLECT_SECONDS:-120}))
while :; do
sql "INSERT INTO kept VALUES (-1, $SECONDS)" >/dev/null
# The samples have their own file; the final scrape keeps the restart's name.
phase=-collected
scrape_retry indexed_objects || { phase=-restart; echo "$id: metrics scrape failed" >&2; exit 4; }
phase=-restart
indexed=$(awk '/^greptime_logstore_object_store_wal_indexed_objects / {print $2}' "$dir/metrics-collected.prom")
printf '%s %s\n' "$(date +%s)" "$indexed" >> "$dir/collected.tsv"
# A start object, the newest object and kept's own may stay; the dropped table's go.
awk -v i="$indexed" -v r="${recovered:-0}" 'BEGIN {exit !(i <= 3 || 2 * i < r)}' && break
((SECONDS < deadline)) || { echo "$id: $indexed of ${recovered:-?} recovered objects still indexed" >&2; exit 9; }
sleep 1
done
}
finish() {
@@ -74,39 +331,62 @@ finish() {
if [[ -n "$cid" ]]; then
# A load that succeeded without its metrics is not a measurement.
scrape_metrics || { echo "$id: metrics scrape failed" >&2; [[ $rc != 0 ]] || rc=4; }
docker stop -t 120 "$cid" >/dev/null
docker logs "$cid" > "$dir/server.log" 2>&1
docker rm -fv "$cid" >/dev/null
stop_server
fi
[[ -z "$wal_dir" ]] || sudo -n rm -rf "$wal_dir"
[[ -z "$home_dir" ]] || sudo -n rm -rf "$home_dir"
jq -n --argjson spec "$spec" --arg device "$wal_device" --arg version "$version" \
--arg host_load_start "$host_load_start" --arg host_load_end "$(host_load)" \
--arg load_seconds "$load_seconds" --argjson exit_code "$rc" --argjson scraped "$metrics_scraped" \
--argjson restart_scraped "$restart_scraped" --arg restart_seconds "$restart_seconds" \
--arg restart_query_seconds "$restart_query_seconds" --arg restart_count "$restart_count" \
--arg indexed_before_kill "$indexed_before_kill" \
--arg server_cpu_seconds "$server_cpu_seconds" --arg host_cpu_busy "$host_cpu_busy" --arg host_cpus "$(nproc 2>/dev/null)" \
'$spec + {wal_device: $device, version: $version, exit_code: $exit_code, metrics_scraped: $scraped,
load_wall_seconds: ($load_seconds | tonumber? // null)}' > "$dir/cell.json"
load_wall_seconds: ($load_seconds | tonumber? // null),
server_cpu_seconds: ($server_cpu_seconds | tonumber? // null),
host_cpu_busy: ($host_cpu_busy | tonumber? // null), host_cpus: ($host_cpus | tonumber? // null),
restart_scraped: $restart_scraped, restart_seconds: ($restart_seconds | tonumber? // null),
restart_query_seconds: ($restart_query_seconds | tonumber? // null),
restart_count: ($restart_count | tonumber? // null),
indexed_before_kill: ($indexed_before_kill | tonumber? // null),
host_load_start: ($host_load_start | fromjson? // null), host_load_end: ($host_load_end | fromjson? // null)}' \
> "$dir/cell.json"
exit "$rc"
}
# One cell in its own process, so errexit holds inside it. The cell's state is
# global because the EXIT trap runs after this function returns.
cell() {
: "${PLAN:?}" "${RESULTS:?}" "${CORPUS_DIR:?}" "${OWNER:?}" "${RUNTIME_IMAGE:?}" "${GREPTIMEDB_IMAGE:?}"
: "${PLAN:?}" "${RESULTS:?}" "${CORPUS_DIR:?}" "${OWNER:?}" "${RUNTIME_IMAGE:?}" "${GREPTIMEDB_IMAGES:?}"
: "${RUN_PREFIX:?}" "${WAL_ROOT:?}" "${S3_BUCKET:?}" "${S3_REGION:?}" "${AWS_ACCESS_KEY_ID:?}" "${AWS_SECRET_ACCESS_KEY:?}"
spec=$1 cid= wal_dir= version= load_seconds= metrics_scraped=false id=
local concurrency name setting started ended
id=$(jq -r .id <<< "$spec"); concurrency=$(jq -r .concurrency <<< "$spec")
dir="$RESULTS/cells/$id"; name="$OWNER-$id"
spec=$1 cid= wal_dir= home_dir= version= load_seconds= metrics_scraped=false id= phase= server_phase=
restart_scraped=false restart_seconds= restart_query_seconds= restart_count= server_cpu_seconds= host_cpu_busy=
indexed_before_kill= host_load_start=$(host_load)
local concurrency setting started cpu_before host_before host_after corpus partitions load script label
id=$(jq -r .id <<< "$spec"); concurrency=$(jq -r .concurrency <<< "$spec") label=$(jq -r .image <<< "$spec")
dir="$RESULTS/cells/$id" name="$OWNER-$id"
# The digest the image of the cell's label was pinned to before the run.
GREPTIMEDB_IMAGE=$(jq -er --arg label "$label" '.[] | select(.label == $label) | .image' <<< "$GREPTIMEDB_IMAGES") \
|| { echo "$id: no image labelled '$label'" >&2; exit 1; }
mkdir -p "$dir"
# The root holds data/ and the WAL's default prefix wal/ side by side.
local settings=(STORAGE__TYPE=S3 "STORAGE__BUCKET=$S3_BUCKET" "STORAGE__REGION=$S3_REGION"
"STORAGE__ROOT=$RUN_PREFIX/$id")
[[ -z "${S3_CONTAINER_ENDPOINT:-}" ]] || settings+=("STORAGE__ENDPOINT=$S3_CONTAINER_ENDPOINT")
# A restarted server needs the metadata the loaded one kept in its data home.
jq -e '.restart or .mode != "load"' <<< "$spec" >/dev/null && settings+=(STORAGE__DATA_HOME=/oswal_home)
while IFS= read -r setting; do settings+=("$setting"); done \
< <(jq -r '.wal | to_entries[] | "WAL__\(.key)=\(.value)"' <<< "$spec")
local docker_env=() mounts=()
docker_env=() mounts=()
for setting in "${settings[@]}"; do
docker_env+=(-e "GREPTIMEDB_STANDALONE__$setting")
printf 'GREPTIMEDB_STANDALONE__%s\n' "$setting" >> "$dir/config.env"
done
while IFS= read -r setting; do
docker_env+=(-e "$setting")
printf '%s\n' "$setting" >> "$dir/config.env"
done < <(jq -r '.env // {} | to_entries[] | "\(.key)=\(.value)"' <<< "$spec")
# Pass the two keys by name: their values never reach argv, a file or the artifact.
export GREPTIMEDB_STANDALONE__STORAGE__ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
export GREPTIMEDB_STANDALONE__STORAGE__SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
@@ -119,33 +399,60 @@ cell() {
else
wal_device="s3://$S3_BUCKET/$RUN_PREFIX/$id/wal"
fi
if jq -e '.restart or .mode != "load"' <<< "$spec" >/dev/null; then
home_dir="$WAL_ROOT/$id-home"; mkdir -p "$home_dir"
mounts+=(--mount "type=bind,src=$home_dir,dst=/oswal_home")
fi
trap finish EXIT
cid=$(docker run -d --init --name "$name" --label "o11ybench.owner=$OWNER" \
--add-host host.docker.internal:host-gateway \
-p 127.0.0.1:4000:4000 -p 127.0.0.1:4001:4001 "${docker_env[@]}" ${mounts[@]+"${mounts[@]}"} \
"$GREPTIMEDB_IMAGE" standalone start --http-addr=0.0.0.0:4000 --grpc-bind-addr=0.0.0.0:4001)
local deadline=$((SECONDS + 300))
until curl --noproxy '*' -fsS --max-time 5 http://127.0.0.1:4000/health >/dev/null 2>&1; do
[[ $(docker inspect --format '{{.State.Running}}' "$cid") == true ]] || { echo "$id: server exited" >&2; exit 1; }
((SECONDS < deadline)) || { echo "$id: server not healthy in 300s" >&2; exit 1; }
sleep 2
done
start_server
version=$(docker exec "$cid" greptime --version | sed -n 's/^version: //p')
[[ -n "$version" ]]
started=$(date +%s.%N)
# Only the load phase, run exactly as run-agent-observability.sh runs it.
if jq -e '.mode == "crash"' <<< "$spec" >/dev/null; then crash_cycles; return; fi
if jq -e '.mode == "drop"' <<< "$spec" >/dev/null; then drop_restart; return; fi
corpus="$CORPUS_DIR/corpus-$(jq -r .rows <<< "$spec")" partitions=$(jq -r '.partitions // 1' <<< "$spec")
# Only the load phase, run exactly as run-agent-observability.sh runs it, or with the
# loader writing into a table split into regions that the cell created first.
load=(scripts/agent-observability/greptimedb.sh load) script=()
if ((partitions > 1)); then
sql "$(python3 "$here/oswal-s3-bench.py" table --summary "$corpus/summary.json" --partitions "$partitions")" >/dev/null
load=(/work/oswal-s3-bench-load.sh)
script=(--mount "type=bind,src=$here/oswal-s3-bench-load.sh,dst=/work/oswal-s3-bench-load.sh,readonly")
fi
cpu_before=$(server_cpu) host_before=$(host_cpu)
started=$(now)
runtime --name "$name-load" --network host \
--mount "type=bind,src=$CORPUS_DIR,dst=/work/corpus,readonly" --mount "type=bind,src=$dir,dst=/results" \
-e GRPC_ENDPOINT=127.0.0.1:4001 -e "DATA_PROFILE=$(jq -r .data_profile "$PLAN")" -e RESULT_NAME=greptimedb-1 \
--mount "type=bind,src=$corpus,dst=/work/corpus,readonly" ${script[@]+"${script[@]}"} \
--mount "type=bind,src=$dir,dst=/results" \
-e GRPC_ENDPOINT=127.0.0.1:4001 -e "DATA_PROFILE=$(jq -r .data_profile <<< "$spec")" -e RESULT_NAME=greptimedb-1 \
-e "ENDPOINT=http://127.0.0.1:4000/v1/sql?db=public&format=greptimedb_v1" \
-e "TARGET_IMAGE=$GREPTIMEDB_IMAGE" -e "TARGET_VERSION=$version" -e "TARGET_DIGEST=${GREPTIMEDB_IMAGE##*@}" \
-e "TARGET_ID=$name" -e TARGET_HOST=127.0.0.1 -e PREPARE_WORKERS= -e "MAX_IN_FLIGHT=$concurrency" \
-e "GREPTIMEDB_BATCH_SIZE=$(jq -r .batch_rows "$PLAN")" --entrypoint /bin/bash \
"$RUNTIME_IMAGE" scripts/agent-observability/greptimedb.sh load > "$dir/load.log" 2>&1
ended=$(date +%s.%N)
load_seconds=$(awk -v a="$started" -v b="$ended" 'BEGIN {printf "%.3f", b - a}')
jq -e --argjson rows "$(jq .rows "$PLAN")" '.count_before == 0 and .count_after == $rows' \
"$RUNTIME_IMAGE" "${load[@]}" > "$dir/load.log" 2>&1
load_seconds=$(elapsed "$started")
host_after=$(host_cpu)
server_cpu_seconds=$(awk -v a="$cpu_before" -v b="$(server_cpu)" 'BEGIN {if (a != "" && b != "") printf "%.3f", b - a}')
# Busy share of all host CPUs during the load, the loader included.
host_cpu_busy=$(awk -v a="$host_before" -v b="$host_after" \
'BEGIN {split(a, x, " "); split(b, y, " "); if (y[2] > x[2]) printf "%.4f", (y[1] - x[1]) / (y[2] - x[2])}')
jq -e --argjson rows "$(jq .rows <<< "$spec")" '.count_before == 0 and .count_after == $rows' \
"$dir/greptimedb-1/load-result.json" >/dev/null
if ((partitions > 1)); then
# The server's own account of the rows each region took.
sql "SELECT p.partition_name, s.region_id, s.region_rows, s.written_bytes_since_open
FROM information_schema.partitions p JOIN information_schema.region_statistics s
ON p.greptime_partition_id = s.region_id
WHERE p.table_schema = 'public' AND p.table_name = 'agent_observations'
ORDER BY s.region_id" > "$dir/regions.json"
jq -e --argjson n "$partitions" '[.output[0].records.rows[] | select(.[2] > 0)] | length == $n' \
"$dir/regions.json" >/dev/null || { echo "$id: fewer than $partitions regions took rows" >&2; exit 8; }
fi
# An idle window: what the server sends with no writes, between two scrapes.
if jq -e '.idle_seconds > 0' <<< "$spec" >/dev/null; then
phase=-idle; scrape_metrics || { echo "$id: metrics scrape failed" >&2; exit 4; }; phase=
sleep "$(jq -r .idle_seconds <<< "$spec")"
fi
if jq -e '.restart' <<< "$spec" >/dev/null; then restart; fi
}
scrub() {
@@ -160,6 +467,7 @@ scrub() {
case "${1:-}" in
check-runtime) check_runtime "${2:?}" ;;
check-images) check_images "${2:?}" ;;
pull) pull_images ;;
wal-disk) wal_disk "${2:?}" ;;
generate) generate ;;
@@ -175,5 +483,5 @@ case "${1:-}" in
done < <(jq -c '.cells[]' "$PLAN")
exit "$failed" ;;
scrub) scrub ;;
*) echo "usage: $0 check-runtime IMAGE | pull | wal-disk DIR | generate | cells | scrub" >&2; exit 2 ;;
*) echo "usage: $0 check-runtime IMAGE | check-images JSON | pull | wal-disk DIR | generate | cells | scrub" >&2; exit 2 ;;
esac
+400 -41
View File
@@ -26,14 +26,30 @@ from pathlib import Path
PREFIX = "greptime_logstore_object_store_wal_"
COUNTERS = ("created_objects_total", "stalled_appends_total", "poisoned_total",
"create_failures_total", "create_conflicts_total")
# The Raft Engine controls every object store WAL cell is compared with: fsync per write and the default.
BASELINES = ("R", "R0")
SAMPLE = re.compile(r'([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(\S+)')
# Mito's line for every region it opened, with the WAL range it replayed.
REPLAY = re.compile(r"Replay WAL for region: (\d+)\(.*?provider: (\w+).*?rows recovered: (\d+), "
r"replay from entry id: (\d+), last entry id: (\d+),.*?elapsed: ([0-9.]+)(ns|µs|us|ms|s)\b")
UNITS = {"ns": 1e-9, "µs": 1e-6, "us": 1e-6, "ms": 1e-3, "s": 1.0}
# An object store WAL entry id is the object sequence above a 20-bit position.
POSITION_BITS = 20
def parse_metrics(text: str) -> dict:
"""Map each unlabeled sample to its value and each histogram to its buckets."""
samples, buckets = {}, {}
"""Map each unlabeled sample to its value, each histogram to its buckets, and the S3
operations (data files and WAL together, as OpenDAL counts its calls: a paginated list or a
multipart write is one operation over several HTTP requests) to their count per kind; the local cache
stores report through the same layer under another scheme and are left out."""
samples, buckets, operations = {}, {}, {}
for line in text.splitlines():
match = SAMPLE.fullmatch(line.strip())
if match and match.group(1) == "opendal_operation_duration_seconds_count":
operation = re.search(r'operation="([^"]+)"', match.group(2) or "")
if operation and re.search(r'scheme="s3"', match.group(2) or ""):
operations[operation.group(1)] = operations.get(operation.group(1), 0) + float(match.group(3))
continue
if not match or not match.group(1).startswith(PREFIX):
continue
name, labels, value = match.group(1)[len(PREFIX):], match.group(2) or "", float(match.group(3))
@@ -42,7 +58,8 @@ def parse_metrics(text: str) -> dict:
buckets.setdefault(name[:-len("_bucket")], []).append((float(le.group(1)), value))
else:
samples[name] = value
return {"samples": samples, "buckets": {name: sorted(items) for name, items in buckets.items()}}
return {"samples": samples, "buckets": {name: sorted(items) for name, items in buckets.items()},
"operations": operations}
def quantile(q: float, buckets: list) -> float | None:
@@ -64,25 +81,151 @@ def geomean(values: list) -> float | None:
return math.exp(sum(map(math.log, values)) / len(values)) if values and all(v > 0 for v in values) else None
def read_metrics(path: Path) -> dict:
return parse_metrics(path.read_text() if path.exists() else "")
def ratio(a, b):
return a / b if a is not None and b else None
def replay(log: Path, metrics: dict) -> dict | None:
"""What a restarted server replayed: every region it opened, the span of object sequences
its WAL range covers, and the log store's time serving region reads (`read_seconds`). The span
bounds the objects a region read from above: objects without its entries are skipped."""
if not log.exists():
return None
regions = []
for match in REPLAY.finditer(log.read_text(errors="replace")):
region, provider, rows, first, last, elapsed, unit = match.groups()
# The range runs from the first entry to replay to the last one; it is empty when first > last.
span = None if provider != "ObjectStore" else 0 if int(first) > int(last) else \
(int(last) >> POSITION_BITS) - (int(first) >> POSITION_BITS) + 1
regions.append({"region": int(region), "rows": int(rows), "first_entry_id": int(first),
"last_entry_id": int(last), "seconds": float(elapsed) * UNITS[unit], "object_span": span})
samples = metrics["samples"]
return {"regions": regions, "rows": sum(r["rows"] for r in regions),
"seconds": max((r["seconds"] for r in regions), default=None),
"object_span": sum(r["object_span"] for r in regions)
if regions and regions[0]["object_span"] is not None else None,
"read_seconds": samples.get("read_seconds_sum"), "reads": samples.get("read_seconds_count")}
def replayed(regions: list) -> str:
"""Each region that replayed rows: its rows, the object sequences its range spans and its time."""
return "; ".join(f"{r['region']}: {r['rows']:,} rows" + (
f", object sequences {r['first_entry_id'] >> POSITION_BITS:,}–{r['last_entry_id'] >> POSITION_BITS:,}"
if r["object_span"] else "") + f", {r['seconds']:.2f} s" for r in regions if r["rows"]) or "—"
def client_latency(path: Path) -> tuple:
"""The 50th and 99th percentile of the loader's own time per write request."""
durations = []
if path.exists():
for line in path.read_text().splitlines():
record = json.loads(line) if line.strip() else {}
if record.get("step") == "grpc_write_batch" and record.get("status") == "success":
durations.append(record["duration_seconds"])
durations.sort()
return tuple(durations[max(0, math.ceil(q * len(durations)) - 1)] if durations else None for q in (0.5, 0.99))
def region_rows(path: Path) -> list | None:
"""Rows per region of a partitioned load cell, as the server reported them after the load."""
if not path.exists():
return None
return [int(row[2] or 0) for row in json.loads(path.read_text())["output"][0]["records"]["rows"]]
def interval_seconds(wal: dict) -> float:
value = wal.get("FLUSH_INTERVAL", "100ms")
return float(value[:-2]) / 1000 if value.endswith("ms") else float(value.rstrip("s"))
def traffic(end: dict, start: dict, seconds, wal: dict) -> dict:
"""WAL traffic between two scrapes of one process over `seconds`; more objects than seal
ticks, or objects near the 8 MiB threshold, mean batches sealed on size rather than on the timer."""
def delta(name):
value = end["samples"].get(name)
return None if value is None else value - start["samples"].get(name, 0)
created, written, objects = delta("created_objects_total"), delta("object_bytes_sum"), delta("object_bytes_count")
per_second = created / seconds if created and seconds else None
mean = written / objects if written and objects else None
return {"wal_mb_per_second": written / seconds / 1e6 if written and seconds else None,
"objects_per_second": per_second, "mean_object_bytes": mean,
"sealed_on_size": None if per_second is None else
per_second > 1.05 / interval_seconds(wal) or (mean or 0) >= 0.9 * 8 * 2**20,
"operations": {op: n - start["operations"].get(op, 0) for op, n in end["operations"].items()
if n != start["operations"].get(op, 0)}}
def crash_cycles(path: Path, wal: dict) -> list:
"""Each cycle's check plus the loaded server's metrics before the kill and the restarted one's after."""
cycles = [json.loads(line) for line in (path / "cycles.jsonl").read_text().splitlines()
if line.strip()] if (path / "cycles.jsonl").exists() else []
for cycle in cycles:
start = read_metrics(path / f"metrics-start-{cycle['cycle']}.prom")
killed = read_metrics(path / f"metrics-cycle-{cycle['cycle']}.prom")
before = killed["samples"]
after = read_metrics(path / f"metrics-restart-{cycle['cycle']}.prom")
once = after["samples"].get("recovery_seconds_count") == 1
cycle |= traffic(killed, start, cycle.get("load_seconds"), wal)
cycle |= {"created_before_kill": before.get("created_objects_total"),
"indexed_before_kill": before.get("indexed_objects"),
"recovery_seconds": after["samples"].get("recovery_seconds_sum") if once else None,
"recovered_objects": after["samples"].get("recovered_objects_total", 0 if once else None),
"restart_operations": after["operations"],
"replay": replay(path / f"server-restart-{cycle['cycle']}.log", after)}
return cycles
def load_cell(path: Path, spec: dict | None = None) -> dict:
"""One cell's row; a planned cell that never recorded a result has no values, so no ratio uses its round."""
recorded = path / "cell.json"
cell = json.loads(recorded.read_text()) if recorded.exists() else spec | {"exit_code": None, "missing": True}
result_path = path / "greptimedb-1" / "load-result.json"
result = json.loads(result_path.read_text()) if result_path.exists() else {}
metrics_path = path / "metrics.prom"
metrics = parse_metrics(metrics_path.read_text() if metrics_path.exists() else "")
ack = metrics["buckets"].get("append_ack_seconds", [])
final, restarted = read_metrics(path / "metrics.prom"), read_metrics(path / "metrics-restart.prom")
# With an idle window the scrape taken when the load ended describes the load.
metrics = read_metrics(path / "metrics-idle.prom") if cell.get("idle_seconds") else final
load_seconds = result.get("load_seconds")
wall, cpu = cell.get("load_wall_seconds"), cell.get("server_cpu_seconds")
row = {
"id": cell["id"], "round": cell["round"], "concurrency": cell["concurrency"],
"id": cell["id"], "group": cell.get("group", ""), "mode": cell.get("mode", "load"),
"image": cell.get("image", ""), "warmup": cell.get("warmup", False),
"round": cell["round"], "concurrency": cell["concurrency"],
"config": cell["config"], "exit_code": cell["exit_code"], "wal_device": cell.get("wal_device"),
"missing": cell.get("missing", False),
"version": cell.get("version"), "load_wall_seconds": cell.get("load_wall_seconds"),
"missing": cell.get("missing", False), "env": cell.get("env", {}),
"version": cell.get("version"), "load_wall_seconds": wall,
"rows": result.get("count_after"), "load_seconds": load_seconds,
"rows_per_second": result.get("rows_per_second") if cell["exit_code"] == 0 else None,
"append_ack_p50_seconds": quantile(0.5, ack), "append_ack_p99_seconds": quantile(0.99, ack),
"server_cpu_cores": cpu / wall if cpu is not None and wall else None,
"host_cpu_busy": cell.get("host_cpu_busy"), "host_cpus": cell.get("host_cpus"),
"host_load_start": cell.get("host_load_start"), "host_load_end": cell.get("host_load_end"),
}
for name, key in (("append_ack_seconds", "append_ack"), ("seal_to_durable_seconds", "seal_to_durable"),
("object_bytes", "object_bytes")):
buckets = metrics["buckets"].get(name, [])
row[f"{key}_p50"], row[f"{key}_p99"] = quantile(0.5, buckets), quantile(0.99, buckets)
row["client_p50"], row["client_p99"] = client_latency(path / "greptimedb-1" / "load-transcript.jsonl")
# An entry is one region's write; an append, counted only in the durable mode, carries one or more.
samples = metrics["samples"]
row["entries_per_object"] = ratio(samples.get("object_entries_sum"), samples.get("object_entries_count"))
row["appends_per_object"] = ratio(samples.get("append_ack_seconds_count"), samples.get("object_bytes_count"))
row["partitions"] = cell.get("partitions", 1)
row["region_rows"] = region_rows(path / "regions.json")
if cell.get("restart"):
# A restarted process observed exactly one recovery, so the sum is its duration.
count = restarted["samples"].get("recovery_seconds_count")
row["restart"] = {
"wall_seconds": cell.get("restart_seconds"), "query_seconds": cell.get("restart_query_seconds"),
"rows": cell.get("restart_count"),
"recovery_seconds": restarted["samples"].get("recovery_seconds_sum") if count == 1 else None,
"recovered_objects": restarted["samples"].get("recovered_objects_total")
if cell.get("restart_scraped") else None,
"indexed_before_kill": cell.get("indexed_before_kill"), "min_objects": cell.get("min_objects", 0),
"replay": replay(path / "server-restart.log", restarted),
}
# The counters register on their first event, so a scrape of an object store WAL
# that created objects omits the ones that never moved: those are zero, not unknown.
active = (cell.get("metrics_scraped") and cell.get("wal", {}).get("PROVIDER") == "experimental_object_store"
@@ -91,49 +234,262 @@ def load_cell(path: Path, spec: dict | None = None) -> dict:
row[counter] = metrics["samples"].get(counter, 0 if active else None)
created = row["created_objects_total"]
row["creates_per_minute"] = created * 60 / load_seconds if created is not None and load_seconds else None
row |= traffic(metrics, {"samples": {}, "operations": {}}, load_seconds, cell.get("wal", {}))
row["restart_operations"] = restarted["operations"]
if cell.get("idle_seconds"):
idle = traffic(final, metrics, cell["idle_seconds"], cell.get("wal", {}))
end = final["samples"].get("created_objects_total")
row["idle"] = {"seconds": cell["idle_seconds"], "operations": idle["operations"],
"creates": None if end is None else end - metrics["samples"].get("created_objects_total", 0)}
if row["mode"] == "drop":
written = read_metrics(path / "metrics-written.prom")["samples"]
restarted_samples = restarted["samples"]
samples = [line.split() for line in (path / "collected.tsv").read_text().splitlines()
if line.strip()] if (path / "collected.tsv").exists() else []
row["drop"] = {
"acked_batches": sum(len((p.read_text()).split()) for p in sorted((path / "dropped").glob("acked-*.log"))),
"indexed_before_drop": written.get("indexed_objects"),
"restart_seconds": cell.get("restart_seconds"),
"recovered_objects": restarted_samples.get("recovered_objects_total"),
"indexed_at_end": int(float(samples[-1][1])) if samples else None,
"deleted_after_restart": restarted_samples.get("deleted_objects_total"),
"seconds_to_collect": int(samples[-1][0]) - int(samples[0][0]) if samples else None,
}
if row["mode"] == "crash":
row["cycles"] = crash_cycles(path, cell.get("wal", {}))
if (path / "objects.tsv").exists():
# A sample is time, indexed, deleted, created; a failed scrape is time and "missing".
row["objects_samples"] = [[int(float(line.split()[0]))] + (
[None] * 3 if line.split()[1:] == ["missing"] else [int(float(v)) for v in line.split()[1:]])
for line in (path / "objects.tsv").read_text().splitlines() if line.strip()]
return row
def ratios(rows: list) -> list:
"""D/R and E/R per round and their geometric mean over the rounds, per concurrency."""
speed = {(r["round"], r["concurrency"], r["config"]): r["rows_per_second"] for r in rows}
"""Each object store WAL cell over each Raft Engine control of the same image per round, and their
geometric mean over the rounds, per concurrency; warm-up cells are left out."""
rows = [r for r in rows if r["mode"] == "load" and not r["warmup"]]
speed = {(r["group"], r["image"], r["round"], r["concurrency"], r["config"]): r["rows_per_second"]
for r in rows}
result = []
for concurrency in sorted({r["concurrency"] for r in rows}):
rounds = sorted({r["round"] for r in rows if r["concurrency"] == concurrency})
for config in ("D", "E"):
per_round = []
for round_number in rounds:
base, value = speed.get((round_number, concurrency, "R")), speed.get((round_number, concurrency, config))
per_round.append(value / base if base and value else None)
complete = [value for value in per_round if value is not None]
result.append({"concurrency": concurrency, "ratio": f"{config}/R", "per_round": per_round,
"geomean": geomean(complete) if len(complete) == len(per_round) else None})
for group, image in dict.fromkeys((r["group"], r["image"]) for r in rows):
members = [r for r in rows if (r["group"], r["image"]) == (group, image)]
series = sorted({r["config"] for r in members if r["config"] not in BASELINES})
baselines = [b for b in BASELINES if any(r["config"] == b for r in members)]
for concurrency in sorted({r["concurrency"] for r in members}):
rounds = sorted({r["round"] for r in members if r["concurrency"] == concurrency})
for config, baseline in ((s, b) for s in series for b in baselines):
per_round = []
for round_number in rounds:
base = speed.get((group, image, round_number, concurrency, baseline))
measured = speed.get((group, image, round_number, concurrency, config))
per_round.append(measured / base if base and measured else None)
complete = [ratio for ratio in per_round if ratio is not None]
result.append({"group": group, "image": image, "concurrency": concurrency,
"ratio": f"{config}/{baseline}", "per_round": per_round,
"geomean": geomean(complete) if len(complete) == len(per_round) else None})
return result
def image_ratios(rows: list, images: list) -> list:
"""Every load cell of every image per round, and each image's rows/s over the first image's in the
same round, with their geometric mean, minimum and maximum over the rounds; warm-up cells are left out."""
rows = [r for r in rows if r["mode"] == "load" and not r["warmup"]]
cells = {(r["group"], r["image"], r["round"], r["concurrency"], r["config"]): r for r in rows}
result = []
for group, concurrency, config in dict.fromkeys((r["group"], r["concurrency"], r["config"]) for r in rows):
rounds = sorted({r["round"] for r in rows if (r["group"], r["concurrency"], r["config"]) == (group, concurrency, config)})
for image in images:
measured = [cells.get((group, image, n, concurrency, config)) or {} for n in rounds]
reference = [cells.get((group, images[0], n, concurrency, config)) or {} for n in rounds]
per_round = [ratio(m.get("rows_per_second"), r.get("rows_per_second")) for m, r in zip(measured, reference)]
complete = image != images[0] and None not in per_round
result.append({
"group": group, "concurrency": concurrency, "config": config, "image": image,
"rounds": [{"round": n, "id": m.get("id"), "rows_per_second": m.get("rows_per_second"),
"client_p50": m.get("client_p50"), "client_p99": m.get("client_p99"),
"host_load_start": m.get("host_load_start"), "host_load_end": m.get("host_load_end")}
for n, m in zip(rounds, measured)],
"ratio_to": None if image == images[0] else images[0],
"per_round": None if image == images[0] else per_round,
"geomean": geomean(per_round) if complete else None,
"min": min(per_round) if complete else None, "max": max(per_round) if complete else None})
return result
def operations(counts: dict) -> str:
return ", ".join(f"{op} {n:,.0f}" for op, n in sorted(counts.items())) or "—"
def overrides(env: dict) -> str:
return " ".join(f"{k}={v}" for k, v in sorted(env.items())) or "—"
def pair(a, b, digits=0, scale=1.0) -> str:
return f"{fmt(a, digits, scale)} / {fmt(b, digits, scale)}"
def fmt(value, digits=0, scale=1.0) -> str:
return "—" if value is None else f"{value * scale:,.{digits}f}"
def render(run: dict, rows: list, pairs: list) -> str:
def load_average(value) -> str:
"""The 1-minute load average of a recorded [1, 5, 15] minute triple."""
return "—" if not value else f"{value[0]:.2f}"
def render(run: dict, rows: list, pairs: list, compared: list = ()) -> str:
lines = [f"## Object store WAL against S3 ({'smoke, MinIO' if run.get('smoke') else run.get('instance_type')})", ""]
lines += [f"GreptimeDB `{run.get('greptimedb_image')}` ({next((r['version'] for r in rows if r['version']), '—')}); "
f"{fmt(run.get('rows'))} rows in {run.get('batch_rows')}-row batches; bucket region {run.get('region')}.", ""]
lines += ["| Cell | Rows/s | Load s | Wall s | Rows | Ack p50 ms | Ack p99 ms | Creates | Creates/min "
"| Stalled | Poisoned | Create failures | Conflicts | WAL on |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |"]
for r in rows:
cell = f"{r['id']} (not run)" if r["missing"] else r["id"] if r["exit_code"] == 0 \
else f"{r['id']} (failed, exit {r['exit_code']})"
images = run.get("greptimedb_images") or []
if len(images) > 1:
lines += [f"{fmt(run.get('rows'))} rows in {run.get('batch_rows')}-row batches; bucket region {run.get('region')}; "
f"each image is compared with the first, `{images[0]['label']}`.", "",
"| Image | Reference | Version | Commit |", "| --- | --- | --- | --- |"]
lines += [f"| {i['label']} | `{i['image']}` | {i['version']} | `{i['commit']}` |" for i in images] + [""]
else:
lines += [f"GreptimeDB `{run.get('greptimedb_image')}` ({next((r['version'] for r in rows if r['version']), '—')}); "
f"{fmt(run.get('rows'))} rows in {run.get('batch_rows')}-row batches; bucket region {run.get('region')}.", ""]
lines += ["| Cell | Overrides | Rows/s | Load s | Wall s | Ack p50 / p99 ms | Seal to durable p50 / p99 ms "
"| Object p50 / p99 MiB | Creates | Creates/min | Stalled | Poisoned | Create failures | Conflicts "
"| Server CPUs | Host busy | WAL on | Load average at start / end |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: "
"| ---: | ---: | --- | ---: |"]
def label(r):
cell = f"{r['id']} (warm-up)" if r["warmup"] else r["id"]
return f"{cell} (not run)" if r["missing"] else cell if r["exit_code"] == 0 \
else f"{cell} (failed, exit {r['exit_code']})"
loads = [r for r in rows if r["mode"] == "load"]
for r in loads:
cell = label(r)
lines.append(" | ".join([
f"| {cell}", fmt(r["rows_per_second"]), fmt(r["load_seconds"], 1), fmt(r["load_wall_seconds"], 1),
fmt(r["rows"]), fmt(r["append_ack_p50_seconds"], 1, 1000), fmt(r["append_ack_p99_seconds"], 1, 1000),
f"| {cell}", overrides(r["env"]), fmt(r["rows_per_second"]), fmt(r["load_seconds"], 1),
fmt(r["load_wall_seconds"], 1), pair(r["append_ack_p50"], r["append_ack_p99"], 1, 1000),
pair(r["seal_to_durable_p50"], r["seal_to_durable_p99"], 1, 1000),
pair(r["object_bytes_p50"], r["object_bytes_p99"], 1, 1 / 2**20),
*(fmt(r[c]) for c in COUNTERS[:1]), fmt(r["creates_per_minute"], 1),
*(fmt(r[c]) for c in COUNTERS[1:]), f"`{r['wal_device'] or '—'}` |"]))
lines += ["", "| Concurrency | Ratio | Per round | Geometric mean |", "| ---: | --- | --- | ---: |"]
*(fmt(r[c]) for c in COUNTERS[1:]), fmt(r["server_cpu_cores"], 2),
"—" if r["host_cpu_busy"] is None else f"{r['host_cpu_busy']:.0%} of {r['host_cpus'] or '?'}",
f"`{r['wal_device'] or '—'}`",
f"{load_average(r['host_load_start'])} / {load_average(r['host_load_end'])} |"]))
lines += ["", "| Cell | WAL MB/s | Objects/s | Mean object MiB | Sealed on size | OpenDAL S3 operations during the load "
"| OpenDAL S3 operations of the restart | Idle s | Idle creates | Idle OpenDAL S3 operations |",
"| --- | ---: | ---: | ---: | --- | --- | --- | ---: | ---: | --- |"]
for r in loads:
idle = r.get("idle") or {}
lines.append(" | ".join([
f"| {label(r)}", fmt(r["wal_mb_per_second"], 1), fmt(r["objects_per_second"], 1),
fmt(r["mean_object_bytes"], 2, 1 / 2**20),
"—" if r["sealed_on_size"] is None else ("yes" if r["sealed_on_size"] else "no"),
operations(r["operations"]), operations(r["restart_operations"]) if "restart" in r else "—",
fmt(idle.get("seconds")), fmt(idle.get("creates")), operations(idle["operations"]) if idle else "—"]) + " |")
crashes = [r for r in rows if r["mode"] == "crash"]
if crashes:
lines += ["", "| Cell | Cycle | Kill after s | Acked | Present | Lost | Lost rows | Extra | In flight "
"| All found | All acked | All lost | Indexed at kill | Restart to /health s | Recovery s "
"| Recovered objects | Pin rows | Verdict |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: "
"| ---: | ---: | ---: | --- |"]
for r in crashes:
for c in r["cycles"] or [{}]:
verdict = "—" if not c else "FAIL: " + "; ".join(c.get("kill_errors", []) + c.get("verify_errors", [])) \
if not c.get("passed") else "pass" if c.get("metrics_complete", True) else "pass, scrape missing"
lines.append(" | ".join([
f"| {label(r)}", fmt(c.get("cycle")), fmt(c.get("kill_after_seconds"), 1),
fmt(c.get("cycle_acked_batches")), fmt(c.get("cycle_present_batches")),
fmt(c.get("cycle_lost_batches")), fmt(c.get("cycle_lost_rows")), fmt(c.get("cycle_extra_batches")),
fmt(c.get("in_flight")), fmt(c.get("total_found_batches")), fmt(c.get("total_acked_batches")),
fmt(c.get("total_lost_batches")), fmt(c.get("indexed_before_kill")),
fmt(c.get("restart_seconds"), 2), fmt(c.get("recovery_seconds"), 3),
fmt(c.get("recovered_objects")), fmt(c.get("pin_rows")), verdict]) + " |")
lines += ["", "| Cell | Cycle | Load s | WAL MB/s | Objects/s | Mean object MiB | Sealed on size "
"| OpenDAL S3 operations until the kill | OpenDAL S3 operations of the restart |",
"| --- | ---: | ---: | ---: | ---: | ---: | --- | --- | --- |"]
for r in crashes:
for c in r["cycles"]:
lines.append(" | ".join([
f"| {r['id']}", fmt(c["cycle"]), fmt(c.get("load_seconds"), 1), fmt(c["wal_mb_per_second"], 1),
fmt(c["objects_per_second"], 1), fmt(c["mean_object_bytes"], 2, 1 / 2**20),
"—" if c["sealed_on_size"] is None else ("yes" if c["sealed_on_size"] else "no"),
operations(c["operations"]), operations(c["restart_operations"])]) + " |")
pinned = [r for r in crashes if r.get("objects_samples")]
if pinned:
lines += ["", "| Cell | Indexed objects every 30 s | Missing samples | Deleted objects at the end "
"| Created objects at the end |", "| --- | --- | ---: | ---: | ---: |"]
for r in pinned:
samples = r["objects_samples"]
last = next((s for s in reversed(samples) if s[1] is not None), [None] * 4)
lines.append(f"| {r['id']} | {', '.join('missing' if s[1] is None else str(s[1]) for s in samples)} "
f"| {sum(s[1] is None for s in samples)} | {fmt(last[2])} | {fmt(last[3])} |")
details = [r for r in loads if any(r[k] is not None for k in ("client_p50", "entries_per_object", "region_rows"))]
if details:
lines += ["", "| Cell | Client p50 / p99 ms | Entries per object | Appends per object | Regions "
"| Rows per region min / max |", "| --- | ---: | ---: | ---: | ---: | ---: |"]
for r in details:
spread = r["region_rows"] or []
lines.append(" | ".join([
f"| {label(r)}", pair(r["client_p50"], r["client_p99"], 1, 1000), fmt(r["entries_per_object"], 1),
fmt(r["appends_per_object"], 2), fmt(len(spread) if spread else r["partitions"]),
pair(min(spread), max(spread)) if spread else "—"]) + " |")
replays = [(r["id"], "—", r["restart"]) for r in loads if r.get("restart", {}).get("replay")]
replays += [(r["id"], fmt(c["cycle"]), c | {"wall_seconds": c.get("restart_seconds")})
for r in crashes for c in r["cycles"] if c.get("replay")]
if replays:
lines += ["", "| Cell | Cycle | Restart to /health s | Recovery s | Recovered objects | Regions opened "
"| Rows replayed | Object span | Slowest region replay s | Read seconds (reads) "
"| Regions with rows |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- |"]
for cell, cycle, x in replays:
y = x["replay"]
lines.append(" | ".join([
f"| {cell}", cycle, fmt(x.get("wall_seconds"), 2), fmt(x.get("recovery_seconds"), 3),
fmt(x.get("recovered_objects")), fmt(len(y["regions"])), fmt(y["rows"]), fmt(y["object_span"]),
fmt(y["seconds"], 2), f"{fmt(y['read_seconds'], 2)} ({fmt(y['reads'])})",
replayed(y["regions"])]) + " |")
drops = [r for r in rows if r.get("drop")]
if drops:
lines += ["", "| Cell | Acked batches before the drop | Indexed before the drop | Restart to /health s "
"| Recovered objects | Indexed at the end | Deleted after the restart | Seconds to collect | Verdict |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |"]
for r in drops:
x = r["drop"]
# Objects the release already collected before the kill leave nothing to show.
verdict = "—" if x["indexed_at_end"] is None else \
"nothing left at the kill" if (x["recovered_objects"] or 0) <= 3 else \
"collected" if r["exit_code"] == 0 else "still indexed"
lines.append(" | ".join([
f"| {label(r)}", fmt(x["acked_batches"]), fmt(x["indexed_before_drop"]), fmt(x["restart_seconds"], 2),
fmt(x["recovered_objects"]), fmt(x["indexed_at_end"]),
fmt(x["deleted_after_restart"]), fmt(x["seconds_to_collect"]), verdict]) + " |")
restarts = [r for r in rows if "restart" in r]
if restarts:
lines += ["", "| Cell | Overrides | Objects created | Indexed at kill | Restart to /health s | Restart to count s "
"| Recovery s | Recovered objects | Rows after restart |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"]
for r in restarts:
x = r["restart"]
short = x["min_objects"] and (x["indexed_before_kill"] or 0) < x["min_objects"]
lines.append(" | ".join([
f"| {label(r)}", overrides(r["env"]), fmt(r["created_objects_total"]),
fmt(x["indexed_before_kill"]) + (f" (under {x['min_objects']:,})" if short else ""),
fmt(x["wall_seconds"], 2),
fmt(x["query_seconds"], 2), fmt(x["recovery_seconds"], 3), fmt(x["recovered_objects"]),
f"{fmt(x['rows'])} |"]))
lines += ["", "| Group | Image | Concurrency | Ratio | Per round | Geometric mean |",
"| --- | --- | ---: | --- | --- | ---: |"]
for p in pairs:
lines.append(f"| {p['concurrency']} | {p['ratio']} | {', '.join(fmt(v, 3) for v in p['per_round'])} "
f"| {fmt(p['geomean'], 3)} |")
lines.append(f"| {p['group'] or '—'} | {p['image'] or '—'} | {p['concurrency']} | {p['ratio']} "
f"| {', '.join(fmt(v, 3) for v in p['per_round'])} | {fmt(p['geomean'], 3)} |")
if compared:
lines += ["", f"| Group | Concurrency | Config | Image | Rows/s per round | Client p50 / p99 ms per round "
f"| Ratio to {compared[0]['image']} per round | Geometric mean | Min | Max |",
"| --- | ---: | --- | --- | --- | --- | --- | ---: | ---: | ---: |"]
for c in compared:
lines.append(" | ".join([
f"| {c['group'] or '—'}", str(c["concurrency"]), c["config"], c["image"],
"; ".join(fmt(r["rows_per_second"]) for r in c["rounds"]),
"; ".join(pair(r["client_p50"], r["client_p99"], 1, 1000) for r in c["rounds"]),
"—" if c["per_round"] is None else ", ".join(fmt(v, 3) for v in c["per_round"]),
fmt(c["geomean"], 3), fmt(c["min"], 3), fmt(c["max"], 3)]) + " |")
return "\n".join(lines) + "\n"
@@ -145,17 +501,20 @@ def main() -> None:
run_path = args.root / "run.json"
run = json.loads(run_path.read_text()) if run_path.exists() else {}
plan_path = args.root / "plan.json"
planned = json.loads(plan_path.read_text())["cells"] if plan_path.exists() else []
plan = json.loads(plan_path.read_text()) if plan_path.exists() else {}
planned = plan.get("cells", [])
# The plan, not what happened to run, defines the expected cells and rounds.
rows = [load_cell(args.root / "cells" / spec["id"], spec) for spec in planned]
rows += [load_cell(path) for path in sorted((args.root / "cells").glob("*"))
if path.name not in {spec["id"] for spec in planned} and (path / "cell.json").exists()]
pairs = ratios(rows)
images = plan.get("images", [""])
compared = image_ratios(rows, images) if len(images) > 1 else []
missing = [r["id"] for r in rows if r["missing"]]
(args.root / "summary.json").write_text(json.dumps(
{"run": run, "planned_cells": len(planned), "missing_cells": missing, "cells": rows, "ratios": pairs},
indent=2) + "\n")
print(render(run, rows, pairs) if rows else "No benchmark cell completed.\n")
{"run": run, "planned_cells": len(planned), "missing_cells": missing, "cells": rows, "ratios": pairs,
"image_ratios": compared}, indent=2) + "\n")
print(render(run, rows, pairs, compared) if rows else "No benchmark cell completed.\n")
if __name__ == "__main__":
+321 -35
View File
@@ -24,26 +24,84 @@ from __future__ import annotations
import argparse
import json
import math
import os
import re
import sys
import time
import urllib.request
from datetime import datetime
from pathlib import Path
SMOKE_ROWS = 50_000
# The o11ybench data profiles; a matching row count generates the canonical dataset.
DATA_PROFILES = {5_000: "S", 2_000_000: "D", 10_000_000: "P", 100_000_000: "M"}
# Identical [storage] for every configuration; only [wal] differs.
# Identical [storage] for every configuration; only [wal] differs. R0 is the standalone default.
WAL_CONFIGS = {
"R": {"PROVIDER": "raft_engine", "SYNC_WRITE": "true", "DIR": "/wal"},
"R0": {"PROVIDER": "raft_engine", "SYNC_WRITE": "false", "DIR": "/wal"},
"D": {"PROVIDER": "experimental_object_store", "ACK_MODE": "durable"},
"E": {"PROVIDER": "experimental_object_store", "ACK_MODE": "enqueued"},
}
ORDERS = (("R", "D", "E"), ("E", "D", "R"))
BASELINES = ("R", "R0")
# Restart cells seal ten times as often as the default so recovery has thousands of objects.
RESTART_FLUSH_INTERVAL = "10ms"
# The loader's CREATE TABLE for the agent observability workload at the pinned runtime image.
AGENT_TABLE = """CREATE TABLE `agent_observations` (
`event_time` TIMESTAMP(3) NOT NULL,
`biz_date` DATE NOT NULL,
`trace_id` STRING NOT NULL SKIPPING INDEX,
`session_id` STRING NOT NULL,
`observation_id` STRING NOT NULL SKIPPING INDEX,
`parent_observation_id` STRING NULL SKIPPING INDEX,
`seq_no` BIGINT NOT NULL,
`type` STRING NOT NULL INVERTED INDEX,
`status` STRING NOT NULL INVERTED INDEX,
`tenant` STRING NOT NULL,
`app` STRING NOT NULL,
`environment` STRING NOT NULL,
`task_category` STRING NULL,
`trace_archetype` STRING NULL,
`model` STRING NULL,
`tool_name` STRING NULL,
`input` STRING NULL FULLTEXT INDEX WITH(analyzer = 'English', backend = 'bloom', case_sensitive = 'false', granularity = '10240', false_positive_rate = '0.01'),
`output` STRING NULL FULLTEXT INDEX WITH(analyzer = 'English', backend = 'bloom', case_sensitive = 'false', granularity = '10240', false_positive_rate = '0.01'),
`input_tokens` BIGINT NULL,
`output_tokens` BIGINT NULL,
`total_cost` DOUBLE NULL,
`latency_ms` BIGINT NULL,
`payload` JSON2 NOT NULL,
TIME INDEX (`event_time`),
PRIMARY KEY (`tenant`, `app`, `environment`)
)
{partitions}ENGINE=mito
WITH(
append_mode = 'true',
'compaction.type' = 'twcs',
'compaction.twcs.time_window' = '2h'
)"""
GROUP_NAME = re.compile(r"[a-z][a-z0-9]{0,11}")
IMAGE_LABEL = re.compile(r"[a-z][a-z0-9]{0,7}")
RESERVED_ENV = ("GREPTIMEDB_STANDALONE__STORAGE__", "GREPTIMEDB_STANDALONE__WAL__")
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
# Docker's reference grammar: path components separated by ".", "_", "__" or runs of "-".
IMAGE = re.compile(r"[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?::[0-9]+)?(?:/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)+"
r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[0-9a-f]{64})?")
NIGHTLY = re.compile(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?-nightly-(\d{8})")
TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}")
DIGEST = re.compile(r".+@sha256:[0-9a-f]{64}")
# Rows/s of a load cell in 1000-row batches on c6id.2xlarge at the concurrency levels earlier runs
# measured, interpolated on a log scale between them; durable writes slow down with several regions.
# Smaller batches are taken to keep the request rate, so their rows/s shrink with the batch.
MEASURED_BATCH_ROWS = 1000
LOAD_RATES = {"durable": [(1, 9_000), (8, 34_000), (32, 55_000)],
"durable, partitioned": [(1, 7_000), (8, 26_000), (32, 55_000)],
"other": [(1, 28_000), (8, 60_000)]}
# Starting, scraping and stopping a server, and the restart of a restart cell or a crash cycle.
CELL_SECONDS, RESTART_SECONDS, CYCLE_SECONDS = 5, 60, 15
# Checkout, dependencies, image pulls and the bucket probe; corpus generation per million rows; and the
# deletion of the run's objects one at a time, which took 12-17 % of the cells' time in earlier runs.
JOB_SECONDS, GENERATE_SECONDS_PER_MILLION, CLEANUP_SHARE = 180, 30, 0.2
def append_github(variable: str, name: str, value: str) -> None:
@@ -71,42 +129,228 @@ def check_regions(bucket: str, runner: str) -> None:
"a cross-region bucket measures the WAN, not S3")
def build_manifest(plan: dict, greptimedb_inspect: list, runtime_image: str,
def build_manifest(plan: dict, images: list, runtime_image: str,
instance_type: str, region: str, prefix: str) -> dict:
image = next((d for d in greptimedb_inspect[0].get("RepoDigests") or [] if DIGEST.fullmatch(d)), None)
if image is None:
raise ValueError("the GreptimeDB image has no sha256 repository digest")
"""The run's record; `images` are the checked images, each pinned to its digest, the first the reference."""
if [image["label"] for image in images] != plan["images"]:
raise ValueError(f"the checked images {[i['label'] for i in images]} are not the plan's {plan['images']}")
if not all(DIGEST.fullmatch(image["image"]) for image in images):
raise ValueError("every GreptimeDB image must be pinned to a sha256 digest")
return {"smoke": plan["smoke"], "rows": plan["rows"], "rounds": plan["rounds"],
"concurrency": plan["concurrency"], "batch_rows": plan["batch_rows"],
"greptimedb_image": image, "runtime_image": runtime_image,
"configs": plan["configs"], "extra_env": plan["extra_env"],
"restart": plan["restart"], "groups": plan["groups"], "warmup": plan["warmup"],
"greptimedb_image": images[0]["image"], "greptimedb_images": images, "runtime_image": runtime_image,
"instance_type": instance_type, "region": region, "prefix": prefix}
def build_plan(concurrency: str, rows: str, batch_rows: str, rounds: str, smoke: bool) -> dict:
def parse_extra_env(text: str) -> dict:
"""A JSON object or K=V lines; the storage and WAL settings that define a cell stay the benchmark's own."""
if text.strip().startswith("{"):
parsed = json.loads(text)
if not all(isinstance(v, (str, int)) and not isinstance(v, bool) for v in parsed.values()):
raise ValueError("extra_env values must be strings or integers")
env = {k: str(v) for k, v in parsed.items()}
else:
env = {}
for line in filter(None, (line.strip() for line in text.splitlines())):
name, sep, value = line.partition("=")
if not sep or name.strip() in env:
raise ValueError(f"extra_env line is not a new K=V: {line!r}")
env[name.strip()] = value.strip()
for name, value in env.items():
# GreptimeDB matches its environment keys without regard to case.
if not ENV_NAME.fullmatch(name) or name.upper().startswith(RESERVED_ENV) or "\n" in value:
raise ValueError(f"extra_env may not set {name!r}")
return env
def partitioned_table(summary: dict, partitions: int) -> str:
"""The loader's table split into equal event_time ranges over the corpus window. The
generator draws event times uniformly from that window, so every range gets an equal
share of the rows and every batch writes to every region."""
def millis(text):
return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() * 1000)
start, end = millis(summary["start"]), millis(summary["end"])
bounds = [start + (end - start) * i // partitions for i in range(1, partitions)]
if len(set(bounds)) != len(bounds) or not start < bounds[0]:
raise ValueError(f"the corpus window {summary['start']}..{summary['end']} is too short for {partitions} ranges")
ranges = [f"`event_time` < {bounds[0]}"]
ranges += [f"`event_time` >= {low} AND `event_time` < {high}" for low, high in zip(bounds, bounds[1:])]
ranges.append(f"`event_time` >= {bounds[-1]}")
clause = "PARTITION ON COLUMNS (`event_time`) (\n " + ",\n ".join(ranges) + "\n)\n"
return AGENT_TABLE.format(partitions=clause)
def parse_images(text: str) -> list:
"""The images to compare: a JSON list of 2..4 {label, image}, the first the reference. Empty
means the one image of greptimedb_tag or greptimedb_image, with an empty label."""
if not text.strip():
return [{"label": "", "image": ""}]
images = json.loads(text)
if not isinstance(images, list) or not 2 <= len(images) <= 4 \
or not all(isinstance(i, dict) and set(i) == {"label", "image"} for i in images):
raise ValueError("images must be a JSON list of 2..4 objects, each with only a label and an image")
labels = [image["label"] for image in images]
for image in images:
if not isinstance(image["label"], str) or not IMAGE_LABEL.fullmatch(image["label"]):
raise ValueError(f"an image label is up to 8 lowercase letters and digits, got {image['label']!r}")
full_reference("an image", image["image"])
if len(set(labels)) != len(labels):
raise ValueError(f"image labels repeat: {labels}")
return images
def cell_id(group: str, round_number: int, level: int, config: str, label: str) -> str:
return (f"{group}-" if group else "") + f"{round_number}-{level}-{config}" + (f"-{label}" if label else "")
def group_cells(group: dict, smoke: bool, labels: list) -> list:
"""The cells of one group, in run order; a group without a name is the top-level inputs.
Each cell runs once per image label, back to back."""
name, mode = group.get("name", ""), group.get("mode", "load")
if mode not in ("load", "crash", "drop"):
raise ValueError(f"group {name!r}: mode must be load, crash or drop, got {mode!r}")
concurrency = str(group["concurrency"])
levels = [positive("concurrency", level.strip(), 256) for level in concurrency.split(",")]
if len(set(levels)) != len(levels):
raise ValueError(f"concurrency repeats a level: {concurrency!r}")
row_count = SMOKE_ROWS if smoke else positive("rows", rows, 100_000_000)
round_count = 1 if smoke else positive("rounds", rounds, 10)
profile = DATA_PROFILES.get(row_count)
chosen = [config.strip() for config in str(group["configs"]).split(",")]
allowed = set(WAL_CONFIGS) - (set(BASELINES) if mode != "load" else set())
if not chosen or len(set(chosen)) != len(chosen) or not set(chosen) <= allowed:
raise ValueError(f"group {name!r}: configs must be distinct names among {', '.join(sorted(allowed))}, "
f"got {group['configs']!r}")
extra = group.get("extra_env", "")
env = parse_extra_env(json.dumps(extra) if isinstance(extra, dict) else str(extra))
restart = group.get("restart", False) is True
rows = SMOKE_ROWS if smoke else positive("rows", str(group["rows"]), 100_000_000)
rounds = 1 if smoke else positive("rounds", str(group["rounds"]), 10)
idle = int(group.get("idle_seconds", 0))
common = {"group": name, "mode": mode, "restart": restart, "idle_seconds": idle}
partitions = int(group.get("partitions", 1))
if partitions != 1 and (mode != "load" or not 2 <= partitions <= 64):
raise ValueError(f"group {name!r}: partitions must be in 2..64 and needs mode load")
if mode == "load":
if not 0 <= idle <= 600:
raise ValueError(f"group {name!r}: idle_seconds must be in 0..600")
# The objects a restart cell must leave indexed at its kill; a smoke run is too small for any.
min_objects = int(group.get("min_objects", 0))
if min_objects < 0 or (min_objects and not restart):
raise ValueError(f"group {name!r}: min_objects needs restart and must not be negative")
common |= {"rows": rows, "data_profile": DATA_PROFILES.get(rows, "custom"),
"min_objects": 0 if smoke else min_objects, "partitions": partitions}
elif mode == "drop":
# Writes one table for write_seconds, drops it, kills and restarts the server, then
# writes and flushes another table and watches the dropped table's objects go.
write_seconds = int(group.get("write_seconds", 30))
if not 1 <= write_seconds <= 600 or restart or idle:
raise ValueError(f"group {name!r}: write_seconds must be in 1..600, without restart or idle")
common |= {"write_seconds": min(write_seconds, 10) if smoke else write_seconds}
else:
cycles = int(group.get("cycles", 5))
kill_after = [int(v) for v in group.get("kill_after", [3, 10])]
if not 1 <= cycles <= 20 or len(kill_after) != 2 or not 1 <= kill_after[0] <= kill_after[1] <= 3600:
raise ValueError(f"group {name!r}: cycles must be in 1..20 and kill_after a [min, max] in 1..3600")
if restart or idle:
raise ValueError(f"group {name!r}: a crash group restarts after every kill and has no idle window")
if smoke:
cycles, kill_after = min(cycles, 2), [min(v, 60) for v in kill_after]
common |= {"cycles": cycles, "kill_after": kill_after, "pin": group.get("pin", False) is True}
series = [config for config in WAL_CONFIGS if config in chosen]
cells = []
for round_number in range(1, round_count + 1):
# Alternate the order between rounds so no configuration always runs first.
for round_number in range(1, rounds + 1):
# Reverse the order every other round so no configuration or image always runs first.
forward = round_number % 2
for level in levels:
for config in ORDERS[(round_number - 1) % 2]:
cells.append({
"id": f"{round_number}-{level}-{config}", "round": round_number,
"concurrency": level, "config": config, "wal": WAL_CONFIGS[config],
})
for config in series if forward else reversed(series):
wal = WAL_CONFIGS[config] | ({"FLUSH_INTERVAL": RESTART_FLUSH_INTERVAL} if restart and config not in BASELINES else {})
for label in labels if forward else reversed(labels):
cells.append(common | {
"id": cell_id(name, round_number, level, config, label), "round": round_number,
"concurrency": level, "config": config, "image": label, "wal": wal, "env": env,
})
return cells
def parse_groups(text: str, defaults: dict) -> list:
"""A JSON list of groups; every key a group leaves out takes the top-level input's value."""
if not text.strip():
return [defaults]
groups = json.loads(text)
if not isinstance(groups, list) or not groups or not all(isinstance(g, dict) for g in groups):
raise ValueError("groups must be a non-empty JSON list of objects")
known = set(defaults) | {"name", "mode", "idle_seconds", "cycles", "kill_after", "pin", "min_objects",
"partitions", "write_seconds"}
names = [g.get("name") for g in groups]
for group, name in zip(groups, names):
if not isinstance(name, str) or not GROUP_NAME.fullmatch(name):
raise ValueError(f"every group needs a name of up to 12 lowercase letters and digits, got {name!r}")
if set(group) - known:
raise ValueError(f"group {name!r} has unknown keys {sorted(set(group) - known)}")
if len(set(names)) != len(names):
raise ValueError(f"group names repeat: {names}")
return [defaults | group for group in groups]
def build_plan(concurrency: str, rows: str, batch_rows: str, rounds: str, smoke: bool,
configs: str = "R,D,E", extra_env: str = "", restart: bool = False, groups: str = "",
images: str = "", warmup: bool = False) -> dict:
defaults = {"concurrency": concurrency, "rows": rows, "rounds": rounds, "configs": configs,
"extra_env": extra_env, "restart": restart}
resolved = parse_groups(groups, defaults)
labels = [image["label"] for image in parse_images(images)]
cells = [cell for group in resolved for cell in group_cells(group, smoke, labels)]
if warmup:
# Round 0 runs the first load cell once per image before anything else; no statistic uses it.
first = next((cell for cell in cells if cell["mode"] == "load"), None)
if first is None:
raise ValueError("warmup needs a load group")
cells = [first | {"id": cell_id(first["group"], 0, first["concurrency"], first["config"], label),
"round": 0, "image": label, "warmup": True} for label in labels] + cells
if len({cell["id"] for cell in cells}) != len(cells):
raise ValueError("two cells share an id")
corpora = []
for count in dict.fromkeys(cell["rows"] for cell in cells if cell["mode"] == "load"):
profile = DATA_PROFILES.get(count)
corpora.append({"rows": count, "dir": f"corpus-{count}", "data_profile": profile or "custom",
"generator_args": ["--data-profile", profile] if profile else ["--line-count", str(count)]})
first = cells[0]
return {
"smoke": smoke, "rows": row_count, "rounds": round_count,
"batch_rows": positive("batch_rows", batch_rows, 100_000), "concurrency": levels,
"data_profile": profile or "custom",
"generator_args": ["--data-profile", profile] if profile else ["--line-count", str(row_count)],
"cells": cells,
"smoke": smoke, "rows": first.get("rows"), "rounds": max(cell["round"] for cell in cells),
"batch_rows": positive("batch_rows", batch_rows, 100_000),
"concurrency": sorted({cell["concurrency"] for cell in cells}),
"configs": list(dict.fromkeys(cell["config"] for cell in cells)),
"extra_env": parse_extra_env(extra_env), "groups": [g.get("name", "") for g in resolved],
"restart": restart, "images": labels, "warmup": warmup, "corpora": corpora, "cells": cells,
}
def load_rate(cell: dict) -> float:
durable = cell["wal"].get("ACK_MODE") == "durable"
points = LOAD_RATES["other" if not durable else "durable, partitioned" if cell["partitions"] > 1 else "durable"]
level = cell["concurrency"]
for (low, low_rate), (high, high_rate) in zip(points, points[1:]):
if level < high:
return low_rate + (high_rate - low_rate) * math.log(max(level, low) / low) / math.log(high / low)
return points[-1][1]
def cell_seconds(cell: dict, batch_rows: int) -> float:
if cell["mode"] == "crash":
return CELL_SECONDS + cell["cycles"] * (sum(cell["kill_after"]) / 2 + CYCLE_SECONDS)
if cell["mode"] == "drop":
return CELL_SECONDS + cell["write_seconds"] + RESTART_SECONDS / 2
rate = load_rate(cell) * min(1.0, batch_rows / MEASURED_BATCH_ROWS)
return CELL_SECONDS + cell["rows"] / rate + cell["idle_seconds"] + (RESTART_SECONDS if cell["restart"] else 0)
def estimate_minutes(plan: dict) -> int:
"""The benchmark job's minutes on c6id.2xlarge, from the times of earlier runs."""
generate = GENERATE_SECONDS_PER_MILLION * sum(corpus["rows"] for corpus in plan["corpora"]) / 1e6
cells = sum(cell_seconds(cell, plan["batch_rows"]) for cell in plan["cells"])
return math.ceil((JOB_SECONDS + generate + cells * (1 + CLEANUP_SHARE)) / 60)
def newest_nightly(tags: list[str]) -> str:
dated = [(match.group(1), tag) for tag in tags if (match := NIGHTLY.fullmatch(tag))]
if not dated:
@@ -114,6 +358,30 @@ def newest_nightly(tags: list[str]) -> str:
return max(dated)[1]
def full_reference(name: str, image: str) -> str:
if not isinstance(image, str) or not IMAGE.fullmatch(image) \
or not re.search(r"(:[^/]+|@sha256:[0-9a-f]{64})$", image):
raise ValueError(f"{name} must be a full reference with a tag or digest: {image!r}")
return image
def resolve_image(image: str, tag: str) -> str:
"""The full image reference when given, else greptime/greptimedb at the resolved tag."""
if image:
return full_reference("greptimedb_image", image)
return f"greptime/greptimedb:{resolve_tag(tag)}"
def resolve_images(images: str, image: str, tag: str) -> list:
"""The images to pull and check: the list to compare, or the one image of the other two inputs."""
entries = parse_images(images)
if entries[0]["label"]:
if image:
raise ValueError("set either images or greptimedb_image, not both")
return entries
return [{"label": "", "image": resolve_image(image, tag)}]
def resolve_tag(requested: str) -> str:
if requested != "nightly":
if not TAG.fullmatch(requested):
@@ -169,14 +437,25 @@ def main() -> int:
for name in ("concurrency", "rows", "batch-rows", "rounds"):
plan.add_argument(f"--{name}", required=True)
plan.add_argument("--smoke", choices=("true", "false"), required=True)
plan.add_argument("--restart", choices=("true", "false"), default="false")
plan.add_argument("--configs", default="R,D,E")
plan.add_argument("--warmup", choices=("true", "false"), default="false")
for name in ("extra-env", "groups", "images"):
plan.add_argument(f"--{name}", default="")
for name in ("timeout-minutes", "ttl-hours", "output"):
plan.add_argument(f"--{name}")
commands.add_parser("tag").add_argument("requested")
image = commands.add_parser("image")
image.add_argument("--tag", required=True)
image.add_argument("--image", default="")
image.add_argument("--images", default="")
regions = commands.add_parser("regions")
for name in ("bucket", "runner"):
regions.add_argument(f"--{name}", required=True)
table = commands.add_parser("table")
table.add_argument("--summary", type=Path, required=True)
table.add_argument("--partitions", type=int, required=True)
manifest = commands.add_parser("manifest")
for name in ("results", "runtime-image", "instance-type", "region", "prefix"):
for name in ("results", "images", "runtime-image", "instance-type", "region", "prefix"):
manifest.add_argument(f"--{name}", required=True)
for name in ("probe", "cleanup"):
bucket = commands.add_parser(name)
@@ -187,26 +466,33 @@ def main() -> int:
if args.command == "plan":
if args.timeout_minutes or args.ttl_hours:
validate_budgets(args.timeout_minutes or "", args.ttl_hours or "")
result = build_plan(args.concurrency, args.rows, args.batch_rows, args.rounds, args.smoke == "true")
result = build_plan(args.concurrency, args.rows, args.batch_rows, args.rounds, args.smoke == "true",
args.configs, args.extra_env, args.restart == "true", args.groups,
args.images, args.warmup == "true")
minutes = estimate_minutes(result)
if args.timeout_minutes and minutes > int(args.timeout_minutes):
raise ValueError(f"the plan needs about {minutes} minutes on c6id.2xlarge, "
f"more than the {args.timeout_minutes}-minute benchmark timeout")
if args.output:
with open(args.output, "w") as output:
json.dump(result, output, indent=2)
print(f"{len(result['cells'])} cells, {result['rows']} rows, profile {result['data_profile']}")
elif args.command == "tag":
tag = resolve_tag(args.requested)
append_github("GITHUB_OUTPUT", "greptimedb_tag", tag)
print(f"GreptimeDB image tag: {tag}")
print(f"{len(result['cells'])} cells; corpora {[c['rows'] for c in result['corpora']]}; "
f"about {minutes} minutes on c6id.2xlarge")
elif args.command == "image":
images = resolve_images(args.images, args.image, args.tag)
append_github("GITHUB_OUTPUT", "requested_images", json.dumps(images))
print("GreptimeDB images: " + ", ".join(f"{i['label']} {i['image']}".strip() for i in images))
elif args.command == "regions":
check_regions(args.bucket, args.runner)
print(f"bucket and runner both in {args.runner}")
elif args.command == "table":
print(partitioned_table(json.loads(args.summary.read_text()), args.partitions))
elif args.command == "manifest":
results = Path(args.results)
manifest = build_manifest(json.loads((results / "plan.json").read_text()),
json.loads((results / "images" / "greptimedb.json").read_text()),
manifest = build_manifest(json.loads((results / "plan.json").read_text()), json.loads(args.images),
args.runtime_image, args.instance_type, args.region, args.prefix)
(results / "run.json").write_text(json.dumps(manifest, indent=2) + "\n")
append_github("GITHUB_ENV", "GREPTIMEDB_IMAGE", manifest["greptimedb_image"])
print(f"GreptimeDB image: {manifest['greptimedb_image']}")
print("GreptimeDB images: " + ", ".join(i["image"] for i in manifest["greptimedb_images"]))
else:
if not re.fullmatch(r"oswal-bench/[0-9]+-[0-9]+", args.prefix):
raise ValueError(f"refusing a prefix outside oswal-bench/<run>: {args.prefix!r}")
@@ -0,0 +1,133 @@
#!/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.
import importlib.util
import subprocess
import sys
import unittest
from pathlib import Path
def load_module():
path = Path(__file__).resolve().parent / "previous-release-tag.py"
spec = importlib.util.spec_from_file_location("previous_release_tag", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
TAGS = [
"v1.1.0",
"v1.2.0",
"v1.2.1",
"v1.2.1^{}",
"v1.3.0-alpha.1",
"v1.3.0-beta.1",
"v1.3.0-beta.2",
"v1.2.0-nightly-20260706",
]
class PreviousReleaseTagTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.mod = load_module()
def test_beta_uses_latest_older_stable_not_an_ancestor(self) -> None:
self.assertEqual(self.mod.previous_stable_tag("v1.3.0-beta.2", TAGS), "v1.2.1")
def test_stable_release_skips_itself(self) -> None:
self.assertEqual(self.mod.previous_stable_tag("v1.2.1", TAGS), "v1.2.0")
def test_same_line_stable_is_not_baseline_for_its_prerelease(self) -> None:
tags = TAGS + ["v1.3.0"]
self.assertEqual(self.mod.previous_stable_tag("v1.3.0-beta.2", tags), "v1.2.1")
self.assertEqual(self.mod.previous_stable_tag("v1.3.0", tags), "v1.2.1")
def test_nightly_of_unreleased_version_uses_previous_stable(self) -> None:
self.assertEqual(
self.mod.previous_stable_tag("v1.3.0-nightly-20261013", TAGS),
"v1.2.1",
)
def test_no_older_stable_tag(self) -> None:
self.assertIsNone(self.mod.previous_stable_tag("v0.1.0", ["v0.1.0", "v1.2.1"]))
def test_invalid_version(self) -> None:
with self.assertRaises(ValueError):
self.mod.previous_stable_tag("nightly-20261013", TAGS)
def test_unordered_tags_use_numeric_order(self) -> None:
forward = ["v1.2.1", "v1.9.0", "v1.10.0", "v0.10.0", "v1.9.9"]
backward = list(reversed(forward))
for tags in (forward, backward):
self.assertEqual(self.mod.previous_stable_tag("v1.10.0", tags), "v1.9.9")
self.assertEqual(self.mod.previous_stable_tag("v1.10.0-rc.1", tags), "v1.9.9")
def test_multi_digit_components_compare_as_integers(self) -> None:
tags = ["v1.9.0", "v1.10.0", "v0.9.10", "v0.10.0", "v2.9.15", "v2.10.0", "v10.2.0"]
self.assertEqual(self.mod.previous_stable_tag("v1.10.0", tags), "v1.9.0")
self.assertEqual(self.mod.previous_stable_tag("v0.10.0", tags), "v0.9.10")
self.assertEqual(self.mod.previous_stable_tag("v2.10.0", tags), "v2.9.15")
self.assertEqual(self.mod.previous_stable_tag("v10.2.0", ["v10.0.9", "v9.20.0"]), "v10.0.9")
class PreviousReleaseTagCliTest(unittest.TestCase):
SCRIPT = Path(__file__).resolve().parent / "previous-release-tag.py"
def run_cli(
self, version: str, tags: list[str] | None = None, stdin: str | None = None
) -> subprocess.CompletedProcess[str]:
command = [sys.executable, str(self.SCRIPT), "--version", version]
if tags is not None:
command.extend(tags)
return subprocess.run(
command,
input=stdin,
text=True,
capture_output=True,
check=False,
)
def test_stdin_selects_baseline_and_exits_zero(self) -> None:
completed = self.run_cli(
"v1.10.0",
stdin="v1.2.1\nv1.9.0\n\nv1.10.0\nv1.9.0^{}\n",
)
self.assertEqual(completed.returncode, 0)
self.assertEqual(completed.stdout, "v1.9.0\n")
self.assertEqual(completed.stderr, "")
def test_positional_tags_ignore_stdin(self) -> None:
completed = self.run_cli("v1.10.0", tags=["v1.9.0"], stdin="v1.9.1\n")
self.assertEqual(completed.returncode, 0)
self.assertEqual(completed.stdout, "v1.9.0\n")
def test_no_older_tag_exits_zero_with_empty_stdout(self) -> None:
completed = self.run_cli("v0.1.0", stdin="v0.1.0\nv1.2.1\n")
self.assertEqual(completed.returncode, 0)
self.assertEqual(completed.stdout, "")
self.assertEqual(completed.stderr, "")
def test_invalid_version_exits_one(self) -> None:
completed = self.run_cli("nightly", stdin="v1.2.1\n")
self.assertEqual(completed.returncode, 1)
self.assertEqual(completed.stdout, "")
self.assertIn("not a release version: nightly", completed.stderr)
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
#!/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.
"""Pick the query-regression baseline tag for a release.
Compatibility tests do not use this tag. They read ``tests/compatibility/ci.toml``.
Release branches diverge, so a tag on ``release/v1.3`` is not a descendant of
``v1.2.1``. Ancestry (``git tag --merged``) therefore skips the real previous
release. Compare version numbers instead: the baseline is the greatest
``vX.Y.Z`` tag whose version is strictly older than the release being built.
Prerelease and nightly tags are not baselines.
"""
from __future__ import annotations
import argparse
import re
import sys
STABLE_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
RELEASE_VERSION = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:-.+)?$")
def _triple(match: re.Match[str]) -> tuple[int, int, int]:
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def release_version(version: str) -> tuple[int, int, int]:
match = RELEASE_VERSION.fullmatch(version.strip())
if match is None:
raise ValueError(f"not a release version: {version}")
return _triple(match)
def previous_stable_tag(version: str, tags: list[str]) -> str | None:
"""Return the newest stable tag older than ``version``, or None."""
current = release_version(version)
best: tuple[int, int, int] | None = None
best_tag: str | None = None
for raw in tags:
tag = raw.strip()
if tag.endswith("^{}"):
tag = tag[: -len("^{}")]
match = STABLE_TAG.fullmatch(tag)
if match is None:
continue
triple = _triple(match)
if triple < current and (best is None or triple > best):
best = triple
best_tag = tag
return best_tag
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True, help="Release version, e.g. v1.3.0-beta.2")
parser.add_argument(
"tags",
nargs="*",
help="Candidate tags. Read stdin when omitted.",
)
args = parser.parse_args(argv)
tags = list(args.tags)
if not tags and not sys.stdin.isatty():
tags = sys.stdin.read().splitlines()
try:
tag = previous_stable_tag(args.version, tags)
except ValueError as exc:
print(exc, file=sys.stderr)
return 1
if tag is None:
return 0
print(tag)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,87 @@
#!/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.
import itertools
import os
import subprocess
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("release-linux-architectures.sh")
ARCHITECTURES = ("amd64", "arm64", "riscv64")
class ReleaseLinuxArchitecturesTest(unittest.TestCase):
def resolve(self, selected, event="workflow_dispatch", publish=False, images=False):
env = dict(
os.environ,
GITHUB_EVENT_NAME=event,
PUBLISH_GITHUB_RELEASE=str(publish).lower(),
RELEASE_IMAGES=str(images).lower(),
)
for arch in ARCHITECTURES:
key = f"BUILD_LINUX_{arch.upper()}_ARTIFACTS"
env.pop(key, None)
if selected is not None:
env[key] = str(arch in selected).lower()
return subprocess.run(
["bash", str(SCRIPT)], env=env, text=True, capture_output=True, check=False
)
def assert_targets(self, result, targets):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
self.assertEqual(
result.stdout,
"".join(f"{arch}={str(arch in targets).lower()}\n" for arch in ARCHITECTURES),
)
def test_checkbox_combinations(self):
for flags in itertools.product((False, True), repeat=3):
selected = {arch for arch, enabled in zip(ARCHITECTURES, flags) if enabled}
for event, publish, images in itertools.product(
("workflow_dispatch", "push", "schedule"), (False, True), (False, True)
):
with self.subTest(selected=selected, event=event, publish=publish, images=images):
expected = (
set(ARCHITECTURES)
if event != "workflow_dispatch" or publish or images
else selected
)
self.assert_targets(self.resolve(selected, event, publish, images), expected)
def test_default_and_omitted_inputs_preserve_previous_behavior(self):
for selected in (None, set()):
for event, publish, images in itertools.product(
("workflow_dispatch", "push", "schedule"), (False, True), (False, True)
):
with self.subTest(selected=selected, event=event, publish=publish, images=images):
# The old build_linux_artifacts switch defaulted to false.
old_build_linux = publish or images or event in ("push", "schedule")
self.assert_targets(
self.resolve(selected, event, publish, images),
set(ARCHITECTURES) if old_build_linux else set(),
)
def test_unsupported_event(self):
result = self.resolve(set(), event="pull_request")
self.assertNotEqual(result.returncode, 0)
self.assertIn("Unsupported release event", result.stderr)
self.assertEqual(result.stdout, "")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Publishing keeps the existing full-build behavior, regardless of checkboxes.
build_all=false
if [[ "${PUBLISH_GITHUB_RELEASE:-false}" == true || "${RELEASE_IMAGES:-false}" == true ]]; then
build_all=true
fi
case "${GITHUB_EVENT_NAME}" in
push|schedule) build_all=true ;;
workflow_dispatch) ;;
*) echo "Unsupported release event: ${GITHUB_EVENT_NAME}" >&2; exit 1 ;;
esac
for arch in amd64 arm64 riscv64; do
input="BUILD_LINUX_${arch^^}_ARTIFACTS"
selected=false
if [[ "$build_all" == true || "${!input:-false}" == true ]]; then
selected=true
fi
printf '%s=%s\n' "$arch" "$selected"
done
@@ -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()
+2 -2
View File
@@ -19,9 +19,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- run: cargo doc --workspace --no-deps --document-private-items
- run: |
@@ -10,6 +10,7 @@ permissions:
jobs:
sweep:
if: ${{ github.repository == 'GreptimeTeam/greptimedb' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
+8 -2
View File
@@ -82,12 +82,18 @@ jobs:
version: "latest"
- name: Run check-version script tests
run: uv run --no-project python .github/scripts/check-version-test.py
- name: Run release Linux architecture tests
run: python3 .github/scripts/release-linux-architectures-test.py
- name: Run previous-release-tag script tests
run: python3 .github/scripts/previous-release-tag-test.py
- name: Run enterprise license check tests
run: python3 scripts/check-enterprise-license-test.py
- name: Run fuzz orchestration script tests
run: .github/scripts/run-fuzz-targets-test.sh
- name: Run CI comment-command tests
run: python3 .github/scripts/tests/test_ci_slash.py
- name: Run metric snapshot benchmark tests
run: python3 -m unittest discover -s .github/scripts/tests -p 'test_metric_snapshot*.py'
- name: Run query-regression Python tooling tests
run: |
python3 tests/perf/test_query_regression_runner_compaction_toctou.py
@@ -113,9 +119,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Rust Cache
uses: Swatinem/rust-cache@v2
+10 -12
View File
@@ -54,9 +54,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Build Binaries Cache
id: build-binaries-cache
@@ -193,9 +193,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -327,9 +327,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
@@ -364,13 +364,11 @@ jobs:
env:
RUSTUP_TOOLCHAIN: ${{ env.FUZZ_RUST_TOOLCHAIN }}
run: |
sudo apt-get install -y libfuzzer-14-dev
cargo install cargo-fuzz --version "${CARGO_FUZZ_VERSION}" --locked
- name: Build fuzz target binaries
if: steps.fuzz-binaries.outputs.cache-hit != 'true'
shell: bash
env:
CUSTOM_LIBFUZZER_PATH: /usr/lib/llvm-14/lib/libFuzzer.a
RUSTUP_TOOLCHAIN: ${{ env.FUZZ_RUST_TOOLCHAIN }}
FUZZ_TARGETS: |
fuzz_create_database
@@ -587,9 +585,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Build Profile-CI Cache
id: build-greptime-ci-cache
@@ -705,9 +703,9 @@ jobs:
uses: ./.github/actions/setup-etcd-cluster
# Only the Kafka WAL helper needs the Rust toolchain.
- if: matrix.mode.kafkaWalHelper
uses: arduino/setup-protoc@v3
uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- if: matrix.mode.kafkaWalHelper
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
@@ -0,0 +1,230 @@
name: Metric Snapshot S3 Benchmark
on:
workflow_dispatch:
inputs:
greptimedb_image:
description: 'Immutable greptime/greptimedb or greptimedb-nightly image (repository@sha256:digest)'
required: true
type: string
expected_commit:
description: 'Full source commit SHA reported by the image'
required: true
type: string
logical_tables_per_group:
description: 'Logical tables per physical table, 50..10000; 10 physical tables, 32 rows per logical table'
type: number
default: 10000
benchmark_timeout_minutes:
description: 'Setup, 500-table smoke and full run timeout, 30..240 minutes'
type: number
default: 240
prior_spend_usd:
description: 'Cumulative estimated cloud spend for all earlier attempts in this $20 campaign'
required: true
type: string
default: '0'
permissions:
contents: read
concurrency:
group: metric-snapshot-s3-bench
cancel-in-progress: false
env:
GREPTIMEDB_IMAGE: ${{ inputs.greptimedb_image }}
EXPECTED_COMMIT: ${{ inputs.expected_commit }}
LOGICAL_TABLES_PER_GROUP: ${{ inputs.logical_tables_per_group }}
BENCHMARK_TIMEOUT_MINUTES: ${{ inputs.benchmark_timeout_minutes }}
PRIOR_SPEND_USD: ${{ inputs.prior_spend_usd }}
RUN_PREFIX: metric-snapshot-bench/${{ github.run_id }}-${{ github.run_attempt }}/
S3_BUCKET: ${{ vars.AWS_CI_TEST_BUCKET }}
S3_REGION: ${{ vars.AWS_CI_TEST_BUCKET_REGION }}
RUNNER_REGION: ${{ vars.EC2_RUNNER_REGION }}
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
instance_type: ${{ steps.runner.outputs.instance_type }}
run_id: ${{ steps.identity.outputs.run_id }}
run_prefix: ${{ steps.identity.outputs.run_prefix }}
steps:
- uses: actions/checkout@v4
with:
path: greptimedb
persist-credentials: false
- name: Test benchmark boundaries
run: |
python3 greptimedb/.github/scripts/tests/test_metric_snapshot_s3_ops.py
python3 greptimedb/.github/scripts/tests/test_metric_snapshot_fixture.py
python3 greptimedb/.github/scripts/tests/test_metric_snapshot_s3_bench.py
- name: Validate pinned version, scale, region and cumulative budget
run: python3 greptimedb/.github/scripts/metric-snapshot-s3-ops.py plan
- name: Bind resource ownership to this validated attempt
id: identity
run: |
echo "run_id=$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT"
echo "run_prefix=$RUN_PREFIX" >> "$GITHUB_OUTPUT"
- uses: astral-sh/setup-uv@v6
- name: Check S3 access before allocating EC2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
run: uv run greptimedb/.github/scripts/metric-snapshot-s3-ops.py probe
- name: Resolve the fixed benchmark runner
id: runner
run: python3 greptimedb/.github/scripts/ci-runner-config.py --provider AWS --instance-type c6id.4xlarge
provision:
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
label: ${{ steps.aws.outputs.label }}
instance_id: ${{ steps.aws.outputs.instance_id }}
runner_name: ${{ steps.aws.outputs.runner_name }}
started_at: ${{ steps.start.outputs.started_at }}
steps:
- uses: actions/checkout@v4
with:
path: greptimedb
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.RUNNER_REGION }}
- uses: astral-sh/setup-uv@v6
- name: Record allocation start
id: start
run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Create ephemeral EC2 runner
id: aws
env:
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
AWS_REGION: ${{ env.RUNNER_REGION }}
AWS_EC2_IMAGE_ID: ${{ vars.BENCHMARK_EC2_IMAGE_ID || 'auto' }}
AWS_EC2_INSTANCE_TYPE: ${{ needs.validate.outputs.instance_type }}
AWS_EC2_SUBNET_ID: ${{ vars.EC2_RUNNER_SUBNET_ID }}
AWS_EC2_SECURITY_GROUP_ID: ${{ vars.EC2_RUNNER_SECURITY_GROUP_ID }}
AWS_EC2_SYSTEM_DISK_GIB: '60'
AWS_EC2_TTL_HOURS: '6'
AWS_EC2_RUN_ID: ${{ needs.validate.outputs.run_id }}
run: uv run greptimedb/.github/scripts/aws-ec2-runner-provision.py
benchmark:
needs:
- validate
- provision
runs-on: ${{ needs.provision.outputs.label }}
timeout-minutes: ${{ fromJSON(format('{0}', inputs.benchmark_timeout_minutes)) }}
env:
RUN_PREFIX: ${{ needs.validate.outputs.run_prefix }}
RESULTS: ${{ github.workspace }}/metric-snapshot-results
DISK_ROOT: /mnt/metric-snapshot-disk
DATA_ROOT: /mnt/metric-snapshot-disk/data
steps:
- uses: actions/checkout@v4
with:
path: greptimedb
persist-credentials: false
- name: Set up benchmark dependencies
uses: ./greptimedb/.github/actions/setup-benchmark
- uses: astral-sh/setup-uv@v6
- name: Mount the temporary local File data disk
run: |
device=$(lsblk -dnpo NAME,MODEL | awk '/Instance Storage/ {print $1; exit}')
test -n "$device"
if [[ "$(lsblk -nro TYPE "$device" | wc -l)" -ne 1 ]] || findmnt --source "$device" >/dev/null; then
echo 'Refusing partitioned or mounted instance storage' >&2
exit 1
fi
probe_status=0
sudo -n blkid -p "$device" || probe_status=$?
if ((probe_status != 2)); then
echo 'Refusing a disk with an existing signature or an unsuccessful signature probe' >&2
exit 1
fi
sudo -n mkfs.ext4 -q "$device"
sudo -n mkdir -p "$DISK_ROOT"
sudo -n mount -o noatime "$device" "$DISK_ROOT"
sudo -n chown "$(id -u):$(id -g)" "$DISK_ROOT"
findmnt -no SOURCE,FSTYPE -T "$DISK_ROOT"
- name: Probe the real same-region S3 bucket
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
run: uv run greptimedb/.github/scripts/metric-snapshot-s3-ops.py probe
- name: Run the 500-table smoke, then one full packed export and restore
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
run: python3 greptimedb/.github/scripts/metric-snapshot-s3-bench.py run
- name: Check results for credentials
id: scrub
if: always()
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
run: python3 greptimedb/.github/scripts/metric-snapshot-s3-ops.py scrub
- name: Publish the readable result
if: ${{ always() && steps.scrub.outcome == 'success' }}
run: |
if test -f "$RESULTS/summary.md"; then
cat "$RESULTS/summary.md" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload bounded-retention evidence
if: ${{ always() && steps.scrub.outcome == 'success' }}
uses: actions/upload-artifact@v4
with:
name: metric-snapshot-s3-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.RESULTS }}
if-no-files-found: warn
retention-days: 14
teardown:
needs:
- validate
- provision
# Start alongside the benchmark so an offline runner cannot hold cleanup in its queue.
if: ${{ always() && needs.validate.outputs.run_id != '' }}
runs-on: ubuntu-latest
timeout-minutes: 330
permissions:
contents: read
actions: read
env:
RUN_PREFIX: ${{ needs.validate.outputs.run_prefix }}
steps:
- uses: actions/checkout@v4
with:
path: greptimedb
persist-credentials: false
- uses: astral-sh/setup-uv@v6
- name: Wait for completion within the allocation deadline
if: ${{ !cancelled() && needs.provision.result == 'success' && needs.provision.outputs.started_at != '' }}
env:
GH_TOKEN: ${{ github.token }}
STARTED_AT: ${{ needs.provision.outputs.started_at }}
run: python3 greptimedb/.github/scripts/metric-snapshot-s3-ops.py wait-for-benchmark --started-at "$STARTED_AT"
- uses: aws-actions/configure-aws-credentials@v4
if: ${{ always() && needs.provision.result != 'skipped' }}
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.RUNNER_REGION }}
- name: Delete EC2, its temporary disks and runner registration
if: ${{ always() && needs.provision.result != 'skipped' }}
env:
GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
AWS_REGION: ${{ env.RUNNER_REGION }}
AWS_EC2_INSTANCE_ID: ${{ needs.provision.outputs.instance_id }}
AWS_EC2_RUNNER_NAME: ${{ needs.provision.outputs.runner_name }}
AWS_EC2_RUN_ID: ${{ needs.validate.outputs.run_id }}
run: uv run greptimedb/.github/scripts/aws-ec2-runner-teardown.py
- name: Remove and verify only this run's S3 prefix
if: always()
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_CI_TEST_SECRET_ACCESS_KEY }}
run: uv run greptimedb/.github/scripts/metric-snapshot-s3-ops.py cleanup
- name: Estimate the cumulative cloud cost
if: ${{ always() && needs.provision.outputs.started_at != '' }}
env:
STARTED_AT: ${{ needs.provision.outputs.started_at }}
run: python3 greptimedb/.github/scripts/metric-snapshot-s3-ops.py cost --started-at "$STARTED_AT" >> "$GITHUB_STEP_SUMMARY"
+7 -6
View File
@@ -53,9 +53,9 @@ jobs:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-cyborg
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Rust Cache
uses: Swatinem/rust-cache@v2
@@ -82,9 +82,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: rui314/setup-mold@v1
- name: Install toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -134,9 +134,9 @@ jobs:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/setup-cyborg
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: KyleMayes/install-llvm-action@v1
with:
version: "14.0"
@@ -157,6 +157,7 @@ jobs:
env:
CARGO_BUILD_RUSTFLAGS: "-C linker=lld-link"
RUST_BACKTRACE: 1
RUST_MIN_STACK: 8388608 # 8 MiB, aligned with Rust CI and release tests.
CARGO_INCREMENTAL: 0
GT_S3_BUCKET: ${{ vars.AWS_CI_TEST_BUCKET }}
GT_S3_ACCESS_KEY_ID: ${{ secrets.AWS_CI_TEST_ACCESS_KEY_ID }}
+53 -8
View File
@@ -10,6 +10,34 @@ on:
description: 'Docker Hub greptime/greptimedb tag; nightly = the newest v*-nightly-* tag (latest is a release without the object store WAL)'
type: string
default: nightly
greptimedb_image:
description: 'Full image reference with a tag or digest; used instead of greptimedb_tag when set'
type: string
default: ''
images:
description: 'JSON list of 2..4 {"label", "image"} to compare on one instance instead of greptimedb_tag and greptimedb_image: every cell runs once per image, in an order that reverses every other round; a label is up to 8 lowercase letters and digits, an image a full reference with a tag or digest, the first the reference of the ratios'
type: string
default: ''
warmup:
description: Before round 1, run the first load cell once per image and leave it out of every statistic
type: boolean
default: false
configs:
description: 'WAL configurations: R (Raft Engine, sync_write), R0 (Raft Engine, standalone default), D (durable), E (enqueued)'
type: string
default: R,D,E
extra_env:
description: 'Environment for every GreptimeDB container: a JSON object or K=V lines'
type: string
default: ''
restart:
description: 'After each load, SIGKILL the server, restart it on the same prefix and data home, and time recovery; object store cells flush every 10ms'
type: boolean
default: false
groups:
description: 'JSON list of cell groups, each overriding configs, extra_env, concurrency, rounds, rows, restart, plus idle_seconds and partitions (regions of the loaded table), or mode crash with cycles, kill_after [min, max] and pin, or mode drop with write_seconds; empty = one group from the inputs above'
type: string
default: ''
instance_type:
description: EC2 instance type in the bucket region; the d of c6id gives a local NVMe instance store for the Raft Engine WAL
type: string
@@ -27,7 +55,7 @@ on:
type: number
default: 1000
rounds:
description: Rounds; the configuration order alternates between rounds (R, D, E then E, D, R)
description: Rounds; the configuration and image order alternates between rounds (R, D, E then E, D, R)
type: number
default: 2
runtime_image:
@@ -47,6 +75,13 @@ permissions:
env:
SMOKE: ${{ inputs.smoke }}
GREPTIMEDB_TAG: ${{ inputs.greptimedb_tag }}
GREPTIMEDB_IMAGE_INPUT: ${{ inputs.greptimedb_image }}
OSWAL_IMAGES: ${{ inputs.images }}
WARMUP: ${{ inputs.warmup }}
CONFIGS: ${{ inputs.configs }}
EXTRA_ENV: ${{ inputs.extra_env }}
RESTART: ${{ inputs.restart }}
OSWAL_GROUPS: ${{ inputs.groups }}
REQUESTED_INSTANCE_TYPE: ${{ inputs.instance_type }}
CONCURRENCY: ${{ inputs.concurrency }}
ROWS: ${{ inputs.rows }}
@@ -61,7 +96,7 @@ jobs:
timeout-minutes: 15
outputs:
instance_type: ${{ steps.runner.outputs.instance_type }}
greptimedb_tag: ${{ steps.plan.outputs.greptimedb_tag }}
greptimedb_images: ${{ steps.images.outputs.greptimedb_images }}
runtime_image: ${{ steps.runtime.outputs.runtime_image }}
steps:
- uses: actions/checkout@v4
@@ -77,8 +112,16 @@ jobs:
run: |
python3 greptimedb/.github/scripts/oswal-s3-bench.py plan --smoke "$SMOKE" \
--concurrency "$CONCURRENCY" --rows "$ROWS" --batch-rows "$BATCH_ROWS" --rounds "$ROUNDS" \
--configs "$CONFIGS" --extra-env "$EXTRA_ENV" --restart "$RESTART" --groups "$OSWAL_GROUPS" \
--images "$OSWAL_IMAGES" --warmup "$WARMUP" \
--timeout-minutes "$BENCHMARK_TIMEOUT_MINUTES" --ttl-hours "$JANITOR_TTL_HOURS"
python3 greptimedb/.github/scripts/oswal-s3-bench.py tag "$GREPTIMEDB_TAG"
python3 greptimedb/.github/scripts/oswal-s3-bench.py image --tag "$GREPTIMEDB_TAG" --image "$GREPTIMEDB_IMAGE_INPUT" \
--images "$OSWAL_IMAGES"
- name: Pull every GreptimeDB image and record its version
id: images
env:
REQUESTED_IMAGES: ${{ steps.plan.outputs.requested_images }}
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh check-images "$REQUESTED_IMAGES"
- name: Resolve CI runner
id: runner
run: python3 greptimedb/.github/scripts/ci-runner-config.py --provider AWS --instance-type "$REQUESTED_INSTANCE_TYPE"
@@ -87,7 +130,7 @@ jobs:
BUCKET_REGION: ${{ vars.AWS_CI_TEST_BUCKET_REGION }}
RUNNER_REGION: ${{ vars.EC2_RUNNER_REGION }}
run: python3 greptimedb/.github/scripts/oswal-s3-bench.py regions --bucket "$BUCKET_REGION" --runner "$RUNNER_REGION"
- name: Check the runtime image
- name: Pin the runtime image digest
id: runtime
run: bash greptimedb/.github/scripts/oswal-s3-bench-run.sh check-runtime "$RUNTIME_IMAGE"
provision:
@@ -132,7 +175,7 @@ jobs:
timeout-minutes: ${{ fromJSON(format('{0}', inputs.benchmark_timeout_minutes)) }}
env:
INSTANCE_TYPE: ${{ inputs.smoke && 'ubuntu-latest' || needs.validate.outputs.instance_type }}
GREPTIMEDB_TAG: ${{ needs.validate.outputs.greptimedb_tag }}
GREPTIMEDB_IMAGES: ${{ needs.validate.outputs.greptimedb_images }}
RUNTIME_IMAGE: ${{ needs.validate.outputs.runtime_image }}
RUN_PREFIX: oswal-bench/${{ github.run_id }}-${{ github.run_attempt }}
OWNER: o11ybench-oswal-${{ github.run_id }}-${{ github.run_attempt }}
@@ -157,12 +200,14 @@ jobs:
run: |
bash greptimedb/.github/scripts/pull-benchmark-image.sh greptime/minio:2024
docker compose -f greptimedb/tests-integration/fixtures/docker-compose.yml up -d --wait minio
- name: Pull images and write the plan
- name: Pull and check images, write the plan
run: |
bash greptimedb/.github/scripts/oswal-s3-bench-run.sh pull
python3 greptimedb/.github/scripts/oswal-s3-bench.py plan --smoke "$SMOKE" --output "$PLAN" \
--concurrency "$CONCURRENCY" --rows "$ROWS" --batch-rows "$BATCH_ROWS" --rounds "$ROUNDS"
python3 greptimedb/.github/scripts/oswal-s3-bench.py manifest --results "$RESULTS" \
--concurrency "$CONCURRENCY" --rows "$ROWS" --batch-rows "$BATCH_ROWS" --rounds "$ROUNDS" \
--configs "$CONFIGS" --extra-env "$EXTRA_ENV" --restart "$RESTART" --groups "$OSWAL_GROUPS" \
--images "$OSWAL_IMAGES" --warmup "$WARMUP"
python3 greptimedb/.github/scripts/oswal-s3-bench.py manifest --results "$RESULTS" --images "$GREPTIMEDB_IMAGES" \
--runtime-image "$RUNTIME_IMAGE" --instance-type "$INSTANCE_TYPE" --region "$S3_REGION" --prefix "$RUN_PREFIX"
- name: Probe the bucket
env:
@@ -14,6 +14,7 @@ permissions:
jobs:
sweep:
if: ${{ github.repository == 'GreptimeTeam/greptimedb' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
+42 -15
View File
@@ -19,7 +19,6 @@ on:
# NIGHTLY_RELEASE_MAX_AGE_DAYS days, see the 'Check nightly release necessity' step.
- cron: '0 0 * * 1-5'
workflow_dispatch: # Allows you to run this workflow manually.
# Notes: The GitHub Actions ONLY support 10 inputs; this final input is the tenth.
inputs:
linux_amd64_runner:
type: choice
@@ -57,9 +56,19 @@ on:
description: Do not run integration tests during the build
type: boolean
default: true
build_linux_artifacts:
build_linux_amd64_artifacts:
type: boolean
description: Build linux artifacts (amd64, arm64, and riscv64)
description: Build linux-amd64 artifacts (publishing always builds all Linux architectures)
required: false
default: false
build_linux_arm64_artifacts:
type: boolean
description: Build linux-arm64 artifacts (publishing always builds all Linux architectures)
required: false
default: false
build_linux_riscv64_artifacts:
type: boolean
description: Build linux-riscv64 artifacts (publishing always builds all Linux architectures)
required: false
default: false
build_macos_artifacts:
@@ -115,6 +124,9 @@ jobs:
if: ${{ github.repository == 'GreptimeTeam/greptimedb' }}
runs-on: ubuntu-latest
outputs:
build-linux-amd64: ${{ steps.linux-architectures.outputs.amd64 }}
build-linux-arm64: ${{ steps.linux-architectures.outputs.arm64 }}
build-linux-riscv64: ${{ steps.linux-architectures.outputs.riscv64 }}
linux-amd64-runner: ${{ steps.start-linux-amd64-runner.outputs.label }}
linux-arm64-runner: ${{ steps.start-linux-arm64-runner.outputs.label }}
macos-runner: ${{ inputs.macos_runner || vars.DEFAULT_MACOS_RUNNER }}
@@ -143,6 +155,17 @@ jobs:
fetch-depth: 0
persist-credentials: false
# Publishing and automatic releases retain the full Linux build.
- name: Resolve Linux architectures
id: linux-architectures
env:
BUILD_LINUX_AMD64_ARTIFACTS: ${{ inputs.build_linux_amd64_artifacts }}
BUILD_LINUX_ARM64_ARTIFACTS: ${{ inputs.build_linux_arm64_artifacts }}
BUILD_LINUX_RISCV64_ARTIFACTS: ${{ inputs.build_linux_riscv64_artifacts }}
PUBLISH_GITHUB_RELEASE: ${{ inputs.publish_github_release }}
RELEASE_IMAGES: ${{ inputs.release_images }}
run: bash .github/scripts/release-linux-architectures.sh >> "$GITHUB_OUTPUT"
- name: Check Rust toolchain version
shell: bash
run: |
@@ -182,7 +205,7 @@ jobs:
./.github/scripts/check-version.sh "${{ steps.create-version.outputs.version }}"
- name: Allocate linux-amd64 runner
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && steps.check-nightly.outputs.nightly-required == 'true' }}
if: ${{ (steps.linux-architectures.outputs.amd64 == 'true' || steps.linux-architectures.outputs.riscv64 == 'true') && steps.check-nightly.outputs.nightly-required == 'true' }}
uses: ./.github/actions/start-runner
id: start-linux-amd64-runner
with:
@@ -196,7 +219,7 @@ jobs:
subnet-id: ${{ vars.EC2_RUNNER_SUBNET_ID }}
- name: Allocate linux-arm64 runner
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && steps.check-nightly.outputs.nightly-required == 'true' }}
if: ${{ steps.linux-architectures.outputs.arm64 == 'true' && steps.check-nightly.outputs.nightly-required == 'true' }}
uses: ./.github/actions/start-runner
id: start-linux-arm64-runner
with:
@@ -211,7 +234,7 @@ jobs:
build-linux-amd64-artifacts:
name: Build linux-amd64 artifacts
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
if: ${{ needs.allocate-runners.outputs.build-linux-amd64 == 'true' && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
needs: [
allocate-runners,
]
@@ -237,7 +260,7 @@ jobs:
build-linux-arm64-artifacts:
name: Build linux-arm64 artifacts
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
if: ${{ needs.allocate-runners.outputs.build-linux-arm64 == 'true' && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
needs: [
allocate-runners,
]
@@ -264,7 +287,7 @@ jobs:
build-linux-riscv64-artifacts:
name: Build linux-riscv64 artifacts
if: ${{ (inputs.build_linux_artifacts || inputs.publish_github_release || inputs.release_images || github.event_name == 'push' || github.event_name == 'schedule') && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
if: ${{ needs.allocate-runners.outputs.build-linux-riscv64 == 'true' && (github.event_name != 'schedule' || needs.allocate-runners.outputs.nightly-required == 'true') }}
needs: [
allocate-runners,
]
@@ -415,11 +438,15 @@ jobs:
REQUESTED_VALIDATION_POLICY: ${{ inputs.release_validation }}
run: |
set -euo pipefail
base_commit="${GITHUB_SHA}"
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
base_commit="${GITHUB_SHA}^"
fi
previous_release_tag="$(git tag --merged "${base_commit}" --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
# Stable tags on older release branches are not ancestors of this
# commit. Pick the newest vX.Y.Z older than the version being released.
release_version="${{ needs.allocate-runners.outputs.version }}"
previous_release_tag="$(
git ls-remote --tags origin 'refs/tags/v*' \
| awk '{print $2}' \
| sed -e 's#^refs/tags/##' \
| python3 .github/scripts/previous-release-tag.py --version "${release_version}"
)"
if [[ -z "${previous_release_tag}" ]]; then
previous_release_tag="main"
fi
@@ -489,9 +516,9 @@ jobs:
fetch-depth: 0
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
+2 -2
View File
@@ -83,10 +83,10 @@ jobs:
java-version: '17'
cache: 'maven'
- name: Setup Python 3.8
- name: Setup Python 3.13
uses: actions/setup-python@v5
with:
python-version: '3.8'
python-version: '3.13'
- name: Setup uv
uses: astral-sh/setup-uv@v3
+15 -14
View File
@@ -40,9 +40,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
@@ -58,9 +58,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy
@@ -102,9 +102,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- name: Install riscv64 cross toolchain
run: sudo apt-get update && sudo apt-get install -y gcc-riscv64-linux-gnu g++-riscv64-linux-gnu
- uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -130,9 +130,9 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/setup-cyborg
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: KyleMayes/install-llvm-action@v1
with:
version: "14.0"
@@ -166,9 +166,9 @@ jobs:
with:
persist-credentials: false
- uses: ./.github/actions/setup-cyborg
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: KyleMayes/install-llvm-action@v1
with:
version: "14.0"
@@ -210,9 +210,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: rui314/setup-mold@v1
- name: Install toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -265,9 +265,9 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: arduino/setup-protoc@v3
- uses: taiki-e/install-action@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
tool: protoc@3.23.4
- uses: rui314/setup-mold@v1
- name: Install toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -294,6 +294,7 @@ jobs:
env:
CARGO_BUILD_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
RUST_BACKTRACE: 1
RUST_MIN_STACK: 8388608 # 8MB, aligned with the test job
CARGO_INCREMENTAL: 0
# Runs the object-store matrix against the local MinIO from
# tests-integration/fixtures. Real AWS S3 is covered by the nightly
+1
View File
@@ -262,6 +262,7 @@ run-it-in-container: start-etcd ## Run integration tests in dev-builder.
docker run --network=host \
-v ${PWD}:/greptimedb -v ${CARGO_REGISTRY_CACHE}:/root/.cargo/registry -v ${CARGO_GIT_CACHE}:/root/.cargo/git -v /tmp:/tmp \
-e GT_S3_BUCKET -e GT_S3_ACCESS_KEY_ID -e GT_S3_ACCESS_KEY -e GT_S3_REGION \
-e RUST_MIN_STACK \
-w /greptimedb ${IMAGE_REGISTRY}/${IMAGE_NAMESPACE}/dev-builder-${BASE_IMAGE}:${DEV_BUILDER_IMAGE_TAG} \
make test sqlness-test BUILD_JOBS=${BUILD_JOBS}
+79
View File
@@ -0,0 +1,79 @@
# Metric snapshot benchmark on AWS S3
The manual `Metric Snapshot S3 Benchmark` workflow tests packed Metric export and
restore against real S3 in `ap-southeast-1`. It must first exist on the repository's
default branch for GitHub to register `workflow_dispatch`. It was validated on AWS
by run [38544464731](https://github.com/GreptimeTeam/greptimedb/actions/runs/38544464731);
[#9573](https://github.com/GreptimeTeam/greptimedb/pull/9573) records its conditions and results.
## Run
Use the repository's existing EC2 runner configuration and CI S3 credentials.
The workflow passes credentials only through CI; no local AWS credentials are
needed. Source, target and CLI use the same immutable image. This image's source
SHA and required CLI capabilities have been checked:
```bash
gh workflow run metric-snapshot-s3-bench.yml \
--repo GreptimeTeam/greptimedb --ref main \
-f greptimedb_image=greptime/greptimedb-nightly@sha256:20a1182bed0f452e6d573ca110b313e0b5cb76de7aedfa41a0c339963d4683d3 \
-f expected_commit=f690bbb2040865c0d22b0b57d51612b60ae27cd0 \
-f logical_tables_per_group=10000 \
-f benchmark_timeout_minutes=240 \
-f prior_spend_usd=0
```
Start a new dispatch for each attempt and update `prior_spend_usd` to include all
earlier cloud attempts in the campaign, including failed setup and cleanup.
Do not reset the estimate to zero when retrying. The workflow accepts 50–10,000
logical tables per group and a 30–240 minute benchmark job timeout.
The runner is fixed at `c6id.4xlarge` (16 vCPU, 32 GiB RAM). At the recorded
Singapore Linux on-demand estimate of $0.9408/hour, the default attempt reserves
$6.4392: four benchmark hours, 45 minutes of provisioning, 10 minutes for queueing,
20 minutes of cleanup, and $1.50 for S3, disk and IPv4 costs. Preflight rejects prior spend at or above
$15, or an attempt whose reserve would exceed the cumulative $20 budget.
These estimates and checks are not an AWS hard spending cap or an AWS bill.
## Workload and validation
- A 10-physical-table × 50-logical-table smoke runs first. Its 500 logical tables
cover all 298 string tag columns before the larger workload starts.
- The default full run uses 10 physical tables and 100,000 logical tables, with
32 rows per logical table: 3.2 million rows. Each logical schema has 10
overlapping tags, a timestamp and a DOUBLE; schemas vary between tables.
- Source and target use local File storage on the temporary NVMe disk. Only
snapshots use S3. Source validation finishes before the source process stops;
restoration then starts a separate target process.
- Export uses `--pack-metric-data`, parallelism 4, chunk parallelism 1, and one
complete time chunk. Import uses task parallelism 1 and automatic batch DDL.
- Every source and restored logical table is checked for membership, schema,
DDL and typed values including NULLs. Target placeholders force table ID
remapping; successful batch DDL and removal of import resume state are checked.
## Results and cleanup
The job summary reports complete export, structural verify and import durations;
data generation and full data validation are timed separately. Evidence artifacts
are credential-scanned and retained for 14 days. Keep conclusions limited to
this version's absolute AWS results; this workflow does not compare old versions.
Artifacts include sampled CPU/RSS, host memory, swap/OOM counters, server metrics,
CLI logs and S3 object/byte inventories. Configured swap is not RAM. A phase with
new swap-out or OOM fails and retains its evidence. OpenDAL operation counters
are not complete AWS HTTP request counts: multipart calls, retries and CLI traffic
are not fully covered. Object inventory bytes are not transferred bytes.
A separate GitHub-hosted teardown job starts when provisioning finishes and polls
the benchmark job. Its deadline includes queue time: allocation start plus 45
minutes, the benchmark timeout and 10 minutes. Completion, cancellation, an API
error or the deadline leads to cleanup. Failed provisioning skips the wait.
It terminates the EC2 instance and its disks, unregisters the runner, deletes every
object listed under the run's unique prefix and verifies the listing is empty. Like
the object store WAL benchmark, it needs only list, put, get and delete on the CI
bucket. It does not abort incomplete multipart uploads or remove noncurrent object
versions; those depend on the bucket's lifecycle configuration. Check teardown completion before another
attempt. A deadline may leave the GitHub benchmark job queued after its resources
are removed; cancel that workflow manually. If cleanup fails, rerun the teardown
job only; it keeps the original attempt's resource identity and deadline. A new benchmark needs a new dispatch because teardown
removes its runner. The daily EC2 janitor is only a fallback and does not clean S3.
+494 -23
View File
@@ -27,11 +27,14 @@ pub use state::{
use std::cmp::Reverse;
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, BinaryHeap, HashMap};
use std::mem::size_of;
use std::sync::Arc;
use datafusion::error::{DataFusionError, Result};
use datatypes::arrow::array::Float64Array;
use datatypes::series::MetricName;
use datatypes::series::samples::{SampleRef, Series, SeriesSchema};
use datatypes::series::samples::{SampleRef, Samples, Series, SeriesSchema};
use datatypes::series::time::{GridSpan, TimeAxis};
use crate::go_format::format_float_f;
use crate::grid_series::{GridSeriesBuilder, external};
@@ -97,6 +100,7 @@ pub struct FloatAggregator {
labels: Vec<Arc<str>>,
group_index: HashMap<LabelKey, usize>,
groups: Vec<Group>,
retained: usize,
}
impl FloatAggregator {
@@ -104,25 +108,78 @@ impl FloatAggregator {
schema: &Arc<SeriesSchema>,
op: AggregateOp,
grouping: Grouping<'_>,
) -> Result<Self> {
Self::try_new_budgeted(schema, op, grouping, |_| Ok(()))
}
pub(crate) fn try_new_budgeted(
schema: &Arc<SeriesSchema>,
op: AggregateOp,
grouping: Grouping<'_>,
mut budget: impl FnMut(usize) -> Result<()>,
) -> Result<Self> {
validate_input(schema, &[], grouping)?;
let (by, labels) = match grouping {
Grouping::By(labels) => (true, labels),
Grouping::Without(labels) => (false, labels),
};
let label_storage =
grouping_bytes(grouping).saturating_add(labels_capacity_bytes(grouping));
budget(label_storage)?;
let mut owned_labels: Vec<Arc<str>> = Vec::new();
owned_labels
.try_reserve_exact(labels.len())
.map_err(|error| {
DataFusionError::Execution(format!(
"cannot reserve aggregate grouping labels: {error}"
))
})?;
owned_labels.extend(labels.iter().map(|label| Arc::from(*label)));
let retained = owned_labels
.capacity()
.saturating_mul(size_of::<Arc<str>>())
.saturating_add(owned_labels.iter().fold(0usize, |bytes, label| {
bytes.saturating_add(label.len() + 2 * size_of::<usize>())
}));
Ok(Self {
schema: schema.clone(),
op,
by,
labels: labels.iter().map(|label| Arc::from(*label)).collect(),
labels: owned_labels,
group_index: HashMap::new(),
groups: Vec::new(),
retained,
})
}
/// Folds `series` into its group. A series that fails validation leaves the state unchanged.
///
/// A budget refusal is not a validation failure: the fold may have opened a group or filled
/// blocks before the allocation it could not admit, so a failed [`Self::update_budgeted`]
/// leaves the state half folded and its retained figure stale. The runtime stops the execution
/// and drops the aggregator on that error, which is why no rollback is kept; a caller holding
/// one must drop it rather than fold into it again.
pub fn update(&mut self, series: &Series) -> Result<()> {
self.update_budgeted(series, |_| Ok(()))
}
pub(crate) fn update_budgeted(
&mut self,
series: &Series,
mut budget: impl FnMut(usize) -> Result<()>,
) -> Result<()> {
validate_float_series(&self.schema, series)?;
let existing_bytes = self.state_bytes();
let grouping_workspace = grouping_workspace_bytes(&series.metric);
let mut workspace_bytes =
grouping_workspace.saturating_add(pending_bytes(self.schema.fields.len()));
budget(existing_bytes.saturating_add(workspace_bytes))?;
workspace_bytes = workspace_bytes.saturating_add(
self.labels
.len()
.saturating_mul(std::mem::size_of::<&str>()),
);
budget(existing_bytes.saturating_add(workspace_bytes))?;
let labels: Vec<&str> = self.labels.iter().map(|label| &**label).collect();
let grouping = if self.by {
Grouping::By(&labels)
@@ -130,39 +187,210 @@ impl FloatAggregator {
Grouping::Without(&labels)
};
let metric = grouping.apply(&series.metric)?;
let index = match self.group_index.entry(label_key(&metric)) {
let key_bytes = metric
.tags()
.len()
.saturating_add(1)
.saturating_mul(std::mem::size_of::<(Arc<str>, Arc<str>)>());
workspace_bytes = workspace_bytes
.saturating_add(grouping_workspace_bytes(&metric))
.saturating_add(key_bytes);
budget(existing_bytes.saturating_add(workspace_bytes))?;
let key = label_key(&metric);
let key_retained = key
.capacity()
.saturating_mul(size_of::<(Arc<str>, Arc<str>)>());
let new_group = !self.group_index.contains_key(&key);
let group_capacity = self.groups.capacity();
let map_capacity = self.group_index.capacity();
let old_map_backing = hash_map_backing_bytes(map_capacity);
if new_group {
let next_group_capacity = if self.groups.len() == group_capacity {
group_capacity.saturating_mul(2).max(4)
} else {
group_capacity
};
let group_realloc_bytes = if next_group_capacity > group_capacity {
next_group_capacity.saturating_mul(size_of::<Group>())
} else {
0
};
budget(
existing_bytes
.saturating_add(metric_bytes(&metric))
.saturating_add(key_bytes)
// A realloc that cannot grow in place keeps both buffers alive while it
// copies, so a growing step admits the old buffer beside the new one --
// the same reason the map below admits both tables. The old buffer alone
// is already in `existing_bytes`; what is added here is the new one.
.saturating_add(group_realloc_bytes)
.saturating_add(if self.group_index.len() == map_capacity {
hash_map_backing_bytes(next_hash_capacity(map_capacity))
} else {
0
})
.saturating_add(workspace_bytes),
)?;
}
let group_capacity_growth = if new_group && self.groups.len() == group_capacity {
group_capacity.saturating_mul(2).max(4)
} else {
group_capacity
};
if new_group && group_capacity_growth > group_capacity {
self.groups.reserve(1);
}
if new_group && self.group_index.len() == self.group_index.capacity() {
self.group_index.reserve(1);
}
let index = match self.group_index.entry(key) {
Entry::Occupied(entry) => *entry.get(),
Entry::Vacant(entry) => {
self.groups.push(Group {
metric,
metric: metric.clone(),
blocks: BTreeMap::new(),
});
*entry.insert(self.groups.len() - 1)
let index = self.groups.len() - 1;
entry.insert(index);
index
}
};
self.groups[index].accumulate(series, self.op);
if new_group {
self.retained = self
.retained
.saturating_add(metric_bytes(&metric))
.saturating_add(key_retained)
.saturating_add(
self.groups
.capacity()
.saturating_sub(group_capacity)
.saturating_mul(size_of::<Group>()),
)
.saturating_add(
hash_map_backing_bytes(self.group_index.capacity())
.saturating_sub(old_map_backing),
);
}
drop(labels);
drop(metric);
self.retained = self.groups[index].accumulate(
series,
self.op,
&mut budget,
self.retained,
self.schema.fields.len(),
)?;
Ok(())
}
/// Number of groups currently retained by this fold.
pub(crate) fn groups(&self) -> usize {
self.groups.len()
}
/// Bytes reserved for this fold, using bounded allowances for its maps and group payloads.
pub(crate) fn state_bytes(&self) -> usize {
self.retained
}
#[cfg(test)]
pub(crate) fn recounted_state_bytes(&self) -> usize {
let groups = self.groups.iter().fold(0usize, |total, group| {
total
.saturating_add(metric_bytes(&group.metric))
.saturating_add(
group
.blocks
.len()
.saturating_mul(BLOCK_POINTS)
.saturating_mul(self.schema.fields.len())
.saturating_mul(std::mem::size_of::<FloatState>()),
)
.saturating_add(btree_backing_bytes(group.blocks.len()))
});
groups
.saturating_add(
self.labels
.capacity()
.saturating_mul(std::mem::size_of::<Arc<str>>()),
)
.saturating_add(hash_map_backing_bytes(self.group_index.capacity()))
.saturating_add(self.group_index.keys().fold(0usize, |bytes, key| {
bytes.saturating_add(
key.capacity()
.saturating_mul(size_of::<(Arc<str>, Arc<str>)>()),
)
}))
.saturating_add(
self.labels
.iter()
.map(|label| label.len() + 2 * std::mem::size_of::<usize>())
.sum::<usize>(),
)
.saturating_add(
self.groups
.capacity()
.saturating_mul(std::mem::size_of::<Group>()),
)
}
/// The result of every group, in the order the groups first appeared.
pub fn finish(self) -> Result<Vec<Series>> {
finish_groups(self.schema, self.op, self.groups)
self.finish_budgeted(|_| Ok(()))
}
pub(crate) fn finish_budgeted(
self,
budget: impl FnMut(usize) -> Result<()>,
) -> Result<Vec<Series>> {
let state = self.state_bytes();
finish_groups_budgeted(self.schema, self.op, self.groups, state, budget)
}
}
/// Turns the state of `groups` into result series, in group order.
fn finish_groups(
/// Turns the state of `groups` into result series, in group order, admitting as it builds.
fn finish_groups_budgeted(
schema: Arc<SeriesSchema>,
op: AggregateOp,
groups: Vec<Group>,
state: usize,
mut budget: impl FnMut(usize) -> Result<()>,
) -> Result<Vec<Series>> {
let width = schema.fields.len();
let group_count = groups.len();
if group_count == 0 {
return Ok(Vec::new());
}
let groups = groups.into_iter();
let mut builder = GridSeriesBuilder::try_new(schema)?;
let mut output = Vec::with_capacity(groups.len());
let mut row = Vec::with_capacity(width);
let output_capacity_bytes = group_count.saturating_mul(std::mem::size_of::<Series>());
let row_capacity_bytes = width.saturating_mul(std::mem::size_of::<Option<f64>>());
let base = state
.saturating_add(output_capacity_bytes)
.saturating_add(row_capacity_bytes);
budget(base)?;
let mut output = Vec::new();
output.try_reserve_exact(group_count).map_err(|error| {
DataFusionError::Execution(format!("cannot reserve aggregate outputs: {error}"))
})?;
let mut row = Vec::new();
row.try_reserve_exact(width).map_err(|error| {
DataFusionError::Execution(format!("cannot reserve aggregate row: {error}"))
})?;
let mut completed_output_bytes = 0usize;
for group in groups {
// One builder serves every group, so each result series starts explicitly.
builder.begin_series();
let (rows, runs) = group_rows_runs(&group, width);
let finish_bytes = finish_working_bytes(rows, runs, width);
budget(
state
.saturating_add(output_capacity_bytes)
.saturating_add(row_capacity_bytes)
.saturating_add(completed_output_bytes)
.saturating_add(finish_bytes),
)?;
builder.reserve_rows(rows)?;
for (&block, states) in &group.blocks {
// The last block can extend past the grid; only points with a sample are pushed.
for (offset, point) in states.chunks_exact(width).enumerate() {
@@ -174,11 +402,89 @@ fn finish_groups(
builder.push(block * BLOCK_POINTS + offset, &row)?;
}
}
output.extend(builder.finish(group.metric)?);
let finished = builder.finish(group.metric)?;
completed_output_bytes = completed_output_bytes.saturating_add(
finished
.iter()
.map(crate::grid_series::series_bytes)
.sum::<usize>(),
);
output.extend(finished);
budget(
state
.saturating_add(output_capacity_bytes)
.saturating_add(row_capacity_bytes)
.saturating_add(completed_output_bytes),
)?;
}
Ok(output)
}
/// The result of every group, without a budget: the entry the partial-state merger takes, which
/// does not hold a query pool to admit against.
fn finish_groups(
schema: Arc<SeriesSchema>,
op: AggregateOp,
groups: Vec<Group>,
) -> Result<Vec<Series>> {
finish_groups_budgeted(schema, op, groups, 0, |_| Ok(()))
}
fn group_rows_runs(group: &Group, width: usize) -> (usize, usize) {
if width == 0 {
return (0, 0);
}
let mut rows = 0usize;
let mut runs = 0usize;
let mut previous: Option<usize> = None;
for (&block, states) in &group.blocks {
for (offset, point) in states.chunks_exact(width).enumerate() {
if point.iter().any(|state| state.seen) {
let index = block.saturating_mul(BLOCK_POINTS).saturating_add(offset);
rows = rows.saturating_add(1);
if previous.is_none_or(|previous| previous.checked_add(1) != Some(index)) {
runs = runs.saturating_add(1);
}
previous = Some(index);
}
}
}
(rows, runs)
}
fn finish_working_bytes(rows: usize, runs: usize, width: usize) -> usize {
let values = rows
.saturating_mul(width)
.saturating_mul(std::mem::size_of::<f64>());
let validity_bytes = rows
.saturating_add(7)
.checked_div(8)
.unwrap_or(0)
.div_ceil(64)
.saturating_mul(64);
let fields = crate::grid_series::field_builder_bytes(width).saturating_add(
width.saturating_mul(
std::mem::size_of::<Float64Array>()
.saturating_add(std::mem::size_of::<Samples>())
.saturating_add(std::mem::size_of::<usize>()),
),
);
let indices = rows.saturating_mul(std::mem::size_of::<usize>());
let spans = runs.saturating_mul(std::mem::size_of::<GridSpan>());
let axis_conversion = runs
.saturating_mul(
2 * std::mem::size_of::<GridSpan>() + std::mem::size_of::<Arc<[GridSpan]>>(),
)
.saturating_add(rows.saturating_mul(std::mem::size_of::<usize>()));
values
.saturating_add(width.saturating_mul(validity_bytes))
.saturating_add(fields)
.saturating_add(indices)
.saturating_add(spans)
.saturating_add(axis_conversion)
.saturating_add(std::mem::size_of::<Series>() + std::mem::size_of::<TimeAxis>())
}
/// `quantile(q, v)`, with `q` read at each grid point from an unlabelled single-field scalar on
/// the same grid, as Prometheus evaluates the parameter at every step. A point where `q` has no
/// value has no result; `q` outside `[0, 1]` or NaN gives what Prometheus' `quantile` gives.
@@ -438,6 +744,105 @@ impl Grouping<'_> {
type LabelKey = Vec<(Arc<str>, Arc<str>)>;
fn metric_bytes(metric: &MetricName) -> usize {
metric
.name()
.map_or(0, str::len)
.saturating_add(
metric
.tags()
.iter()
.map(|tag| tag.name.len() + tag.value.len())
.sum::<usize>(),
)
.saturating_add(
metric
.tags()
.len()
.saturating_mul(std::mem::size_of::<(Arc<str>, Arc<str>)>()),
)
.saturating_add(std::mem::size_of::<MetricName>())
}
fn grouping_workspace_bytes(metric: &MetricName) -> usize {
metric_bytes(metric)
.saturating_add(
metric
.tags()
.len()
.saturating_add(1)
.saturating_mul(std::mem::size_of::<(Arc<str>, Arc<str>)>()),
)
.saturating_add(std::mem::size_of::<LabelKey>())
}
/// Conservative backing estimate for Rust 1.98.1's insert-only `HashMap` (hashbrown 0.17.1).
/// Bucket count follows its 7/8 load factor; entries, one control byte per bucket and the
/// 16-byte control-group tail are counted. This models requested table storage, not allocator slack.
/// An upper bound on the bytes the group index holds for `capacity` entries.
///
/// The table keeps its entries in a power-of-two number of buckets below `16/7` of the usable
/// capacity, with one control byte beside each bucket. Three buckets per entry therefore covers the
/// table whatever exact growth the map uses, which is what keeps this a bound rather than a model of
/// one implementation: a toolchain that changes how the map grows cannot make it under-count.
fn hash_map_backing_bytes(capacity: usize) -> usize {
capacity
.saturating_mul(3)
.saturating_mul(size_of::<(LabelKey, usize)>() + 1)
}
/// An upper bound on the capacity a map holding `capacity` entries grows to.
fn next_hash_capacity(capacity: usize) -> usize {
capacity.saturating_mul(2).max(7)
}
/// An upper bound on the bytes one B-tree node holds.
///
/// A node keeps at most eleven key/value pairs and twelve child pointers, plus a header of a few
/// words; the leaf form is smaller than that. The header allowance is generous on purpose, so the
/// bound does not depend on how a toolchain lays the node out.
fn btree_node_bytes() -> usize {
11 * size_of::<(usize, Box<[FloatState]>)>() + 16 * size_of::<usize>()
}
/// An upper bound on the bytes a B-tree of `entries` holds.
///
/// Every node but the root keeps at least five entries, so `entries / 5` nodes is enough to hold
/// them; a tree of one entry is one node.
fn btree_backing_bytes(entries: usize) -> usize {
if entries == 0 {
return 0;
}
entries.div_ceil(5).saturating_mul(btree_node_bytes())
}
fn pending_bytes(fields: usize) -> usize {
working_set_bytes(fields, BLOCK_POINTS)
}
fn working_set_bytes(fields: usize, pending_capacity: usize) -> usize {
pending_capacity
.saturating_mul(std::mem::size_of::<(usize, f64)>())
.saturating_add(fields.saturating_mul(std::mem::size_of::<Option<f64>>() + 1))
}
fn labels_capacity_bytes(grouping: Grouping<'_>) -> usize {
let labels = match grouping {
Grouping::By(labels) | Grouping::Without(labels) => labels,
};
labels.len().saturating_mul(std::mem::size_of::<Arc<str>>())
}
fn grouping_bytes(grouping: Grouping<'_>) -> usize {
let labels = match grouping {
Grouping::By(labels) | Grouping::Without(labels) => labels,
};
labels
.iter()
.map(|label| label.len() + std::mem::size_of::<Arc<str>>())
.sum::<usize>()
}
fn label_key(metric: &MetricName) -> LabelKey {
let mut labels = Vec::with_capacity(metric.tags().len() + 1);
if let Some(name) = metric.name() {
@@ -463,9 +868,29 @@ struct Group {
blocks: BTreeMap<usize, Box<[FloatState]>>,
}
/// Which field of which layout a fold is writing, and how it folds it.
///
/// One value carried through the per-block work instead of three separate arguments: the three
/// never vary independently within one series, and the block loop is the only place that reads
/// them.
#[derive(Clone, Copy)]
struct FieldFold {
width: usize,
field_index: usize,
op: AggregateOp,
}
impl Group {
fn accumulate(&mut self, series: &Series, op: AggregateOp) {
let width = series.fields.len();
fn accumulate(
&mut self,
series: &Series,
op: AggregateOp,
budget: &mut impl FnMut(usize) -> Result<()>,
mut retained: usize,
width: usize,
) -> Result<usize> {
let pending_capacity_bytes = BLOCK_POINTS.saturating_mul(size_of::<(usize, f64)>());
budget(retained.saturating_add(pending_capacity_bytes))?;
let mut pending = Vec::with_capacity(BLOCK_POINTS);
for (field_index, field) in series.fields.iter().enumerate() {
// The axis says which absolute positions the field holds and the field iterator
@@ -475,35 +900,81 @@ impl Group {
let mut block = usize::MAX;
for ((index, _), sample) in series.time.positions().zip(field.iter()) {
if index / BLOCK_POINTS != block {
self.update_block(block, width, field_index, op, &mut pending);
retained = self.update_block(
block,
FieldFold {
width,
field_index,
op,
},
&mut pending,
budget,
retained,
)?;
block = index / BLOCK_POINTS;
}
if let SampleRef::Float(sample) = sample {
pending.push((index % BLOCK_POINTS, sample));
}
}
self.update_block(block, width, field_index, op, &mut pending);
retained = self.update_block(
block,
FieldFold {
width,
field_index,
op,
},
&mut pending,
budget,
retained,
)?;
}
Ok(retained)
}
/// Applies one field's samples within a block, so a block costs one lookup per field run.
fn update_block(
&mut self,
block: usize,
width: usize,
field_index: usize,
op: AggregateOp,
fold: FieldFold,
pending: &mut Vec<(usize, f64)>,
) {
budget: &mut impl FnMut(usize) -> Result<()>,
retained: usize,
) -> Result<usize> {
if pending.is_empty() {
return;
return Ok(retained);
}
let new_block = !self.blocks.contains_key(&block);
let block_bytes = if new_block {
BLOCK_POINTS
.saturating_mul(fold.width)
.saturating_mul(std::mem::size_of::<FloatState>())
} else {
0
};
let backing_delta = if new_block {
btree_backing_bytes(self.blocks.len() + 1)
.saturating_sub(btree_backing_bytes(self.blocks.len()))
} else {
0
};
if new_block {
budget(
retained
.saturating_add(backing_delta)
.saturating_add(block_bytes)
.saturating_add(pending.capacity().saturating_mul(size_of::<(usize, f64)>())),
)?;
}
let states = self.blocks.entry(block).or_insert_with(|| {
vec![FloatState::default(); BLOCK_POINTS * width].into_boxed_slice()
vec![FloatState::default(); BLOCK_POINTS * fold.width].into_boxed_slice()
});
for (offset, sample) in pending.drain(..) {
states[offset * width + field_index].update(op, sample);
states[offset * fold.width + fold.field_index].update(fold.op, sample);
}
Ok(retained
.saturating_add(backing_delta)
.saturating_add(block_bytes))
}
}
+317
View File
@@ -14,6 +14,7 @@
use std::collections::BTreeMap;
use datafusion::error::DataFusionError;
use datatypes::arrow::array::{Float64Array, UInt32Array};
use datatypes::series::Label;
use datatypes::series::histogram::HistogramBatchBuilder;
@@ -345,6 +346,27 @@ fn avg_compensation_matches_prometheus_bits() {
}
}
#[test]
fn one_field_missing_and_another_nan_keep_row_nullability() {
let schema = schema(&["missing", "value"]);
let series = input(
&schema,
metric("m", &[]),
0,
&[
vec![None, None, None],
vec![Some(1.0), Some(f64::NAN), Some(2.0)],
],
);
let output = aggregate_float(&schema, &[series], AggregateOp::Sum, Grouping::By(&[])).unwrap();
assert_eq!(output.len(), 1);
assert_eq!(values(&output[0], 0).1, vec![None, None, None]);
let values = values(&output[0], 1).1;
assert_eq!(values[0], Some(1.0));
assert!(values[1].unwrap().is_nan());
assert_eq!(values[2], Some(2.0));
}
#[test]
fn fields_are_aggregated_independently() {
// A field without input values at a row is null there, even for count and group; the row
@@ -476,6 +498,301 @@ fn fields_are_aggregated_independently() {
}
}
#[test]
fn incremental_retained_bytes_match_full_recount_after_updates() {
let schema = schema_with_grid_len(&["value", "other"], 256);
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&["zone", "rack"]))
.unwrap();
assert_eq!(
aggregator.state_bytes(),
aggregator.recounted_state_bytes(),
"initial state"
);
let updates = [
input(
&schema,
metric("m", &[("zone", "a"), ("rack", "1")]),
0,
&[vec![Some(1.0)], vec![Some(2.0)]],
),
input(
&schema,
metric("m", &[("zone", "a"), ("rack", "1")]),
64,
&[vec![Some(3.0)], vec![Some(4.0)]],
),
input(
&schema,
metric("m", &[("zone", "a"), ("rack", "1")]),
0,
&[vec![Some(5.0)], vec![Some(6.0)]],
),
input(
&schema,
metric("m", &[("zone", "b"), ("rack", "2")]),
128,
&[vec![Some(7.0)], vec![Some(8.0)]],
),
];
for (index, series) in updates.iter().enumerate() {
let previous = aggregator.state_bytes();
aggregator.update(series).unwrap();
assert_eq!(
aggregator.state_bytes(),
aggregator.recounted_state_bytes(),
"update {index}"
);
if index == 2 {
assert_eq!(aggregator.state_bytes(), previous);
}
}
}
#[test]
fn incremental_retained_bytes_match_full_recount_for_many_groups() {
let schema = schema(&["value"]);
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&["id"])).unwrap();
for id in 0..512 {
let id = id.to_string();
let series = input(
&schema,
metric("m", &[("id", &id)]),
id.parse::<usize>().unwrap() % 64,
&[vec![Some(1.0)]],
);
aggregator.update(&series).unwrap();
assert_eq!(
aggregator.state_bytes(),
aggregator.recounted_state_bytes(),
"group {id}"
);
}
}
/// The unbudgeted entry point is the budgeted one with a no-op budget, so this compares one path
/// with itself: it is a smoke check that the wrapper still runs, not evidence that the kernels are
/// right. The numbers are checked against Prometheus in `aggregates_match_prometheus` and
/// `stat_aggregates_match_prometheus`.
#[test]
fn every_supported_kind_runs_through_the_unbudgeted_entry_point() {
let schema = schema(&["value"]);
let series = input(&schema, metric("m", &[]), 0, &[vec![Some(1.0), Some(3.0)]]);
for op in [
AggregateOp::Sum,
AggregateOp::Count,
AggregateOp::Avg,
AggregateOp::Min,
AggregateOp::Max,
AggregateOp::Group,
AggregateOp::Stddev,
AggregateOp::Stdvar,
] {
let mut plain = FloatAggregator::try_new(&schema, op, Grouping::By(&[])).unwrap();
plain.update(&series).unwrap();
let plain = plain.finish().unwrap();
let mut budgeted =
FloatAggregator::try_new_budgeted(&schema, op, Grouping::By(&[]), |_| Ok(())).unwrap();
budgeted.update_budgeted(&series, |_| Ok(())).unwrap();
let budgeted = budgeted.finish_budgeted(|_| Ok(())).unwrap();
assert_eq!(
values_of(&plain, 0, 128),
values_of(&budgeted, 0, 128),
"{op:?}"
);
}
}
#[test]
fn repeated_updates_in_one_group_and_block_keep_one_block_charge() {
let schema = schema(&["value"]);
let first = input(&schema, metric("m", &[]), 0, &[vec![Some(1.0)]]);
let second = input(&schema, metric("m", &[]), 1, &[vec![Some(3.0)]]);
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&[])).unwrap();
aggregator.update(&first).unwrap();
let retained = aggregator.state_bytes();
aggregator.update(&second).unwrap();
assert_eq!(aggregator.state_bytes(), retained);
}
#[test]
fn adjacent_blocks_are_admitted_and_accounted_separately() {
let schema = schema(&["value"]);
let series = input(&schema, metric("m", &[]), 63, &[vec![Some(1.0), Some(2.0)]]);
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&[])).unwrap();
let mut admissions = Vec::new();
aggregator
.update_budgeted(&series, |bytes| {
admissions.push(bytes);
Ok(())
})
.unwrap();
assert_eq!(aggregator.groups(), 1);
assert_eq!(aggregator.groups[0].blocks.len(), 2);
assert_eq!(
admissions.last().copied(),
Some(
aggregator
.state_bytes()
.saturating_add(std::mem::size_of::<(usize, f64)>() * BLOCK_POINTS,)
)
);
}
#[test]
fn a_rejected_block_allocation_does_not_insert_a_block() {
let schema = schema(&["value"]);
let series = input(&schema, metric("m", &[]), 0, &[vec![Some(1.0)]]);
// The figure a new block's admission adds: the block's states and its pending buffer, on
// top of whatever the fold already holds. Located by the value, not by which call reaches it,
// so reordering the earlier admissions cannot make this test pass without testing anything.
let block_states = BLOCK_POINTS
.saturating_mul(schema.fields.len())
.saturating_mul(std::mem::size_of::<FloatState>());
let pending = BLOCK_POINTS * std::mem::size_of::<(usize, f64)>();
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&[])).unwrap();
let mut refused = None;
let result = aggregator.update_budgeted(&series, |bytes| {
// Everything up to the block's own admission is admitted; only the block is refused.
if bytes >= block_states.saturating_add(pending) {
refused = Some(bytes);
return Err(DataFusionError::Execution("block allocation budget".into()));
}
Ok(())
});
assert!(
result.is_err(),
"the block was admitted with no budget left"
);
let refused = refused.expect("the refusal did not come from a block admission");
assert!(
refused >= block_states.saturating_add(pending),
"the refusal came from an admission smaller than a block"
);
assert_eq!(aggregator.groups(), 1);
assert!(
aggregator.groups[0].blocks.is_empty(),
"a refused block was inserted anyway"
);
}
#[test]
fn a_map_growth_rejection_does_not_insert_the_new_group() {
let schema = schema(&["value"]);
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&["i"])).unwrap();
let first = input(&schema, metric("m", &[("i", "0")]), 0, &[vec![Some(1.0)]]);
aggregator.update(&first).unwrap();
let mut index = 1;
while aggregator.group_index.len() < aggregator.group_index.capacity() {
let label = index.to_string();
let series = input(
&schema,
metric("m", &[("i", &label)]),
0,
&[vec![Some(1.0)]],
);
aggregator.update(&series).unwrap();
index += 1;
}
let group_count = aggregator.groups();
let old_state = aggregator.state_bytes();
assert_eq!(aggregator.group_index.capacity(), group_count);
// A resize keeps the old table while it fills the new one, so the figure the growth has to
// admit is the whole new table on top of the old state -- not the difference between them.
// That is the property asserted here: the refusal threshold is the state plus a whole new
// table, so the test fails if the admission ever drops to the delta.
let new_table = hash_map_backing_bytes(next_hash_capacity(group_count));
let threshold = old_state.saturating_add(new_table);
let label = index.to_string();
let series = input(
&schema,
metric("m", &[("i", &label)]),
0,
&[vec![Some(1.0)]],
);
let mut peak = 0;
let result = aggregator.update_budgeted(&series, |bytes| {
peak = peak.max(bytes);
if bytes >= threshold {
Err(DataFusionError::Execution("old+new map budget".into()))
} else {
Ok(())
}
});
assert!(
result.is_err(),
"the map grew with no budget for both tables"
);
assert!(
peak >= threshold,
"the growth admitted {peak}, less than the old state plus a new table ({threshold})"
);
assert_eq!(aggregator.group_index.capacity(), group_count);
assert_eq!(aggregator.groups(), group_count);
assert_eq!(
aggregator
.groups
.iter()
.map(|group| group.blocks.len())
.sum::<usize>(),
group_count
);
}
#[test]
fn finish_rejection_after_first_group_is_budgeted_before_the_next() {
let schema = schema(&["value"]);
let mut aggregator =
FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&["i"])).unwrap();
for (label, value) in [("a", 1.0), ("b", 2.0)] {
let series = input(
&schema,
metric("m", &[("i", label)]),
0,
&[vec![Some(value)]],
);
aggregator.update(&series).unwrap();
}
let mut calls = Vec::new();
let result = aggregator.finish_budgeted(|bytes| {
calls.push(bytes);
if calls.len() == 4 {
Err(DataFusionError::Execution("budget".into()))
} else {
Ok(())
}
});
assert!(result.is_err());
assert_eq!(calls.len(), 4);
assert!(calls[1] > calls[0], "first builder peak");
assert!(calls[2] < calls[1], "first group settle");
assert!(calls[3] > calls[2], "second builder peak");
// The base already includes the output Vec backing; later callbacks add only the
// builder workspace or completed Series allocation.
let builder_workspace = finish_working_bytes(1, 1, 1);
let expected_output = aggregate_float(
&schema,
&[
input(&schema, metric("m", &[("i", "a")]), 0, &[vec![Some(1.0)]]),
input(&schema, metric("m", &[("i", "b")]), 0, &[vec![Some(2.0)]]),
],
AggregateOp::Sum,
Grouping::By(&["i"]),
)
.unwrap();
let completed_series = crate::grid_series::series_bytes(&expected_output[0]);
assert_eq!(calls[1] - calls[0], builder_workspace);
assert_eq!(calls[2] - calls[0], completed_series);
assert_eq!(calls[3] - calls[2], builder_workspace);
}
#[test]
fn state_blocks_keep_absolute_indices_across_boundaries_and_gaps() {
// A 512-point grid spans eight state blocks; inputs straddle a block edge, leave whole
+2
View File
@@ -14,6 +14,7 @@
mod absent;
mod empty_metric;
mod fused_series;
mod histogram_fold;
mod instant_manipulate;
mod normalize;
@@ -37,6 +38,7 @@ use datafusion::error::{DataFusionError, Result as DataFusionResult};
use datafusion::logical_expr::{Expr, Extension, LogicalPlan};
use datatypes::data_type::DataType as _;
pub use empty_metric::{EmptyMetric, EmptyMetricExec, EmptyMetricStream, build_special_time_expr};
pub use fused_series::{FusedSeries, FusedSeriesBinding, FusedSeriesExec, FusedSeriesStream};
pub use histogram_fold::{
HistogramFold, HistogramFoldExec, HistogramFoldOperation, HistogramFoldStream,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+130 -20
View File
@@ -19,12 +19,12 @@ use std::collections::hash_map::Entry;
use std::sync::Arc;
use datafusion::arrow::array::builder::NullBufferBuilder;
use datafusion::arrow::array::{Float64Array, UInt32Array};
use datafusion::arrow::array::{Array, Float64Array, UInt32Array};
use datafusion::error::{DataFusionError, Result};
use datatypes::series::MetricName;
use datatypes::series::histogram::HistogramBatchBuilder;
use datatypes::series::histogram::{HistogramBatch, HistogramBatchBuilder};
use datatypes::series::samples::{
HistogramSamples, SampleRef, Samples, Series, SeriesFields, SeriesSchema,
DenseSamplesRef, HistogramSamples, SampleRef, Samples, Series, SeriesFields, SeriesSchema,
};
use datatypes::series::time::{EvalGrid, GridSpan, TimeAxis};
@@ -35,6 +35,70 @@ pub(crate) mod reference;
#[cfg(test)]
mod tests;
pub(crate) fn series_bytes(series: &Series) -> usize {
let runs = series.time.runs().count();
let segments: usize = series
.fields
.iter()
.map(|field| field.segments().len())
.sum();
std::mem::size_of::<Series>()
.saturating_add(std::mem::size_of::<TimeAxis>())
.saturating_add(runs.saturating_mul(std::mem::size_of::<(usize, usize)>()))
.saturating_add(
segments.saturating_mul(std::mem::size_of::<Samples>() + std::mem::size_of::<usize>()),
)
.saturating_add(fields_bytes(&series.fields))
.saturating_add(series.metric.name().map_or(0, str::len))
.saturating_add(std::mem::size_of_val(series.metric.tags()))
.saturating_add(
series
.metric
.tags()
.iter()
.map(|tag| tag.name.len() + tag.value.len())
.sum::<usize>(),
)
}
pub(crate) fn fields_bytes(fields: &SeriesFields) -> usize {
fields
.iter()
.map(|field| {
field.segments().iter().fold(0usize, |total, segment| {
total.saturating_add(segment_bytes(segment))
})
})
.fold(0usize, usize::saturating_add)
}
/// The bytes one dense segment of samples holds.
pub(crate) fn segment_bytes(segment: &Samples) -> usize {
match segment.dense() {
Some(DenseSamplesRef::Float(values)) => values.get_array_memory_size(),
Some(DenseSamplesRef::Histogram(histograms)) => histograms
.indices()
.get_array_memory_size()
.saturating_add(table_bytes(histograms.batch())),
Some(DenseSamplesRef::Mixed(mixed)) => mixed
.floats()
.get_array_memory_size()
.saturating_add(mixed.histograms().indices().get_array_memory_size())
.saturating_add(table_bytes(mixed.histograms().batch())),
// `segments` hands out dense segments only, so a nested chunk never reaches here.
None => 0,
}
}
/// The bytes one histogram table holds: the batch struct and the arrays its columns view.
pub(crate) fn table_bytes(table: &Arc<HistogramBatch>) -> usize {
std::mem::size_of::<HistogramBatch>().saturating_add(table.as_arrow().get_array_memory_size())
}
pub(crate) fn field_builder_bytes(width: usize) -> usize {
width.saturating_mul(std::mem::size_of::<FieldBuilder>())
}
/// Builds one grid series from rows pushed at absolute grid indices.
///
/// A push names one position and the value each field holds there. Rows are accumulated
@@ -114,6 +178,36 @@ impl GridSeriesBuilder {
})
}
/// Reserves capacity for the exact number of valued rows in the next series.
///
/// Call after `begin_series` and before its first push. Existing public callers may continue
/// to rely on normal growth; callers with a budget can reserve the concrete row capacity first.
pub(crate) fn reserve_rows(&mut self, rows: usize) -> Result<()> {
self.indices
.try_reserve_exact(rows.saturating_sub(self.indices.len()))
.map_err(|error| {
DataFusionError::Execution(format!("cannot reserve grid row indices: {error}"))
})?;
if rows == 0 {
return Ok(());
}
if self.fields.is_empty() {
self.fields = (0..self.schema.fields.len())
.map(|_| FieldBuilder::new(rows))
.collect();
}
for field in &mut self.fields {
field
.values
.try_reserve_exact(rows.saturating_sub(field.values.len()))
.map_err(|error| {
DataFusionError::Execution(format!("cannot reserve grid values: {error}"))
})?;
field.nulls = NullBufferBuilder::new(rows);
}
Ok(())
}
/// Pushes the field values at absolute grid `index`; `None` is a missing value.
///
/// Indices must be strictly increasing; skipped indices are positions that hold no value.
@@ -176,7 +270,7 @@ impl GridSeriesBuilder {
return Ok(false);
}
if self.fields.is_empty() {
self.fields = (0..len).map(|_| FieldBuilder::new()).collect();
self.fields = (0..len).map(|_| FieldBuilder::new(0)).collect();
}
self.indices.push(index);
Ok(true)
@@ -239,24 +333,28 @@ impl GridSeriesBuilder {
}
let fields = std::mem::take(&mut self.fields);
let indices = std::mem::take(&mut self.indices);
let samples = fields
.into_iter()
.map(|mut field| {
let floats = Float64Array::new(field.values.into(), field.nulls.finish());
let Some(column) = field.histograms else {
return Ok(Samples::from_float(floats));
};
let mut samples = Vec::new();
samples.try_reserve_exact(fields.len()).map_err(|error| {
DataFusionError::Execution(format!("cannot reserve grid field descriptions: {error}"))
})?;
for mut field in fields {
let floats = Float64Array::new(field.values.into(), field.nulls.finish());
let sample = if let Some(column) = field.histograms {
let batch = column.batch.finish().map_err(external)?;
let histograms =
HistogramSamples::try_new(UInt32Array::from(column.indices), batch)
.map_err(external)?;
Samples::try_from_mixed(floats, histograms).map_err(external)
})
.collect::<Result<Vec<_>>>()?;
Samples::try_from_mixed(floats, histograms).map_err(external)?
} else {
Samples::from_float(floats)
};
samples.push(sample);
}
let fields = SeriesFields::try_from_vec(samples).map_err(external)?;
// The runs are all the axis has to remember about the gaps, and the rows are already
// compact, so no position holding no value is visited or stored.
let spans = spans_of(&indices);
let run_count = count_runs(&indices);
let spans = spans_of(&indices, run_count)?;
let time = TimeAxis::from_grid_spans(self.grid.clone(), spans).map_err(external)?;
Series::try_new(&self.schema, metric, time, fields)
.map(Some)
@@ -265,8 +363,19 @@ impl GridSeriesBuilder {
}
/// Cuts the retained indices into maximal runs of consecutive positions.
fn spans_of(indices: &[usize]) -> Vec<GridSpan> {
fn count_runs(indices: &[usize]) -> usize {
indices
.windows(2)
.filter(|pair| pair[1] != pair[0] + 1)
.count()
.saturating_add(usize::from(!indices.is_empty()))
}
fn spans_of(indices: &[usize], runs: usize) -> Result<Vec<GridSpan>> {
let mut spans: Vec<GridSpan> = Vec::new();
spans.try_reserve_exact(runs).map_err(|error| {
DataFusionError::Execution(format!("cannot reserve grid spans: {error}"))
})?;
let mut start = 0;
while let Some(&position) = indices.get(start) {
let mut len = 1;
@@ -276,14 +385,14 @@ fn spans_of(indices: &[usize]) -> Vec<GridSpan> {
spans.push(GridSpan::new(position, len));
start += len;
}
spans
Ok(spans)
}
impl FieldBuilder {
fn new() -> Self {
fn new(rows: usize) -> Self {
Self {
values: Vec::new(),
nulls: NullBufferBuilder::new(0),
nulls: NullBufferBuilder::new(rows),
histograms: None,
}
}
@@ -454,7 +563,8 @@ pub fn merge_same_labelset(schema: &Arc<SeriesSchema>, series: Vec<Series>) -> R
// The runs are all the axis remembers about the holes, so a sparse merge stores one span
// per run and no row for the positions between them.
let time =
TimeAxis::from_grid_spans(grid.clone(), spans_of(&positions)).map_err(external)?;
TimeAxis::from_grid_spans(grid.clone(), spans_of(&positions, count_runs(&positions))?)
.map_err(external)?;
let fields = SeriesFields::try_from_vec(fields).map_err(external)?;
// The first fragment names the output, as it always has: a label set ignores empty values,
// so the metric of the first member is the one the merged series keeps.
+243
View File
@@ -0,0 +1,243 @@
// 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.
//! The run-time state of an aggregate node.
//!
//! An aggregate folds a whole vector into one series per group, so its node is the one that cannot
//! answer as its input arrives: a group's value at a grid point is the fold of every value every
//! member of the group has there, and the last member to arrive is the one that decides it. What the
//! node keeps between chunks is therefore the fold's states and nothing else -- a chunk is folded as
//! it arrives and dropped -- and it produces once, when the input ends.
//!
//! That is also why this runtime cannot tell an incomplete group from a complete one: it folds what
//! it is handed. A [`AggregateStage::Complete`] aggregate is correct only if every contribution to
//! each group lies within the streams this execution reads, which is the physical plan's job -- the
//! caller that schedules scans and exchanges guarantees it, and a plan that ran a `Complete`
//! aggregate once per partition and then combined the answers would be combining values that were
//! each missing the other partitions' contributions. The stages that exist for that shape are
//! [`AggregateStage::Partial`] and [`AggregateStage::Final`], which this runtime does not execute
//! yet.
use std::sync::Arc;
use datafusion::error::{DataFusionError, Result};
use datatypes::series::samples::{Series, SeriesSchema};
use crate::aggregate::{AggregateOp, FloatAggregator, Grouping};
use crate::evaluated::EvaluatedChunk;
use crate::grid_series::series_bytes;
use crate::map::validate_float_series;
use crate::native::execute::{NodeBuffer, evaluated_bytes};
use crate::native::metrics::NodePath;
use crate::native::plan::{
AggregateKind, AggregateStage, BoundAggregate, Grouping as BoundGrouping,
};
/// One aggregate node's states, from the first chunk it is handed to the series it finishes with.
pub(crate) struct AggregateRuntime {
/// What the node computes, as the tree bound it: the kind it folds by and the stage it reports.
op: BoundAggregate,
/// The layout its input and output share: the fields every chunk has and the grid they are
/// addressed on. A `Complete` aggregate neither adds nor drops a field, and it evaluates on its
/// input's grid, so one schema describes both sides.
schema: Arc<SeriesSchema>,
/// The fold. `None` once [`Self::compute`] has taken it, which is also what says the node has
/// nothing left to fold into.
accumulator: Option<FloatAggregator>,
/// Whether the fold has been finished and its series handed over. A node computes once: a second
/// pull finds the series it queued rather than a fold that ran again.
///
/// The driver reads this to decide whether the node settled: an aggregate answers only once its
/// fold has run, so a child with nothing left to hand over is not an answer.
pub(crate) computed: bool,
}
impl AggregateRuntime {
/// What this node computes, for a rejection that has to say what it refused.
///
/// It names the kind this node computes, which distinguishes it from another node in the tree.
fn describe(&self) -> String {
format!("{:?}", self.op.kind)
}
/// The states of a `Complete` aggregate over `schema`.
///
/// A stage other than `Complete` and a kind without a supported kernel are refused here, before a single
/// chunk is folded: a node this runtime cannot compute must not start, because a partial answer
/// would be one the caller never asked for. The rejection names what was found, so a reader can
/// tell a tree this runtime cannot run from one it cannot run correctly.
pub(crate) fn new(
op: BoundAggregate,
schema: Arc<SeriesSchema>,
buffer: &mut NodeBuffer,
) -> Result<Self> {
if op.stage != AggregateStage::Complete {
return Err(DataFusionError::NotImplemented(format!(
"the native runtime does not execute a `{:?}` aggregate yet: a `Partial` aggregate \
reports the state a later stage merges and a `Final` aggregate merges that state, \
and only `Complete` -- every contribution inside one execution -- is computed here",
op.stage
)));
}
let kind = match op.kind {
AggregateKind::Sum => AggregateOp::Sum,
AggregateKind::Count => AggregateOp::Count,
AggregateKind::Avg => AggregateOp::Avg,
AggregateKind::Min => AggregateOp::Min,
AggregateKind::Max => AggregateOp::Max,
AggregateKind::Group => AggregateOp::Group,
AggregateKind::Stddev => AggregateOp::Stddev,
AggregateKind::Stdvar => AggregateOp::Stdvar,
other => {
return Err(DataFusionError::NotImplemented(format!(
"the native runtime does not execute the `{other:?}` aggregate yet: only the supported `Complete` aggregate kinds are computed here"
)));
}
};
let name_count = match &op.group {
BoundGrouping::By(labels) | BoundGrouping::Without(labels) => labels.len(),
};
let name_bytes = name_count.saturating_mul(std::mem::size_of::<&str>());
buffer.grow(name_bytes)?;
let names: Vec<&str> = match &op.group {
BoundGrouping::By(labels) | BoundGrouping::Without(labels) => {
labels.iter().map(|label| label.as_ref()).collect()
}
};
let grouping = match &op.group {
BoundGrouping::By(_) => Grouping::By(&names),
BoundGrouping::Without(_) => Grouping::Without(&names),
};
let name_bytes = names.len().saturating_mul(std::mem::size_of::<&str>());
let initial = FloatAggregator::try_new_budgeted(&schema, kind, grouping, |bytes| {
buffer.resize(bytes.saturating_add(name_bytes))
})?;
drop(names);
buffer.resize(initial.state_bytes())?;
Ok(Self {
op,
schema,
accumulator: Some(initial),
computed: false,
})
}
/// What folding `chunk` reserves while it is folded.
///
/// The chunk is not retained: it is folded into the states and dropped at the end of the call,
/// so what this node holds afterwards is the fold and none of the input. The charge is therefore
/// the chunk's own bytes -- the figure [`evaluated_bytes`] reports, which is one pass over the
/// chunk and no state between chunks -- and not the `operand_chunk_bytes` figure a binary
/// operand keeps, which exists because such an operand holds a series for the whole execution
/// and so must count the slices of one buffer instead of the buffer once per chunk.
pub(crate) fn pending(&self, chunk: &EvaluatedChunk) -> usize {
evaluated_bytes(chunk)
}
/// Folds one chunk into its group.
///
/// A chunk that closes its series closes nothing here: a group is not a series, so the last
/// contribution of one member says nothing about the group. The fold keeps its states, which is
/// what makes a group's answer its whole group's answer once the input ends.
pub(crate) fn accumulate(
&mut self,
path: &NodePath,
chunk: EvaluatedChunk,
buffer: &mut NodeBuffer,
pending: usize,
) -> Result<()> {
// `Series` keeps its fields public, so a chunk can be built by struct literal against
// another schema; the states are indexed by absolute position on this node's grid, so a
// chunk of another field count or another grid would be folded into the wrong places.
validate_float_series(&self.schema, &chunk.series)
.map_err(|error| folded_chunk(path, &self.describe(), error))?;
let accumulator = self.accumulator.as_mut().ok_or_else(|| no_fold(path))?;
// Keep the chunk covered while the fold decides whether it needs a new group or block.
accumulator.update_budgeted(&chunk.series, |fold_bytes| {
buffer.resize(pending.saturating_add(fold_bytes))
})?;
drop(chunk);
buffer.resize(accumulator.state_bytes())
}
/// The number of retained groups, for budgeting the output queue backing storage.
pub(crate) fn groups(&self) -> usize {
self.accumulator.as_ref().map_or(0, FloatAggregator::groups)
}
/// Finishes the fold and returns one series per group, or `None` if the fold already ran.
///
/// `None` is what a node reports after it has queued its series: the caller reads them from the
/// queue, exactly as it reads the chunks a binary node queued, so a second pull does not finish
/// a fold whose states are gone.
pub(crate) fn compute(
&mut self,
path: &NodePath,
buffer: &mut NodeBuffer,
queue_bytes: usize,
) -> Result<Option<Vec<Series>>> {
if self.computed {
return Ok(None);
}
if self.accumulator.is_none() {
return Err(no_fold(path));
}
self.computed = true;
let output = self
.accumulator
.take()
.ok_or_else(|| no_fold(path))?
.finish_budgeted(|total| buffer.resize(total.saturating_add(queue_bytes)))?;
let output_bytes = output.iter().fold(0usize, |bytes, series| {
bytes.saturating_add(series_bytes(series))
});
let output_vec_bytes = output
.capacity()
.saturating_mul(std::mem::size_of::<Series>());
buffer.resize(
output_bytes
.saturating_add(output_vec_bytes)
.saturating_add(queue_bytes),
)?;
Ok(Some(output))
}
}
/// The rejection of a chunk that is not the layout this node folds.
///
/// The fold indexes its states by absolute grid position and by field, so a chunk of another field
/// count or another grid would be folded into states that mean something else. `error` is what the
/// series check reported, which says which of the two disagreed.
/// Wraps the error of a chunk this node cannot fold.
///
/// Only a chunk that does not fit the schema is the node's own refusal; anything else is the
/// kernel saying it does not compute this shape at all -- a native histogram in a float aggregate,
/// for instance -- and a caller that falls back to another planner on `NotImplemented` still can.
/// Wrapping that one in an `Execution` would deny it.
fn folded_chunk(path: &NodePath, aggregate: &str, error: DataFusionError) -> DataFusionError {
match error {
DataFusionError::NotImplemented(_) => error,
error => DataFusionError::Execution(format!(
"the `{aggregate}` aggregate at {path} folds chunks bound to its own schema, and this \
one is not: {error}"
)),
}
}
/// The error for a fold that has already been finished.
fn no_fold(path: &NodePath) -> DataFusionError {
DataFusionError::Execution(format!(
"the aggregate at {path} already finished its fold, so it takes no more input"
))
}
+3
View File
@@ -127,6 +127,9 @@ fn describe(node: &NativeNode) -> String {
BoundVectorOp::Histogram(HistogramFunction::Fraction { lower, upper }) => {
format!("histogram_fraction {lower} {upper}")
}
BoundVectorOp::CountValues { label, group } => {
format!("count_values {label:?} {}", grouping(group))
}
},
NativeExpr::Aggregate { op, .. } => format!(
"{} {} [{}] {}",
+7
View File
@@ -446,6 +446,13 @@ fn a_vector_operation_names_its_binding() {
},
r#" root Vector absent {job="x"}"#,
),
(
BoundVectorOp::CountValues {
label: "value".into(),
group: Grouping::Without(Arc::new(["job".into()])),
},
r#" root Vector count_values "value" without [job]"#,
),
];
for (op, line) in lines {
let tree = NativeTree::try_new(NativeNode {
+269 -217
View File
@@ -91,9 +91,9 @@ use std::sync::Arc;
use datafusion::error::{DataFusionError, Result};
use datafusion::execution::memory_pool::{MemoryConsumer, MemoryPool, MemoryReservation};
use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
use datatypes::arrow::array::{Array, Float64Array};
use datatypes::arrow::array::Float64Array;
use datatypes::arrow::buffer::{Buffer, ScalarBuffer};
use datatypes::series::histogram::{HistogramBatch, HistogramView};
use datatypes::series::histogram::HistogramView;
use datatypes::series::raw::{RawSeriesChunk, RawTimestamps};
use datatypes::series::samples::{
DenseSamplesRef, RowCursor, SampleRef, Samples, Series, SeriesFields, SeriesSchema,
@@ -101,8 +101,10 @@ use datatypes::series::samples::{
use datatypes::series::time::{EvalGrid, GridRange, GridSpan, GridSpec, TimeAxis};
use datatypes::series::{MetricName, SeriesOrdering};
use crate::aggregate;
use crate::grid_series::{
GridSample, GridSeriesBuilder, LabelSet, label_set, merge_same_labelset, owned,
GridSample, GridSeriesBuilder, LabelSet, fields_bytes, label_set, merge_same_labelset, owned,
series_bytes, table_bytes,
};
use crate::histogram_eval::evaluate_histograms;
use crate::instant_eval::select_instants;
@@ -110,12 +112,16 @@ use crate::map::{
ArithmeticOp, Comparison, ComparisonOp, MathOp, ScalarOperand, drop_metric_name,
map_arithmetic, map_compare, map_math,
};
use crate::native::aggregate::AggregateRuntime;
use crate::native::batch::EvaluatedChunk;
use crate::native::metrics::{NodeMetrics, NodePath};
use crate::native::plan::{
BoundInstant, BoundScalar, BoundVectorOp, Grouping, NativeExpr, NativeNode, NativeTree,
NativeType,
};
#[cfg(test)]
pub(crate) use crate::native::runs::absorb_rebuilds;
use crate::native::runs::{Run, absorb};
use crate::range_eval::RangeEvaluator;
use crate::select::{self, SortOrder};
use crate::vector_match::{
@@ -189,34 +195,11 @@ fn raw_bytes(chunk: &RawSeriesChunk) -> usize {
/// The bytes an evaluated chunk holds: its struct and the Arrow buffers behind its fields.
///
/// The grid is shared by every chunk addressed on it, so it is not counted per chunk.
fn evaluated_bytes(chunk: &EvaluatedChunk) -> usize {
std::mem::size_of::<EvaluatedChunk>().saturating_add(fields_bytes(&chunk.series.fields))
}
/// The bytes one evaluated series holds: its header, the axis that addresses it, the identity it
/// carries and the sample buffers behind its fields.
///
/// It is the figure a fragment of an affected window is charged with as it arrives. The axis's runs
/// and the segments of its fields are counted in one pass over the series, so the estimate costs
/// the fragment rather than a walk over everything the node holds. A payload a fragment shares with
/// another — a histogram table behind two slices of it, the grid behind every axis — is counted
/// once per fragment: that over-count is accepted here rather than a deduplication that would have
/// to keep state across fragments.
fn series_bytes(series: &Series) -> usize {
let runs = series.time.runs().count();
let segments: usize = series
.fields
.iter()
.map(|field| field.segments().len())
.sum();
std::mem::size_of::<Series>()
.saturating_add(std::mem::size_of::<TimeAxis>())
.saturating_add(runs.saturating_mul(std::mem::size_of::<(usize, usize)>()))
.saturating_add(
segments.saturating_mul(std::mem::size_of::<Samples>() + std::mem::size_of::<usize>()),
)
.saturating_add(fields_bytes(&series.fields))
.saturating_add(metric_bytes(&series.metric))
/// It is what an aggregate node charges for a chunk it is folding: that node hands the chunk over
/// to its fold and drops it, so the chunk alone is what it holds for as long as it holds anything.
pub(super) fn evaluated_bytes(chunk: &EvaluatedChunk) -> usize {
std::mem::size_of::<EvaluatedChunk>().saturating_add(fields_bytes(&chunk.series.fields))
}
/// The bytes one chunk a binary operand retains holds on its own.
@@ -342,42 +325,6 @@ fn merge_workspace_bytes(rows: usize, fragments: usize, fields: usize) -> usize
.saturating_add(output)
}
/// The bytes the fields of a chunk hold: the sample buffers of every segment, and the histogram
/// tables those segments reference.
fn fields_bytes(fields: &SeriesFields) -> usize {
fields
.iter()
.map(|field| {
field.segments().iter().fold(0usize, |total, segment| {
total.saturating_add(segment_bytes(segment))
})
})
.fold(0usize, usize::saturating_add)
}
/// The bytes one dense segment of samples holds.
fn segment_bytes(segment: &Samples) -> usize {
match segment.dense() {
Some(DenseSamplesRef::Float(values)) => values.get_array_memory_size(),
Some(DenseSamplesRef::Histogram(histograms)) => histograms
.indices()
.get_array_memory_size()
.saturating_add(table_bytes(histograms.batch())),
Some(DenseSamplesRef::Mixed(mixed)) => mixed
.floats()
.get_array_memory_size()
.saturating_add(mixed.histograms().indices().get_array_memory_size())
.saturating_add(table_bytes(mixed.histograms().batch())),
// `segments` hands out dense segments only, so a nested chunk never reaches here.
None => 0,
}
}
/// The bytes one histogram table holds: the batch struct and the arrays its columns view.
fn table_bytes(table: &Arc<HistogramBatch>) -> usize {
std::mem::size_of::<HistogramBatch>().saturating_add(table.as_arrow().get_array_memory_size())
}
/// The bytes the allocation `buffer` is a slice of.
///
/// The capacity is the whole allocation, which is what a slice keeps alive: a key of a few bytes
@@ -408,6 +355,8 @@ enum NodeTag {
/// Matches two evaluated vectors and applies one operation to every matched pair of series, or
/// broadcasts one constant over the chunks of one evaluated vector.
Binary,
/// Folds every series of the input into one series per group, when the input ends.
Aggregate,
/// Keeps some samples of its whole input vector at each grid point.
Vector,
}
@@ -422,6 +371,7 @@ impl NodeTag {
Self::Window => "window",
Self::Map => "map",
Self::Binary => "binary",
Self::Aggregate => "aggregate",
Self::Vector => "vector operation",
}
}
@@ -576,7 +526,9 @@ impl MergeInput {
/// in the query; the metrics report the same figure so a slow node can be told apart from a wide
/// one. The reservation is also the figure itself: a node holds nothing beside what it reserved, so
/// a gauge reads its size rather than a copy that could drift from it.
struct NodeBuffer {
// Visible to the aggregate node so that its fold can reserve for what it is about to allocate; the
// reservation belongs to a node, not to a module.
pub(super) struct NodeBuffer {
metrics: Arc<NodeMetrics>,
reservation: MemoryReservation,
}
@@ -592,7 +544,7 @@ impl NodeBuffer {
}
/// Reserves `bytes` beside what this node already holds.
fn grow(&mut self, bytes: usize) -> Result<()> {
pub(super) fn grow(&mut self, bytes: usize) -> Result<()> {
self.reservation.try_grow(bytes)?;
self.record();
Ok(())
@@ -603,7 +555,7 @@ impl NodeBuffer {
/// What a node that consumes its input chunk by chunk holds afterwards is not the sum of what
/// it consumed, so the reservation is set from the state that is left rather than grown per
/// chunk.
fn resize(&mut self, bytes: usize) -> Result<()> {
pub(super) fn resize(&mut self, bytes: usize) -> Result<()> {
self.reservation.try_resize(bytes)?;
self.record();
Ok(())
@@ -628,20 +580,6 @@ impl NodeBuffer {
}
}
/// One run of grid positions, half-open, that hold a value in one field.
#[derive(Clone, Copy, Debug)]
struct Run {
start: usize,
end: usize,
}
impl Run {
/// Whether two runs share a position.
fn overlaps(self, other: Self) -> bool {
self.start < other.end && other.start < self.end
}
}
/// The positions one node has handed out, per output label set and field.
///
/// A node that rewrites labels — a `Map` dropping the metric name, a `Window` dropping the name
@@ -876,109 +814,6 @@ fn misaligned_field(
))
}
// How many times `absorb` rebuilt the stored runs instead of appending to them.
//
// Test-only, and per thread: the runs stored afterwards are the same either way, so a rebuild shows
// only in what it costs, and a test that reads its own thread's figure is not disturbed by another
// test's chunks.
#[cfg(test)]
thread_local! {
static ABSORB_REBUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread's [`absorb`] rebuilt the stored runs.
#[cfg(test)]
fn absorb_rebuilds() -> usize {
ABSORB_REBUILDS.with(std::cell::Cell::get)
}
/// Merges `incoming` into `stored`, keeping the result in position order and joining the runs that
/// touch.
///
/// Within a chunk one pass over both sides is what keeps this linear, since each side is sorted and
/// disjoint. Adding the runs one at a time and rebuilding the array after each would cost the square
/// of the runs a chunk yields: a chunk of one hundred thousand positions alternating between values
/// and nulls yields fifty thousand runs, and re-merging after each is over a billion element moves
/// for a single series.
///
/// One pass per chunk is still a pass over everything the series has stored so far, so it is not
/// free once the *history* grows across chunks: a series split into `K` chunks of one position each,
/// a hole apart, stores one more run per chunk and moves `K(K+1)/2` of them, with no label collision
/// and no illegal input anywhere. A chunk that starts no earlier than the end of the last stored run
/// only ever adds to the end — the stored runs are sorted and disjoint, so every one of them ends
/// before anything this chunk holds — and it is appended instead, which is amortised constant per
/// run. That is the shape a scan hands over as its positions move forward chunk after chunk. A chunk
/// that starts before the last stored run can split a run or reach back past it, so it still costs
/// the full merge.
///
/// Either way the result is sorted, disjoint, and has the runs that touch joined, which is what
/// [`Emitted::occupy`] looks a position up by.
fn absorb(stored: &mut Vec<Run>, incoming: &[Run]) {
if incoming.is_empty() {
return;
}
if stored
.last()
.is_none_or(|last| incoming[0].start >= last.end)
{
append_runs(stored, incoming);
} else {
merge_runs(stored, incoming);
}
}
/// Appends `incoming` to `stored`, all of which lies past the last stored run.
///
/// Nothing already stored has to be placed again: the two sides are each in position order and
/// nothing `incoming` holds can reach back into what is stored, so its runs are pushed on the end and
/// the vector's own growth is the only move — amortised over the runs appended. Where its first run
/// touches the end of the last stored run the two are one run, as they are after a merge, so it is
/// joined rather than left beside it.
fn append_runs(stored: &mut Vec<Run>, incoming: &[Run]) {
let mut incoming = incoming;
if let Some(last) = stored.last_mut()
&& let Some(first) = incoming.first()
&& first.start <= last.end
{
last.end = last.end.max(first.end);
incoming = &incoming[1..];
}
stored.extend_from_slice(incoming);
}
/// Merges `incoming` into `stored`, where the two can interleave.
///
/// One pass is enough because each side is sorted and disjoint: whichever run starts first is
/// placed, and it joins the run before it where the two touch.
fn merge_runs(stored: &mut Vec<Run>, incoming: &[Run]) {
#[cfg(test)]
ABSORB_REBUILDS.with(|rebuilds| rebuilds.set(rebuilds.get() + 1));
let mut merged: Vec<Run> = Vec::with_capacity(stored.len() + incoming.len());
let (mut a, mut b) = (0, 0);
while a < stored.len() || b < incoming.len() {
let larger = match (stored.get(a), incoming.get(b)) {
(Some(left), Some(right)) if right.start < left.start => {
b += 1;
*right
}
(Some(left), _) => {
a += 1;
*left
}
(None, Some(right)) => {
b += 1;
*right
}
(None, None) => break,
};
match merged.last_mut() {
Some(last) if larger.start <= last.end => last.end = last.end.max(larger.end),
_ => merged.push(larger),
}
}
*stored = merged;
}
/// What a map node applies.
struct MapRuntime {
ops: Arc<[MathOp]>,
@@ -1915,6 +1750,19 @@ fn vector_series(
BoundVectorOp::Histogram(function) => {
evaluate_histograms(*function, schema, &merge_same_labelset(schema, series)?)
}
BoundVectorOp::CountValues { label, group } => {
let (by, labels) = match group {
Grouping::By(labels) => (true, labels),
Grouping::Without(labels) => (false, labels),
};
let labels: Vec<&str> = labels.iter().map(|label| &**label).collect();
let grouping = if by {
aggregate::Grouping::By(&labels)
} else {
aggregate::Grouping::Without(&labels)
};
aggregate::count_values(schema, &series, label, grouping)
}
BoundVectorOp::Absent { labels } => Ok(select::absent(schema, &series, labels.clone())?
.into_iter()
.collect()),
@@ -1933,6 +1781,10 @@ fn vector_series(
/// - A sort: the value each series is keyed by, and the floats `sort` and `sort_desc` rebuild a
/// series into when it holds histograms; the label sorts move the series they were given.
/// - `absent`: a presence flag and a result value per grid point and field.
/// - `count_values`, per field sample, since every field's distinct values make groups of their
/// own and a gauge with distinct values makes about one group per sample: the group's label set
/// twice (its metric and its key) with the value written into it, its count per field, and the
/// result series it becomes.
/// - `histogram_quantile` and `histogram_fraction`: the merge of label-set pieces before the kernel
/// ([`merge_workspace_bytes`]); per series, its group entry and label pairs and a cursor per
/// field; per row, the bucket it contributes and a result value per field.
@@ -1979,6 +1831,21 @@ fn vector_workspace_bytes(op: &BoundVectorOp, schema: &SeriesSchema, series: &[S
},
);
}
BoundVectorOp::CountValues { .. } => {
let label = std::mem::size_of::<(Arc<str>, Arc<str>)>();
// The value label at the length of a 17-digit value with sign, point and exponent;
// the `'f'` form writes a large magnitude out in full, which this does not follow.
let value = 24;
let group = std::mem::size_of::<Vec<f64>>()
+ fields * std::mem::size_of::<f64>()
+ std::mem::size_of::<Series>()
+ fields * std::mem::size_of::<Samples>();
return series.iter().fold(0usize, |total, item| {
let labels = 2 * (item.metric.tags().len() + 2) * label + value;
let samples = item.time.len().saturating_mul(fields);
total.saturating_add(samples.saturating_mul(group + labels))
});
}
BoundVectorOp::TopK { .. }
| BoundVectorOp::LimitK { .. }
| BoundVectorOp::LimitRatio { .. } => {}
@@ -2260,6 +2127,14 @@ struct NodeRuntime {
/// `None` for every other node, and for a binary node that broadcasts a constant instead: that
/// one holds no operand at all and is described by [`NodeRuntime::broadcast`].
binary: Option<BinaryRuntime>,
/// What an aggregate node holds: the fold over what it was handed.
///
/// Unlike a binary node's operands, nothing of the input survives a chunk: the fold keeps its
/// states and the chunk it read is dropped, so this is the one node whose reservation does not
/// follow what it was handed but what it accumulated. That is also why this node cannot report
/// its input complete from what it holds -- there is nothing to hold -- and reads the driver's
/// [`NativeExecutor::inputs_finished`] and its child's [`NativeExecutionState::settled`] instead.
aggregate: Option<AggregateRuntime>,
/// What a vector node holds.
vector: Option<VectorRuntime>,
/// What a binary node holds when one operand is a constant: the operation and the layout of the
@@ -2669,6 +2544,102 @@ impl NodeRuntime {
self.buffer.resize(queued)
}
/// Folds one chunk into this node's aggregate.
///
/// The chunk is reserved for before it is folded, by what the node reports it is worth, and the
/// reservation settles afterwards on the fold that is left. Unlike [`Self::retain_binary`], what
/// stops being held here is the chunk itself: the fold keeps its states, so a chunk that closes
/// its series leaves nothing behind and the node is empty of input again.
fn retain_aggregate(&mut self, path: &NodePath, chunk: NativeChunk) -> Result<()> {
let NativeChunk::Evaluated(chunk) = chunk else {
return Err(DataFusionError::Execution(format!(
"the aggregate at {path} consumes evaluated chunks, not raw samples"
)));
};
// The figure is read before the chunk is folded, because reserving after the fact would be
// reserving for memory the fold already read.
let pending = self
.aggregate
.as_ref()
.ok_or_else(|| no_aggregate(path))?
.pending(&chunk);
let positions = chunk.series.time.len();
self.buffer.grow(pending)?;
let metrics = Arc::clone(&self.buffer.metrics);
{
let _timer = metrics.timer();
let (aggregate, buffer) = (&mut self.aggregate, &mut self.buffer);
aggregate
.as_mut()
.ok_or_else(|| no_aggregate(path))?
.accumulate(path, chunk, buffer, pending)?;
}
metrics.record_input(1, positions);
// A chunk that closed its series closed it for the node below: a group is not a series, so
// the fold completing nothing on it is why nothing is counted here. The series this node
// closes are the ones it hands out, counted when it queues them.
Ok(())
}
/// Finishes this node's fold and queues one chunk per group it produced.
///
/// Each group is one series, decided in full by the fold, so the one chunk it is handed over as
/// closes it: a consumer above reads "nothing more here" from that chunk rather than from another
/// one arriving.
fn compute_aggregate(&mut self, path: &NodePath) -> Result<()> {
let metrics = Arc::clone(&self.buffer.metrics);
let _timer = metrics.timer();
let groups = self
.aggregate
.as_ref()
.map_or(0, |aggregate| aggregate.groups());
let queue_capacity = self.queued.capacity();
let queue_target = self.queued.len().saturating_add(groups);
let queue_growth = queue_target
.saturating_sub(queue_capacity)
.saturating_mul(std::mem::size_of::<(NativeChunk, usize)>());
self.buffer.grow(queue_growth)?;
if groups > 0 {
self.queued.try_reserve_exact(groups).map_err(|error| {
DataFusionError::Execution(format!(
"cannot reserve aggregate output queue: {error}"
))
})?;
}
let queue_bytes = queue_target
.max(queue_capacity)
.saturating_mul(std::mem::size_of::<(NativeChunk, usize)>());
let output = {
let aggregate = self.aggregate.as_mut().ok_or_else(|| no_aggregate(path))?;
aggregate.compute(path, &mut self.buffer, queue_bytes)?
};
// The fold has returned its series; reserve their handover before appending them to the queue.
let Some(output) = output else {
return Ok(());
};
let mut closed = 0usize;
for series in output {
let bytes = series_bytes(&series);
let chunk = NativeChunk::Evaluated(EvaluatedChunk::try_new(series, true)?);
self.queued.push_back((chunk, bytes));
closed += 1;
}
// A group is one series and this node closes each of them exactly once, on the one chunk it
// hands it over as.
self.buffer.metrics.record_completed_series(closed);
// The output Vec has been consumed; the node now holds only queue backing and chunks.
let queued_bytes = self
.queued
.iter()
.fold(0usize, |total, (_, bytes)| total.saturating_add(*bytes));
let queue_backing = self
.queued
.capacity()
.saturating_mul(std::mem::size_of::<(NativeChunk, usize)>());
self.buffer
.resize(queued_bytes.saturating_add(queue_backing))
}
/// Feeds one chunk to this node's instant selection.
///
/// `None` means the series this chunk belongs to has not ended: the selection reads one whole
@@ -2871,6 +2842,8 @@ impl NodeRuntime {
self.window = None;
self.instant = None;
self.binary = None;
// The fold is what an aggregate node holds, and dropping it is what gives its states back.
self.aggregate = None;
self.vector = None;
self.broadcast = None;
self.source_schema = None;
@@ -2963,15 +2936,14 @@ impl NativeExecutionState {
// two vectors, or a constant broadcast over one -- is decided where the operands'
// layouts are read.
NativeExpr::Binary { .. } => NodeTag::Binary,
// An aggregate is built here and narrowed below, for the same reason: `validate`
// admits every kind and every stage as a legal tree, and the runtime computes one
// kind at one stage.
NativeExpr::Aggregate { .. } => NodeTag::Aggregate,
NativeExpr::Vector { .. } => NodeTag::Vector,
// A scalar is a value node: it produces the constant it was bound to, hands over no
// chunk and holds no state, so it is built like any other node and pulled like none.
NativeExpr::Scalar(_) => NodeTag::Scalar,
// An aggregate is legal in a bound tree, and `validate` stays the authority on that;
// this runtime simply does not execute it yet. Rejected before a single chunk is
// read: a tree the runtime cannot execute must not start, because a partial answer
// would be one the caller never asked for.
NativeExpr::Aggregate { .. } => return Err(not_implemented("Aggregate", &path)),
};
let buffer = NodeBuffer::new(&path, handles, &pool);
let mut runtime = NodeRuntime {
@@ -2982,6 +2954,7 @@ impl NativeExecutionState {
window: None,
map: None,
binary: None,
aggregate: None,
vector: None,
broadcast: None,
source_schema: None,
@@ -3081,6 +3054,29 @@ impl NativeExecutionState {
// out. A broadcast that keeps the labels it reads is not where two series
// become one either, so it keeps none; the one that drops them does, above.
}
NativeExpr::Aggregate { input, op } => {
// A `Complete` aggregate neither adds nor drops a field and evaluates on its
// input's grid, so the node's own output is the layout it folds. What it does
// not execute -- another kind, or another stage -- is refused here, before the
// pass reaches the input that would feed it, and naming what it found is that
// rejection's job: a `Partial` aggregate's output is state and names no samples
// at all, so the input's layout stands in for it and the stage decides.
let schema = node
.output
.samples()
.or_else(|| input.output.samples())
.cloned()
.ok_or_else(|| {
DataFusionError::Execution(format!(
"the aggregate at {path} does not produce samples"
))
})?;
runtime.aggregate = Some(AggregateRuntime::new(
op.clone(),
schema,
&mut runtime.buffer,
)?);
}
NativeExpr::Vector { op, .. } => {
let schema = node.output.samples().cloned().ok_or_else(|| {
DataFusionError::Execution(format!(
@@ -3103,10 +3099,9 @@ impl NativeExecutionState {
// A vector operation keeps the labels of the series it reads, or answers under
// label sets of its own, so it keeps no ledger of what it handed out.
}
NativeExpr::Scalar(_) | NativeExpr::Aggregate { .. } => {
NativeExpr::Scalar(_) => {
// A scalar holds nothing: what its consumer reads is the constant the node was
// bound to, which is on the tree rather than in this state. The aggregate is
// rejected above, where the pass has the position to name.
// bound to, which is on the tree rather than in this state.
}
}
// The inputs this node reads, in the order the tree declares them: a unary node's one
@@ -3114,12 +3109,11 @@ impl NativeExecutionState {
// pushed back to front, so the node the spec names is the one the pass reaches next and
// nothing but its own subtree lies between the two.
let reads_input = match &node.expr {
NativeExpr::Input(_) | NativeExpr::Scalar(_) | NativeExpr::Aggregate { .. } => {
false
}
NativeExpr::Input(_) | NativeExpr::Scalar(_) => false,
NativeExpr::Instant { input, .. }
| NativeExpr::Window { input, .. }
| NativeExpr::Map { input, .. }
| NativeExpr::Aggregate { input, .. }
| NativeExpr::Vector { input, .. } => {
pending.push((input.as_ref(), path.input()));
true
@@ -3189,6 +3183,14 @@ impl NativeExecutionState {
holds_shared[index] = input_holds_shared;
}
NodeTag::Map => holds_shared[index] = true,
NodeTag::Aggregate => {
// An aggregate hands over one series per group, each under the label set its
// group was keyed by and each closed on its own chunk: two groups are two
// label sets, so nothing above has to join pieces of a series. Its own input
// is read whole -- the fold holds what it accumulated, not the chunks -- so
// nothing about its input's flag reaches it either.
holds_shared[index] = false;
}
NodeTag::Scalar => {
// A scalar produces one value and no labels, so it is not a stream of series
// and nothing above it can read two of them under one label set. The flag
@@ -3229,8 +3231,9 @@ impl NativeExecutionState {
// what ranking the merged series would, and what is above treats them as
// it treats a map's. `absent` answers with one series under the labels it
// was bound to, the histogram functions merge the label sets they answer
// with, and `limitk` counts series in input order, so it merges the pieces
// first; none of them hands over pieces.
// with, `count_values` answers with one series per label set it makes, and
// `limitk` counts series in input order, so it merges the pieces first;
// none of them hands over pieces.
let op = match expr {
NativeExpr::Vector { op, .. } => Some(op),
_ => None,
@@ -3245,7 +3248,11 @@ impl NativeExecutionState {
let whole = limitk
|| matches!(
op,
Some(BoundVectorOp::Absent { .. } | BoundVectorOp::Histogram(_))
Some(
BoundVectorOp::Absent { .. }
| BoundVectorOp::Histogram(_)
| BoundVectorOp::CountValues { .. }
)
);
holds_shared[index] = input_holds_shared && !whole;
}
@@ -3742,9 +3749,24 @@ impl NativeExecutor {
// A scalar produces its value without reading anything, so it is settled from the
// start: there is no chunk it owes its consumer.
NodeTag::Scalar => true,
// These report as they are handed chunks, so a child with nothing left is all it takes
// for them to have nothing left either.
NodeTag::Map | NodeTag::Window | NodeTag::Instant => self.state.nodes[index]
.input
.is_none_or(|child| self.settled(child)),
// An aggregate answers one series per group and only once its input ended, so its own
// fold has to have run as well: a child that has handed everything up is not enough,
// because every one of those chunks was folded and none of them answered yet. This is
// what keeps it out of the line above, which asks only about the child.
NodeTag::Aggregate => {
runtime
.aggregate
.as_ref()
.is_some_and(|aggregate| aggregate.computed)
&& self.state.nodes[index]
.input
.is_none_or(|child| self.settled(child))
}
NodeTag::Vector => {
runtime
.vector
@@ -3942,6 +3964,7 @@ impl NativeExecutor {
// does, so its values are counted rather than read as its positions.
runtime.pop_queued(evaluated_values)
}
NodeTag::Aggregate => self.aggregate_node(index),
NodeTag::Vector => {
let child = input.ok_or_else(|| missing_input(tag))?;
// Which samples are kept at a point is not decided until the whole input has
@@ -3961,6 +3984,35 @@ impl NativeExecutor {
}
}
/// Folds everything the aggregate at `index` can read and hands over one group's series.
///
/// What it reads is taken to the end of what its child has, because nothing the fold is handed
/// is retained: a chunk is folded as it arrives and dropped, so reading the child dry costs the
/// fold's states and not the input's bytes. Nothing is answered until the input has ended --
/// before that, a group's value at a point is still missing the members that have not arrived.
///
/// That is the difference from a binary node. A binary node retains both operands and so can ask
/// whether they hold anything still open; an aggregate keeps none of them and asks the driver
/// instead: [`Self::inputs_finished`] says the caller will hand over nothing more, and
/// [`NativeExecutionState::settled`] says the child has none left to produce.
fn aggregate_node(&mut self, index: usize) -> Result<Option<NativeChunk>> {
let child = self.state.nodes[index]
.input
.ok_or_else(|| missing_input(NodeTag::Aggregate))?;
while let Some(chunk) = self.pull(child)? {
let path = &self.state.paths[index];
self.state.runtimes[index].retain_aggregate(path, chunk)?;
}
if !self.inputs_finished || !self.settled(child) {
return Ok(None);
}
let path = &self.state.paths[index];
self.state.runtimes[index].compute_aggregate(path)?;
// A group's series holds one value per field and point it decided, so its values are counted
// rather than read as its positions, as a binary result's are.
self.state.runtimes[index].pop_queued(evaluated_values)
}
/// Pulls the vector side of the broadcast at `index` until it hands over a chunk, and returns
/// that chunk transformed.
///
@@ -4188,11 +4240,7 @@ fn valid_values(chunk: &EvaluatedChunk) -> usize {
.sum()
}
/// The values a chunk a binary node hands over holds.
///
/// A binary result is an evaluated chunk, so this is [`valid_values`]: one value per field sample
/// that holds one. A raw chunk cannot reach a binary node's queue, and is answered as its positions
/// so an impossible queue cannot under-count.
/// The values an evaluated chunk hands over, or the timestamps a raw source hands over.
fn evaluated_values(chunk: &NativeChunk) -> usize {
match chunk {
NativeChunk::Evaluated(chunk) => valid_values(chunk),
@@ -4200,18 +4248,6 @@ fn evaluated_values(chunk: &NativeChunk) -> usize {
}
}
/// The rejection of a node kind this runtime does not execute yet.
///
/// It names the kind and the node's position: the position is what the metrics and `EXPLAIN` call
/// the same node, so a reader can find it in the tree. An aggregate is the one kind left here: a
/// scalar executes as a value node, and a binary node executes both as a vector match and as a
/// constant broadcast.
fn not_implemented(kind: &str, path: &NodePath) -> DataFusionError {
DataFusionError::NotImplemented(format!(
"the native runtime does not execute {kind} nodes yet: {kind} at {path}"
))
}
/// The error for a window whose evaluator this execution already closed.
fn closed(path: &NodePath) -> DataFusionError {
DataFusionError::Execution(format!("the window at {path} is already closed"))
@@ -4293,11 +4329,27 @@ fn truncated_input(path: &NodePath) -> DataFusionError {
))
}
/// The rejection of a node kind this runtime does not execute yet.
///
/// It names the kind and the node's position: the position is what the metrics and `EXPLAIN` call
/// the same node, so a reader can find it in the tree.
fn not_implemented(kind: &str, path: &NodePath) -> DataFusionError {
DataFusionError::NotImplemented(format!(
"the native runtime does not execute {kind} nodes yet: {kind} at {path}"
))
}
/// The error for a node asked to compute without the runtime its kind holds.
fn no_binary(path: &NodePath) -> DataFusionError {
DataFusionError::Execution(format!("the node at {path} holds no binary operation"))
}
/// The error for an aggregate node whose fold is missing.
fn no_aggregate(path: &NodePath) -> DataFusionError {
DataFusionError::Execution(format!("the node at {path} holds no aggregate"))
}
/// The error for a node asked to run a vector operation without one.
fn no_vector(path: &NodePath) -> DataFusionError {
DataFusionError::Execution(format!("the node at {path} holds no vector operation"))
}
+849 -26
View File
@@ -36,7 +36,7 @@ use datatypes::series::{Label, MetricName, SeriesOrdering};
use super::{
BinaryRuntime, Emitted, NativeChunk, NativeExecutionState, NativeExecutor, NodeBuffer,
NodeRuntime, absorb_rebuilds, evaluated_bytes, grid_end_ms, map_chunk, operand_chunk_bytes,
raw_bytes,
raw_bytes, vector_workspace_bytes,
};
use crate::evaluated::EvaluatedChunk;
use crate::grid_series::merge_same_labelset;
@@ -45,8 +45,9 @@ use crate::instant_eval::select_instants;
use crate::map::MathOp;
use crate::native::metrics::{NodeMetric, NodeMetrics, NodePath};
use crate::native::plan::{
AggregateKind, AggregateStage, BoundAggregate, BoundInput, BoundInstant, BoundMap, BoundScalar,
BoundVectorOp, BoundWindow, Grouping, NativeExpr, NativeNode, NativeTree, NativeType,
AggregateKind, AggregateStage, AggregateStateSchema, BoundAggregate, BoundInput, BoundInstant,
BoundMap, BoundScalar, BoundVectorOp, BoundWindow, Grouping, NativeExpr, NativeNode,
NativeTree, NativeType,
};
use crate::range_eval::{RangeEvaluator, RangeFunction, RangeWindow};
use crate::select::SortOrder;
@@ -487,6 +488,7 @@ fn source_runtime(pool: &Arc<dyn MemoryPool>) -> NodeRuntime {
window: None,
map: None,
binary: None,
aggregate: None,
broadcast: None,
vector: None,
source_schema: None,
@@ -509,6 +511,7 @@ fn binary_runtime(pool: &Arc<dyn MemoryPool>, schema: &Arc<SeriesSchema>) -> Nod
Arc::new(binary_matching(BinaryOp::Add)),
[Arc::clone(schema), Arc::clone(schema)],
)),
aggregate: None,
broadcast: None,
vector: None,
source_schema: None,
@@ -1088,30 +1091,17 @@ fn a_downstream_window_emits_before_its_input_series_ends() {
}
}
/// Every node kind the runtime does not execute is rejected, naming the kind and its position.
/// Every binary shape the runtime does not execute is rejected, naming the kind and its position.
///
/// A scalar is not one of them any more: it is a value node, and a binary node over a constant is
/// executed as a broadcast. The binary shapes that stay refused are the ones no kernel here takes,
/// and the aggregate is still a kind this runtime does not run at all.
/// executed as a broadcast. The binary shapes that stay refused are the ones no kernel here takes;
/// the aggregate kinds and stages this runtime does not execute are refused by
/// `an_aggregate_kind_or_stage_the_runtime_does_not_compute_is_named`.
#[test]
fn an_unimplemented_node_is_reported_not_panicked() {
// One grid shared by the nodes that validate by allocation identity.
let statement = || grid(0, 120_000, 2);
let aggregate_tree = {
let out = statement();
NativeNode {
expr: NativeExpr::Aggregate {
input: Box::new(window(input(), Arc::clone(&out))),
op: BoundAggregate {
kind: AggregateKind::Sum,
group: Grouping::By(Arc::new(["job".into()])),
stage: AggregateStage::Complete,
},
},
output: NativeType::Samples(evaluated_schema(out)),
}
};
// Two constants make a scalar, which is a legal node and a shape no binary here broadcasts
// over: there is no vector to pair either of them with.
let two_scalars_tree = {
@@ -1143,12 +1133,6 @@ fn an_unimplemented_node_is_reported_not_panicked() {
};
let cases = [
(
"Aggregate",
"root",
"does not execute Aggregate",
aggregate_tree,
),
("Binary", "root", "two constants", two_scalars_tree),
("Binary", "root", "matching modifiers", matched_scalar_tree),
];
@@ -6563,6 +6547,158 @@ fn a_constant_on_the_right_multiplies_every_value() {
assert_eq!(execution.state().reserved_bytes(), 0);
}
/// A `Complete` aggregate over `inner`, carrying `inner`'s fields and evaluated on its grid.
///
/// A `Complete` aggregate neither adds nor drops a field and lands on its input's grid, so the node's
/// own output is the layout its input produces.
fn aggregate(inner: NativeNode, op: BoundAggregate) -> NativeNode {
let fields = inner
.output
.samples()
.expect("an aggregate reads samples")
.fields
.clone();
let grid = inner
.output
.grid()
.expect("a complete aggregate reads an evaluated vector")
.clone();
let output = NativeType::Samples(Arc::new(SeriesSchema {
fields,
eval_grid: Some(grid),
}));
NativeNode {
expr: NativeExpr::Aggregate {
input: Box::new(inner),
op,
},
output,
}
}
/// A `Complete` `sum` grouped as `group`.
fn sum_grouping(group: Grouping) -> BoundAggregate {
BoundAggregate {
kind: AggregateKind::Sum,
group,
stage: AggregateStage::Complete,
}
}
/// `name{job=...,instance=...}`, the two labels these tests group by.
fn job_instance_metric(name: &str, job: &str, instance: &str) -> MetricName {
MetricName::try_new(
Some(name.into()),
vec![
Label {
name: "job".into(),
value: job.into(),
},
Label {
name: "instance".into(),
value: instance.into(),
},
],
)
.unwrap()
}
/// What one aggregate result reports: its name, its tags and the value at each point it decided.
type GroupResult = (
Option<String>,
Vec<(String, String)>,
Vec<(usize, Option<f64>)>,
);
/// The groups a run produced, each with the absolute positions it decided, sorted by identity.
///
/// Sorting is the test's, not the runtime's: a group's place in the answer is the order its first
/// input appeared, which the tests that check it assert on the stream rather than here.
fn groups_of(chunks: &[EvaluatedChunk]) -> Vec<GroupResult> {
let mut results: Vec<GroupResult> = chunks
.iter()
.map(|chunk| {
let mut tags: Vec<(String, String)> = chunk
.series
.metric
.tags()
.iter()
.map(|tag| (tag.name.to_string(), tag.value.to_string()))
.collect();
tags.sort();
(
chunk.series.metric.name().map(str::to_string),
tags,
decided(std::slice::from_ref(chunk)),
)
})
.collect();
results.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1)));
results
}
/// One group's answer under the name and tags `tags` give it, ordered by label name.
fn grouped(tags: &[(&str, &str)], points: Vec<(usize, Option<f64>)>) -> GroupResult {
let mut tags: Vec<(String, String)> = tags
.iter()
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
.collect();
tags.sort();
(None, tags, points)
}
/// One group's answer when every label of its input was dropped.
fn unlabelled(points: Vec<(usize, Option<f64>)>) -> GroupResult {
grouped(&[], points)
}
/// `sum` with no grouping drops every label, name and tags alike, and adds the members per point.
#[test]
fn an_aggregate_drops_every_label_and_adds_its_group_per_point() {
let grid = grid(0, 60_000, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let set = ExecutionPlanMetricsSet::new();
let mut execution = executor(&tree, pool(), &set);
for (metric, values) in [
(
job_instance_metric("requests", "api", "a"),
vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)],
),
(
job_instance_metric("requests", "db", "b"),
vec![Some(10.0), Some(20.0), Some(30.0), Some(40.0)],
),
] {
execution
.push_evaluated(evaluated_chunk_of(&schema, metric, 0, values, true))
.unwrap();
}
let output = execution.finish().unwrap();
assert_eq!(
groups_of(&output),
vec![unlabelled(vec![
(0, Some(11.0)),
(1, Some(22.0)),
(2, Some(33.0)),
(3, Some(44.0)),
])],
"the two series were not folded into one unlabelled group"
);
// One group is one series, so it closes exactly once and its chunk says so.
assert!(output.len() == 1 && output[0].is_last);
assert_eq!(
count(&set, &NodePath::root(), NodeMetric::COMPLETED_SERIES),
1
);
assert_eq!(execution.state().reserved_bytes(), 0);
}
/// The side a constant is on decides which operand the vector's value is.
///
/// Division is not commutative, so `x / 100` and `100 / x` answering different values is what says
@@ -7036,6 +7172,208 @@ fn a_window_above_a_broadcast_merges_the_series_it_collapses() {
assert_eq!(execution.state().reserved_bytes(), 0);
}
/// Every supported `Complete` kind reaches its matching float kernel through the native runtime.
#[test]
fn complete_aggregate_kinds_fold_two_values() {
let cases = [
(AggregateKind::Sum, 4.0),
(AggregateKind::Count, 2.0),
(AggregateKind::Avg, 2.0),
(AggregateKind::Min, 1.0),
(AggregateKind::Max, 3.0),
(AggregateKind::Group, 1.0),
(AggregateKind::Stddev, 1.0),
(AggregateKind::Stdvar, 1.0),
];
for (kind, expected) in cases {
let grid = grid(0, 60_000, 1);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
BoundAggregate {
kind,
group: Grouping::By(Arc::new([])),
stage: AggregateStage::Complete,
},
))
.unwrap();
let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new());
for value in [1.0, 3.0] {
execution
.push_evaluated(evaluated_chunk_of(
&schema,
metric(),
0,
vec![Some(value)],
true,
))
.unwrap();
}
let output = execution.finish().unwrap();
assert_eq!(
groups_of(&output),
vec![unlabelled(vec![(0, Some(expected))])],
"{kind:?} did not compute its native complete result"
);
}
}
/// The same series, delivered in two chunks, fold to what the whole series folds to.
#[test]
fn an_aggregate_folds_a_series_split_across_chunks_like_a_whole_one() {
let bound = grid(0, 60_000, 4);
let schema = evaluated_schema(Arc::clone(&bound));
// A fresh tree per execution: the node's state is the execution's, not the tree's.
let tree = || {
NativeTree::try_new(aggregate(
evaluated_input(grid(0, 60_000, 4)),
sum_grouping(Grouping::By(Arc::new(["job".into()]))),
))
.unwrap()
};
let (first, second) = (
job_instance_metric("requests", "api", "a"),
job_instance_metric("requests", "api", "b"),
);
// One chunk per series.
let mut whole = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new());
for (metric, values) in [
(
first.clone(),
vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)],
),
(
second.clone(),
vec![Some(10.0), Some(20.0), Some(30.0), Some(40.0)],
),
] {
whole
.push_evaluated(evaluated_chunk_of(&schema, metric, 0, values, true))
.unwrap();
}
let whole = whole.finish().unwrap();
// The first series split in two, the second whole: the group is the same group, folded from
// positions the chunks name absolutely.
let mut split = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new());
split
.push_evaluated(evaluated_chunk_of(
&schema,
first.clone(),
0,
vec![Some(1.0), Some(2.0)],
false,
))
.unwrap();
assert!(
split.next_chunk().unwrap().is_none(),
"a chunk that does not close its series answered for the group"
);
split
.push_evaluated(evaluated_chunk_of(
&schema,
first,
2,
vec![Some(3.0), Some(4.0)],
true,
))
.unwrap();
split
.push_evaluated(evaluated_chunk_of(
&schema,
second,
0,
vec![Some(10.0), Some(20.0), Some(30.0), Some(40.0)],
true,
))
.unwrap();
let split = split.finish().unwrap();
assert_eq!(groups_of(&split), groups_of(&whole));
assert_eq!(
groups_of(&split),
vec![grouped(
&[("job", "api")],
vec![
(0, Some(11.0)),
(1, Some(22.0)),
(2, Some(33.0)),
(3, Some(44.0)),
],
)],
"the split series was folded as two groups or at the wrong positions"
);
}
/// A point no member of a group has a value at is not a point of the group's result.
///
/// Sparse input is what says the fold indexes its states by absolute position: a chunk holding
/// positions 0 and 1 and another holding 1 and 2 leave position 3 to nothing, and the hole between
/// the rows is never filled with a value.
#[test]
fn an_aggregate_reports_only_the_points_a_group_holds_a_value_at() {
let grid = grid(0, 60_000, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new());
// Positions 0 and 1 of the first series, 1 and 2 of the second, and nothing at 3.
execution
.push_evaluated(sparse_chunk(
&schema,
job_instance_metric("requests", "api", "a"),
&[(0, 2)],
vec![1.0, 2.0],
true,
))
.unwrap();
execution
.push_evaluated(sparse_chunk(
&schema,
job_instance_metric("requests", "db", "b"),
&[(1, 2)],
vec![20.0, 300.0],
true,
))
.unwrap();
let output = execution.finish().unwrap();
assert_eq!(
groups_of(&output),
vec![
// Position 1 is the only one both members hold, and it is their sum.
unlabelled(vec![(0, Some(1.0)), (1, Some(22.0)), (2, Some(300.0))]),
],
"the group was answered at a point no member holds, or summed the wrong members"
);
}
/// An aggregate handed no series produces nothing at all: no group, so no empty series either.
#[test]
fn an_aggregate_over_no_series_produces_nothing() {
let grid = grid(0, 60_000, 4);
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new(["job".into()]))),
))
.unwrap();
let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new());
let output = execution.finish().unwrap();
assert!(
output.is_empty(),
"an aggregate with no input answered {} series",
output.len()
);
assert_eq!(execution.state().reserved_bytes(), 0);
}
/// A broadcast hands one chunk over for every chunk it reads, and closes the series once.
///
/// The metrics are what says the chunk-per-chunk behaviour held: one input batch per chunk read, one
@@ -7251,6 +7589,411 @@ fn a_broadcast_refuses_two_series_that_share_a_label_set_and_releases_the_reserv
);
}
/// A series that ended is one member of a group, not the group: nothing is answered until the input
/// ends.
///
/// The observable state between the two is what this pins: the fold has read the closing chunk of
/// the first series and answered nothing, and `computed` still says so.
#[test]
fn an_aggregate_answers_nothing_until_its_whole_input_ends() {
let grid = grid(0, 60_000, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new());
let computed = |execution: &NativeExecutor| {
execution.state().runtimes[0]
.aggregate
.as_ref()
.expect("the root holds an aggregate")
.computed
};
execution
.push_evaluated(evaluated_chunk_of(
&schema,
job_instance_metric("requests", "api", "a"),
0,
vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)],
true,
))
.unwrap();
assert!(execution.next_chunk().unwrap().is_none());
assert!(!computed(&execution), "the fold finished on one series");
assert!(
execution.next_chunk().unwrap().is_none(),
"the closing chunk of one series answered for its group"
);
// The second series closed too, and the input still has not ended.
execution
.push_evaluated(evaluated_chunk_of(
&schema,
job_instance_metric("requests", "db", "b"),
0,
vec![Some(10.0), Some(20.0), Some(30.0), Some(40.0)],
true,
))
.unwrap();
assert!(
!computed(&execution),
"the fold finished before the input ended"
);
assert!(
execution.next_chunk().unwrap().is_none(),
"every series closed, and the group was answered before the input ended"
);
execution.end_of_input().unwrap();
assert!(
!computed(&execution),
"ending the input finished the fold before anything pulled for it"
);
let mut output = Vec::new();
while let Some(chunk) = execution.next_chunk().unwrap() {
output.push(chunk);
}
assert_eq!(
groups_of(&output),
vec![unlabelled(vec![
(0, Some(11.0)),
(1, Some(22.0)),
(2, Some(33.0)),
(3, Some(44.0)),
])],
"the group was not answered once the input ended"
);
}
/// A chunk of another layout is refused, naming the aggregate and its position, and the execution
/// stops with nothing reserved.
///
/// The fold indexes its states by absolute position and by field, so a chunk of another field count
/// would be folded into states that mean something else. It is refused rather than folded, and what
/// the refusal leaves behind is a stopped execution whose reservation went back to the pool.
#[test]
fn an_aggregate_refuses_a_chunk_of_another_layout_and_returns_its_reservation() {
let grid = grid(0, 60_000, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new());
execution
.push_evaluated(evaluated_chunk_of(
&schema,
metric(),
0,
vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0)],
true,
))
.unwrap();
// Two fields where the tree bound one: the source node accepts it, the fold refuses it.
let wider = Arc::new(SeriesSchema {
fields: field_count(2),
eval_grid: Some(Arc::clone(&grid)),
});
execution
.push_evaluated(evaluated_columns(
&wider,
metric(),
0,
vec![vec![Some(1.0); 4], vec![Some(2.0); 4]],
true,
))
.unwrap();
let error = execution
.next_chunk()
.expect_err("a chunk of another field count was folded");
assert!(
matches!(error, DataFusionError::Execution(_)),
"the refusal is not an execution error: {error}"
);
let message = error.to_string();
assert!(
message.contains("aggregate at root"),
"the refusal does not name the aggregate: {message}"
);
assert!(
message.contains("field count"),
"the refusal does not say what disagreed: {message}"
);
assert_eq!(execution.state().reserved_bytes(), 0);
// A stopped execution is not resumed: the fold must not be asked to continue after this.
assert!(execution.next_chunk().is_err());
}
/// A pool rejection while the first aggregate block is admitted releases the source and fold.
#[test]
fn aggregate_budget_rejects_first_block_and_releases_everything() {
let grid = grid(0, 1, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let pool = bounded_pool(2_000);
let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new());
execution
.push_evaluated(evaluated_chunk_of(
&schema,
metric(),
0,
vec![Some(2.0)],
true,
))
.unwrap();
assert!(execution.next_chunk().is_err());
assert_eq!(pool.reserved(), 0);
assert_eq!(execution.state().reserved_bytes(), 0);
}
/// The aggregate runtime charges its output queue, releases each output as it is pulled, and keeps
/// queue backing until the runtime itself is released.
#[test]
fn aggregate_runtime_queue_keeps_only_backing_after_pops_until_release() {
let grid = grid(0, 1, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new(["job".into()]))),
))
.unwrap();
let pool = bounded_pool(1 << 20);
let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new());
for (job, value) in [("api", 1.0), ("db", 2.0)] {
execution
.push_evaluated(evaluated_chunk_of(
&schema,
job_instance_metric("requests", job, "a"),
0,
vec![Some(value)],
true,
))
.unwrap();
}
execution.end_of_input().unwrap();
let first = execution.next_chunk().unwrap().expect("first group output");
assert_eq!(first.series.time.positions().count(), 1);
let runtime = &execution.state().runtimes[0];
let entry = std::mem::size_of::<(NativeChunk, usize)>();
assert_eq!(runtime.queued.len(), 1);
let remaining = runtime.queued[0].1;
let expected = runtime.queued.capacity() * entry + remaining;
assert_eq!(runtime.buffer.reservation.size(), expected);
let second = execution
.next_chunk()
.unwrap()
.expect("second group output");
assert_eq!(second.series.time.positions().count(), 1);
let runtime = &execution.state().runtimes[0];
assert!(runtime.queued.is_empty());
let backing = runtime.queued.capacity() * entry;
assert!(backing > 0);
assert_eq!(runtime.buffer.reservation.size(), backing);
assert!(execution.next_chunk().unwrap().is_none());
// Pulling the terminal None releases the queue backing; only explicit close checks the
// runtime's final cleanup below.
assert_eq!(execution.state().runtimes[0].buffer.reservation.size(), 0);
execution.state.close().unwrap();
assert_eq!(execution.state().runtimes[0].queued.capacity(), 0);
assert_eq!(execution.state().runtimes[0].buffer.reservation.size(), 0);
assert_eq!(pool.reserved(), 0);
}
/// An aggregate whose sparse output has a few points on a large grid can finish in a small pool.
#[test]
fn aggregate_sparse_output_budget_uses_rows_not_grid_length() {
let grid = grid(0, 1, 100_000);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let pool = bounded_pool(64 * 1024);
let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new());
execution
.push_evaluated(evaluated_chunk_of(
&schema,
metric(),
10,
vec![Some(2.0)],
true,
))
.unwrap();
let output = execution.finish().unwrap();
assert_eq!(groups_of(&output), vec![unlabelled(vec![(10, Some(2.0))])]);
assert_eq!(pool.reserved(), 0);
}
/// An aggregate retains its fold before finish and release returns its reservation.
#[test]
fn aggregate_fold_state_is_released_after_input() {
let grid = grid(0, 1, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(aggregate(
evaluated_input(Arc::clone(&grid)),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap();
let pool = bounded_pool(1 << 20);
let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new());
execution
.push_evaluated(evaluated_chunk_of(
&schema,
metric(),
0,
vec![Some(2.0)],
true,
))
.unwrap();
assert!(execution.next_chunk().unwrap().is_none());
let runtime = &execution.state.runtimes[0];
let aggregate = runtime.aggregate.as_ref().unwrap();
assert_eq!(aggregate.groups(), 1);
assert!(!aggregate.computed);
assert!(pool.reserved() > 0);
execution.state.close().unwrap();
assert_eq!(pool.reserved(), 0);
}
/// An aggregate kind or stage this runtime does not compute is refused, naming what was found.
#[test]
fn an_aggregate_kind_or_stage_the_runtime_does_not_compute_is_named() {
let grid = || grid(0, 60_000, 4);
let kind_tree = NativeTree::try_new(aggregate(
evaluated_input(grid()),
BoundAggregate {
kind: AggregateKind::Quantile { quantile: 0.5 },
group: Grouping::By(Arc::new([])),
stage: AggregateStage::Complete,
},
))
.unwrap();
let partial_tree = {
let out = grid();
let group = Grouping::By(Arc::new(["job".into()]));
let state = Arc::new(AggregateStateSchema {
group: group.clone(),
accumulators: fields(),
grid: Arc::clone(&out),
});
NativeTree::try_new(NativeNode {
expr: NativeExpr::Aggregate {
input: Box::new(evaluated_input(Arc::clone(&out))),
op: BoundAggregate {
kind: AggregateKind::Sum,
group,
stage: AggregateStage::Partial,
},
},
output: NativeType::AggregateState(state),
})
.unwrap()
};
for (found, tree) in [("Quantile", kind_tree), ("Partial", partial_tree)] {
let error = match NativeExecutionState::try_new(
&tree,
pool(),
&ExecutionPlanMetricsSet::new(),
0,
) {
Ok(_) => panic!("the runtime accepted a {found} aggregate"),
Err(error) => error,
};
assert!(
matches!(error, DataFusionError::NotImplemented(_)),
"{found}: {error}"
);
let message = error.to_string();
assert!(message.contains(found), "{found}: {message}");
// A `Partial` rejection has to say why it is not computed: the stages it would need.
if found == "Partial" {
assert!(message.contains("Final"), "{message}");
}
}
}
/// An aggregate above a window that collects and merges its input waits for that merge.
///
/// The multiplication drops the metric name, so the two input series land on one label set and the
/// `last_over_time` above them has to collect and merge before it evaluates. The aggregate above the
/// window must wait for that merge: its guard is `inputs_finished && settled(child)`, and with only
/// the first half it finalizes on the pull that follows the caller's last chunk, before the window
/// has answered.
#[test]
fn an_aggregate_above_a_collecting_window_waits_for_the_merge() {
let grid = grid(0, 60_000, 4);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = || {
NativeTree::try_new(aggregate(
window_with(
broadcast_right(
evaluated_input(Arc::clone(&grid)),
2.0,
BinaryOp::Mul,
false,
),
RangeFunction::LastOverTime,
60_000,
Some(Arc::clone(&grid)),
Arc::clone(&grid),
),
sum_grouping(Grouping::By(Arc::new([]))),
))
.unwrap()
};
let mut execution = executor(&tree(), pool(), &ExecutionPlanMetricsSet::new());
// Two series that only differ by name, each holding half of the grid the other leaves empty:
// after the name is dropped they are one series only once merged.
execution
.push_evaluated(evaluated_chunk_of(
&schema,
named_metric("requests", "x"),
0,
vec![Some(1.0), Some(2.0)],
true,
))
.unwrap();
assert!(
execution.next_chunk().unwrap().is_none(),
"the aggregate answered before the window above it had merged"
);
execution
.push_evaluated(evaluated_chunk_of(
&schema,
named_metric("requests_total", "x"),
2,
vec![Some(3.0), Some(4.0)],
true,
))
.unwrap();
let out = run(&mut execution);
assert_eq!(
decided(&out),
vec![
(0, Some(2.0)),
(1, Some(4.0)),
(2, Some(6.0)),
(3, Some(8.0)),
],
"the aggregate did not fold the merged window result"
);
assert_eq!(execution.state().reserved_bytes(), 0);
}
fn vector_op(inner: NativeNode, op: BoundVectorOp) -> NativeNode {
let output = inner.output.clone();
NativeNode {
@@ -7702,3 +8445,83 @@ fn histogram_quantile_reads_the_pieces_of_one_label_set_as_one_series() {
.collect();
assert_eq!(positions, [0, 1]);
}
/// `count_values` counts the samples that share a value across the whole vector at a point, under
/// a label set per value; `a` arrives in two chunks and counts at both points.
#[test]
fn count_values_counts_the_samples_sharing_a_value() {
let grid = grid(0, 60_000, 2);
let schema = evaluated_schema(Arc::clone(&grid));
let tree = NativeTree::try_new(vector_op(
evaluated_input(Arc::clone(&grid)),
BoundVectorOp::CountValues {
label: "v".into(),
group: Grouping::By(Arc::from([])),
},
))
.unwrap();
let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new());
for (job, start, value, is_last) in [
("a", 0, 1.0, false),
("a", 1, 1.0, true),
("b", 0, 2.0, true),
("c", 0, 1.0, true),
] {
execution
.push_evaluated(evaluated_chunk_of(
&schema,
named_metric("m", job),
start,
vec![Some(value)],
is_last,
))
.unwrap();
}
let mut counts: Vec<(String, Decided)> = run(&mut execution)
.iter()
.map(|chunk| {
(
chunk.series.metric.get("v").unwrap().to_string(),
decided(std::slice::from_ref(chunk)),
)
})
.collect();
counts.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
counts,
[
("1".to_string(), vec![(0, Some(2.0)), (1, Some(1.0))]),
("2".to_string(), vec![(0, Some(1.0))]),
]
);
}
/// Every field's distinct values make `count_values` groups of their own, so its estimate grows
/// with the field count, not only with the rows: a row of four fields reserves at least four
/// times what a row of one field does.
#[test]
fn count_values_reserves_a_group_per_field_sample() {
let grid = grid(0, 60_000, 1);
let op = BoundVectorOp::CountValues {
label: "v".into(),
group: Grouping::By(Arc::from([])),
};
let estimate = |fields: usize| {
let schema = SeriesSchema {
fields: field_count(fields),
eval_grid: Some(Arc::clone(&grid)),
};
let samples = (0..fields)
.map(|field| Samples::from_float(Float64Array::from(vec![field as f64])))
.collect();
let series = Series::try_new(
&schema,
named_metric("m", "a"),
TimeAxis::Grid(GridRange::try_new(Arc::clone(&grid), 0, 1).unwrap()),
SeriesFields::try_from_vec(samples).unwrap(),
)
.unwrap();
vector_workspace_bytes(&op, &schema, &[series])
};
assert!(estimate(4) >= 4 * estimate(1));
}
+2
View File
@@ -27,8 +27,10 @@
//! - A series closes exactly once, on the chunk that says so, so a consumer can tell "nothing more
//! here" from "no more input".
mod aggregate;
pub mod batch;
mod display;
pub mod execute;
pub mod metrics;
pub mod plan;
pub(crate) mod runs;
+4 -1
View File
@@ -259,7 +259,7 @@ pub struct BoundAggregate {
/// An operator over the whole input vector at each grid point: one that keeps some of the input's
/// samples with their series' labels and values, orders the series, reports where none is present,
/// or reads a classic histogram from its bucket series.
/// reads a classic histogram from its bucket series, or counts the samples sharing a value.
///
/// Its constants are bound here, as a window's are: a `k` or a ratio only known at run time has no
/// field.
@@ -282,6 +282,9 @@ pub enum BoundVectorOp {
/// `histogram_quantile` or `histogram_fraction`: each classic histogram is read from the bucket
/// series that share its labels, so this too reads the whole vector at a point.
Histogram(HistogramFunction),
/// `count_values(label, v)`: how many samples of each group share a value, under a label set
/// per value. It writes a label per distinct value, which an aggregate's fixed groups cannot.
CountValues { label: Arc<str>, group: Grouping },
}
/// What a node computes. Every variant holds a bound operation, never a name to resolve.
+146
View File
@@ -0,0 +1,146 @@
// 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.
//! The grid positions a field occupies, as sorted, disjoint runs.
//!
//! A node that rewrites labels — a `Map` dropping the metric name, a `Window` dropping the name and
//! the empty-valued labels — can turn two input series into one label set, and every layer above
//! then reads the two chunks as one series. What decides whether that pair is legal is which
//! positions of each field already hold a value: two values of one field at one position cannot be
//! merged away, while a chunk that fills positions the set left empty merges cleanly, as
//! [`crate::grid_series::merge_same_labelset`] does.
//!
//! The state is kept per label set, per field, as runs, so a chunk of many positions costs one run
//! rather than one entry per position. Keeping it in one place is what keeps the two layers that
//! check it — the native runtime's own [`crate::native::execute::Emitted`] table and the
//! cross-partition [`crate::extension_plan::fused_series::LabelSets`] — from drifting apart: both
//! ask the same functions for the same answers, so the fast path one of them earned is the fast path
//! the other gets.
/// One run of grid positions, half-open, that hold a value in one field.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Run {
pub(crate) start: usize,
pub(crate) end: usize,
}
impl Run {
/// Whether two runs share a position.
pub(crate) fn overlaps(self, other: Self) -> bool {
self.start < other.end && other.start < self.end
}
}
// How many times `absorb` rebuilt the stored runs instead of appending to them.
//
// Test-only, and per thread: the runs stored afterwards are the same either way, so a rebuild shows
// only in what it costs, and a test that reads its own thread's figure is not disturbed by another
// test's chunks.
#[cfg(test)]
thread_local! {
static ABSORB_REBUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many times this thread's [`absorb`] rebuilt the stored runs.
#[cfg(test)]
pub(crate) fn absorb_rebuilds() -> usize {
ABSORB_REBUILDS.with(std::cell::Cell::get)
}
/// Merges `incoming` into `stored`, keeping the result in position order and joining the runs that
/// touch.
///
/// Within a chunk one pass over both sides is what keeps this linear, since each side is sorted and
/// disjoint. Adding the runs one at a time and rebuilding the array after each would cost the square
/// of the runs a chunk yields: a chunk of one hundred thousand positions alternating between values
/// and nulls yields fifty thousand runs, and re-merging after each is over a billion element moves
/// for a single series.
///
/// One pass per chunk is still a pass over everything the series has stored so far, so it is not
/// free once the *history* grows across chunks: a series split into `K` chunks of one position each,
/// a hole apart, stores one more run per chunk and moves `K(K+1)/2` of them, with no label collision
/// and no illegal input anywhere. A chunk that starts no earlier than the end of the last stored run
/// only ever adds to the end — the stored runs are sorted and disjoint, so every one of them ends
/// before anything this chunk holds — and it is appended instead, which is amortised constant per
/// run. That is the shape a scan hands over as its positions move forward chunk after chunk. A chunk
/// that starts before the last stored run can split a run or reach back past it, so it still costs
/// the full merge.
///
/// Either way the result is sorted, disjoint, and has the runs that touch joined, which is what a
/// look-up by [`Run::overlaps`] on the runs stored here relies on.
pub(crate) fn absorb(stored: &mut Vec<Run>, incoming: &[Run]) {
if incoming.is_empty() {
return;
}
if stored
.last()
.is_none_or(|last| incoming[0].start >= last.end)
{
append_runs(stored, incoming);
} else {
merge_runs(stored, incoming);
}
}
/// Appends `incoming` to `stored`, all of which lies past the last stored run.
///
/// Nothing already stored has to be placed again: the two sides are each in position order and
/// nothing `incoming` holds can reach back into what is stored, so its runs are pushed on the end and
/// the vector's own growth is the only move — amortised over the runs appended. Where its first run
/// touches the end of the last stored run the two are one run, as they are after a merge, so it is
/// joined rather than left beside it.
fn append_runs(stored: &mut Vec<Run>, incoming: &[Run]) {
let mut incoming = incoming;
if let Some(last) = stored.last_mut()
&& let Some(first) = incoming.first()
&& first.start <= last.end
{
last.end = last.end.max(first.end);
incoming = &incoming[1..];
}
stored.extend_from_slice(incoming);
}
/// Merges `incoming` into `stored`, where the two can interleave.
///
/// One pass is enough because each side is sorted and disjoint: whichever run starts first is
/// placed, and it joins the run before it where the two touch.
fn merge_runs(stored: &mut Vec<Run>, incoming: &[Run]) {
#[cfg(test)]
ABSORB_REBUILDS.with(|rebuilds| rebuilds.set(rebuilds.get() + 1));
let mut merged: Vec<Run> = Vec::with_capacity(stored.len() + incoming.len());
let (mut a, mut b) = (0, 0);
while a < stored.len() || b < incoming.len() {
let larger = match (stored.get(a), incoming.get(b)) {
(Some(left), Some(right)) if right.start < left.start => {
b += 1;
*right
}
(Some(left), _) => {
a += 1;
*left
}
(None, Some(right)) => {
b += 1;
*right
}
(None, None) => break,
};
match merged.last_mut() {
Some(last) if larger.start <= last.end => last.end = last.end.max(larger.end),
_ => merged.push(larger),
}
}
*stored = merged;
}
+544 -6
View File
@@ -13,17 +13,17 @@
// limitations under the License.
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use datafusion::error::DataFusionError;
use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool};
use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
use datatypes::arrow::array::Float64Array;
use datatypes::arrow::buffer::{Buffer, ScalarBuffer};
use datatypes::series::MetricName;
use datatypes::series::SeriesOrdering;
use datatypes::series::raw::{RawSeriesChunk, RawTimestamps};
use datatypes::series::samples::{FieldSpec, SampleRef, Samples, SeriesFields, SeriesSchema};
use datatypes::series::{Label, MetricName};
use promql::evaluated::EvaluatedChunk;
use promql::map::MathOp;
use promql::native::execute::{NativeExecutionState, NativeExecutor};
@@ -3138,11 +3138,25 @@ fn large_subquery_child_grids_compile_deferred() {
assert_eq!(grid.2, range_ms);
}
/// The largest millisecond endpoint the platform can represent as a `SystemTime`.
///
/// The tests below need an endpoint close enough to `i64::MAX` milliseconds that a one-second
/// rounding or a one-millisecond offset crosses the representable range. Unix-like platforms can
/// hold that instant; Windows represents `SystemTime` as a 64-bit count of 100-nanosecond
/// intervals since 1601, so the same instant does not fit and `UNIX_EPOCH + duration` panics. The
/// behaviour under test is about arithmetic on the millisecond value, not about the platform's
/// clock range, so a platform that cannot represent the instant skips instead of failing.
fn extreme_millisecond_endpoint() -> Option<SystemTime> {
UNIX_EPOCH.checked_add(Duration::from_millis(i64::MAX as u64))
}
/// A sampled child endpoint can fit even when the unrounded read-window endpoint does not.
#[test]
fn a_subquery_accepts_an_in_range_sampled_endpoint() {
let Some(endpoint) = extreme_millisecond_endpoint() else {
return;
};
let mut stmt = eval_stmt("rate(x[1s:1s] offset -1ms)");
let endpoint = UNIX_EPOCH + Duration::from_millis(i64::MAX as u64);
stmt.start = endpoint;
stmt.end = endpoint;
stmt.interval = Duration::from_millis(1);
@@ -3161,6 +3175,9 @@ fn a_subquery_accepts_an_in_range_sampled_endpoint() {
/// An anchored fold offset that cannot be represented as i64 is a plan error.
#[test]
fn a_subquery_with_an_overflowing_fold_offset_is_refused() {
let Some(endpoint) = extreme_millisecond_endpoint() else {
return;
};
let mut stmt = eval_stmt("rate(x[5m:1m] @ 0)");
let PromExpr::Call(call) = &mut stmt.expr else {
unreachable!("query parses to a call");
@@ -3168,9 +3185,7 @@ fn a_subquery_with_an_overflowing_fold_offset_is_refused() {
let PromExpr::Subquery(subquery) = &mut *call.args.args[0] else {
unreachable!("call argument parses to a subquery");
};
subquery.at = Some(AtModifier::At(
UNIX_EPOCH + Duration::from_millis(i64::MAX as u64),
));
subquery.at = Some(AtModifier::At(endpoint));
subquery.offset = Some(Offset::Neg(Duration::from_millis(1)));
let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime)
@@ -3723,3 +3738,526 @@ fn whole_vector_functions_bind_a_vector_operation() {
assert_eq!(root, format!(" root Vector {op}"), "`{query}`");
}
}
/// One raw chunk of `metric`, keyed by `key`, closing the series.
///
/// `timestamps` are milliseconds and `values` one per timestamp, which is what a scan of the series
/// hands the tree.
fn agg_raw_chunk(
schema: &SeriesSchema,
key: &[u8],
metric: MetricName,
timestamps: &[i64],
values: &[f64],
) -> RawSeriesChunk {
agg_raw_chunk_with_last(schema, key, metric, timestamps, values, true)
}
/// One raw chunk of `metric`, keyed by `key`, with an explicit series-ending flag.
fn agg_raw_chunk_with_last(
schema: &SeriesSchema,
key: &[u8],
metric: MetricName,
timestamps: &[i64],
values: &[f64],
is_last: bool,
) -> RawSeriesChunk {
RawSeriesChunk::try_new(
schema,
Buffer::from_slice_ref(key),
Some(metric),
RawTimestamps::try_new(ScalarBuffer::from(timestamps.to_vec())).unwrap(),
SeriesFields::one(Samples::from_float(Float64Array::from(values.to_vec()))),
is_last,
)
.unwrap()
}
/// `name` with the labels in `tags`, in the order given.
fn agg_metric(name: &str, tags: &[(&str, &str)]) -> MetricName {
MetricName::try_new(
Some(name.into()),
tags.iter()
.map(|(name, value)| Label {
name: (*name).into(),
value: (*value).into(),
})
.collect(),
)
.unwrap()
}
/// What one result series of a native execution reports.
type AggSeries = (
Option<String>,
Vec<(String, String)>,
Vec<(usize, Option<f64>)>,
);
/// Reads the outputs a native execution produced.
fn aggregate_results(output: &[EvaluatedChunk]) -> Vec<AggSeries> {
let mut results: Vec<AggSeries> = output
.iter()
.map(|chunk| {
let mut tags: Vec<(String, String)> = chunk
.series
.metric
.tags()
.iter()
.map(|tag| (tag.name.to_string(), tag.value.to_string()))
.collect();
tags.sort();
let points = chunk
.series
.time
.positions()
.map(|(position, row)| {
let sample = chunk.series.fields.as_slice()[0]
.iter()
.nth(row)
.expect("the axis and the field are aligned");
(
position,
match sample {
SampleRef::Float(value) => Some(value),
_ => None,
},
)
})
.collect();
(chunk.series.metric.name().map(str::to_string), tags, points)
})
.collect();
results.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1)));
results
}
/// Runs `tree` over `chunks`, which enter at the tree's only input, and reads the answer.
///
/// The answer is read the way a caller reads it: the whole input is pushed, the input is ended, and
/// what the root reports is collected. Each series is reported by its identity and the value it
/// decided at each absolute grid position.
fn run_aggregate_tree(tree: &NativeTree, chunks: Vec<RawSeriesChunk>) -> Vec<AggSeries> {
let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(1 << 26));
let state =
NativeExecutionState::try_new(tree, pool, &ExecutionPlanMetricsSet::new(), 0).unwrap();
let mut execution = NativeExecutor::new(state);
for chunk in chunks {
execution
.push_raw(chunk)
.unwrap_or_else(|error| panic!("a source chunk was refused: {error}"));
}
let output = execution
.finish()
.unwrap_or_else(|error| panic!("the tree did not answer: {error}"));
aggregate_results(&output)
}
/// The 21 grid points of a statement over 100 seconds in 5-second steps.
fn agg_points_0_to_100() -> Vec<usize> {
(0..21).collect()
}
/// The timestamps of the same 21 points, in milliseconds.
fn agg_timestamps_0_to_100() -> Vec<i64> {
(0..21).map(|index| index * 5_000).collect()
}
/// `sum(x)` over two series: one unlabelled group, and every label of both inputs dropped.
#[test]
fn a_compiled_sum_drops_every_label_and_adds_its_group() {
let tree = compile(
&eval_stmt("sum(x)"),
raw_schema(),
SeriesOrdering::KeyThenTime,
)
.unwrap();
let schema = raw_schema();
let timestamps = agg_timestamps_0_to_100();
let chunks = vec![
agg_raw_chunk(
&schema,
b"a",
agg_metric("x", &[("job", "api"), ("instance", "a")]),
&timestamps,
&(1..=21).map(f64::from).collect::<Vec<_>>(),
),
agg_raw_chunk(
&schema,
b"b",
agg_metric("x", &[("job", "db"), ("instance", "b")]),
&timestamps,
&(1..=21).map(|value| (2 * value) as f64).collect::<Vec<_>>(),
),
];
let output = run_aggregate_tree(&tree, chunks);
// `3` at the first point, three more at each of the twenty after it: the two members summed.
let values: Vec<(usize, Option<f64>)> = agg_points_0_to_100()
.into_iter()
.map(|position| (position, Some(3.0 * (position as f64 + 1.0))))
.collect();
assert_eq!(output, vec![(None, Vec::new(), values)]);
}
#[test]
fn a_compiled_sum_aggregates_series_chunks_after_input_ends() {
let schema = raw_schema();
let timestamps = agg_timestamps_0_to_100();
let values: Vec<f64> = (1..=21).map(f64::from).collect();
let cases = [
("sum(x)", None, Vec::new()),
(
"sum by(job)(x)",
None,
vec![("job".to_string(), "api".to_string())],
),
(
"sum without(instance)(x)",
None,
vec![("job".to_string(), "api".to_string())],
),
];
for (query, name, tags) in cases {
let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime).unwrap();
let metric = agg_metric("x", &[("job", "api"), ("instance", "a")]);
let chunks = vec![
agg_raw_chunk_with_last(
&schema,
b"a",
metric.clone(),
&timestamps[..10],
&values[..10],
false,
),
agg_raw_chunk_with_last(
&schema,
b"a",
metric,
&timestamps[10..],
&values[10..],
true,
),
];
let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(1 << 26));
let state =
NativeExecutionState::try_new(&tree, pool, &ExecutionPlanMetricsSet::new(), 0).unwrap();
let mut execution = NativeExecutor::new(state);
for chunk in chunks {
execution.push_raw(chunk).unwrap();
assert!(
execution.next_chunk().unwrap().is_none(),
"`{query}` produced output before the whole input ended"
);
}
let output = execution.finish().unwrap();
let results = aggregate_results(&output);
let expected_points = values
.iter()
.enumerate()
.map(|(position, value)| (position, Some(*value)))
.collect();
assert_eq!(
results,
vec![(name.map(str::to_string), tags, expected_points)],
"`{query}`"
);
}
}
/// Empty source input is not a member of an aggregate's implicit group.
#[test]
fn a_compiled_aggregate_over_empty_input_produces_no_groups() {
for query in ["sum(x)", "sum by(job)(x)", "sum without(instance)(x)"] {
let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime).unwrap();
assert_eq!(
run_aggregate_tree(&tree, Vec::new()),
Vec::new(),
"`{query}` invented a group for empty input"
);
}
}
/// `sum by(job)(x)` over five series: two groups, each the sum of its own members.
///
/// The members cover different points, so each group is a sum over a subset that changes from point
/// to point: one series holds the whole grid, one holds a single sample between two grid points, and
/// one stops a whole lookback before the grid does. No last sample sits exactly one lookback behind
/// a grid point, so which points each member covers does not depend on how the boundary is read.
#[test]
fn a_compiled_sum_by_groups_its_members_and_sums_each_group() {
let tree = compile(
&eval_stmt("sum by(job)(x)"),
raw_schema(),
SeriesOrdering::KeyThenTime,
)
.unwrap();
let schema = raw_schema();
let all = agg_timestamps_0_to_100();
let chunks = vec![
// The whole grid, holding one.
agg_raw_chunk(
&schema,
b"a",
agg_metric("x", &[("job", "api"), ("instance", "a")]),
&all,
&[1.0; 21],
),
// One sample halfway between grid points 0 and 1, so only grid point 1 selects it.
agg_raw_chunk(
&schema,
b"b",
agg_metric("x", &[("job", "api"), ("instance", "b")]),
&[2_500],
&[2.0],
),
// Four samples between grid points, covering grid points 1 to 4.
agg_raw_chunk(
&schema,
b"e",
agg_metric("x", &[("job", "api"), ("instance", "e")]),
&[2_400, 7_400, 12_400, 17_400],
&[5.0; 4],
),
// The whole grid, holding ten.
agg_raw_chunk(
&schema,
b"c",
agg_metric("x", &[("job", "db"), ("instance", "c")]),
&all,
&[10.0; 21],
),
// One sample halfway between grid points 0 and 1, holding twenty.
agg_raw_chunk(
&schema,
b"d",
agg_metric("x", &[("job", "db"), ("instance", "d")]),
&[2_500],
&[20.0],
),
];
let output = run_aggregate_tree(&tree, chunks);
// `api` is `a` alone at points 0 and 5 to 20, and every member at the points they cover:
// point 1 is `1 + 2 + 5`, points 2 to 4 are `1 + 5`.
let api: Vec<(usize, Option<f64>)> = agg_points_0_to_100()
.into_iter()
.map(|position| {
let value = match position {
1 => 8.0,
2..=4 => 6.0,
_ => 1.0,
};
(position, Some(value))
})
.collect();
// `db` is the same shape over its own members: point 1 is `10 + 20`, the rest is `c` alone.
let db: Vec<(usize, Option<f64>)> = agg_points_0_to_100()
.into_iter()
.map(|position| (position, Some(if position == 1 { 30.0 } else { 10.0 })))
.collect();
assert_eq!(
output,
vec![
(None, vec![("job".to_string(), "api".to_string())], api),
(None, vec![("job".to_string(), "db".to_string())], db),
]
);
}
/// `sum without(instance)(x)`: `instance` goes, `job` and `zone` stay, and so does `__name__` going.
#[test]
fn a_compiled_sum_without_drops_the_named_label_and_the_name() {
let tree = compile(
&eval_stmt("sum without(instance)(x)"),
raw_schema(),
SeriesOrdering::KeyThenTime,
)
.unwrap();
let schema = raw_schema();
let timestamps = agg_timestamps_0_to_100();
let chunks = vec![
agg_raw_chunk(
&schema,
b"a",
agg_metric("x", &[("job", "api"), ("instance", "a"), ("zone", "west")]),
&timestamps,
&[1.0; 21],
),
agg_raw_chunk(
&schema,
b"b",
agg_metric("x", &[("job", "api"), ("instance", "b"), ("zone", "east")]),
&timestamps,
&[2.0; 21],
),
];
let output = run_aggregate_tree(&tree, chunks);
let values_of = |value: f64| -> Vec<(usize, Option<f64>)> {
agg_points_0_to_100()
.into_iter()
.map(|position| (position, Some(value)))
.collect()
};
// Two groups, because `zone` is not the dropped label: the two series differ by more than the
// label the grouping removes.
assert_eq!(
output,
vec![
(
None,
vec![
("job".to_string(), "api".to_string()),
("zone".to_string(), "east".to_string()),
],
values_of(2.0),
),
(
None,
vec![
("job".to_string(), "api".to_string()),
("zone".to_string(), "west".to_string()),
],
values_of(1.0),
),
]
);
}
/// `sum by(job)(rate(x[5m]))`: an aggregate over a window, mid-chain.
///
/// Every series is a counter rising by one per five seconds, and every grid point's window ends on a
/// sample and begins five seconds before one, so the extrapolated rate is the same at every point:
/// `59 / 295 = 0.2` per second, which the double arithmetic lands on the literals below.
#[test]
fn a_compiled_sum_over_a_rate_groups_the_window_it_reads() {
let statement = EvalStmt {
expr: promql_parser::parser::parse("sum by(job)(rate(x[5m]))").unwrap(),
start: UNIX_EPOCH + Duration::from_secs(300),
end: UNIX_EPOCH + Duration::from_secs(400),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(5),
};
let tree = compile(&statement, raw_schema(), SeriesOrdering::KeyThenTime).unwrap();
// The window reads 300 seconds, so the samples start a full window before the first grid point.
let schema = raw_schema();
let timestamps: Vec<i64> = (0..=80).map(|index| index * 5_000).collect();
let values: Vec<f64> = (0..=80).map(|index| f64::from(index) + 1.0).collect();
let chunks = vec![
agg_raw_chunk(
&schema,
b"a",
agg_metric("x", &[("job", "api"), ("instance", "a")]),
&timestamps,
&values,
),
agg_raw_chunk(
&schema,
b"b",
agg_metric("x", &[("job", "api"), ("instance", "b")]),
&timestamps,
&values,
),
agg_raw_chunk(
&schema,
b"c",
agg_metric("x", &[("job", "db"), ("instance", "c")]),
&timestamps,
&values,
),
];
let output = run_aggregate_tree(&tree, chunks);
let at_every_point = |value: f64| -> Vec<(usize, Option<f64>)> {
(0..21).map(|position| (position, Some(value))).collect()
};
// One `rate` is `0.19999999999999998`, and the pair of them add to `0.39999999999999997`.
assert_eq!(
output,
vec![
(
None,
vec![("job".to_string(), "api".to_string())],
at_every_point(0.39999999999999997),
),
(
None,
vec![("job".to_string(), "db".to_string())],
at_every_point(0.19999999999999998),
),
]
);
}
/// Splitting one counter's samples does not change the rate aggregate or make it answer early.
#[test]
fn a_compiled_sum_over_a_rate_aggregates_series_chunks_after_input_ends() {
let statement = EvalStmt {
expr: promql_parser::parser::parse("sum by(job)(rate(x[5m]))").unwrap(),
start: UNIX_EPOCH + Duration::from_secs(300),
end: UNIX_EPOCH + Duration::from_secs(400),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(5),
};
let tree = compile(&statement, raw_schema(), SeriesOrdering::KeyThenTime).unwrap();
let schema = raw_schema();
let timestamps: Vec<i64> = (0..=80).map(|index| index * 5_000).collect();
let values: Vec<f64> = (0..=80).map(|index| f64::from(index) + 1.0).collect();
let metric = agg_metric("x", &[("job", "api"), ("instance", "a")]);
let chunks = [
agg_raw_chunk_with_last(
&schema,
b"a",
metric.clone(),
&timestamps[..41],
&values[..41],
false,
),
agg_raw_chunk_with_last(
&schema,
b"a",
metric,
&timestamps[41..],
&values[41..],
true,
),
];
let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(1 << 26));
let state =
NativeExecutionState::try_new(&tree, pool, &ExecutionPlanMetricsSet::new(), 0).unwrap();
let mut execution = NativeExecutor::new(state);
for chunk in chunks {
execution.push_raw(chunk).unwrap();
assert!(
execution.next_chunk().unwrap().is_none(),
"the rate aggregate produced output before the whole input ended"
);
}
let output = execution.finish().unwrap();
assert_eq!(
aggregate_results(&output),
vec![(
None,
vec![("job".to_string(), "api".to_string())],
(0..21)
.map(|position| (position, Some(0.19999999999999998)))
.collect(),
)]
);
}
/// Empty source input is not a member of this aggregate's implicit group, with or without a window.
#[test]
fn a_compiled_sum_over_a_rate_with_empty_input_produces_no_groups() {
let statement = EvalStmt {
expr: promql_parser::parser::parse("sum by(job)(rate(x[5m]))").unwrap(),
start: UNIX_EPOCH + Duration::from_secs(300),
end: UNIX_EPOCH + Duration::from_secs(400),
interval: Duration::from_secs(5),
lookback_delta: Duration::from_secs(5),
};
let tree = compile(&statement, raw_schema(), SeriesOrdering::KeyThenTime).unwrap();
assert_eq!(run_aggregate_tree(&tree, Vec::new()), Vec::new());
}
File diff suppressed because it is too large Load Diff