This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 54364277 fix(benchmark): gate on recent other-process load and report 
only finished cases (#967)
54364277 is described below

commit 543642771a1440f8470053831463052e190b3923
Author: Y Ethan Guo <[email protected]>
AuthorDate: Tue Nov 3 00:46:44 2026 -0800

    fix(benchmark): gate on recent other-process load and report only finished 
cases (#967)
---
 benchmark/upsert/README.md               |  19 ++--
 benchmark/upsert/bench.py                | 165 ++++++++++++++++++++++++++++---
 benchmark/upsert/summarize.py            |  52 +++++++---
 benchmark/upsert/tests/test_measure.py   | 127 +++++++++++++++++++++++-
 benchmark/upsert/tests/test_summarize.py |  95 ++++++++++++++++++
 5 files changed, 418 insertions(+), 40 deletions(-)

diff --git a/benchmark/upsert/README.md b/benchmark/upsert/README.md
index b9125fbd..88ea1471 100644
--- a/benchmark/upsert/README.md
+++ b/benchmark/upsert/README.md
@@ -202,12 +202,19 @@ counts must match across repetitions and byte counts 
within 1 percent,
 otherwise `summary.writes_consistent` is false.
 
 **Headroom gate.** Before each case and each timed call, the harness refuses to
-go on if the 1-minute load average is above `--max-load1` (2.0), available
-memory is below `--min-mem-gib` (16), or free disk is below 3x the base data.
-A high load average alone is first given `--settle-timeout` seconds (180) to
-come down. `--force` turns load and memory refusals into warnings and marks
-the results, which `summarize.py` then renders only with `--allow-loaded`; a
-disk shortfall always refuses.
+go on if other processes have kept more than `--max-load` cores (2.0) busy over
+the last 10 seconds, available memory is below `--min-mem-gib` (16), or free
+disk is below 3x the base data. The load leaves out the CPU time of the
+benchmark and its phase subprocesses, so one commit's work never holds up the
+next; the 1-minute load average is recorded alongside for reference. Each
+phase subprocess's first check waits until it has 10 seconds of samples (it
+starts sampling when it starts, so most of that overlaps reading its inputs).
+Memory is read while the phase process holds its data, so `--min-mem-gib` is
+headroom beyond the benchmark's own footprint. High load alone is first given
+`--settle-timeout` seconds (180) to come down. `--force` turns load and memory
+refusals into warnings, judged over a 1-second window, and marks the results,
+which `summarize.py` then renders only with `--allow-loaded`; a disk shortfall
+always refuses.
 
 **Reclaim.** After each commit, outside the timed region, the harness deletes
 data files the latest file slices no longer reference, so a copy-on-write run
diff --git a/benchmark/upsert/bench.py b/benchmark/upsert/bench.py
index 1d945c9a..91ad3c0b 100755
--- a/benchmark/upsert/bench.py
+++ b/benchmark/upsert/bench.py
@@ -44,6 +44,7 @@ import statistics
 import subprocess
 import sys
 import tempfile
+import threading
 import time
 import traceback
 import types
@@ -213,7 +214,7 @@ class HudiCase(Case):
             "target_file_size": "library default",
             "upsert": "HudiTable.upsert(arrow_table)",
             "delete": "HudiTable.delete_keys(keys)",
-            "read": "HudiTable.read()",
+            "read": "HudiTable.read(); every read projects the data columns 
(get_schema)",
             "incremental_read": (
                 "HudiTable.read(HudiReadOptions().with_query_type(Incremental)"
                 ".with_start_timestamp(<previous completion time>)"
@@ -253,6 +254,9 @@ class HudiCase(Case):
     def open(self, root, plan):
         self.plan = plan
         self.t = self._hudi().HudiTable(str(root))
+        # Reads project the data columns, as the other cases return them; the
+        # five _hoodie_* meta columns would otherwise be decoded on every read.
+        self.columns = self.t.get_schema().names
         self.last = None
 
     @staticmethod
@@ -286,6 +290,7 @@ class HudiCase(Case):
         return {"num_deleted": r.num_deletes, "instant": r.requested_time}
 
     def _read(self, opts=None):
+        opts = (opts or 
self._hudi().HudiReadOptions()).with_projection(self.columns)
         batches = self.t.read(opts)
         if not batches:
             return None
@@ -401,16 +406,144 @@ def reclaim(case, root, after):
     return {"reclaimed_files": len(stale), "reclaimed_bytes": freed}
 
 
+# Window over which the gate averages the CPU other processes use.
+LOAD_WINDOW_S = 10.0
+
+
+class OtherLoad:
+    """Cores kept busy by other processes over the last LOAD_WINDOW_S seconds.
+
+    The one-minute load average lags and counts the benchmark's own work, so a
+    gate on it waits for each commit's own load to decay. This samples system
+    busy CPU time once a second and subtracts the CPU time of this process and
+    its descendants: live ones through psutil, reaped ones through
+    getrusage(RUSAGE_CHILDREN). psutil's children_* fields cannot stand in for
+    the latter; they read 0 on macOS even after the child is reaped."""
+
+    def __init__(self):
+        self.samples = []
+        self.lock = threading.Lock()
+        threading.Thread(target=self._run, daemon=True).start()
+
+    @staticmethod
+    def sample():
+        """(time, system busy s, own CPU s), or None when no consistent reading
+        of this process tree's CPU could be taken."""
+        own = own_cpu_s()
+        return None if own is None else (time.monotonic(), system_busy_s(), 
own)
+
+    def _run(self):
+        while True:
+            self._record()
+            time.sleep(1.0)
+
+    def _record(self):
+        # A sampler that died would leave its last sample as the start of every
+        # later window, averaging recent load away, so a failed sample is 
skipped.
+        try:
+            s = self.sample()
+        except Exception:
+            return
+        if s is None:
+            return
+        with self.lock:
+            self.samples.append(s)
+            self.samples = [x for x in self.samples if s[0] - x[0] <= 2 * 
LOAD_WINDOW_S]
+
+    def read(self, window):
+        """(busy cores, seconds covered) over the latest ``window`` seconds;
+        blocks until that much history exists, which only a process's first
+        reading waits for."""
+        deadline = time.monotonic() + window + 60
+        while True:
+            now = self.sample()
+            if now is not None:
+                with self.lock:
+                    old = [x for x in self.samples if now[0] - x[0] >= window]
+                if old:
+                    return busy_cores(old[-1], now), now[0] - old[-1][0]
+            elif time.monotonic() > deadline:
+                raise RuntimeError(
+                    "could not read the benchmark's own CPU time consistently; 
"
+                    "an exited descendant is not being reaped"
+                )
+            time.sleep(0.5)
+
+
+def system_busy_s():
+    """Busy CPU seconds across all cores, computed as psutil's cpu_percent 
does:
+    iowait is idle time, and guest time is already inside user time."""
+    t = psutil.cpu_times()
+    busy = sum(t) - t.idle
+    for field in ("iowait", "guest", "guest_nice"):
+        busy -= getattr(t, field, 0.0)
+    return busy
+
+
+def reaped_cpu_s():
+    r = resource.getrusage(resource.RUSAGE_CHILDREN)
+    return r.ru_utime + r.ru_stime
+
+
+def own_cpu_s(attempts=20):
+    """CPU seconds used by this process and every descendant, live or reaped, 
or
+    None when no consistent reading was taken. A descendant moves from the live
+    sum to the reaped one when it is waited for. One that exits but is not yet
+    reaped, or is reaped while the tree is walked, is in neither sum and would
+    hide a whole phase's CPU, so such a walk is taken again."""
+    for _ in range(attempts):
+        reaped = reaped_cpu_s()
+        live, consistent = 0.0, True
+        for child in psutil.Process().children(recursive=True):
+            try:
+                c = child.cpu_times()
+            except psutil.NoSuchProcess:  # also a zombie, on macOS
+                consistent = False
+                break
+            except psutil.AccessDenied:
+                continue
+            live += c.user + c.system
+        if consistent and reaped_cpu_s() == reaped:
+            me = resource.getrusage(resource.RUSAGE_SELF)
+            return me.ru_utime + me.ru_stime + reaped + live
+        time.sleep(0.01)
+    return None
+
+
+def busy_cores(start, end):
+    """Cores other processes kept busy between two OtherLoad samples."""
+    (t0, busy0, own0), (t1, busy1, own1) = start, end
+    return max(0.0, ((busy1 - busy0) - (own1 - own0)) / (t1 - t0))
+
+
+_other_load = None
+
+
+def other_load(window=LOAD_WINDOW_S):
+    global _other_load
+    if _other_load is None:
+        _other_load = OtherLoad()
+    return _other_load.read(window)
+
+
 def headroom(work_dir, base_bytes, limits):
+    # Under --force a load reading only warns, so it need not wait for a full
+    # window of history.
+    load, window = other_load(1.0 if limits.force else LOAD_WINDOW_S)
     avail = psutil.virtual_memory().available
     r = {
+        "other_load": load,
+        "other_load_window_s": round(window, 1),
         "load1": os.getloadavg()[0],
         "mem_available_bytes": avail,
         "disk_free_bytes": shutil.disk_usage(work_dir).free,
     }
     v = {}
-    if r["load1"] > limits.max_load1:
-        v["load1"] = f"load1 {r['load1']:.2f} > {limits.max_load1}"
+    if r["other_load"] > limits.max_load:
+        v["load"] = (
+            f"other processes kept {r['other_load']:.2f} cores busy over the 
last "
+            f"{r['other_load_window_s']:.0f}s > {limits.max_load}"
+        )
     if avail < limits.min_mem_gib * 2**30:
         v["memory"] = (
             f"available memory {avail / 2**30:.1f} GiB < {limits.min_mem_gib} 
GiB"
@@ -427,10 +560,10 @@ def headroom(work_dir, base_bytes, limits):
 def gate(work_dir, base_bytes, limits, what):
     """Wait for the machine to be idle, then return the readings.
 
-    load1 is a one-minute moving average that the benchmark itself raises, so
-    when it is the only violation the gate polls for up to --settle-timeout,
-    letting the previous commit's own load decay instead of counting it against
-    the next; under --force, where a load violation only warns, it does not
+    Load is the CPU other processes used over the last LOAD_WINDOW_S seconds,
+    so the benchmark's own work never counts against it. When load is the only
+    violation the gate polls for up to --settle-timeout for the other processes
+    to quiet down; under --force, where a load violation only warns, it does 
not
     wait. Memory and disk shortfalls do not settle, so they are judged at
     once. A disk shortfall refuses even under --force: a copy-on-write commit
     can rewrite the whole table, and running out of disk mid-commit hurts more
@@ -440,7 +573,7 @@ def gate(work_dir, base_bytes, limits, what):
     while True:
         r = headroom(work_dir, base_bytes, limits)
         v = r["violations"]
-        settling = list(v) == ["load1"] and not limits.force
+        settling = list(v) == ["load"] and not limits.force
         if not settling or time.monotonic() - start >= limits.settle_timeout:
             break
         time.sleep(2)
@@ -814,6 +947,8 @@ def compare_tables(table, exp, key_cols):
 
 
 def phase_main(argv):
+    # Starts sampling now, so the gate's first window fills while inputs load.
+    other_load(0.0)
     p = argparse.ArgumentParser(prog="bench.py _phase")
     p.add_argument("--case", required=True)
     p.add_argument("--phase", required=True, choices=PHASES)
@@ -1094,6 +1229,7 @@ def summarize_case(case):
     if readings:
         s["headroom"] = {
             "readings": len(readings),
+            "max_other_load": max(r["other_load"] for r in readings),
             "max_load1": max(r["load1"] for r in readings),
             "min_mem_available_bytes": min(r["mem_available_bytes"] for r in 
readings),
             "min_disk_free_bytes": min(r["disk_free_bytes"] for r in readings),
@@ -1183,7 +1319,8 @@ def run_rep(name, case, r, a, plan, bd, wd, root, 
log_dir):
         return rep
     h = rep["headroom_before"]
     print(
-        f"{tag} headroom: load1 {h['load1']:.2f}, available memory "
+        f"{tag} headroom: other load {h['other_load']:.2f} (load1 
{h['load1']:.2f}), "
+        f"available memory "
         f"{h['mem_available_bytes'] / 2**30:.1f} GiB, "
         f"free disk {h['disk_free_bytes'] / 2**30:.1f} GiB",
         flush=True,
@@ -1207,7 +1344,7 @@ def run_rep(name, case, r, a, plan, bd, wd, root, 
log_dir):
             "--base-dir", str(bd),
             "--workload-dir", str(wd),
             "--out", str(out),
-            "--max-load1", str(a.max_load1),
+            "--max-load", str(a.max_load),
             "--min-mem-gib", str(a.min_mem_gib),
             "--settle-timeout", str(a.settle_timeout),
             "--base-bytes", str(a.base_bytes),
@@ -1284,10 +1421,11 @@ def run_rep(name, case, r, a, plan, bd, wd, root, 
log_dir):
 
 def add_headroom_args(p):
     p.add_argument(
-        "--max-load1",
+        "--max-load",
         type=float,
         default=2.0,
-        help="refuse to time anything while the 1-minute load average is above 
this",
+        help="refuse to time anything while other processes have kept more 
than this "
+        f"many cores busy over the last {LOAD_WINDOW_S:.0f} seconds",
     )
     p.add_argument(
         "--min-mem-gib",
@@ -1476,7 +1614,8 @@ def run(a, sc, params, cases, work, slug, out):
             "reclaim": a.reclaim,
             "extra_cases": [Path(x).name for x in a.extra_cases],
             "headroom_limits": {
-                "max_load1": a.max_load1,
+                "max_other_load": a.max_load,
+                "other_load_window_s": 1.0 if a.force else LOAD_WINDOW_S,
                 "min_mem_available_gib": a.min_mem_gib,
                 "min_disk_free": f"{DISK_FACTOR} x base dataset 
({a.base_bytes} bytes)",
                 "settle_timeout_s": a.settle_timeout,
diff --git a/benchmark/upsert/summarize.py b/benchmark/upsert/summarize.py
index 5ed7cda7..3ec0fc10 100755
--- a/benchmark/upsert/summarize.py
+++ b/benchmark/upsert/summarize.py
@@ -124,11 +124,13 @@ def violated(case):
 
 def case_row(name, case, scenario, scale, stat):
     s = case.get("summary", {})
-    st = s.get("stats", {})
+    ok = case.get("status") == "ok"
+    # A refused, failed or timed-out case keeps the stats of the repetitions it
+    # finished; they are not a result.
+    st = s.get("stats", {}) if ok else {}
     by_phase = s.get("peak_rss_bytes_by_phase", {})
     write_rss = [by_phase[p] for p in ("upsert", "delete") if p in by_phase]
-    files = s.get("upsert_data_files_created_per_commit")
-    ok = case.get("status") == "ok"
+    files = s.get("upsert_data_files_created_per_commit") if ok else None
     inc = timing(st.get("incremental_mean_s"), stat)
     if inc != "-" and "incremental_rows_mean" in s:
         inc += f" ({s['incremental_rows_mean']:,.0f})"
@@ -145,8 +147,8 @@ def case_row(name, case, scenario, scale, stat):
         timing(st.get("readback_s"), stat),
         inc,
         timing(st.get("time_travel_mean_s"), stat),
-        _mib(s.get("upsert_bytes_per_commit")),
-        _mib(s.get("delete_bytes_per_commit")),
+        _mib(s.get("upsert_bytes_per_commit")) if ok else "-",
+        _mib(s.get("delete_bytes_per_commit")) if ok else "-",
         "-" if files is None else f"{files:g}",
         _mib(by_phase.get("load")) if ok else "-",
         _mib(max(write_rss)) if write_rss and ok else "-",
@@ -157,11 +159,15 @@ def case_row(name, case, scenario, scale, stat):
 
 def case_notes(scenario, name, case):
     s = case.get("summary", {})
+    ok = case.get("status") == "ok"
     out = []
     noisy = [
         f"{label} {st['cv']:.0%}"
         for key, label in TIMING_COLUMNS.items()
-        if (st := s.get("stats", {}).get(key)) and st.get("cv") and st["cv"] > 
CV_NOTE
+        if ok
+        and (st := s.get("stats", {}).get(key))
+        and st.get("cv")
+        and st["cv"] > CV_NOTE
     ]
     if noisy:
         out.append(
@@ -178,6 +184,27 @@ def case_notes(scenario, name, case):
     return out
 
 
+def headroom_note(scenario, name, case):
+    """The machine readings behind a case's timings, or None if it has none.
+    Results written before the gate measured other-process load have only 
load1."""
+    h = case.get("summary", {}).get("headroom")
+    if not h:
+        return None
+    mem = h.get("min_mem_available_bytes")
+    load = (
+        f"max other-process load {h['max_other_load']:.2f} cores, "
+        if "max_other_load" in h
+        else ""
+    )
+    return (
+        f"{scenario} {name} headroom over {h['readings']} readings: {load}"
+        f"max load1 {h['max_load1']:.2f}, min available memory "
+        f"{'-' if mem is None else f'{mem / 1024**3:.1f} GiB'}, min free disk "
+        f"{h['min_disk_free_bytes'] / 1024**3:.1f} GiB"
+        + (" (HEADROOM VIOLATED)" if violated(case) else "")
+    )
+
+
 def render(rows, markdown):
     if markdown:
         out = [
@@ -257,16 +284,9 @@ def main():
         for name, case in res.get("cases", {}).items():
             rows.append(case_row(name, case, scenario, scale, a.stat))
             notes += case_notes(scenario, name, case)
-            h = case.get("summary", {}).get("headroom")
-            if h:
-                mem = h.get("min_mem_available_bytes")
-                notes.append(
-                    f"{scenario} {name} headroom over {h['readings']} 
readings: "
-                    f"max load1 {h['max_load1']:.2f}, min available memory "
-                    f"{'-' if mem is None else f'{mem / 1024**3:.1f} GiB'}, 
min free disk "
-                    f"{h['min_disk_free_bytes'] / 1024**3:.1f} GiB"
-                    + (" (HEADROOM VIOLATED)" if violated(case) else "")
-                )
+            note = headroom_note(scenario, name, case)
+            if note:
+                notes.append(note)
             if case.get("error"):
                 errors.append(f"{scenario} {name} ({case['status']}): 
{case['error']}")
 
diff --git a/benchmark/upsert/tests/test_measure.py 
b/benchmark/upsert/tests/test_measure.py
index 76559532..6214bc1b 100644
--- a/benchmark/upsert/tests/test_measure.py
+++ b/benchmark/upsert/tests/test_measure.py
@@ -20,7 +20,11 @@ run lock: the parts that decide what a number means or 
whether it is taken."""
 
 import argparse
 import os
+import subprocess
+import sys
+import threading
 import time
+import types
 from pathlib import Path
 
 import bench
@@ -93,7 +97,7 @@ def 
test_reclaim_refuses_when_the_live_listing_is_empty(tmp_path):
 
 def limits(**kw):
     base = {
-        "max_load1": 2.0,
+        "max_load": 2.0,
         "min_mem_gib": 16.0,
         "settle_timeout": 30.0,
         "force": False,
@@ -107,6 +111,7 @@ def fake_headroom(monkeypatch, *violation_sets):
     def headroom(work_dir, base_bytes, lim):
         v = seq.pop(0) if len(seq) > 1 else seq[0]
         return {
+            "other_load": 9.0,
             "load1": 9.0,
             "mem_available_bytes": 1,
             "disk_free_bytes": 1,
@@ -118,7 +123,7 @@ def fake_headroom(monkeypatch, *violation_sets):
 
 
 def test_gate_does_not_wait_on_memory_shortfall(monkeypatch):
-    fake_headroom(monkeypatch, {"memory": "low", "load1": "busy"})
+    fake_headroom(monkeypatch, {"memory": "low", "load": "busy"})
     start = time.monotonic()
     with pytest.raises(bench.HeadroomRefused, match="low"):
         bench.gate(".", 1, limits(), "x")
@@ -126,14 +131,14 @@ def 
test_gate_does_not_wait_on_memory_shortfall(monkeypatch):
 
 
 def test_gate_waits_for_load_to_settle(monkeypatch):
-    fake_headroom(monkeypatch, {"load1": "busy"}, {"load1": "busy"}, {})
+    fake_headroom(monkeypatch, {"load": "busy"}, {"load": "busy"}, {})
     r = bench.gate(".", 1, limits(), "x")
     assert not r["violations"]
     assert "headroom_violated" not in r
 
 
 def test_force_downgrades_load_but_not_disk(monkeypatch):
-    fake_headroom(monkeypatch, {"load1": "busy"})
+    fake_headroom(monkeypatch, {"load": "busy"})
     r = bench.gate(".", 1, limits(force=True, settle_timeout=0), "x")
     assert r["headroom_violated"]
     fake_headroom(monkeypatch, {"disk": "full"})
@@ -147,10 +152,11 @@ def 
test_forced_gate_does_not_wait_for_load_to_settle(monkeypatch):
     def headroom(work_dir, base_bytes, lim):
         calls.append(1)
         return {
+            "other_load": 9.0,
             "load1": 9.0,
             "mem_available_bytes": 1,
             "disk_free_bytes": 1,
-            "violations": {"load1": "busy"},
+            "violations": {"load": "busy"},
         }
 
     monkeypatch.setattr(bench, "headroom", headroom)
@@ -190,3 +196,114 @@ def test_run_lock_takes_over_a_stale_lock(tmp_path):
     lock.write_text(str(pid))
     with bench.RunLock(tmp_path, "s"):
         assert lock.read_text() == str(os.getpid())
+
+
+def test_busy_cores_excludes_the_benchmarks_own_cpu():
+    # 10 s apart: the system was busy for 50 core-seconds, 30 of them this 
process's.
+    assert bench.busy_cores((0.0, 100.0, 5.0), (10.0, 150.0, 35.0)) == 2.0
+    # Own CPU time is sampled separately from system time, so it can come out
+    # slightly ahead; the reading never goes negative.
+    assert bench.busy_cores((0.0, 100.0, 5.0), (10.0, 110.0, 16.0)) == 0.0
+
+
+def test_headroom_judges_other_process_load_not_load1(monkeypatch, tmp_path):
+    windows = []
+
+    def fake_other_load(window):
+        windows.append(window)
+        return load, window
+
+    monkeypatch.setattr(bench.os, "getloadavg", lambda: (50.0, 50.0, 50.0))
+    monkeypatch.setattr(bench, "other_load", fake_other_load)
+    load = 1.0
+    r = bench.headroom(tmp_path, 1, limits(min_mem_gib=0))
+    assert "load" not in r["violations"] and r["load1"] == 50.0
+    load = 3.0
+    assert "load" in bench.headroom(tmp_path, 1, 
limits(min_mem_gib=0))["violations"]
+    # A forced run only warns on load, so it does not wait for a full window.
+    bench.headroom(tmp_path, 1, limits(min_mem_gib=0, force=True))
+    assert windows == [bench.LOAD_WINDOW_S, bench.LOAD_WINDOW_S, 1.0]
+
+
+def test_other_load_reads_the_newest_sample_a_full_window_old(monkeypatch):
+    load = bench.OtherLoad.__new__(bench.OtherLoad)
+    load.lock = threading.Lock()
+    # One sample a second; the system ran 3 cores busy, 1 of them this process.
+    load.samples = [(float(t), 3.0 * t, 1.0 * t) for t in range(0, 11)]
+    monkeypatch.setattr(
+        bench.OtherLoad, "sample", staticmethod(lambda: (10.5, 31.5, 10.5))
+    )
+    assert load.read(10.0) == (2.0, 10.5)
+    assert load.read(1.0) == (2.0, 1.5)
+
+
+def test_own_cpu_counts_a_reaped_child():
+    before = bench.own_cpu_s()
+    subprocess.run(
+        [
+            sys.executable,
+            "-c",
+            "import time\nt=time.process_time()\nwhile 
time.process_time()-t<0.3: pass",
+        ],
+        check=True,
+    )
+    assert bench.own_cpu_s() - before >= 0.25
+
+
+class _Child:
+    def __init__(self, *outcomes):
+        self.outcomes = list(outcomes)
+
+    def cpu_times(self):
+        o = self.outcomes.pop(0)
+        if isinstance(o, Exception):
+            raise o
+        return types.SimpleNamespace(user=o, system=0.0)
+
+
+def fake_tree(monkeypatch, child, reaped):
+    monkeypatch.setattr(
+        bench.psutil,
+        "Process",
+        lambda: types.SimpleNamespace(children=lambda recursive: [child]),
+    )
+    monkeypatch.setattr(bench, "reaped_cpu_s", lambda: reaped.pop(0))
+    monkeypatch.setattr(bench.time, "sleep", lambda s: None)
+
+
+def test_own_cpu_walks_again_when_a_child_is_reaped_mid_walk(monkeypatch):
+    # The child's 5 s show up in neither sum on the first walk.
+    child = _Child(5.0, 7.0)
+    fake_tree(monkeypatch, child, [10.0, 15.0, 15.0, 15.0])
+    me = bench.resource.getrusage(bench.resource.RUSAGE_SELF)
+    got = bench.own_cpu_s() - (me.ru_utime + me.ru_stime)
+    assert got == pytest.approx(22.0, abs=0.5)
+
+
+def test_own_cpu_walks_again_past_an_unreaped_child(monkeypatch):
+    child = _Child(bench.psutil.ZombieProcess(1), 7.0)
+    fake_tree(monkeypatch, child, [10.0, 15.0, 15.0])
+    me = bench.resource.getrusage(bench.resource.RUSAGE_SELF)
+    assert bench.own_cpu_s() - (me.ru_utime + me.ru_stime) == pytest.approx(
+        22.0, abs=0.5
+    )
+
+
+def test_own_cpu_gives_up_rather_than_undercount(monkeypatch):
+    child = _Child(*[bench.psutil.ZombieProcess(1)] * 3)
+    fake_tree(monkeypatch, child, [10.0] * 6)
+    assert bench.own_cpu_s(attempts=3) is None
+
+
+def test_sampler_skips_a_failed_sample(monkeypatch):
+    load = bench.OtherLoad.__new__(bench.OtherLoad)
+    load.lock = threading.Lock()
+    load.samples = [(1.0, 1.0, 1.0)]
+
+    def boom():
+        raise OSError("transient")
+
+    for sample in (boom, lambda: None):
+        monkeypatch.setattr(bench.OtherLoad, "sample", staticmethod(sample))
+        load._record()
+    assert load.samples == [(1.0, 1.0, 1.0)]
diff --git a/benchmark/upsert/tests/test_summarize.py 
b/benchmark/upsert/tests/test_summarize.py
new file mode 100644
index 00000000..a09d9586
--- /dev/null
+++ b/benchmark/upsert/tests/test_summarize.py
@@ -0,0 +1,95 @@
+# 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.
+
+"""How summarize.py renders a case row."""
+
+import pytest
+import summarize
+
+
+def case(status, repetitions):
+    stat = {"n": repetitions, "median": 0.5, "min": 0.4, "max": 0.6, "cv": 0.1}
+    return {
+        "status": status,
+        "repeat": 3,
+        "repetitions": [{"phases": {"readback": {}}}] * repetitions,
+        "summary": {
+            "repetitions": repetitions,
+            "stats": {
+                k: stat
+                for k in ("load_s", "upsert_mean_s", "delete_mean_s", 
"readback_s")
+            },
+            "upsert_bytes_per_commit": 8 * summarize.MIB,
+            "delete_bytes_per_commit": 4 * summarize.MIB,
+            "upsert_data_files_created_per_commit": 2.0,
+            "correct": status == "ok",
+        },
+    }
+
+
+def test_a_case_that_finished_shows_its_timings_and_bytes():
+    row = summarize.case_row("hudi-mor", case("ok", 3), "uuid", "1,000 rows", 
"median")
+    assert row[3:14] == [
+        "ok",
+        "3/3",
+        "0.50 s (0.40 to 0.60)",
+        "0.50 s (0.40 to 0.60)",
+        "0.50 s (0.40 to 0.60)",
+        "0.50 s (0.40 to 0.60)",
+        "-",
+        "-",
+        "8",
+        "4",
+        "2",
+    ]
+
+
[email protected]("status", ["refused", "failed", "timeout"])
+def test_a_case_that_did_not_finish_shows_no_timings_or_bytes(status):
+    row = summarize.case_row(
+        "hudi-mor", case(status, 2), "uuid", "1,000 rows", "median"
+    )
+    assert row[3:4] == [status]
+    assert row[5:14] == ["-"] * 9
+
+
[email protected]("status, noted", [("ok", True), ("failed", False)])
+def test_only_a_finished_case_gets_a_variation_note(status, noted):
+    c = case(status, 2)
+    c["summary"]["stats"]["upsert_mean_s"]["cv"] = 0.5
+    notes = summarize.case_notes("uuid", "hudi-mor", c)
+    assert any("upsert" in n for n in notes) == noted
+
+
[email protected](
+    "extra, shown",
+    [({"max_other_load": 1.25}, "max other-process load 1.25 cores, "), ({}, 
"")],
+)
+def test_headroom_note_shows_other_load_when_recorded(extra, shown):
+    c = case("ok", 3)
+    c["summary"]["headroom"] = {
+        "readings": 4,
+        "max_load1": 3.5,
+        "min_mem_available_bytes": 20 * 1024**3,
+        "min_disk_free_bytes": 100 * 1024**3,
+        **extra,
+    }
+    assert summarize.headroom_note("uuid", "hudi-mor", c) == (
+        f"uuid hudi-mor headroom over 4 readings: {shown}max load1 3.50, "
+        "min available memory 20.0 GiB, min free disk 100.0 GiB"
+    )
+    assert summarize.headroom_note("uuid", "hudi-mor", case("ok", 3)) is None

Reply via email to