Skip to content

Commit 5ad4e53

Browse files
anxkhnvdusek
andauthored
fix(system): do not fail memory snapshots on inaccessible processes (#2078)
- `get_memory_info()` no longer raises when a process cannot be inspected. In restricted environments (`hidepid`, hardened containers) it failed on every sample, which meant a traceback in the log every second and an autoscaler running on a stale memory history. Processes that deny inspection, exit mid-measurement, or lose their `/proc` entry are now skipped instead. - Failure to list the child processes no longer aborts the snapshot either - the parent's own usage is still reported. - A kernel that exposes no `smaps` at all makes psutil alias `memory_full_info` to `memory_info`, whose result has no `pss` field, so reading it raised `AttributeError` on every sample. The metric is now read defensively, and the verdict is latched: such a machine is detected once instead of being re-probed for every process on every sample. - A `smaps` file can be empty, which psutil parses to a PSS of zero. That was reported as zero bytes used, which the autoscaler reads as free memory. A PSS of zero now falls back to the RSS of the same process, which `memory_full_info()` has already read anyway. - A single process denying PSS falls back to RSS just for itself - a denial says nothing about the other processes, so it is not latched. - Every degraded path warns once, so an estimate that misses a subprocess or falls back from PSS to RSS shows up in the log instead of being silently wrong. - `LoggerOnce` is now thread-safe, since memory metrics are sampled in a worker thread - this PR adds its first caller that runs off the event loop. - Nothing changes when PSS is readable: on a normal Linux process tree the reported `current_size` is byte-for-byte what `master` reports, with no warnings and no RSS fallbacks. - Tests cover the denied, exited, zombie, and missing-`/proc` cases, both PSS fallbacks and their warnings, the latch, and a vanished process not being misreported as a denial. *✍️ Drafted by Claude Code* --------- Co-authored-by: Vlada Dusek <v.dusek96@gmail.com>
1 parent 4a4c8c1 commit 5ad4e53

3 files changed

Lines changed: 323 additions & 24 deletions

File tree

src/crawlee/_utils/log.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
from __future__ import annotations
22

33
import logging
4+
import threading
45

56

67
class LoggerOnce:
78
"""Emits each log message at most once, keyed by an explicit string.
89
910
Useful for diagnostic warnings that would otherwise spam the log when the same condition recurs (per-request
1011
misconfiguration warnings, repeated fallback paths, etc.). Deduplication scope follows the lifetime of the
11-
instance — a module-level instance gives process-wide dedup; an attribute on a class gives per-instance dedup.
12+
instance - a module-level instance gives process-wide dedup; an attribute on a class gives per-instance dedup.
13+
14+
Safe to call from multiple threads - some callers (e.g. system metric sampling) run in a worker thread.
1215
"""
1316

1417
def __init__(self, logger: logging.Logger) -> None:
1518
self._logger = logger
1619
self._seen: set[str] = set()
20+
self._lock = threading.Lock()
1721

1822
def log(self, message: str, *, key: str, level: int = logging.INFO) -> None:
1923
"""Log `message` at `level` the first time `key` is seen on this instance; later calls are no-ops.
@@ -23,7 +27,10 @@ def log(self, message: str, *, key: str, level: int = logging.INFO) -> None:
2327
key: Deduplication key. Two calls with the same key emit at most once.
2428
level: Standard `logging` level (e.g. `logging.WARNING`). Defaults to `logging.INFO`.
2529
"""
26-
if key in self._seen:
27-
return
28-
self._seen.add(key)
30+
# The check and the insert have to be atomic, otherwise two threads racing on the same key both emit.
31+
with self._lock:
32+
if key in self._seen:
33+
return
34+
self._seen.add(key)
35+
2936
self._logger.log(level, message)

src/crawlee/_utils/system.py

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,125 @@
22

33
import os
44
import sys
5-
from contextlib import suppress
65
from datetime import datetime, timezone
7-
from logging import getLogger
6+
from logging import WARNING, getLogger
87
from typing import TYPE_CHECKING, Annotated
98

109
import psutil
1110
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, PlainValidator
1211

1312
from crawlee._utils.byte_size import ByteSize
13+
from crawlee._utils.log import LoggerOnce
1414

1515
logger = getLogger(__name__)
16+
logger_once = LoggerOnce(logger)
17+
18+
# Reading a memory metric of a process that is denied or gone raises either a `psutil.Error` or a bare `OSError` -
19+
# psutil re-raises `FileNotFoundError` as is when a `/proc` entry is missing for a process that is still alive.
20+
_METRIC_ERRORS = (psutil.Error, OSError)
1621

17-
if sys.platform == 'linux':
18-
"""Get the most suitable available used memory metric.
1922

20-
`Proportional Set Size (PSS)`, is the amount of own memory and memory shared with other processes, accounted in a
21-
way that the shared amount is divided evenly between the processes that share it. Available on Linux. Suitable for
22-
avoiding overestimation by counting the same shared memory used by children processes multiple times.
23+
class _PssAvailability:
24+
"""Process-wide latch for whether the PSS memory metric exists on this system at all.
2325
24-
`Resident Set Size (RSS)` is the non-swapped physical memory a process has used; it includes shared memory. It
25-
should be available everywhere.
26+
Memory is sampled on a short recurring interval, so once psutil is known to not expose PSS there is no point in
27+
asking for it again for every process on every sample. Only the system-wide verdict is latched; a single process
28+
refusing to be inspected says nothing about the others.
2629
"""
2730

31+
is_available = True
32+
33+
34+
if sys.platform == 'linux':
35+
2836
def _get_used_memory(process: psutil.Process) -> int:
29-
return int(process.memory_full_info().pss)
37+
"""Get the most suitable available used memory metric of a single process.
38+
39+
`Proportional Set Size (PSS)` is the amount of own memory and memory shared with other processes, accounted in
40+
a way that the shared amount is divided evenly between the processes that share it. Available on Linux.
41+
Suitable for avoiding overestimation by counting the same shared memory used by children processes multiple
42+
times.
43+
44+
`Resident Set Size (RSS)` is the non-swapped physical memory a process has used; it includes shared memory. It
45+
should be available everywhere, so it is used whenever PSS cannot be read. It counts shared memory in full for
46+
every process that maps it, so a sharing process tree gets overestimated.
47+
48+
Raises:
49+
psutil.Error: If the process refuses inspection or is gone.
50+
OSError: If a `/proc` entry of the process is missing.
51+
"""
52+
if _PssAvailability.is_available:
53+
try:
54+
# A system that does not expose `smaps` at all makes psutil alias `memory_full_info` to
55+
# `memory_info`, whose result has no `pss` field.
56+
memory = process.memory_full_info()
57+
except psutil.NoSuchProcess:
58+
# A process that is gone is not refusing inspection, so let the RSS read below fail for it as usual.
59+
# `ZombieProcess` is a subclass of `NoSuchProcess`, so a zombie lands here too.
60+
pass
61+
except _METRIC_ERRORS:
62+
# A restricted environment may deny `/proc/<pid>/smaps`, which is a property of the single process, so
63+
# only that one process falls back to RSS. Still worth reporting - when the denial covers the whole
64+
# process tree, the estimate switches to RSS with nothing else to show it.
65+
logger_once.log(
66+
'Unable to read the PSS memory metric of a process, falling back to RSS for it - shared memory '
67+
'may be counted repeatedly.',
68+
key='pss_denied',
69+
level=WARNING,
70+
)
71+
else:
72+
pss = getattr(memory, 'pss', None)
73+
74+
if pss is None:
75+
_PssAvailability.is_available = False
76+
logger_once.log(
77+
'Unable to read the PSS memory metric, falling back to RSS - shared memory may be counted '
78+
'repeatedly.',
79+
key='pss_unavailable',
80+
level=WARNING,
81+
)
82+
# A `smaps` file can be empty for some processes, which parses to a PSS of zero. No live process
83+
# really uses zero memory, so treat it as a missing reading rather than as a measurement.
84+
elif pss > 0:
85+
return int(pss)
86+
87+
# `memory_full_info` reads the RSS on its way to the PSS, so the fallback does not have to read it
88+
# again.
89+
return int(memory.rss)
90+
91+
return int(process.memory_info().rss)
3092
else:
3193

3294
def _get_used_memory(process: psutil.Process) -> int:
95+
"""Get the used memory metric of a single process.
96+
97+
`Resident Set Size (RSS)` is the non-swapped physical memory a process has used; it includes shared memory, so
98+
a process tree that shares memory gets overestimated. It is the only metric available outside of Linux.
99+
100+
Raises:
101+
psutil.Error: If the process refuses inspection or is gone.
102+
OSError: If the memory metric of the process cannot be read.
103+
"""
33104
return int(process.memory_info().rss)
34105

35106

107+
def _get_child_used_memory(child: psutil.Process) -> int:
108+
"""Get the used memory of a child process, or zero if the child cannot be measured at all."""
109+
try:
110+
return _get_used_memory(child)
111+
except psutil.NoSuchProcess:
112+
# A child that exits mid-measurement just drops out of the sum, which is business as usual.
113+
return 0
114+
except _METRIC_ERRORS:
115+
# A child we cannot inspect at all drops out of the sum too, which does hide its memory usage.
116+
logger_once.log(
117+
'Unable to read the memory usage of a child process, it is excluded from the estimate.',
118+
key='child_unmeasurable',
119+
level=WARNING,
120+
)
121+
return 0
122+
123+
36124
class CpuInfo(BaseModel):
37125
"""Information about the CPU usage."""
38126

@@ -67,7 +155,12 @@ class MemoryUsageInfo(BaseModel):
67155
PlainSerializer(lambda size: size.bytes),
68156
Field(alias='currentSize'),
69157
]
70-
"""Memory usage of the current Python process and its children."""
158+
"""Memory usage of the current Python process and its children.
159+
160+
This is a best-effort estimate - a process that cannot be inspected is left out of the sum, and the metric used
161+
may be RSS, which counts memory shared between the processes repeatedly. When only some of the processes expose
162+
PSS, the sum mixes both metrics, so the memory those processes share with the rest of the tree is counted twice.
163+
"""
71164

72165
# Workaround for Pydantic and type checkers when using Annotated with default_factory
73166
if TYPE_CHECKING:
@@ -117,20 +210,31 @@ def get_cpu_info() -> CpuInfo:
117210
def get_memory_info() -> MemoryInfo:
118211
"""Retrieve the current memory usage of the process and its children.
119212
120-
It utilizes the `psutil` library.
213+
It utilizes the `psutil` library. The reported `current_size` is best-effort - processes that cannot be inspected
214+
are left out of the sum, and PSS may be substituted by RSS for some or all of the processes.
121215
"""
122216
logger.debug('Calling get_memory_info()...')
123217
current_process = psutil.Process(os.getpid())
124218

125-
# Retrieve estimated memory usage of the current process.
219+
# Retrieve estimated memory usage of the current process. Deliberately not guarded - a process can always read
220+
# its own RSS, and if it somehow cannot, failing the whole snapshot is safer than reporting a sum that is missing
221+
# the main process: the autoscaler would read the gap as free memory and keep scaling up.
126222
current_size_bytes = _get_used_memory(current_process)
127223

128224
# Sum memory usage by all children processes, try to exclude shared memory from the sum if allowed by OS.
129-
for child in current_process.children(recursive=True):
130-
# Ignore any NoSuchProcess exception that might occur if a child process ends before we retrieve
131-
# its memory usage.
132-
with suppress(psutil.NoSuchProcess):
133-
current_size_bytes += _get_used_memory(child)
225+
children: list[psutil.Process] = []
226+
try:
227+
children = current_process.children(recursive=True)
228+
except _METRIC_ERRORS:
229+
# A missing child list hides the whole subprocess tree from the estimate, so do not degrade silently.
230+
logger_once.log(
231+
'Unable to list child processes, their memory usage is excluded from the estimate.',
232+
key='children_unavailable',
233+
level=WARNING,
234+
)
235+
236+
for child in children:
237+
current_size_bytes += _get_child_used_memory(child)
134238

135239
vm = psutil.virtual_memory()
136240

0 commit comments

Comments
 (0)