Skip to content

fix(monitor): measure descendant CPU; untrack config; retune disk thresholds - #16

Merged
rasmusjen merged 5 commits into
mainfrom
chore/untrack-config-and-tune-thresholds
Aug 25, 2026
Merged

fix(monitor): measure descendant CPU; untrack config; retune disk thresholds#16
rasmusjen merged 5 commits into
mainfrom
chore/untrack-config-and-tune-thresholds

Conversation

@rasmusjen

Copy link
Copy Markdown
Owner

Summary

Untracks config/config.yaml, retunes the bottleneck thresholds for SSD
storage, refuses pre-v2 metrics files instead of misreading them — and fixes a
second, still-live instance of the original "monitor reports 0.0" bug that
survived PR #15 and was found while verifying the above.

Problem

Three requested items, plus one found along the way.

  1. config/config.yaml was tracked despite .gitignore's config/*
    intent, so machine-specific absolute paths churned in every diff.

  2. Disk thresholds assumed a mechanical disk (disk_high_mb_per_s: 100,
    disk_high_iops: 1000). The data actually lives on a Samsung 870 EVO — a
    SATA SSD — so ordinary runs would be reported as disk-bound.

  3. Pre-v2 metrics files were misread, not rejected. They lack every
    canonical process-tree column, so analysis.py summarised absent data as
    zeros and reported, with full confidence:

    No clear bottleneck: CPU 0.0% (p95), disk 0.0 MB/s, peak memory 0 MB.
    There is headroom to increase max_processes.

    Confirmed against a real file on disk (GL-NuF/2024/metrics_fcc.csv, 2283
    rows). That is worse than a crash — it is a wrong answer that looks right.

  4. The monitor still reported 0.0% CPU for every child process. Found when
    tests/test_analysis.py::TestRealWorkloadMonitoring failed during
    verification; it fails on main too, so this is not a regression from the
    changes here.

Approach

The descendant-CPU defect (item 4)

psutil records the CPU-times baseline on the Process instance.
children(recursive=True) constructs fresh objects on every sample, so
each descendant only ever produced its first — by definition 0.0 — reading:

sample 0: fresh-object=[0.0]  cached-object=[0.0]
sample 1: fresh-object=[0.0]  cached-object=[84.8]
sample 2: fresh-object=[0.0]  cached-object=[84.4]
sample 3: fresh-object=[0.0]  cached-object=[79.3]

PR #15 fixed which processes were walked; it did not fix the fact that the
walk discarded the state the CPU reading depends on. Where the root process
happens to do the work itself the aggregate still looked right, which is why
CI stayed green.

It surfaces when the launched process is a stub that re-execs into a child —
here, a Windows venv python.exe shim. That is the same shape as
eddypro_rp.exe spawning workers
, which is the case this monitor exists to
measure.

Fixed by caching psutil.Process instances by PID in _iter_tracked(), with
eviction when a process exits (cumulative I/O is already retained separately in
_io_by_pid, so nothing is lost).

Measured on a real subprocess, before and after:

Metric Before After
cpu_percent_of_core 0.0 for every sample ~77–89%
read_mb_per_s / write_mb_per_s 0.0 ~182–197 MB/s
num_processes 2 (tracked, but idle-read) 2

Changes Made

  • monitor.py: _proc_cache: dict[int, psutil.Process]; _iter_tracked()
    reuses cached instances via setdefault and evicts exited PIDs from both the
    cache and _primed_pids.
  • analysis.py: CANONICAL_COLUMNS / LEGACY_COLUMNS; analyze_rows()
    returns UNKNOWN with a schema-mismatch explanation when no canonical column
    is present, distinguishing "written by a pre-v2 monitor" from "missing every
    process-tree column".
  • analysis.py: DEFAULT_THRESHOLDS disk limits raised to SATA SSD scale
    disk_high_mb_per_s 100 → 450, disk_moderate_mb_per_s 50 → 250,
    disk_high_iops 1000 → 20000 — with inline NVMe and mechanical figures.
  • config/config.yaml untracked via git rm --cached. The file stays on
    disk at the same path and the default --config value is unchanged, so no
    invocation changes; only config.yaml.example is version-controlled now.
  • Docs: CONFIG.md gains a per-medium threshold table (NVMe / SATA /
    mechanical) and the Get-PhysicalDisk one-liner to identify a drive; both
    CONFIG.md and README.md state that a fresh clone must copy the example
    config; CLAUDE.md records that the file is untracked and must not be
    re-added with git add -f.

Tests

199 passing, up from 196; coverage 75.9%.

  • TestLegacySchema — a real-shaped pre-v2 CSV must classify as UNKNOWN with
    its 10 rows still counted, and the current schema must not be mistaken for it.
  • test_cpu_is_measured_for_descendants_not_just_the_root — the launched
    process deliberately idles while a child does all the work, so the
    aggregate CPU can only be non-zero if descendant instances are cached. This
    is the gap that let the defect through CI: the existing real-workload test
    passes on any platform where the root does the work itself.
  • Existing threshold tests rescaled to the new SATA-scale defaults (a 200 MB/s
    run is no longer disk-bound; the NVMe override case now uses 600 MB/s against
    3000/1500 limits).

Manual verification: validate passes against both the default config path and
examples/multi_year_config.yaml; run --dry-run completes and writes its
manifest and report; config/config.yaml confirmed byte-identical to a
pre-change backup and absent from git ls-files.

Risks

  • The threshold change alters existing verdicts. A run previously reported
    as DISK_THROUGHPUT on SSD storage will now likely report NONE or CPU.
    This is the intended correction, but past HTML reports and the new ones will
    disagree. Override per-machine via performance_thresholds: if the data sits
    on a mechanical drive.
  • UNKNOWN will appear for historical runs. Any report regenerated from a
    pre-v2 metrics_*.csv now says it cannot classify. Correct, but it is a
    visible change from the previous (wrong) confident answer.
  • A fresh clone has no config/config.yaml. Anyone with an existing
    checkout is unaffected — the file is already on disk and untouched — but a new
    clone must cp config/config.yaml.example config/config.yaml before the
    default --config path resolves. Documented in README and CONFIG.md.
  • The process cache holds psutil.Process objects for the run's duration.
    Bounded by the number of live descendants and evicted on exit, so it does not
    grow with run length.

Rollback Plan

Each commit is independently revertable and scoped to one concern:

  • 2ce019b — config untracking. Reverting re-adds the file to the index; the
    working copy is unaffected either way.
  • 0233df7 — monitor and analysis fixes. Reverting restores the all-zero
    descendant CPU column, so prefer overriding thresholds in config over
    reverting this commit.
  • 2a70924 — docs and CHANGELOG only.

Threshold behaviour can be restored without touching code by setting the old
values under performance_thresholds: in config.

Checklist

  • Lint/format/typecheck clean (ruff 0.16.4, black 26.5.1, mypy, bandit)
  • Tests updated; coverage >=70% (75.9%)
  • Documentation updated (README, CHANGELOG, docs/CONFIG.md, CLAUDE.md)
  • Pre-commit hooks pass
  • CI pipeline green — pending

Notes

Two things worth raising separately from the diff:

  1. The local .venv had black 25.9.0 while pyproject.toml pins
    >=26.5.1,<27 — the same local/CI drift PR fix: repair performance monitoring, run manifest, and multi-year docs #15 bounded, just never synced
    locally. Resolved with pip install -e ".[dev]" (now black 26.5.1, ruff
    0.16.4). No repo change needed; noting it so the next stale-venv confusion
    is quick to diagnose.

  2. A --dry-run used for verification wrote run_manifest.json and
    run_report.html
    under D:\L1_processed\GL-Dsk\2025\ec_rflux_sc26\reports\.
    No raw or processed data was touched, but CLAUDE.md says not to write under
    D:/L1_processed at all; a scratch output dir should have been used.

Item 2 from the previous round — verification against a real EddyPro run —
is still outstanding, and matters more now: a genuine multi-process run is
exactly what would have exposed the descendant-CPU defect.

🤖 Generated with Claude Code

https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj

rasmusjen and others added 5 commits August 24, 2026 21:18
The file holds machine-specific absolute paths and was tracked despite
.gitignore's `config/*` intent, so every local path change churned in
the diff. Untracked with `git rm --cached`; the file stays on disk and
the default `--config` path is unchanged, so no invocation changes.

Only config/config.yaml.example is version-controlled now, which means
a fresh clone must copy it before first use. README and CLAUDE.md say
so explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
Three related corrections to the bottleneck verdict.

monitor: psutil records the CPU-times baseline on the Process *instance*,
and children(recursive=True) constructs fresh objects on every sample, so
each descendant only ever produced its first (always 0.0) reading. Process
instances are now cached by PID and evicted when the process exits. This
reproduces whenever the real work runs in a child rather than in the
launched process -- a Windows venv python.exe shim, or EddyPro spawning
workers -- and yielded exactly the all-zero CPU column the process-tree
fix was meant to eliminate. Verified against a real subprocess: 0.0% to
~85% of one core.

analysis: a metrics file with none of the canonical process-tree columns
is now classified UNKNOWN with an explanation, rather than summarising
absent data as "no clear bottleneck, CPU 0.0%". Every pre-v2 file on disk
hits this path.

analysis: default disk thresholds raised from mechanical-disk to SATA SSD
scale (450/250 MB/s, 20000 IOPS), since ordinary SSD runs were being
reported as disk-bound.

Adds a regression test in which the launched process idles and a child
does the work, so the descendant-CPU defect cannot return unnoticed on
platforms where the root happens to do the work itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
- CONFIG.md gains a suggested-limits table for NVMe, SATA SSD and
  mechanical drives, plus the PowerShell one-liner to find out which
  medium a drive letter is on. The defaults table is updated to the new
  SATA-scale numbers.
- CONFIG.md states that config/config.yaml is untracked and must be
  copied from the example on a fresh clone.
- config.yaml.example and examples/multi_year_config.yaml carry the
  concrete threshold blocks rather than a bare mention.
- CHANGELOG entries under [Unreleased] for the monitor fix, the pre-v2
  metrics handling, the threshold retune and the config untracking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
CI failed on all four Python versions after config/config.yaml was
untracked: seven tests invoked the CLI with no --config and relied on
the repo shipping that file. It passed locally only because the file is
still on disk here. Two separate defects behind it.

A real CLI bug: main() validated the config file before dispatching, so
`eddypro-batch` with no subcommand reported "Configuration file not
found" instead of printing usage. On a fresh clone that is the very
first command anyone runs. The no-command case now short-circuits to
print_help(), and the dispatch chain becomes a table since argparse
already restricts the remaining values.

A test-design flaw the untracking exposed: tests must not depend on a
machine-specific file. tests/test_cli.py and tests/test_cli_functions.py
now pass config/config.yaml.example explicitly via a shared
EXAMPLE_CONFIG constant.

Verified by moving config/config.yaml aside and running the full suite,
which is the state CI actually sees: 199 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
CI failed at `assert 19.8 > 20.0` and `assert 15.8 > 20.0`. Both figures
are real, non-zero readings -- the monitor fix works -- but a throttled
2-vCPU shared runner does not reach 20% of a core on this workload, so
the threshold was measuring the runner rather than the code.

The defect being guarded against produces exactly 0.0 on every sample,
so magnitude is the wrong discriminator. Assert instead that CPU
registered at all (> 1.0, a 15x margin below the slowest observed real
reading) and, for the descendant test, that at least a third of samples
recorded child CPU -- the defect records none.

Verified by reintroducing the defect (appending the fresh child object
rather than the cached instance): both tests still fail, at
`assert 0.0 > 1.0`. The relaxed bounds lose no discriminating power.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hHYmK3AS1NjyeSJiJK4uj
@rasmusjen

Copy link
Copy Markdown
Owner Author

CI is green on 3.10, 3.11, 3.12, 3.13 and the security job.

Two rounds of CI failures were fixed after the PR was opened; both were real, neither was a flake.

1. fix(cli) (cf8aeeb) — the untracking broke seven tests, and exposed a genuine CLI bug.

Seven tests invoked the CLI with no --config and relied on the repo shipping config/config.yaml. They passed locally only because that file is still on disk there. Behind it were two separate defects:

  • A real CLI bug: main() validated the config file before dispatching, so eddypro-batch with no subcommand printed Configuration file not found: config/config.yaml instead of usage. On a fresh clone that is the first command anyone runs — the untracking turned a latent bug into a visible one. The no-command case now short-circuits to print_help(); the dispatch chain became a table since argparse already restricts the remaining values.
  • A test-design flaw: tests must not depend on a machine-specific file. test_cli.py and test_cli_functions.py now pass config/config.yaml.example explicitly via a shared EXAMPLE_CONFIG constant.

Verified by moving config/config.yaml aside and running the full suite in the state CI actually sees.

2. test(monitor) (3bbdf4e) — my own assertion threshold, not the code.

CI failed at assert 19.8 > 20.0 and assert 15.8 > 20.0. Both are real, non-zero readings, so the monitor fix works on CI; a throttled 2-vCPU shared runner simply does not reach 20% of a core on that workload. The threshold was measuring the runner.

Since the defect produces exactly 0.0 on every sample, magnitude is the wrong discriminator. The assertions are now shape-based: CPU registered at all (> 1.0, a 15x margin below the slowest observed real reading), plus — for the descendant test — that at least a third of samples recorded child CPU, where the defect records none.

This was checked for lost discriminating power: the defect was reintroduced (appending the fresh child object rather than the cached instance) and both tests still failed, at assert 0.0 > 1.0. monitor.py was then restored and confirmed identical to the committed version.

Final state: 199 passing, coverage 75.8%, ruff / black / mypy / bandit clean.

The one item from the PR description that remains outstanding is verification against a real EddyPro run — still the only thing that would exercise the descendant-CPU path against actual eddypro_rp.exe workers rather than a synthetic burner.

@rasmusjen
rasmusjen merged commit 04a1a57 into main Aug 25, 2026
5 checks passed
@rasmusjen
rasmusjen deleted the chore/untrack-config-and-tune-thresholds branch August 25, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant