mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-07-08 15:00:39 +00:00
feat: add query regression perf harness (#8406)
* feat: add query regression perf harness Signed-off-by: discord9 <discord9@163.com> * feat: extend query regression cases Signed-off-by: discord9 <discord9@163.com> * ci: harden query regression workflows Signed-off-by: discord9 <discord9@163.com> * fix: address query regression review comments Signed-off-by: discord9 <discord9@163.com> * ci: limit query regression PR triggers Signed-off-by: discord9 <discord9@163.com> * ci: run full query regression case set Signed-off-by: discord9 <discord9@163.com> * refactor: model query regression scenarios Signed-off-by: discord9 <discord9@163.com> * fix: avoid unenforced query regression thresholds Signed-off-by: discord9 <discord9@163.com> --------- Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
44
.github/scripts/query-regression-pr-metadata.py
vendored
Normal file
44
.github/scripts/query-regression-pr-metadata.py
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/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.
|
||||
|
||||
"""Write metadata consumed by the trusted query-regression comment workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", default=Path("query-regression-pr.json"), type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
metadata = {
|
||||
"pr_number": int(os.environ["PR_NUMBER"]),
|
||||
"head_sha": os.environ["HEAD_SHA"],
|
||||
"head_repo": os.environ["HEAD_REPO"],
|
||||
"base_repo": os.environ["BASE_REPO"],
|
||||
"run_id": int(os.environ["RUN_ID"]),
|
||||
"run_attempt": int(os.environ["RUN_ATTEMPT"]),
|
||||
}
|
||||
args.output.write_text(json.dumps(metadata, sort_keys=True) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
184
.github/scripts/query-regression-run.py
vendored
Normal file
184
.github/scripts/query-regression-run.py
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/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 or more query regression cases after the binaries are built."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_CASES = [
|
||||
"tests/perf/query_cases/smoke_direct_sst/case.toml",
|
||||
"tests/perf/query_cases/promql_pushdown_7913/case.toml",
|
||||
"tests/perf/query_cases/sql_topk_order_by/case.toml",
|
||||
"tests/perf/query_cases/sql_aggregate_order_by/case.toml",
|
||||
"tests/perf/query_cases/sql_join_filter_order/case.toml",
|
||||
]
|
||||
|
||||
|
||||
def split_cases(values: list[str]) -> list[str]:
|
||||
tokens: list[str] = []
|
||||
for value in values:
|
||||
tokens.extend(part for part in re.split(r"[\s,]+", value.strip()) if part)
|
||||
if not tokens or tokens == ["all"]:
|
||||
return DEFAULT_CASES.copy()
|
||||
if "all" in tokens:
|
||||
raise ValueError("'all' cannot be mixed with explicit case paths")
|
||||
return list(dict.fromkeys(tokens))
|
||||
|
||||
|
||||
def parse_bool(value: str) -> bool:
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def profile_dir(cargo_profile: str) -> str:
|
||||
if cargo_profile == "dev":
|
||||
return "debug"
|
||||
return cargo_profile
|
||||
|
||||
|
||||
def resolve_case_path(candidate_src: Path, case: str) -> Path:
|
||||
path = Path(case)
|
||||
if path.is_absolute() or path.parts[:1] == (candidate_src.name,):
|
||||
return path
|
||||
return candidate_src / path
|
||||
|
||||
|
||||
def case_slug(case_path: Path) -> str:
|
||||
raw = case_path.parent.name if case_path.name == "case.toml" else case_path.stem
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-") or "case"
|
||||
|
||||
|
||||
def append_github_output(path: str | None, status: int) -> None:
|
||||
if not path:
|
||||
return
|
||||
with open(path, "a", encoding="utf-8") as fp:
|
||||
fp.write(f"status={status}\n")
|
||||
|
||||
|
||||
def append_step_summary(summary: Path) -> None:
|
||||
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not step_summary or not summary.exists():
|
||||
return
|
||||
with open(step_summary, "a", encoding="utf-8") as out:
|
||||
out.write(summary.read_text())
|
||||
|
||||
|
||||
def run_case(args: argparse.Namespace, case_path: Path, work_dir: Path) -> int:
|
||||
target_dir = profile_dir(args.cargo_profile)
|
||||
cmd = [
|
||||
"uv",
|
||||
"run",
|
||||
"--no-project",
|
||||
"python",
|
||||
str(args.candidate_src / "tests/perf/query_regression_runner.py"),
|
||||
"--case",
|
||||
str(case_path),
|
||||
"--base-bin",
|
||||
str(args.base_src / "target" / target_dir / "greptime"),
|
||||
"--candidate-bin",
|
||||
str(args.candidate_src / "target" / target_dir / "greptime"),
|
||||
"--fixture-generator",
|
||||
str(args.candidate_src / "target" / target_dir / "query_perf_fixture"),
|
||||
"--work-dir",
|
||||
str(work_dir),
|
||||
"--http-timeout",
|
||||
str(args.http_timeout),
|
||||
]
|
||||
if parse_bool(args.allow_large_fixture):
|
||||
cmd.append("--allow-large-fixture")
|
||||
|
||||
print(f"::group::Query regression case: {case_path}", flush=True)
|
||||
try:
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
finally:
|
||||
print("::endgroup::", flush=True)
|
||||
|
||||
|
||||
def write_summary(args: argparse.Namespace, reports: list[Path]) -> int:
|
||||
cmd = ["uv", "run", "--no-project", "python", str(args.summary_script)]
|
||||
for report in reports:
|
||||
cmd.extend(["--report", str(report)])
|
||||
cmd.extend(
|
||||
[
|
||||
"--run-url",
|
||||
args.run_url,
|
||||
"--case-name",
|
||||
args.case_name,
|
||||
"--base-ref",
|
||||
args.base_ref,
|
||||
"--candidate-ref",
|
||||
args.candidate_ref,
|
||||
"--output",
|
||||
str(args.summary_output),
|
||||
]
|
||||
)
|
||||
return subprocess.run(cmd, check=False).returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--cases", action="append", help="'all' or comma/space separated case paths")
|
||||
parser.add_argument("--base-src", type=Path, default=Path("base-src"))
|
||||
parser.add_argument("--candidate-src", type=Path, default=Path("candidate-src"))
|
||||
parser.add_argument("--cargo-profile", default=os.environ.get("CARGO_PROFILE", "nightly"))
|
||||
parser.add_argument("--work-dir", default=Path("query-regression-work"), type=Path)
|
||||
parser.add_argument("--http-timeout", default=os.environ.get("HTTP_TIMEOUT", "300"))
|
||||
parser.add_argument("--allow-large-fixture", default=os.environ.get("ALLOW_LARGE_FIXTURE", "false"))
|
||||
parser.add_argument(
|
||||
"--summary-script",
|
||||
type=Path,
|
||||
default=Path("candidate-src/.github/scripts/query-regression-summary.py"),
|
||||
)
|
||||
parser.add_argument("--summary-output", type=Path, default=Path("query-regression-summary.md"))
|
||||
parser.add_argument("--run-url", default=os.environ.get("RUN_URL", ""))
|
||||
parser.add_argument("--case-name", default=os.environ.get("CASE_NAME", "default case set"))
|
||||
parser.add_argument("--base-ref", default=os.environ.get("BASE_REF", ""))
|
||||
parser.add_argument("--candidate-ref", default=os.environ.get("CANDIDATE_REF", ""))
|
||||
parser.add_argument("--github-output", default=os.environ.get("GITHUB_OUTPUT"))
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
cases = split_cases(args.cases or [os.environ.get("CASE_PATHS", "all")])
|
||||
except ValueError as err:
|
||||
print(f"error: {err}", flush=True)
|
||||
append_github_output(args.github_output, 1)
|
||||
return 0
|
||||
|
||||
reports: list[Path] = []
|
||||
status = 0
|
||||
for case in cases:
|
||||
case_path = resolve_case_path(args.candidate_src, case)
|
||||
work_dir = args.work_dir / case_slug(case_path)
|
||||
reports.append(work_dir / "query-regression-report.json")
|
||||
case_status = run_case(args, case_path, work_dir)
|
||||
if case_status != 0:
|
||||
status = case_status or 1
|
||||
|
||||
summary_status = write_summary(args, reports)
|
||||
if summary_status != 0 and status == 0:
|
||||
status = summary_status
|
||||
append_step_summary(args.summary_output)
|
||||
append_github_output(args.github_output, status)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
224
.github/scripts/query-regression-summary.py
vendored
Normal file
224
.github/scripts/query-regression-summary.py
vendored
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/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.
|
||||
|
||||
"""Format tests/perf query regression JSON reports as GitHub Markdown."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fmt_ms(value: Any) -> str:
|
||||
if value is None:
|
||||
return "N/A"
|
||||
try:
|
||||
return f"{float(value):.2f}"
|
||||
except (TypeError, ValueError):
|
||||
return "N/A"
|
||||
|
||||
|
||||
def esc(value: Any) -> str:
|
||||
text = "N/A" if value is None else str(value)
|
||||
return text.replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def status_emoji(status: str | None) -> str:
|
||||
return {"ok": "✅", "measured": "✅", "failed": "❌", "planned": "📝", "fixture-ready": "🧪"}.get(
|
||||
status or "", "⚠️"
|
||||
)
|
||||
|
||||
|
||||
def measurement_map(target: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
return {m.get("name") or f"query-{i}": m for i, m in enumerate(target.get("measurements", []))}
|
||||
|
||||
|
||||
def regression_pct(base: Any, candidate: Any) -> str:
|
||||
try:
|
||||
b = float(base)
|
||||
c = float(candidate)
|
||||
except (TypeError, ValueError):
|
||||
return "N/A"
|
||||
if b == 0:
|
||||
return "N/A"
|
||||
return f"{(c - b) / b * 100:+.1f}%"
|
||||
|
||||
|
||||
def threshold_status(thresholds: list[dict[str, Any]], query: str) -> str:
|
||||
hits = [t for t in thresholds if t.get("query") == query]
|
||||
if not hits:
|
||||
return "N/A"
|
||||
return ", ".join(f"{t.get('threshold')}: {t.get('status')}" for t in hits)
|
||||
|
||||
|
||||
def target_table(targets: list[dict[str, Any]]) -> str:
|
||||
rows = ["| Target | Status | Validation errors | Region | Datanode data home |", "| --- | --- | ---: | --- | --- |"]
|
||||
for target in targets:
|
||||
errors = len(target.get("validation_errors") or [])
|
||||
discovered = target.get("discovered") or {}
|
||||
if isinstance(discovered, list):
|
||||
region = ", ".join(str(item.get("region_id")) for item in discovered)
|
||||
else:
|
||||
region = discovered.get("region_id")
|
||||
rows.append(
|
||||
"| {name} | {status} {raw} | {errors} | {region} | `{data}` |".format(
|
||||
name=esc(target.get("name")),
|
||||
status=status_emoji(target.get("status")),
|
||||
raw=esc(target.get("status")),
|
||||
errors=errors,
|
||||
region=esc(region),
|
||||
data=esc(target.get("datanode_data_home") or target.get("data_dir")),
|
||||
)
|
||||
)
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def comparison_table(targets: list[dict[str, Any]], thresholds: list[dict[str, Any]]) -> str:
|
||||
if len(targets) < 2:
|
||||
return "No base/candidate measurements found."
|
||||
base = measurement_map(targets[0])
|
||||
candidate = measurement_map(targets[1])
|
||||
names = sorted(set(base) | set(candidate))
|
||||
rows = [
|
||||
"| Query | Base median ms | Base p95 ms | Candidate median ms | Candidate p95 ms | Regression | Threshold |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
for name in names:
|
||||
bm = base.get(name, {})
|
||||
cm = candidate.get(name, {})
|
||||
rows.append(
|
||||
"| {q} | {bm} | {bp} | {cm} | {cp} | {reg} | {th} |".format(
|
||||
q=esc(name),
|
||||
bm=fmt_ms(bm.get("latency_ms_median")),
|
||||
bp=fmt_ms(bm.get("latency_ms_p95")),
|
||||
cm=fmt_ms(cm.get("latency_ms_median")),
|
||||
cp=fmt_ms(cm.get("latency_ms_p95")),
|
||||
reg=regression_pct(bm.get("latency_ms_median"), cm.get("latency_ms_median")),
|
||||
th=esc(threshold_status(thresholds, name)),
|
||||
)
|
||||
)
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def build_markdown(
|
||||
report: dict[str, Any],
|
||||
report_path: Path,
|
||||
run_url: str | None,
|
||||
case_name: str | None,
|
||||
base_ref: str | None,
|
||||
candidate_ref: str | None,
|
||||
) -> str:
|
||||
status = report.get("status", "missing")
|
||||
case = report.get("case") or {}
|
||||
title_case = case_name or case.get("name") or report_path.name
|
||||
lines = [f"## {status_emoji(status)} Query regression report: `{esc(title_case)}`", ""]
|
||||
lines.append(f"- **Status:** `{esc(status)}`")
|
||||
lines.append(f"- **Case path:** `{esc(report.get('case_path'))}`")
|
||||
lines.append(f"- **Query mode:** `{esc(report.get('query_mode'))}`")
|
||||
if base_ref:
|
||||
lines.append(f"- **Base ref:** `{esc(base_ref)}`")
|
||||
if candidate_ref:
|
||||
lines.append(f"- **Candidate ref:** `{esc(candidate_ref)}`")
|
||||
if run_url:
|
||||
lines.append(f"- **Workflow run:** {run_url}")
|
||||
if report.get("error"):
|
||||
lines.append(f"- **Error:** `{esc(report.get('error'))}`")
|
||||
lines.append("- **Artifacts:** query-regression-work logs, fixture metadata, and JSON report are uploaded with this run.")
|
||||
|
||||
targets = report.get("targets") or []
|
||||
lines.extend(["", "### Targets", "", target_table(targets)])
|
||||
lines.extend(["", "### Query comparison", "", comparison_table(targets, report.get("thresholds") or [])])
|
||||
|
||||
not_enforced = [t for t in report.get("thresholds") or [] if t.get("status") == "not_enforced"]
|
||||
if not_enforced:
|
||||
lines.extend(["", "### Not enforced thresholds", ""])
|
||||
for item in not_enforced:
|
||||
lines.append(f"- `{esc(item.get('query'))}` / `{esc(item.get('threshold'))}`: {esc(item.get('reason'))}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def build_combined_markdown(
|
||||
reports: list[tuple[Path, dict[str, Any]]],
|
||||
run_url: str | None,
|
||||
case_name: str | None,
|
||||
base_ref: str | None,
|
||||
candidate_ref: str | None,
|
||||
) -> str:
|
||||
if len(reports) == 1:
|
||||
path, report = reports[0]
|
||||
return build_markdown(report, path, run_url, case_name, base_ref, candidate_ref)
|
||||
|
||||
overall = "failed" if any(report.get("status") != "ok" for _, report in reports) else "ok"
|
||||
lines = [f"## {status_emoji(overall)} Query regression report: `{esc(case_name or 'multiple cases')}`", ""]
|
||||
lines.append(f"- **Status:** `{esc(overall)}`")
|
||||
if base_ref:
|
||||
lines.append(f"- **Base ref:** `{esc(base_ref)}`")
|
||||
if candidate_ref:
|
||||
lines.append(f"- **Candidate ref:** `{esc(candidate_ref)}`")
|
||||
if run_url:
|
||||
lines.append(f"- **Workflow run:** {run_url}")
|
||||
lines.append("- **Artifacts:** query-regression-work logs, fixture metadata, and JSON reports are uploaded with this run.")
|
||||
|
||||
lines.extend(["", "### Cases", "", "| Case | Status | Report |", "| --- | --- | --- |"])
|
||||
for path, report in reports:
|
||||
case = report.get("case") or {}
|
||||
name = case.get("name") or path.parent.name
|
||||
status = report.get("status", "missing")
|
||||
lines.append(f"| `{esc(name)}` | {status_emoji(status)} `{esc(status)}` | `{esc(path)}` |")
|
||||
|
||||
for path, report in reports:
|
||||
case = report.get("case") or {}
|
||||
name = case.get("name") or path.parent.name
|
||||
lines.extend(["", "---", ""])
|
||||
lines.append(build_markdown(report, path, None, name, None, None).rstrip())
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--report", required=True, type=Path, action="append")
|
||||
parser.add_argument("--run-url")
|
||||
parser.add_argument("--case-name")
|
||||
parser.add_argument("--base-ref")
|
||||
parser.add_argument("--candidate-ref")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
reports = []
|
||||
for report_path in args.report:
|
||||
if report_path.exists():
|
||||
report = json.loads(report_path.read_text())
|
||||
else:
|
||||
report = {"status": "failed", "error": f"report not found: {report_path}", "targets": []}
|
||||
reports.append((report_path, report))
|
||||
markdown = build_combined_markdown(
|
||||
reports,
|
||||
args.run_url,
|
||||
args.case_name,
|
||||
args.base_ref,
|
||||
args.candidate_ref,
|
||||
)
|
||||
if args.output:
|
||||
args.output.write_text(markdown)
|
||||
else:
|
||||
print(markdown, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
177
.github/workflows/query-regression-comment.yml
vendored
Normal file
177
.github/workflows/query-regression-comment.yml
vendored
Normal file
@@ -0,0 +1,177 @@
|
||||
name: Query Regression Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Query Regression"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: >-
|
||||
${{ github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.event.workflow_run.conclusion != 'skipped' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Locate query regression comment artifact
|
||||
id: artifact
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const run_id = context.payload.workflow_run.id;
|
||||
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner,
|
||||
repo,
|
||||
run_id,
|
||||
per_page: 100,
|
||||
});
|
||||
const artifact = data.artifacts.find(
|
||||
item => item.name === 'query-regression-comment' && !item.expired
|
||||
);
|
||||
if (!artifact) {
|
||||
core.info('No query-regression-comment artifact found; skipping.');
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
const maxBytes = 1024 * 1024;
|
||||
if (artifact.size_in_bytes > maxBytes) {
|
||||
core.warning(`Comment artifact too large: ${artifact.size_in_bytes} bytes`);
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('id', String(artifact.id));
|
||||
|
||||
- name: Download query regression comment artifact
|
||||
id: download
|
||||
if: ${{ steps.artifact.outputs.found == 'true' }}
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: query-regression-comment
|
||||
path: query-regression-comment
|
||||
repository: ${{ github.repository }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
- name: Validate PR metadata and prepare comment
|
||||
id: validate
|
||||
if: ${{ steps.download.outcome == 'success' }}
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
WORKFLOW_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const artifactDir = 'query-regression-comment';
|
||||
const metadataPath = path.join(artifactDir, 'query-regression-pr.json');
|
||||
const summaryPath = path.join(artifactDir, 'query-regression-summary.md');
|
||||
|
||||
function skip(message) {
|
||||
core.info(message);
|
||||
core.setOutput('should_post', 'false');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
return skip('Missing query-regression-pr.json; skipping sticky comment.');
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||
} catch (error) {
|
||||
core.warning(`Invalid PR metadata JSON: ${error.message}`);
|
||||
return skip('Invalid PR metadata JSON; skipping sticky comment.');
|
||||
}
|
||||
|
||||
const expectedRunId = Number(process.env.WORKFLOW_RUN_ID);
|
||||
const expectedRunAttempt = Number(process.env.WORKFLOW_RUN_ATTEMPT);
|
||||
if (metadata.run_id !== expectedRunId || metadata.run_attempt !== expectedRunAttempt) {
|
||||
return skip('Artifact metadata does not match this workflow_run; skipping.');
|
||||
}
|
||||
|
||||
if (metadata.base_repo !== context.repo.owner + '/' + context.repo.repo) {
|
||||
return skip(`PR targets ${metadata.base_repo}, not this repository; skipping.`);
|
||||
}
|
||||
|
||||
const prNumber = Number(metadata.pr_number);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
return skip('Invalid PR number in metadata; skipping.');
|
||||
}
|
||||
|
||||
const run = context.payload.workflow_run;
|
||||
const trustedPrNumbers = new Set(
|
||||
(run.pull_requests || [])
|
||||
.map(pr => Number(pr.number))
|
||||
.filter(Number.isInteger)
|
||||
);
|
||||
try {
|
||||
const { data: associatedPrs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: run.head_sha,
|
||||
});
|
||||
for (const pr of associatedPrs) {
|
||||
trustedPrNumbers.add(Number(pr.number));
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Could not list PRs associated with workflow_run head SHA: ${error.message}`);
|
||||
}
|
||||
if (!trustedPrNumbers.has(prNumber)) {
|
||||
return skip(`PR #${prNumber} is not associated with workflow_run ${run.id}; skipping.`);
|
||||
}
|
||||
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
if (pull.state !== 'open') {
|
||||
return skip(`PR #${prNumber} is ${pull.state}; skipping.`);
|
||||
}
|
||||
if (pull.base.repo.full_name !== metadata.base_repo || pull.head.repo.full_name !== metadata.head_repo) {
|
||||
return skip('Current PR repository metadata does not match artifact; skipping.');
|
||||
}
|
||||
if (pull.head.sha !== metadata.head_sha) {
|
||||
return skip('Current PR head SHA differs from artifact; skipping stale run.');
|
||||
}
|
||||
|
||||
let body = 'Query regression completed, but no summary artifact was found.';
|
||||
if (fs.existsSync(summaryPath)) {
|
||||
const maxSummaryBytes = 1024 * 1024;
|
||||
const summarySize = fs.statSync(summaryPath).size;
|
||||
if (summarySize > maxSummaryBytes) {
|
||||
return skip(`Summary file too large: ${summarySize} bytes.`);
|
||||
}
|
||||
body = fs.readFileSync(summaryPath, 'utf8');
|
||||
}
|
||||
body = body
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/@/g, '@\u200b');
|
||||
if (body.length > 59999) {
|
||||
body = body.slice(0, 59500) + '\n\n…\n\n_Comment truncated because it exceeded 60000 characters._\n';
|
||||
}
|
||||
fs.writeFileSync(summaryPath, body);
|
||||
|
||||
core.setOutput('should_post', 'true');
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('summary_path', summaryPath);
|
||||
|
||||
- name: Post sticky PR comment
|
||||
if: ${{ steps.validate.outputs.should_post == 'true' }}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: query-regression-report
|
||||
number: ${{ steps.validate.outputs.pr_number }}
|
||||
path: ${{ steps.validate.outputs.summary_path }}
|
||||
152
.github/workflows/query-regression.yml
vendored
Normal file
152
.github/workflows/query-regression.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
name: Query Regression
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
case:
|
||||
description: Query perf case path(s) in candidate checkout, or all
|
||||
required: true
|
||||
default: all
|
||||
base_ref:
|
||||
description: Base ref/sha to build
|
||||
required: true
|
||||
default: main
|
||||
candidate_ref:
|
||||
description: Candidate ref/sha to build (empty = current ref)
|
||||
required: false
|
||||
default: ""
|
||||
allow_large_fixture:
|
||||
description: Pass --allow-large-fixture to runner
|
||||
type: boolean
|
||||
default: true
|
||||
http_timeout:
|
||||
description: Runner HTTP timeout seconds
|
||||
required: true
|
||||
default: "300"
|
||||
cargo_profile:
|
||||
description: Cargo profile to build base and candidate binaries with
|
||||
required: true
|
||||
type: choice
|
||||
default: nightly
|
||||
options:
|
||||
- nightly
|
||||
- release
|
||||
- dev
|
||||
pull_request:
|
||||
types: [labeled, ready_for_review, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
query-regression:
|
||||
if: >-
|
||||
${{ github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
!github.event.pull_request.draft &&
|
||||
contains(github.event.pull_request.labels.*.name, 'query-regression') &&
|
||||
(github.event.action != 'labeled' || github.event.label.name == 'query-regression')) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
CARGO_PROFILE: ${{ github.event_name == 'pull_request' && 'nightly' || inputs.cargo_profile }}
|
||||
steps:
|
||||
- name: Checkout base source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || inputs.base_ref }}
|
||||
path: base-src
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout candidate source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/merge', github.event.pull_request.number) || inputs.candidate_ref || github.ref }}
|
||||
path: candidate-src
|
||||
persist-credentials: false
|
||||
|
||||
- uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Rust cache (base)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: base-src
|
||||
shared-key: query-regression-base
|
||||
cache-all-crates: "true"
|
||||
save-if: false
|
||||
|
||||
- name: Rust cache (candidate)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: candidate-src
|
||||
shared-key: query-regression-candidate
|
||||
cache-all-crates: "true"
|
||||
save-if: false
|
||||
|
||||
- name: Build base greptime
|
||||
working-directory: base-src
|
||||
run: cargo build --profile "${CARGO_PROFILE}" -p cmd --bin greptime
|
||||
|
||||
- name: Build candidate greptime and fixture generator
|
||||
working-directory: candidate-src
|
||||
run: cargo build --profile "${CARGO_PROFILE}" -p cmd --bin greptime --bin query_perf_fixture
|
||||
|
||||
- name: Run query regression
|
||||
id: run
|
||||
env:
|
||||
CASE_PATHS: ${{ github.event_name == 'pull_request' && 'all' || inputs.case }}
|
||||
HTTP_TIMEOUT: ${{ github.event_name == 'pull_request' && '300' || inputs.http_timeout }}
|
||||
ALLOW_LARGE_FIXTURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.allow_large_fixture }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
CASE_NAME: ${{ github.event_name == 'pull_request' && 'default case set' || inputs.case }}
|
||||
BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || inputs.base_ref }}
|
||||
CANDIDATE_REF: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/merge', github.event.pull_request.number) || inputs.candidate_ref || github.ref }}
|
||||
run: uv run --no-project python candidate-src/.github/scripts/query-regression-run.py
|
||||
|
||||
- name: Write PR metadata for trusted comment workflow
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
run: uv run --no-project python candidate-src/.github/scripts/query-regression-pr-metadata.py
|
||||
|
||||
- name: Upload query regression artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: query-regression-report
|
||||
path: |
|
||||
query-regression-work/**
|
||||
query-regression-summary.md
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload trusted comment artifact
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: query-regression-comment
|
||||
path: |
|
||||
query-regression-pr.json
|
||||
query-regression-summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Fail on regression failure
|
||||
if: ${{ steps.run.outputs.status != '0' }}
|
||||
run: exit 1
|
||||
Reference in New Issue
Block a user