blob: 4b593d78789b654b2e816bf7bc93d1655c8201b7 [file]
#!/usr/bin/env python3
# ***************************************************************************************************************************
# * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements. See the NOTICE file
# * distributed with this work for additional information regarding copyright ownership. The ASF licenses this file
# * to you 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.
# ***************************************************************************************************************************
"""
CI perf guard for Apache Juneau (per-module, TODO-160).
Reads per-module Surefire XML totals (or a --timing-log JSONL written by scripts/test.py)
and compares them against the per-module baselines in the project-root perf-baseline.txt.
Intended to run in Jenkinsfile's post-build block after the main Maven build has already
produced Surefire XML reports across the whole reactor.
Usage:
python3 scripts/ci-perf-guard.py [--timing-log <path>] [--dry-run]
Options:
--timing-log <path> Read actual times from a JSONL written by scripts/test.py --timing-log.
When omitted, discovers and reads Surefire XML directly from every
<module>/target/surefire-reports/ under the reactor.
--dry-run Print detected totals and baselines but do not exit non-zero on breach.
--help Show this help message.
Env vars:
JUNEAU_CI_PERF_THRESHOLD Tolerance as a decimal fraction (default 0.20 = ±20%).
Separate from JUNEAU_PUSH_TIMING_THRESHOLD.
Baseline format (perf-baseline.txt, project root):
<key> = <seconds> # optional trailing comment
'suite' -> overall reactor wall-clock baseline (not used here; ci-perf-guard
works off Surefire XML test-time, not wall-clock).
'<module>/<bucket>' -> per-module Surefire test-time baseline; <bucket> ∈
{core, container.springboot, container.jetty, container.tomcat}.
Modules with no baseline entry are treated as new (warn, not fail).
NOTE: Baselines were calibrated on a developer M1 Pro laptop. The first Jenkins run after a
re-baseline will likely show different values; recalibrate against the CI Linux numbers.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
# Container annotation marker -> bucket. Checked against the test class source.
CONTAINER_MARKERS = (
("SpringbootTest", "container.springboot"),
("JettyMicroserviceTest", "container.jetty"),
("TomcatMicroserviceTest", "container.tomcat"),
)
def classify_bucket(module_dir: Path, class_name: str) -> str:
"""Bucket a test class as core or a container.* flavor via source-annotation inspection."""
source = module_dir / "src" / "test" / "java" / Path("/".join(class_name.split("."))).with_suffix(".java")
if source.exists():
try:
content = source.read_text(encoding="utf-8")
for marker, bucket in CONTAINER_MARKERS:
if marker in content:
return bucket
return "core"
except OSError:
pass
lowered = class_name.lower()
if "springboot" in lowered:
return "container.springboot"
if "tomcat" in lowered:
return "container.tomcat"
if "jetty" in lowered:
return "container.jetty"
return "core"
def aggregate_module_reports(module_dir: Path, reports_dir: Path) -> dict:
"""Return {bucket: seconds} for one module's surefire-reports tree (flat or nested)."""
buckets: dict = {}
for xml_file in sorted(reports_dir.rglob("TEST-*.xml")):
try:
root = ET.parse(xml_file).getroot()
except (ET.ParseError, OSError):
continue
bucket = classify_bucket(module_dir, root.attrib.get("name", ""))
buckets[bucket] = buckets.get(bucket, 0.0) + float(root.attrib.get("time", 0.0))
return buckets
def read_baselines_from_file(baseline_file: Path) -> dict:
"""Parse perf-baseline.txt: '<key> = <seconds>'. Keeps 'suite' and '<module>/<bucket>' keys."""
result: dict = {}
if not baseline_file.exists():
return result
for line in baseline_file.read_text(encoding="utf-8").splitlines():
stripped = line.split("#", 1)[0].strip()
if not stripped or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
key = key.strip()
if key != "suite" and "/" not in key:
continue
try:
result[key] = float(value.strip().split()[0])
except (ValueError, IndexError):
continue
return result
def read_actuals_from_jsonl(log_path: Path) -> dict:
"""Return {'<module>/<bucket>': seconds} for the latest run_id in the JSONL (skips the suite row)."""
rows: list[dict] = []
for line in log_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
rows.append(json.loads(stripped))
except json.JSONDecodeError:
continue
if not rows:
return {}
latest_run_id = rows[-1].get("run_id")
result: dict = {}
for row in rows:
if row.get("run_id") != latest_run_id:
continue
module = row.get("module", "?")
execution = row.get("execution", "?")
if module == "reactor" and execution == "suite":
continue
result[f"{module}/{execution}"] = float(row.get("wallclock_s", 0.0))
return result
def read_actuals_from_surefire(repo_root: Path) -> dict:
"""Discover every <module>/target/surefire-reports/ under the reactor: {'<module>/<bucket>': seconds}."""
actuals: dict = {}
for reports_dir in sorted(repo_root.rglob("target/surefire-reports")):
if not reports_dir.is_dir():
continue
module_dir = reports_dir.parent.parent
module_key = module_dir.relative_to(repo_root).as_posix()
for bucket, seconds in aggregate_module_reports(module_dir, reports_dir).items():
actuals[f"{module_key}/{bucket}"] = seconds
return actuals
def main() -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--timing-log")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--help", "-h", action="store_true")
args, unknown = parser.parse_known_args()
if args.help:
print(__doc__)
return 0
if unknown:
print(f"Unknown option(s): {' '.join(unknown)}")
print(__doc__)
return 1
repo_root = Path(__file__).parent.parent
baseline_file = repo_root / "perf-baseline.txt"
baselines = read_baselines_from_file(baseline_file)
if not baselines:
print(f"PERF-GUARD: baseline file not found or empty ({baseline_file}); skipping.")
return 0
if args.timing_log:
log_path = Path(args.timing_log).expanduser()
if not log_path.exists():
print(f"PERF-GUARD: timing log not found ({log_path}); falling back to Surefire XML.")
actuals = read_actuals_from_surefire(repo_root)
else:
actuals = read_actuals_from_jsonl(log_path)
else:
actuals = read_actuals_from_surefire(repo_root)
tolerance = float(os.environ.get("JUNEAU_CI_PERF_THRESHOLD", "0.20"))
pct = f"±{tolerance * 100:.0f}%"
guard_failed = False
total_actual = sum(actuals.values())
print(f"PERF-GUARD CI: detected total {total_actual:.1f}s across {len(actuals)} module-bucket(s).")
checked = 0
regressions = 0
new_keys = []
for key in sorted(actuals):
actual = actuals[key]
baseline = baselines.get(key)
if baseline is None:
new_keys.append(key)
continue
checked += 1
threshold = baseline * (1 + tolerance)
if actual > threshold:
print(
f"PERF-GUARD FAIL [{key}]: {actual:.1f}s "
f"(baseline {baseline}s, tolerance {pct}, threshold {threshold:.1f}s).\n"
f" If intentional, bump '{key}' in perf-baseline.txt (project root)."
)
guard_failed = True
regressions += 1
for key in new_keys:
print(f"PERF-GUARD WARN [{key}]: {actuals[key]:.1f}s — no baseline entry yet (new/unknown; not failing).")
print(
f"PERF-GUARD SUMMARY: {checked} module-bucket(s) checked, {regressions} regression(s), "
f"{len(new_keys)} new/unknown."
)
if guard_failed and args.dry_run:
print("PERF-GUARD: dry-run mode; breach detected but not failing the build.")
return 0
return 1 if guard_failed else 0
if __name__ == "__main__":
raise SystemExit(main())