feat(ci): track time.sleep in docker test profiler

The profiler now monkey-patches time.sleep alongside subprocess.run,
capturing the invisible "gap" time from polling loops (wait_for_container_ready,
poll_container, wait_for_log, etc.) that was previously unaccounted for.

The JSON report now includes per-test total_sleep_s, sleep_count, and
a sleeps[] array with caller location. The summary includes
total_sleep_s and total_wall_s (docker + sleep). The CI merge step
also aggregates sleep totals.

Local profile now shows: 201s docker + 75s sleep = 276s wall (38s
runner wall with 32-way parallelism). The biggest sleep consumer is
test_dashboard_insecure_env_var_no_longer_bypasses at 11.8s of
poll_container sleeps.
This commit is contained in:
ethernet 2026-07-14 16:22:05 -04:00
parent ca6ede33e1
commit 3fc4e413d2
2 changed files with 133 additions and 64 deletions

View file

@ -2,8 +2,9 @@
Activated by ``HERMES_DOCKER_TEST_PROFILE=1``. Instruments every
``subprocess.run`` call whose argv starts with ``docker`` to measure
wall-clock time, and collects per-test breakdowns so we can see exactly
which docker operations dominate the slow CI runs.
wall-clock time, and also tracks ``time.sleep`` calls so we can see the
full wall-clock picture including the invisible sleep time in polling
loops that doesn't show up as docker call duration.
Outputs:
- JSON report at ``$HERMES_DOCKER_PROFILE_OUT`` (default:
@ -36,26 +37,54 @@ _ACTIVE = bool(os.environ.get("HERMES_DOCKER_TEST_PROFILE"))
@dataclass
class DockerCall:
"""One instrumented docker subprocess call."""
class TimedEvent:
"""Base for any timed event in a test's lifecycle."""
argv: list[str]
duration_s: float
returncode: int
timestamp: float # monotonic
@dataclass
class DockerCall(TimedEvent):
"""One instrumented docker subprocess call."""
argv: list[str]
returncode: int
@dataclass
class SleepGap(TimedEvent):
"""A time.sleep() that occurred between docker calls.
This captures the invisible "gap" time the polling sleeps, the
Python logic between assertions, etc. Each SleepGap is attributed to
the test that was running when it occurred.
"""
caller: str # short description of who called sleep
@dataclass
class TestProfile:
"""Per-test accumulation of docker call timings."""
"""Per-test accumulation of docker calls and sleep gaps."""
name: str
calls: list[DockerCall] = field(default_factory=list)
sleeps: list[SleepGap] = field(default_factory=list)
@property
def total_docker_s(self) -> float:
return sum(c.duration_s for c in self.calls)
@property
def total_sleep_s(self) -> float:
return sum(s.duration_s for s in self.sleeps)
@property
def total_wall_s(self) -> float:
"""Docker time + sleep time — the test's visible wall-clock cost."""
return self.total_docker_s + self.total_sleep_s
@property
def call_count(self) -> int:
return len(self.calls)
@ -81,11 +110,14 @@ class ProfileCollector:
self.tests: dict[str, TestProfile] = {}
self.current: Optional[TestProfile] = None
self._original_run: Any = None
self._original_sleep: Any = None
self._patched = False
self._last_docker_end: float = 0.0
def start_test(self, name: str) -> None:
self.current = TestProfile(name=name)
self.tests[name] = self.current
self._last_docker_end = 0.0
def end_test(self) -> None:
self.current = None
@ -93,14 +125,20 @@ class ProfileCollector:
def record(self, call: DockerCall) -> None:
if self.current is not None:
self.current.calls.append(call)
self._last_docker_end = call.timestamp + call.duration_s
def record_sleep(self, gap: SleepGap) -> None:
if self.current is not None:
self.current.sleeps.append(gap)
def install_patch(self) -> None:
"""Monkey-patch subprocess.run to capture docker call timings."""
"""Monkey-patch subprocess.run and time.sleep to capture timings."""
if self._patched:
return
import subprocess
self._original_run = subprocess.run
self._original_sleep = time.sleep
collector = self
def timed_run(*args: Any, **kwargs: Any) -> Any:
@ -113,23 +151,51 @@ class ProfileCollector:
elapsed = time.monotonic() - t0
rc = getattr(result, "returncode", -1)
call = DockerCall(
argv=[str(a) for a in argv],
duration_s=round(elapsed, 4),
returncode=rc,
timestamp=t0,
argv=[str(a) for a in argv],
returncode=rc,
)
collector.record(call)
return result
def timed_sleep(secs: float) -> None:
# Only track sleeps that happen between docker calls within a test.
# Short sleeps (< 0.05s) are probably just Python scheduling noise.
if collector.current is None or secs < 0.05:
return collector._original_sleep(secs)
t0 = time.monotonic()
collector._original_sleep(secs)
elapsed = time.monotonic() - t0
# Try to identify the caller for context
import traceback
stack = traceback.extract_stack(limit=4)
caller = ""
for frame in reversed(stack):
fname = frame.filename
if "conftest" in fname or "test_" in fname:
caller = f"{Path(fname).name}:{frame.lineno}"
break
gap = SleepGap(
duration_s=round(elapsed, 4),
timestamp=t0,
caller=caller,
)
collector.record_sleep(gap)
subprocess.run = timed_run
time.sleep = timed_sleep
self._patched = True
def uninstall_patch(self) -> None:
if not self._patched or self._original_run is None:
if not self._patched:
return
import subprocess
subprocess.run = self._original_run
if self._original_run is not None:
subprocess.run = self._original_run
if self._original_sleep is not None:
time.sleep = self._original_sleep
self._patched = False
def build_report(self) -> dict[str, Any]:
@ -139,18 +205,23 @@ class ProfileCollector:
"summary": {},
}
all_docker_time = 0.0
all_sleep_time = 0.0
all_call_count = 0
all_sleep_count = 0
subcmd_totals: dict[str, float] = defaultdict(float)
subcmd_counts: dict[str, int] = defaultdict(int)
for _name, tp in sorted(
self.tests.items(), key=lambda x: x[1].total_docker_s, reverse=True
self.tests.items(), key=lambda x: x[1].total_wall_s, reverse=True
):
by_sub = tp.by_subcommand()
test_entry: dict[str, Any] = {
"name": tp.name,
"total_docker_s": round(tp.total_docker_s, 3),
"total_sleep_s": round(tp.total_sleep_s, 3),
"total_wall_s": round(tp.total_wall_s, 3),
"call_count": tp.call_count,
"sleep_count": len(tp.sleeps),
"by_subcommand": {
sub: {
"count": len(calls),
@ -172,16 +243,27 @@ class ProfileCollector:
},
"calls": [
{
"type": "docker",
"argv": " ".join(c.argv[:8]),
"duration_s": c.duration_s,
"returncode": c.returncode,
}
for c in sorted(tp.calls, key=lambda x: x.duration_s, reverse=True)
],
"sleeps": [
{
"type": "sleep",
"duration_s": s.duration_s,
"caller": s.caller,
}
for s in sorted(tp.sleeps, key=lambda x: x.duration_s, reverse=True)
],
}
report["tests"].append(test_entry)
all_docker_time += tp.total_docker_s
all_sleep_time += tp.total_sleep_s
all_call_count += tp.call_count
all_sleep_count += len(tp.sleeps)
for sub, calls in by_sub.items():
subcmd_totals[sub] += sum(c.duration_s for c in calls)
subcmd_counts[sub] += len(calls)
@ -189,7 +271,10 @@ class ProfileCollector:
report["summary"] = {
"total_tests": len(self.tests),
"total_docker_s": round(all_docker_time, 3),
"total_sleep_s": round(all_sleep_time, 3),
"total_wall_s": round(all_docker_time + all_sleep_time, 3),
"total_calls": all_call_count,
"total_sleeps": all_sleep_count,
"by_subcommand": {
sub: {
"count": subcmd_counts[sub],
@ -218,7 +303,7 @@ class ProfileCollector:
return
print("\n" + "=" * 72, file=sys.stderr)
print("[docker-profile] Docker operation timing breakdown", file=sys.stderr)
print("[docker-profile] Docker + sleep timing breakdown", file=sys.stderr)
print("=" * 72, file=sys.stderr)
subcmd_totals: dict[str, float] = defaultdict(float)
@ -227,15 +312,17 @@ class ProfileCollector:
for sub, calls in tp.by_subcommand().items():
subcmd_totals[sub] += sum(c.duration_s for c in calls)
subcmd_counts[sub] += len(calls)
total_sleep = sum(tp.total_sleep_s for tp in self.tests.values())
total_docker = sum(tp.total_docker_s for tp in self.tests.values())
total_wall = total_docker + total_sleep
total = sum(subcmd_totals.values())
print(
f"\n Total docker time: {total:.1f}s across"
f" {sum(subcmd_counts.values())} calls\n",
f"\n Total wall time: {total_wall:.1f}s"
f" = {total_docker:.1f}s docker + {total_sleep:.1f}s sleep\n",
file=sys.stderr,
)
print(
f" {'Subcommand':<15} {'Calls':>8} {'Total':>10} {'Avg':>8} {'%':>6}",
f" {'Category':<15} {'Count':>8} {'Total':>10} {'Avg':>8} {'%':>6}",
file=sys.stderr,
)
print(
@ -247,58 +334,35 @@ class ProfileCollector:
):
t = subcmd_totals[sub]
n = subcmd_counts[sub]
pct = (t / total * 100) if total else 0
pct = (t / total_wall * 100) if total_wall else 0
print(
f" {sub:<15} {n:>8} {t:>9.1f}s {t / n:>7.2f}s {pct:>5.1f}%",
f" docker {sub:<8} {n:>8} {t:>9.1f}s {t / n:>7.2f}s {pct:>5.1f}%",
file=sys.stderr,
)
# Sleep row
sleep_count = sum(len(tp.sleeps) for tp in self.tests.values())
pct = (total_sleep / total_wall * 100) if total_wall else 0
print(
f"\n Top 10 slowest tests (by docker operation time):\n",
f" {'time.sleep':<15} {sleep_count:>8} {total_sleep:>9.1f}s"
f" {total_sleep / sleep_count if sleep_count else 0:>7.2f}s {pct:>5.1f}%",
file=sys.stderr,
)
# Top 10 slowest tests by WALL time
print(
f"\n Top 10 slowest tests (by wall time = docker + sleep):\n",
file=sys.stderr,
)
sorted_tests = sorted(
self.tests.values(), key=lambda t: t.total_docker_s, reverse=True
self.tests.values(), key=lambda t: t.total_wall_s, reverse=True
)
for i, tp in enumerate(sorted_tests[:10], 1):
print(
f" {i:>2}. {tp.total_docker_s:>6.1f}s"
f" {tp.call_count:>3} calls {tp.name}",
f" {i:>2}. {tp.total_wall_s:>6.1f}s "
f"({tp.total_docker_s:.1f}s docker + {tp.total_sleep_s:.1f}s sleep) "
f"{tp.call_count:>3} calls ...{tp.name[-50:]}",
file=sys.stderr,
)
by_sub = tp.by_subcommand()
for sub, calls in sorted(
by_sub.items(),
key=lambda x: sum(c.duration_s for c in x[1]),
reverse=True,
):
t = sum(c.duration_s for c in calls)
if t < 0.1:
continue
print(
f" {sub:<13} {t:>5.1f}s ({len(calls)} calls)",
file=sys.stderr,
)
all_calls: list[tuple[str, DockerCall]] = []
for tp in self.tests.values():
for c in tp.calls:
all_calls.append((tp.name, c))
all_calls.sort(key=lambda x: x[1].duration_s, reverse=True)
if all_calls:
print(
f"\n Top 10 slowest individual docker calls:\n",
file=sys.stderr,
)
for i, (test_name, c) in enumerate(all_calls[:10], 1):
argv_short = " ".join(c.argv[:6])
if len(c.argv) > 6:
argv_short += " ..."
print(
f" {i:>2}. {c.duration_s:>6.1f}s {argv_short}",
file=sys.stderr,
)
print(f" test: {test_name}", file=sys.stderr)
print("\n" + "=" * 72, file=sys.stderr)
@ -330,7 +394,7 @@ def pytest_runtest_call(item):
def pytest_sessionstart(session):
"""Install the subprocess.run patch at session start."""
"""Install the subprocess.run + time.sleep patches at session start."""
if not _ACTIVE:
return
collector = _get_collector()
@ -344,9 +408,6 @@ def pytest_sessionfinish(session, exitstatus):
collector = _get_collector()
collector.uninstall_patch()
# Default to a per-PID filename so parallel subprocesses (one per
# test file, spawned by run_tests_parallel.py) don't clobber each
# other. The CI step merges them into a single report.
default_out = str(Path.cwd() / f"docker-test-profile-{os.getpid()}.json")
out = os.environ.get("HERMES_DOCKER_PROFILE_OUT", default_out)
out_path = Path(out)