diff --git a/src/qlever/commands/index.py b/src/qlever/commands/index.py index 522f5263..ca228b3c 100644 --- a/src/qlever/commands/index.py +++ b/src/qlever/commands/index.py @@ -4,6 +4,7 @@ import json import re import shlex +from importlib import import_module from pathlib import Path from qlever.command import QleverCommand @@ -17,23 +18,23 @@ run_command, ) +USAGE_PLOT_MODULE = "qlever.resource_usage.usage_plot" + def render_usage_plot( - dataset: str, - stxxl_memory: str, - settings_json: str, - plot_max_points: int, - plot_only: bool, - main_command_name: str, - engine_display_name: str, + args, plot_only: bool, engine_module: str ) -> Path | None: - """Render the resource-usage plot. + """ + Render the resource-usage plot, taking the engine-specific parts from + the `overlay` and `subtitle` of `engine_module`. - When the plotting libraries are missing, this is an error if the - user asked for the plot directly via `plot_only`, otherwise it notes - how to get the plot at info level since the index build succeeded. + The plotting libraries are an optional dependency, so the import has + to happen here and not at module level. Missing them is an error when + the user asked for the plot directly via `plot_only`, and only a hint + otherwise, because then the index build itself succeeded. """ try: + engine_plot = import_module(engine_module) from qlever.resource_usage import usage_plot except ImportError: if plot_only: @@ -45,15 +46,13 @@ def render_usage_plot( log.info( "To plot the resource-usage log, install matplotlib and " "numpy (`pip install qlever[plot]`), then run " - f"`{main_command_name} index --resource-usage-plot-only`." + f"`{args.main_command_name} index --resource-usage-plot-only`." ) return None return usage_plot.render_usage_plot( - dataset, - stxxl_memory=stxxl_memory, - settings_json=settings_json, - plot_max_points=plot_max_points, - engine_display_name=engine_display_name, + args, + engine_overlay=engine_plot.overlay, + engine_subtitle=engine_plot.subtitle, ) @@ -237,13 +236,7 @@ def execute(self, args) -> bool: # rebuilding the index. if args.resource_usage_plot_only: plot_path = render_usage_plot( - args.name, - stxxl_memory=args.stxxl_memory or "", - settings_json=args.settings_json, - plot_max_points=args.resource_usage_plot_max_points, - plot_only=True, - main_command_name=args.main_command_name, - engine_display_name=args.engine_display_name, + args, plot_only=True, engine_module=USAGE_PLOT_MODULE ) if plot_path is None: return False @@ -416,13 +409,7 @@ def execute(self, args) -> bool: or Path(f"{args.name}.resource-usage-log.tsv").exists() ): plot_path = render_usage_plot( - args.name, - stxxl_memory=args.stxxl_memory or "", - settings_json=args.settings_json, - plot_max_points=args.resource_usage_plot_max_points, - plot_only=False, - main_command_name=args.main_command_name, - engine_display_name=args.engine_display_name, + args, plot_only=False, engine_module=USAGE_PLOT_MODULE ) if plot_path is not None: log.info(f"Resource-usage plot saved to `{plot_path.name}`") diff --git a/src/qlever/resource_usage/resource_monitor.py b/src/qlever/resource_usage/resource_monitor.py new file mode 100644 index 00000000..21d352e2 --- /dev/null +++ b/src/qlever/resource_usage/resource_monitor.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, fields +from pathlib import Path + +import psutil + +from qlever.containerize import Containerize +from qlever.log import log +from qlever.util import ( + container_memory_to_bytes, + find_process_by_binary, + resource_usage_prefix, + run_command, +) + + +@dataclass +class Sample: + """One sample of elapsed time, memory (RSS), and CPU usage; None + fields are written as empty TSV columns.""" + + elapsed_s: float | None = None + rss: int | None = None + cpu_percent: float | None = None + + +def sample_to_tsv_row(sample: Sample) -> str: + """Format a Sample as a TSV row; None fields become empty columns.""" + values = [getattr(sample, field.name) for field in fields(sample)] + return "\t".join("" if v is None else str(v) for v in values) + "\n" + + +def sample_process(proc: psutil.Process) -> Sample: + """ + One RSS+CPU read from a psutil.Process; empty Sample on access errors. + """ + try: + mem = proc.memory_info() + cpu_pct = proc.cpu_percent(interval=None) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return Sample() + return Sample(rss=mem.rss, cpu_percent=cpu_pct) + + +def sample_container(system: str, container: str) -> Sample: + """ + One RSS+CPU read via ` stats --no-stream` on a named container. + """ + try: + output = run_command( + f"{system} stats --no-stream" + f" --format '{{{{.MemUsage}}}}\t{{{{.CPUPerc}}}}'" + f" {container}", + return_output=True, + ) + memory_field, cpu_field = output.strip().split("\t") + used_memory = memory_field.split("/")[0].strip() + cpu_percent = float(cpu_field.strip().rstrip("%")) + return Sample( + rss=container_memory_to_bytes(used_memory), + cpu_percent=cpu_percent, + ) + except Exception: + return Sample() + + +def read_last_elapsed_s(log_path: Path) -> float | None: + """ + Read the `elapsed_s` of the last sample of an existing usage log, so + that a further run can continue from it; 0.0 if the log has a header + but no samples yet. None if the log does not start with a header row, + in which case it holds nothing worth keeping and can be overwritten. + """ + lines = log_path.read_text().splitlines() + if not lines or lines[0].split("\t")[0] != fields(Sample)[0].name: + return None + for line in reversed(lines[1:]): + elapsed_s = line.split("\t")[0] + if elapsed_s: + try: + return float(elapsed_s) + except ValueError: + continue + return 0.0 + + +class ResourceMonitor: + """ + Monitor resource usage (memory, CPU) of an index-building + process. Works in both native mode (via psutil) and container mode + (via docker/podman stats). + + Usage as a context manager: + + with ResourceMonitor(dataset="wikidata", engine="oxigraph", + binary="oxigraph"): + run_command(cmd, show_output=True) + + # For container mode: + with ResourceMonitor(dataset="wikidata", + engine="oxigraph", + binary="oxigraph", + container="oxigraph.index.wikidata", + system="docker"): + run_command(cmd, show_output=True) + """ + + def __init__( + self, + dataset: str, + engine: str, + binary: str, + container: str | None = None, + system: str | None = None, + interval: float = 1.0, + output_dir: Path | None = None, + parent_pid: int | None = None, + append: bool = False, + ): + """ + Args: + dataset: Name of the dataset being indexed. + engine: Engine key, which the log and plot names start with. + binary: Name of the index executable, matched against the + descendant processes (native mode only). + container: Container name to sample; when set with `system`, + sampling uses `docker/podman stats` not psutil. + system: Container runtime ("docker" or "podman"). + interval: Seconds between samples. + output_dir: Directory for the TSV usage log file. + parent_pid: PID whose descendants are searched for the index + process. Defaults to the current process; pass a + different PID when the target re-parents away from + us. + append: Add this run's samples to an existing usage log + instead of overwriting it, continuing `elapsed_s` + from its last row. For engines that build an index + in several runs. A run that raises is rolled back. + """ + self.dataset = dataset + self.engine = engine + self.binary = binary + self.container = container + self.system = system + self.interval = interval + self.output_dir = output_dir or Path.cwd() + self.parent_pid = parent_pid + self.append = append + self.peak_rss = 0 + self.worker_proc = None + self.log_file = None + self.stop_event = threading.Event() + self.start_time = 0 + # Set in `__enter__` when appending to an existing log: the + # `elapsed_s` to continue from, and the size the file had before + # this run, which `__exit__` truncates back to on failure. + self.elapsed_offset = 0.0 + self.append_offset = None + + @classmethod + def from_args(cls, args) -> ResourceMonitor: + """Monitor the index build configured by `args`.""" + return cls( + dataset=args.name, + engine=args.engine_short_name, + binary=args.index_binary, + container=args.index_container, + system=args.system, + interval=args.resource_usage_interval, + ) + + def take_sample(self) -> Sample: + """ + Dispatch to container or native sampling, caching the resolved + process. + """ + if self.system in Containerize.supported_systems(): + return sample_container(self.system, self.container) + if self.worker_proc is None or not self.worker_proc.is_running(): + self.worker_proc = find_process_by_binary( + self.parent_pid, self.binary + ) + if self.worker_proc is None: + return Sample() + # cpu_percent reports usage since the previous call, so this + # first call seeds the baseline and its 0.0 result is discarded. + try: + self.worker_proc.cpu_percent(interval=None) + except (psutil.NoSuchProcess, psutil.AccessDenied): + self.worker_proc = None + return Sample() + return sample_process(self.worker_proc) + + def run_loop(self): + """ + Polling loop on a background thread. Samples resource usage + and appends one TSV row per iteration until stop_event is set. + """ + while not self.stop_event.is_set(): + sample = self.take_sample() + sample.elapsed_s = round( + self.elapsed_offset + time.monotonic() - self.start_time, 1 + ) + if sample.rss is not None and self.log_file is not None: + self.peak_rss = max(self.peak_rss, sample.rss) + self.log_file.write(sample_to_tsv_row(sample)) + self.log_file.flush() + self.stop_event.wait(self.interval) + + def __enter__(self): + """ + Open the TSV log and start the sampling thread. Writes a header to + a fresh log; continues an existing one when `append` was set. + """ + prefix = resource_usage_prefix(self.engine, self.dataset) + self.log_path = ( + self.output_dir / f"{prefix}.index.resource-usage-log.tsv" + ) + previous_elapsed_s = ( + read_last_elapsed_s(self.log_path) + if self.append and self.log_path.exists() + else None + ) + if previous_elapsed_s is None: + self.log_file = open(self.log_path, "w") + header = "\t".join(f.name for f in fields(Sample)) + "\n" + self.log_file.write(header) + else: + self.elapsed_offset = previous_elapsed_s + self.append_offset = self.log_path.stat().st_size + self.log_file = open(self.log_path, "a") + self.log_file.flush() + self.start_time = time.monotonic() + self.thread = threading.Thread(target=self.run_loop, daemon=True) + self.thread.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Stop sampling and close the log, reporting where it was saved. + When appending, a run that raised is rolled back: it completed no + index build, so its samples belong to no run and would stretch + `elapsed_s` beyond what the index log accounts for. + """ + self.stop_event.set() + self.thread.join() + self.log_file.close() + if exc_type is not None and self.append_offset is not None: + with open(self.log_path, "r+") as log_file: + log_file.truncate(self.append_offset) + log.warning( + "Discarded the resource-usage samples of the failed run " + f"from `{self.log_path.name}`" + ) + return False + if self.peak_rss > 0: + log.info( + "Resource-usage log (RSS memory and CPU usage) saved to " + f"`{self.log_path.name}`" + ) + else: + log.warning( + "Resource usage was not recorded (no samples collected)." + ) + return False diff --git a/src/qlever/resource_usage/usage_plot.py b/src/qlever/resource_usage/usage_plot.py index f798fd5c..4f2d7c41 100644 --- a/src/qlever/resource_usage/usage_plot.py +++ b/src/qlever/resource_usage/usage_plot.py @@ -3,6 +3,7 @@ import csv import json import warnings +from collections.abc import Callable from datetime import datetime from pathlib import Path @@ -18,10 +19,14 @@ iter_permutation_phases, parse_git_hash, parse_phase_markers, + resource_usage_prefix, ) GB = 1024**3 +# One shaded region of the plot: name, start and end in seconds. +BandType = tuple[str, float, float] + def read_usage_tsv(path: Path) -> dict[str, np.ndarray]: """ @@ -159,6 +164,48 @@ def add(name: str, start: datetime | None, end: datetime | None) -> None: return phases +def bands_from_durations( + durations: dict[str, float], +) -> list[BandType]: + """ + Turn phase durations in seconds into `(label, start_s, end_s)` bands, + laying the phases back to back from the build start in the given + order. The `TOTAL time` entry is skipped. + """ + bands = [] + start_s = 0.0 + for label, duration_s in durations.items(): + if label == "TOTAL time": + continue + bands.append((label, start_s, start_s + duration_s)) + start_s += duration_s + return bands + + +# Separator between the fields of a subtitle, and the width at which the +# title starts to be clipped by a 12in figure's axes. +SUBTITLE_SEPARATOR = " | " +SUBTITLE_MAX_CHARS = 105 + + +def wrap_subtitle(text: str) -> str: + """Break a subtitle at its field separators into lines that fit the axes.""" + lines = [] + for line in text.split("\n"): + fields = line.split(SUBTITLE_SEPARATOR) + current = fields[0] + for field in fields[1:]: + if len(current) + len(SUBTITLE_SEPARATOR) + len(field) > ( + SUBTITLE_MAX_CHARS + ): + lines.append(current) + current = field + else: + current += SUBTITLE_SEPARATOR + field + lines.append(current) + return "\n".join(lines) + + def build_plot_subtitle( log_path: Path, stxxl_memory: str, settings_json: str ) -> str | None: @@ -183,24 +230,39 @@ def build_plot_subtitle( parts.append(f"git = {git_hash}") if stxxl_memory: parts.append(f"STXXL = {stxxl_memory}") - return " | ".join(parts) if parts else None + return SUBTITLE_SEPARATOR.join(parts) if parts else None + + +def overlay(args, log_path: Path) -> list[BandType]: + """Shade one band per phase of a QLever index build.""" + phases = compute_phase_boundaries(log_path) + return [ + (name, start_s, end_s) for name, (start_s, end_s) in phases.items() + ] + + +def subtitle(args, log_path: Path) -> str | None: + """Subtitle for a QLever index build.""" + return build_plot_subtitle( + log_path, args.stxxl_memory or "", args.settings_json + ) def write_usage_plot( tsv_path: Path, - log_path: Path, - stxxl_memory: str, - settings_json: str, out_path: Path, title: str, + bands: list[BandType], + subtitle_text: str | None, plot_max_points: int = 500, + sample_interval_s: float = 1.0, ) -> bool: """ - Read the usage TSV and index log, render a dual-axis plot of - memory and CPU over time with phase bands from the index log, - and save it to `out_path`. Returns True if a plot was saved, - False if the TSV has no usable samples. `plot_max_points` caps - the number of points drawn per series. + Read the usage TSV, render a dual-axis plot of memory and CPU over + time with the `bands` regions shaded, and save it to `out_path`. + Returns True if a plot was saved, False if the TSV has no usable + samples. `plot_max_points` caps the number of points drawn per + series. """ data = read_usage_tsv(tsv_path) if not data or len(data.get("elapsed_s", [])) == 0: @@ -214,8 +276,6 @@ def write_usage_plot( data = {name: values[valid[0] :] for name, values in data.items()} data["elapsed_s"] = data["elapsed_s"] - data["elapsed_s"][0] - phases = compute_phase_boundaries(log_path) - data = downsample_for_plot(data, plot_max_points) elapsed_s = data["elapsed_s"] @@ -232,10 +292,10 @@ def write_usage_plot( band_colors = plt.colormaps["Pastel1"].colors total_s = float(elapsed_s[-1]) if len(elapsed_s) else 0.0 - # skip drawing the phase name when the band is too narrow to fit it + # skip drawing the region name when the band is too narrow to fit it # legibly; arbitrary 2% of total duration. min_label_s = total_s * 0.02 - for band_idx, (name, (start_s, end_s)) in enumerate(phases.items()): + for band_idx, (name, start_s, end_s) in enumerate(bands): band_s = end_s - start_s if band_s <= 0: continue @@ -248,9 +308,13 @@ def write_usage_plot( ) if band_s < min_label_s: continue - mid = (start_s + end_s) / 2 / x_factor + mid = (start_s + end_s) / 2 + # Skip the name when the band's middle is past the last sample. + # Text that far outside the axes collapses the layout. + if mid > total_s: + continue ax_mem.text( - mid, + mid / x_factor, 0.98, name, transform=ax_mem.get_xaxis_transform(), @@ -298,47 +362,68 @@ def write_usage_plot( bbox_to_anchor=(1.08, 0.5), ) - subtitle = build_plot_subtitle(log_path, stxxl_memory, settings_json) - ax_mem.set_title(f"{title}\n{subtitle}" if subtitle else title) + # The bands describe the index log's timeline, the axis describes the + # samples. If the bands reach past the last sample, the two do not + # cover the same run and the shading sits on the wrong part of the + # curve. Allow for the sampling stopping a little early. + tolerance_s = 2 * sample_interval_s + 5 + bands_end_s = max((end_s for _, _, end_s in bands), default=0.0) + if bands_end_s > total_s + tolerance_s: + note = "(!) shading exceeds the sampled range" + log.warning( + f"The shaded regions cover {bands_end_s:.0f}s but only " + f"{total_s:.0f}s were sampled, so they may not line up with " + "the curves" + ) + # On its own line: the subtitle is already near the axes width. + subtitle_text = f"{subtitle_text}\n{note}" if subtitle_text else note + + ax_mem.set_title( + f"{title}\n{wrap_subtitle(subtitle_text)}" if subtitle_text else title + ) fig.savefig(out_path, dpi=120) plt.close(fig) return True def render_usage_plot( - dataset: str, - engine_display_name: str, - stxxl_memory: str = "", - settings_json: str = "{}", + args, + *, + engine_overlay: Callable[..., list[BandType]], + engine_subtitle: Callable[..., str | None], output_dir: Path | None = None, - plot_max_points: int = 500, ) -> Path | None: """ - Render `.resource-usage-plot.png` from - `.index.resource-usage-log.tsv` in `output_dir`, falling - back to `.resource-usage-log.tsv` as written by older - qlever versions. Returns the plot path on success, None if the log - is missing or the plot could not be rendered. + Render `.resource-usage-plot.png` from + `.index.resource-usage-log.tsv` in `output_dir`, where + `prefix` comes from `resource_usage_prefix`, falling back to + `.resource-usage-log.tsv` as written by older qlever + versions. `engine_overlay` and `engine_subtitle` are called with + `(args, log_path)` and provide the engine-specific parts of the plot. + Returns the plot path on success, None if the log is missing or the + plot could not be rendered. """ + dataset = args.name + prefix = resource_usage_prefix(args.engine_short_name, dataset) output_dir = output_dir or Path.cwd() - tsv_path = output_dir / f"{dataset}.index.resource-usage-log.tsv" + tsv_path = output_dir / f"{prefix}.index.resource-usage-log.tsv" # Backwards compatibility with older resource-usage log filename if not tsv_path.exists(): - tsv_path = output_dir / f"{dataset}.resource-usage-log.tsv" + tsv_path = output_dir / f"{prefix}.resource-usage-log.tsv" log_path = output_dir / f"{dataset}.index-log.txt" - plot_path = output_dir / f"{dataset}.resource-usage-plot.png" + plot_path = output_dir / f"{prefix}.resource-usage-plot.png" if not tsv_path.exists(): log.warning(f"Resource-usage log not found: `{tsv_path.name}`") return None try: rendered = write_usage_plot( tsv_path=tsv_path, - log_path=log_path, - stxxl_memory=stxxl_memory, - settings_json=settings_json, out_path=plot_path, - title=f"{engine_display_name} index build: {dataset}", - plot_max_points=plot_max_points, + title=f"{args.engine_display_name} index build: {dataset}", + bands=engine_overlay(args, log_path), + subtitle_text=engine_subtitle(args, log_path), + plot_max_points=args.resource_usage_plot_max_points, + sample_interval_s=args.resource_usage_interval, ) except Exception as error: log.warning(f"Could not render resource-usage plot: {error}") diff --git a/src/qlever/util.py b/src/qlever/util.py index d90e0fda..083c7bee 100644 --- a/src/qlever/util.py +++ b/src/qlever/util.py @@ -259,6 +259,17 @@ def get_existing_index_files( return [path.name for path in existing_index_files] +def resource_usage_prefix(engine: str, dataset: str) -> str: + """ + Name that the resource-usage log and plot start with, that is + `.`. The engine goes after the dataset, so that it + is clear which engine a log or plot belongs to. QLever's own binaries + write the log and do not know the engine name, so QLever gets plain + ``. + """ + return dataset if engine == "qlever" else f"{dataset}.{engine}" + + def show_process_info(psutil_process, cmdline_regex, show_heading=True): """ Helper function that shows information about a process if information diff --git a/test/qlever/resource_usage/test_resource_monitor.py b/test/qlever/resource_usage/test_resource_monitor.py new file mode 100644 index 00000000..ad94409f --- /dev/null +++ b/test/qlever/resource_usage/test_resource_monitor.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock + +import psutil +import pytest + +from qlever.resource_usage.resource_monitor import ( + Sample, + sample_container, + sample_process, + sample_to_tsv_row, +) + +MODULE = "qlever.resource_usage.resource_monitor" + + +@pytest.mark.parametrize( + "sample,expected", + [ + (Sample(elapsed_s=1.0, rss=100, cpu_percent=5.0), "1.0\t100\t5.0\n"), + (Sample(), "\t\t\n"), + (Sample(elapsed_s=2.0), "2.0\t\t\n"), + # Zero is a real reading, not a missing one: it renders as "0" + # / "0.0", never as an empty column. + (Sample(elapsed_s=0.0, rss=0, cpu_percent=0.0), "0.0\t0\t0.0\n"), + ], +) +def test_sample_to_tsv_row(sample, expected): + assert sample_to_tsv_row(sample) == expected + + +def test_sample_container_parses_stats_output(mock_command): + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "1.5GiB / 7.6GiB\t12.5%" + sample = sample_container("docker", "qlever.index.test") + assert sample.rss == int(1.5 * 1024**3) + assert sample.cpu_percent == 12.5 + + +def test_sample_container_returns_empty_on_malformed_output(mock_command): + run_cmd_mock = mock_command(MODULE, "run_command") + run_cmd_mock.return_value = "garbage" + sample = sample_container("docker", "qlever.index.test") + assert sample == Sample() + + +def test_sample_process_reads_rss_and_cpu(): + proc = MagicMock() + proc.memory_info.return_value.rss = 2048 + proc.cpu_percent.return_value = 7.5 + sample = sample_process(proc) + assert sample.rss == 2048 + assert sample.cpu_percent == 7.5 + + +def test_sample_process_returns_empty_when_process_gone(): + proc = MagicMock() + proc.memory_info.side_effect = psutil.NoSuchProcess(pid=123) + sample = sample_process(proc) + assert sample == Sample() diff --git a/test/qlever/resource_usage/test_usage_plot.py b/test/qlever/resource_usage/test_usage_plot.py index b581e897..cb8da75c 100644 --- a/test/qlever/resource_usage/test_usage_plot.py +++ b/test/qlever/resource_usage/test_usage_plot.py @@ -1,3 +1,6 @@ +import logging +from types import SimpleNamespace + import pytest # The plot extra (numpy, matplotlib) is optional, so skip this whole @@ -6,12 +9,18 @@ pytest.importorskip("matplotlib") from qlever.resource_usage.usage_plot import ( # noqa: E402 + SUBTITLE_SEPARATOR, + bands_from_durations, build_plot_subtitle, compute_phase_boundaries, downsample_for_plot, + overlay, pick_time_unit, read_usage_tsv, render_usage_plot, + subtitle, + wrap_subtitle, + write_usage_plot, ) @@ -209,8 +218,85 @@ def test_compute_phase_boundaries_skips_incomplete_phase(tmp_path): assert phases == {} +def test_bands_from_durations_lays_phases_back_to_back(): + bands = bands_from_durations( + {"Load": 10.0, "Optimize": 5.0, "TOTAL time": 15.0} + ) + assert bands == [("Load", 0.0, 10.0), ("Optimize", 10.0, 15.0)] + + +def test_wrap_subtitle_keeps_a_short_subtitle_on_one_line(): + subtitle = SUBTITLE_SEPARATOR.join(["batch = 10M triples", "git = abc123"]) + assert wrap_subtitle(subtitle) == subtitle + + +def test_wrap_subtitle_breaks_a_long_subtitle_at_the_separators(): + fields = [f"field {i} = {'x' * 20}" for i in range(5)] + lines = wrap_subtitle(SUBTITLE_SEPARATOR.join(fields)).split("\n") + assert len(lines) > 1 + assert all(len(line) <= 105 for line in lines) + # No field is split in the middle and none is lost. + assert SUBTITLE_SEPARATOR.join(lines).split(SUBTITLE_SEPARATOR) == fields + + +def write_samples(tmp_path, last_elapsed_s): + """Write a two-row usage TSV ending at `last_elapsed_s`.""" + tsv_path = tmp_path / "data.tsv" + tsv_path.write_text( + "elapsed_s\trss\tcpu_percent\n" + f"0\t100\t5.0\n{last_elapsed_s}\t200\t6.0\n" + ) + return tsv_path + + +def write_plot_with_bands(tmp_path, last_elapsed_s, bands): + """Render a plot from `bands` over samples ending at `last_elapsed_s`.""" + return write_usage_plot( + tsv_path=write_samples(tmp_path, last_elapsed_s), + out_path=tmp_path / "plot.png", + title="Test", + bands=bands, + subtitle_text=None, + ) + + +def test_write_usage_plot_warns_when_shading_exceeds_samples(tmp_path, caplog): + with caplog.at_level(logging.WARNING, logger="qlever"): + assert write_plot_with_bands(tmp_path, 10, [("Phase", 0.0, 300.0)]) + assert "300s" in caplog.text and "10s were sampled" in caplog.text + + +def test_write_usage_plot_quiet_when_shading_fits_samples(tmp_path, caplog): + with caplog.at_level(logging.WARNING, logger="qlever"): + assert write_plot_with_bands(tmp_path, 10, [("Phase", 0.0, 10.0)]) + assert caplog.text == "" + + +def plot_args(name): + """The `args` attributes that `render_usage_plot` reads.""" + return SimpleNamespace( + name=name, + engine_short_name="qlever", + engine_display_name="QLever", + resource_usage_plot_max_points=500, + resource_usage_interval=1, + stxxl_memory="", + settings_json="{}", + ) + + +def render(name, tmp_path): + """Render a QLever usage plot for `name` in `tmp_path`.""" + return render_usage_plot( + plot_args(name), + engine_overlay=overlay, + engine_subtitle=subtitle, + output_dir=tmp_path, + ) + + def test_render_usage_plot_missing_tsv(tmp_path): - assert render_usage_plot("missing", "QLever", output_dir=tmp_path) is None + assert render("missing", tmp_path) is None def test_render_usage_plot_header_only_tsv_renders_nothing(tmp_path): @@ -218,7 +304,7 @@ def test_render_usage_plot_header_only_tsv_renders_nothing(tmp_path): # or leave a PNG behind. tsv_path = tmp_path / "data.index.resource-usage-log.tsv" tsv_path.write_text("elapsed_s\trss\tcpu_percent\n") - assert render_usage_plot("data", "QLever", output_dir=tmp_path) is None + assert render("data", tmp_path) is None assert not (tmp_path / "data.resource-usage-plot.png").exists() @@ -228,6 +314,6 @@ def test_render_usage_plot_falls_back_to_old_tsv_name(tmp_path): tsv_path.write_text( "elapsed_s\trss\tcpu_percent\n1.0\t100\t5.0\n2.0\t200\t6.0\n" ) - plot_path = render_usage_plot("data", "QLever", output_dir=tmp_path) + plot_path = render("data", tmp_path) assert plot_path == tmp_path / "data.resource-usage-plot.png" assert plot_path.exists()