|
2 | 2 |
|
3 | 3 | import os |
4 | 4 | import sys |
5 | | -from contextlib import suppress |
6 | 5 | from datetime import datetime, timezone |
7 | | -from logging import getLogger |
| 6 | +from logging import WARNING, getLogger |
8 | 7 | from typing import TYPE_CHECKING, Annotated |
9 | 8 |
|
10 | 9 | import psutil |
11 | 10 | from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, PlainValidator |
12 | 11 |
|
13 | 12 | from crawlee._utils.byte_size import ByteSize |
| 13 | +from crawlee._utils.log import LoggerOnce |
14 | 14 |
|
15 | 15 | 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) |
16 | 21 |
|
17 | | -if sys.platform == 'linux': |
18 | | - """Get the most suitable available used memory metric. |
19 | 22 |
|
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. |
23 | 25 |
|
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. |
26 | 29 | """ |
27 | 30 |
|
| 31 | + is_available = True |
| 32 | + |
| 33 | + |
| 34 | +if sys.platform == 'linux': |
| 35 | + |
28 | 36 | 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) |
30 | 92 | else: |
31 | 93 |
|
32 | 94 | 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 | + """ |
33 | 104 | return int(process.memory_info().rss) |
34 | 105 |
|
35 | 106 |
|
| 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 | + |
36 | 124 | class CpuInfo(BaseModel): |
37 | 125 | """Information about the CPU usage.""" |
38 | 126 |
|
@@ -67,7 +155,12 @@ class MemoryUsageInfo(BaseModel): |
67 | 155 | PlainSerializer(lambda size: size.bytes), |
68 | 156 | Field(alias='currentSize'), |
69 | 157 | ] |
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 | + """ |
71 | 164 |
|
72 | 165 | # Workaround for Pydantic and type checkers when using Annotated with default_factory |
73 | 166 | if TYPE_CHECKING: |
@@ -117,20 +210,31 @@ def get_cpu_info() -> CpuInfo: |
117 | 210 | def get_memory_info() -> MemoryInfo: |
118 | 211 | """Retrieve the current memory usage of the process and its children. |
119 | 212 |
|
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. |
121 | 215 | """ |
122 | 216 | logger.debug('Calling get_memory_info()...') |
123 | 217 | current_process = psutil.Process(os.getpid()) |
124 | 218 |
|
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. |
126 | 222 | current_size_bytes = _get_used_memory(current_process) |
127 | 223 |
|
128 | 224 | # 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) |
134 | 238 |
|
135 | 239 | vm = psutil.virtual_memory() |
136 | 240 |
|
|
0 commit comments