fix: correct host CPU and disk utilization metrics - #1036
Conversation
Signed-off-by: Nathy MacKinlay <61921733+waninggibbon@users.noreply.github.com>
6d3517d to
9ecc35b
Compare
| def _run(self) -> None: | ||
| # psutil tracks non-blocking CPU samples per thread. Prime the baseline on this | ||
| # long-lived thread so every logged value covers one complete metrics interval. | ||
| psutil.cpu_percent() |
There was a problem hiding this comment.
The priming call psutil.cpu_percent() runs outside any exception handling, unlike the psutil calls inside log_metrics (which are wrapped in try/except). If this call raises (e.g. a transient psutil/OS error), the _run thread dies immediately and no host metrics are ever logged for the lifetime of the context manager — silently, since the exception propagates out of a thread with no handler. Consider wrapping the prime in try/except (log-and-continue), consistent with how log_metrics already tolerates psutil failures.
Signed-off-by: Nathy MacKinlay <61921733+waninggibbon@users.noreply.github.com>
| self._timer = None | ||
| self._stop_event.set() | ||
| if self._thread: | ||
| # Bounded join so that a wedged metrics collection (e.g. an unresponsive |
There was a problem hiding this comment.
wedged seems to be a common Opus 5 favorite word :)
| # Bounded join so that a wedged metrics collection (e.g. an unresponsive | ||
| # nvidia-smi) cannot block Worker shutdown. The thread is a daemon, so it | ||
| # will not keep the process alive if it outlives this join. | ||
| self._thread.join(timeout=self.JOIN_TIMEOUT_S) |
There was a problem hiding this comment.
I would be careful here, 5s is a long time to join, since this is a context enter / exit, does it cost the call stack higher using this context any issues?
|
Thanks for this — the CPU-priming diagnosis is a good catch, and the shift to one long-lived thread is the right shape for it. I dug into the The join is safe, and usually free
The full 5s is only ever paid when the thread is mid- Where 5s does cost somethingWindows service stop is the tight one. Spot / ASG shutdown is additive. systemd is fine. The unit generated by One ordering note: in The unremarked risk is in
|
| self.log_metrics() | ||
| self._stop_event.clear() | ||
| self._thread = Thread(target=self._run, name="HostMetricsLogger", daemon=True) | ||
| self._thread.start() |
There was a problem hiding this comment.
Suggestion 1 of 3 (highest priority): this is a new crash path for the whole agent.
Before this PR, __enter__ called log_metrics(), which try/excepts everything internally — it could not fail. Now it calls Thread.start(), which raises RuntimeError when the OS can't create a thread (resource exhaustion, hitting a thread/process limit — plausible on a loaded render host running many session subprocesses).
That RuntimeError propagates out of __enter__ → out of the with (...) in Worker.run() → up to entrypoint, where the generic except Exception handler calls sys.exit(1). So a failure in an optional, best-effort observability feature now takes down the Worker Agent, where previously it would at worst have logged nothing.
This is the mirror image of the earlier bot finding: that one caught the unprotected psutil prime inside _run (fixed nicely in 58fcf78 via _prime_metrics), but the unprotected start() here is still open and has a much bigger blast radius.
Suggest degrading to "no metrics" instead of failing:
def __enter__(self) -> HostMetricsLogger:
self._stop_event.clear()
thread = Thread(target=self._run, name="HostMetricsLogger", daemon=True)
try:
thread.start()
except RuntimeError as e:
module_logger.warning(
f"Failed to start the host metrics thread. Host metrics will not be logged. Error: {e}"
)
else:
self._thread = thread
return selfAssigning self._thread only on success also keeps __exit__ correct for free — it already guards on if self._thread, so it becomes a no-op rather than joining a thread that never started.
There was a problem hiding this comment.
Thanks for the suggested fix, applied in latest commit
leongdl
left a comment
There was a problem hiding this comment.
Please add the 3 changes, especially the nvidia-smi timeout one.
There was a problem hiding this comment.
Suggestion 2 of 3: this is the unbounded blocker that the new 5s join is compensating for.
This check_output has no timeout=, so it can block indefinitely. A wedged nvidia-smi is a real failure mode on unhealthy GPU drivers, where the process can sit in uninterruptible sleep — and it's exactly the "unresponsive nvidia-smi" case cited in the new __exit__ comment.
This line is outside the diff, so it's pre-existing rather than something this PR introduced. But it's worth fixing here, because it's the root cause: it's the only thing inside log_metrics() that can keep the metrics thread from noticing _stop_event promptly, and therefore the only reason the bounded join can ever run out its clock. Bounding the collection makes the join's timeout nearly unreachable in practice.
output = subprocess.check_output(
["nvidia-smi", f"--query-gpu={query_str}", "--format=csv,noheader,nounits"],
stderr=subprocess.PIPE,
universal_newlines=True,
timeout=..., # a few seconds, comfortably under interval_s
)One thing to check if you take this: subprocess.TimeoutExpired is not a subclass of CalledProcessError, so it would fall through to the existing except Exception catch-all below. That does the right thing (debug log, then _host_has_no_gpu = True), but note the _host_has_no_gpu latch means a single transient timeout permanently disables GPU metrics for the process lifetime. A dedicated except subprocess.TimeoutExpired that logs and returns without setting the latch is probably what you want, so a one-off hang doesn't cost you GPU metrics forever.
There was a problem hiding this comment.
I went ahead with a 5 second time out, skipped the _host_has_no_gpu latch, and added a dedicated timeout expired handler.
Agree that a transient hang causing us to drop a sample is preferable here. Thanks for the suggestion!
|
|
||
| # How long to wait for the metrics thread to exit during shutdown before | ||
| # abandoning it. | ||
| JOIN_TIMEOUT_S = 5.0 |
There was a problem hiding this comment.
Suggestion 3 of 3: 5s is a long time to spend on a metrics thread during shutdown.
To be clear about what's not wrong here: the join is correctly written. join(timeout=...) never raises, the is_alive() check afterwards logs rather than assuming success, and __exit__ returning None means it won't suppress the in-flight exception on the raise paths in Worker.run(). And because _run blocks on _stop_event.wait(self.interval_s) and __exit__ sets the event first, the normal case returns in microseconds. This value is only ever paid when the thread is stuck mid-collection.
The concern is what it costs when that does happen, given where __exit__ sits in the shutdown sequence:
- Windows service stop.
SvcStop()reportsSERVICE_STOP_PENDINGonce, with no explicit wait hint and no checkpoint loop. pywin32's default hint is on the order of 5s, so this join alone can consume the whole SCM budget before the executor join and_agent_shutdown'supdate_workercall even start. That path was already over budget pre-PR, so this worsens rather than creates the problem — and I couldn't verify pywin32's exact default, so worth a second look. - Spot / ASG.
_monitor_ec2_shutdownreturns agrace_timecovering the entire remaining window, and_scheduler.shutdown(grace_time=...)runs inside thewith. The scheduler can consume that whole window, then this join adds up to 5s, and only then does_agent_shutdowntry to record STOPPED/STOPPING. That can cost the final status update. - systemd is fine — the generated unit sets no
TimeoutStopSec, so it inherits the ~90s default.
Worth noting the ordering too: in Worker.run() the managers are entered as (self._executor, AwsCredentialsRefresher(...), host_metrics_logger), so they exit in reverse and this join runs first, ahead of self._executor.__exit__(). It's strictly serial added latency on every shutdown path.
Given the thread is a daemon and _stop_event.set() already wakes it immediately, there's no correctness reason to wait 5s — a bounded join here is really just tidiness, and tidiness shouldn't cost 5s on the Spot path. I'd suggest JOIN_TIMEOUT_S = 1.0 (or dropping the join entirely and relying on the daemon flag). If suggestion 2 lands and nvidia-smi gets a timeout=, the collection itself becomes bounded and this timeout gets even harder to reach.
Minor, while you're in here: the comment just below uses "wedged" and then "unresponsive" two words later — worth settling on "unresponsive" or "hung" for both.
There was a problem hiding this comment.
Yeah good callout, I went ahead and dropped the timeout to just 1 second. The fix you suggested in (2) should make the timeout harder to reach at all. I think this leaves us a place where the existing pywin32 issue isn't addressed but also isn't exacerbated. Let me know what you think here.
I also dropped "wedged" in favor of unresponsive.
Degrade to logging no host metrics when the metrics thread cannot be started, instead of propagating RuntimeError out of the context manager and terminating the Worker Agent over an optional observability feature. Bound the nvidia-smi query with a timeout so a hung GPU driver cannot block the metrics thread from observing the stop event, and handle subprocess.TimeoutExpired without latching _host_has_no_gpu so that a transient hang does not disable GPU metrics for the process lifetime. Reduce the shutdown join timeout from 5s to 1s. The thread is a daemon and setting the stop event wakes it immediately, so the join is tidiness rather than correctness, and it runs first in Worker shutdown where it would otherwise eat into the Windows SCM and EC2 spot grace budgets. Signed-off-by: Nathy MacKinlay <61921733+waninggibbon@users.noreply.github.com>
|
|
||
| def _run(self) -> None: | ||
| self._prime_metrics() | ||
| while not self._stop_event.wait(self.interval_s): |
There was a problem hiding this comment.
Startup no longer logs an immediate metrics line. Previously __enter__ called log_metrics() synchronously, so a first sample was emitted at t=0 and then every interval_s. Now _run primes baselines and then blocks on self._stop_event.wait(self.interval_s) before the first log_metrics(), so the first line appears only after a full interval (default 60s).
Two consequences worth confirming are intended:
- A Worker that lives less than one interval will now log no host metrics at all, whereas before it logged at least one line on startup.
- Observability of a freshly started Worker is delayed by up to
interval_s.
If the goal was only to get accurate (non-zeroed) rates on the first sample, that is already achieved by _prime_metrics(); you could still emit an immediate priming sample or shorten the first wait to preserve the previous startup-visibility behavior.
| # Computed from the root-based total/used values reported above rather than | ||
| # using psutil's disk.percent, which is measured against user-available | ||
| # space and so would not agree with the other total-disk-* metrics. | ||
| "total-disk-used-percent": str(round(disk.used / disk.total * 100, ndigits=1)), |
There was a problem hiding this comment.
total-disk-used-percent changes scale/units for existing consumers. The old code emitted round(disk.used / disk.total, 1) — a 0.0–1.0 fraction (e.g. 0.2 for a 25%-full disk), while the new code emits round(disk.used / disk.total * 100, 1) — a 0–100 percentage (e.g. 25.0).
This is the correct value (the old one was never a real percent), but it is a ~100× change to a metric a public product emits. Any existing CloudWatch alarm, dashboard, or log-based metric keyed on total-disk-used-percent will silently misbehave after this ships: a threshold like > 0.8 will now fire almost always, and a > 80 threshold that never fired before will start working. Worth calling out in the changelog / release notes so operators can re-tune anything built on the previous (fractional) values.
What was the problem/requirement? (What/Why)
Host CPU utilization was frequently logged as
0.0even while a worker was active.HostMetricsLoggerscheduled each collection with a newthreading.Timer, butnon-blocking
psutil.cpu_percent()compares against the previous call made fromthe same thread. The first call from each timer thread therefore produced an
uninitialized zero sample.
The
total-disk-used-percentmetric also emitteddisk.used / disk.total, whichis a fractional ratio rather than the percentage indicated by the metric name.
What was the solution? (How)
stop event.
psutil.cpu_percent()on that thread, wait one collection interval, andcollect every subsequent sample on the same thread.
psutil.disk_usage(...).percentfortotal-disk-used-percent.sampling, and the corrected disk percentage.
What is the impact of this change?
CPU utilization now represents activity over a complete collection interval
instead of repeatedly reporting psutil's uninitialized value. Disk utilization
is reported on the expected 0-100 scale.
The first host metrics event is emitted after one complete collection interval
instead of immediately, avoiding an invalid initial CPU sample.
How was this change tested?
hatch run test: 3,024 passed, 47 skipped; 84.82% coverage.hatch run lint: Ruff lint and formatting passed; Mypy passed for 193 sourcefiles.
hatch build: source distribution and wheel built successfully.Was this change documented?
No documentation change is required. Existing metric names and the log schema
are unchanged.
Is this a breaking change?
No.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.