diff --git a/tools/build_timings.py b/tools/build_timings.py index 2eb7db16..c39c70c0 100644 --- a/tools/build_timings.py +++ b/tools/build_timings.py @@ -1,12 +1,14 @@ """Measured build times, for packing the build jobs by time (vinca-gha --timings). Each build-branch job records how long each of its recipes took to build (`rs -build-ci`, in distros//work/build_timings.jsonl) and keeps that file as an artifact -(vinca-gha --keep-file). `robostack-bot update-build-timings` merges the last week's into -shared/build_timings/.json, {recipe: {distro: seconds}}, one recipe per line, and -`rs gha` hands vinca-gha each recipe's average over the distributions: a recipe new -to one distribution still has a time (the names are the same, ros2-). The -compatibility recipes (ros--) aren't kept: they get ALIAS_SECONDS. +build-ci`) and to upload (`rs upload`), in distros//work/build_timings.jsonl, +and keeps that file as an artifact (vinca-gha --keep-file). `robostack-bot +update-build-timings` merges the last week's into shared/build_timings/.json, +{recipe: {distro: seconds}}, one recipe per line, and `rs gha` hands vinca-gha +each recipe's average over the distributions: a recipe new to one distribution still has a +time (the names are the same, ros2-). The compatibility recipes +(ros--) aren't kept: they get ALIAS_SECONDS, and their uploads count for +ros2-, which they are built right after. pixi run python tools/build_timings.py # what the bot command does """ @@ -31,18 +33,21 @@ ALIAS_SECONDS = 0.3 -def note(distro: str, recipe: str, seconds: float) -> None: - """Record a recipe's build time in the work directory's journal.""" +def note(distro: str, recipe: str, seconds: float, upload: bool = False) -> None: + """Record a recipe's build (or upload) time in the work directory's journal.""" from vinca.platforms import get_conda_subdir entry = {"platform": get_conda_subdir(), "recipe": recipe, "seconds": round(seconds, 1)} + if upload: + entry["upload"] = True with (rs.work_dir(distro) / JOURNAL).open("a", encoding="utf-8") as fh: fh.write(json.dumps(entry) + "\n") def collect(days: int = 8) -> int: """Merge the build times the build-branch runs of the last `days` days recorded into - shared/build_timings/ (newer runs win); returns how many times were recorded.""" + shared/build_timings/ (newer runs win), uploads added to the build that precedes them in + the job's journal; returns how many build times were recorded.""" since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d") runs = json.loads(subprocess.run( ["gh", "run", "list", "--repo", "RoboStack/robostack", "--workflow", "build.yaml", "--status", "completed", @@ -59,9 +64,16 @@ def collect(days: int = 8) -> int: for journal in sorted(out.rglob(JOURNAL)): for line in journal.read_text().splitlines(): entry = json.loads(line) - if entry["recipe"].startswith(f"ros-{distro}-"): + recipe, alias = entry["recipe"], f"ros-{distro}-" + by_distro = files.setdefault(entry["platform"], {}) + if entry.get("upload"): + times = by_distro.get(recipe.replace(alias, "ros2-", 1), {}) + if distro in times: + times[distro] = round(times[distro] + entry["seconds"], 1) + continue + if recipe.startswith(alias): continue # a compatibility recipe: ALIAS_SECONDS - files.setdefault(entry["platform"], {}).setdefault(entry["recipe"], {})[distro] = entry["seconds"] + by_distro.setdefault(recipe, {})[distro] = entry["seconds"] count += 1 TIMINGS.mkdir(parents=True, exist_ok=True) for platform, recipes in files.items(): diff --git a/tools/robostack.py b/tools/robostack.py index d7303eeb..2f79bf2a 100644 --- a/tools/robostack.py +++ b/tools/robostack.py @@ -383,7 +383,14 @@ def task(distro: str, name: str, args: list[str]) -> int: rc = run(["vinca-sort-vinca-lists", *args, "vinca.yaml"], d) return rc or run(["vinca-sort-yaml-keys", *args, *files], d) if name == "upload": - return upload(distro, args, w if w.is_dir() else d) + started = time.monotonic() + rc = upload(distro, args, w if w.is_dir() else d) + if rc == 0 and len(args) == 1 and os.environ.get("GITHUB_ACTIONS"): + import build_timings # uploads take long too (attestations, two channels) + + recipe = Path(args[0]).name.removesuffix(".conda").removesuffix(".tar.bz2").rsplit("-", 2)[0] + build_timings.note(distro, recipe, time.monotonic() - started, upload=True) + return rc if name in ("update-snapshot", "find-stale", "update-pinning", "dependency-report"): import maintenance diff --git a/tools/tests/test_build_timings.py b/tools/tests/test_build_timings.py index b86b51f7..aed3bb47 100644 --- a/tools/tests/test_build_timings.py +++ b/tools/tests/test_build_timings.py @@ -9,8 +9,10 @@ def test_note_records_build_times(tmp_path, monkeypatch): monkeypatch.setattr(build_timings.rs, "work_dir", lambda distro: tmp_path) build_timings.note("jazzy", "ros2-a", 12.34) build_timings.note("jazzy", "ros-jazzy-a", 1.0) + build_timings.note("jazzy", "ros2-a", 2.0, upload=True) entries = [json.loads(line) for line in (tmp_path / build_timings.JOURNAL).read_text().splitlines()] - assert [(e["recipe"], e["seconds"]) for e in entries] == [("ros2-a", 12.3), ("ros-jazzy-a", 1.0)] + assert [(e["recipe"], e["seconds"], e.get("upload", False)) for e in entries] == [ + ("ros2-a", 12.3, False), ("ros-jazzy-a", 1.0, False), ("ros2-a", 2.0, True)] assert all(e["platform"] for e in entries) @@ -19,7 +21,10 @@ def test_collect_merges_the_runs_journals(tmp_path, monkeypatch): runs = [{"databaseId": 2, "headBranch": "buildbranch_jazzy_linux_64", "createdAt": "2026-10-22T02:00:00Z"}, {"databaseId": 1, "headBranch": "buildbranch_jazzy_linux_64", "createdAt": "2026-10-22T01:00:00Z"}, {"databaseId": 3, "headBranch": "buildbranch_lyrical_linux_64", "createdAt": "2026-10-22T03:00:00Z"}] - journals = {1: [("ros2-a", 10.0), ("ros2-b", 5.0), ("ros-jazzy-a", 0.2)], 2: [("ros2-a", 12.0)], 3: [("ros2-a", 20.0)]} + # (recipe, seconds, upload): the uploads come after the job's builds + journals = {1: [("ros2-a", 10.0, False), ("ros2-b", 5.0, False), ("ros-jazzy-a", 0.2, False), ("ros2-b", 1.0, True)], + 2: [("ros2-a", 12.0, False), ("ros2-a", 2.0, True), ("ros-jazzy-a", 1.0, True)], + 3: [("ros2-a", 20.0, False)]} def run(cmd, **kwargs): if cmd[1:3] == ["run", "list"]: @@ -28,23 +33,24 @@ def run(cmd, **kwargs): job = Path(cmd[cmd.index("--dir") + 1]) / "stage_0_job_0-1" job.mkdir(parents=True) (job / build_timings.JOURNAL).write_text("".join( - json.dumps({"platform": "linux-64", "recipe": r, "seconds": s}) + "\n" for r, s in journals[run_id])) + json.dumps({"platform": "linux-64", "recipe": r, "seconds": s, **({"upload": True} if u else {})}) + "\n" + for r, s, u in journals[run_id])) return subprocess.CompletedProcess(cmd, 0, b"", b"") monkeypatch.setattr(build_timings.subprocess, "run", run) assert build_timings.collect() == 4 # the compatibility recipe is not kept - # the newer jazzy run wins; each distribution keeps its own time; compatibility - # recipes aren't kept; one recipe per line + # the newer jazzy run wins; each distribution keeps its own time, build plus uploads; + # compatibility recipes aren't kept, their uploads count for ros2-; one recipe per line text = (tmp_path / "timings" / "linux-64.json").read_text() - assert json.loads(text) == {"ros2-a": {"jazzy": 12.0, "lyrical": 20.0}, "ros2-b": {"jazzy": 5.0}} - assert text.splitlines()[1] == '"ros2-a": {"jazzy": 12.0, "lyrical": 20.0},' + assert json.loads(text) == {"ros2-a": {"jazzy": 15.0, "lyrical": 20.0}, "ros2-b": {"jazzy": 6.0}} + assert text.splitlines()[1] == '"ros2-a": {"jazzy": 15.0, "lyrical": 20.0},' # vinca-gha gets the average over the distributions out = tmp_path / "vinca.json" assert build_timings.vinca_timings("humble", "linux-64", out) assert json.loads(out.read_text()) == { - "recipes": {"ros2-a": 16.0, "ros2-b": 5.0, "ros-humble-a": build_timings.ALIAS_SECONDS, + "recipes": {"ros2-a": 17.5, "ros2-b": 6.0, "ros-humble-a": build_timings.ALIAS_SECONDS, "ros-humble-b": build_timings.ALIAS_SECONDS}, - "default": 10.5} + "default": 11.75} assert not build_timings.vinca_timings("humble", "win-64", out)