pytest-xdist-memory 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_xdist_memory-0.1.0/.gitignore +65 -0
- pytest_xdist_memory-0.1.0/CHANGELOG.md +9 -0
- pytest_xdist_memory-0.1.0/LICENSE +21 -0
- pytest_xdist_memory-0.1.0/PKG-INFO +166 -0
- pytest_xdist_memory-0.1.0/README.md +113 -0
- pytest_xdist_memory-0.1.0/pyproject.toml +63 -0
- pytest_xdist_memory-0.1.0/src/pytest_xdist_memory/__init__.py +3 -0
- pytest_xdist_memory-0.1.0/src/pytest_xdist_memory/memory.py +122 -0
- pytest_xdist_memory-0.1.0/src/pytest_xdist_memory/plugin.py +419 -0
- pytest_xdist_memory-0.1.0/src/pytest_xdist_memory/profile.py +115 -0
- pytest_xdist_memory-0.1.0/tests/test_plugin.py +234 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.eggs/
|
|
9
|
+
build/
|
|
10
|
+
dist/
|
|
11
|
+
.installed.cfg
|
|
12
|
+
MANIFEST
|
|
13
|
+
|
|
14
|
+
# Virtualenvs
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
ENV/
|
|
18
|
+
env/
|
|
19
|
+
|
|
20
|
+
# uv
|
|
21
|
+
.uv/
|
|
22
|
+
uv.lock
|
|
23
|
+
|
|
24
|
+
# Tooling
|
|
25
|
+
.mypy_cache/
|
|
26
|
+
.ruff_cache/
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.coverage
|
|
29
|
+
.coverage.*
|
|
30
|
+
htmlcov/
|
|
31
|
+
coverage.xml
|
|
32
|
+
*.cover
|
|
33
|
+
|
|
34
|
+
# IDE
|
|
35
|
+
.idea/
|
|
36
|
+
.vscode/
|
|
37
|
+
*.swp
|
|
38
|
+
*.swo
|
|
39
|
+
|
|
40
|
+
# OS
|
|
41
|
+
.DS_Store
|
|
42
|
+
Thumbs.db
|
|
43
|
+
|
|
44
|
+
# django-run-site / django-dev-helpers dotfiles
|
|
45
|
+
.dev_helpers_token
|
|
46
|
+
.dev_helpers_port
|
|
47
|
+
.dev_helpers_pg_port
|
|
48
|
+
.dev_helpers_redis_port
|
|
49
|
+
.run-site-*
|
|
50
|
+
|
|
51
|
+
# Django
|
|
52
|
+
*.log
|
|
53
|
+
local_settings.py
|
|
54
|
+
db.sqlite3
|
|
55
|
+
db.sqlite3-journal
|
|
56
|
+
|
|
57
|
+
# Test artifacts
|
|
58
|
+
.tox/
|
|
59
|
+
.cache/
|
|
60
|
+
test-output/
|
|
61
|
+
|
|
62
|
+
# Examples — generated at runtime
|
|
63
|
+
examples/test_site/.venv/
|
|
64
|
+
examples/test_site/db.sqlite3
|
|
65
|
+
.pytest-xdist-memory.json
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- First release: per-worker memory measurement including child processes
|
|
6
|
+
(PSS on Linux), optional docker container measurement (by name or by
|
|
7
|
+
published port), per-machine profile per `-m` expression, `-n auto`
|
|
8
|
+
answered from available RAM, optional watchdog, ini equivalents for all
|
|
9
|
+
options.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michał Pasternak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-xdist-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Size pytest-xdist by available RAM: measure worker memory (with browsers and other child processes), keep a per-machine profile, answer -n auto from it, and optionally kill a run before it freezes the machine.
|
|
5
|
+
Project-URL: Homepage, https://github.com/iplweb/pytest-xdist-memory
|
|
6
|
+
Project-URL: Source, https://github.com/iplweb/pytest-xdist-memory
|
|
7
|
+
Project-URL: Issues, https://github.com/iplweb/pytest-xdist-memory/issues
|
|
8
|
+
Author-email: Michał Pasternak <michal.dtz@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Michał Pasternak
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: RAM,memory,oom,parallel,playwright,pytest,xdist
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Framework :: Pytest
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
44
|
+
Classifier: Topic :: Software Development :: Testing
|
|
45
|
+
Requires-Python: >=3.10
|
|
46
|
+
Requires-Dist: psutil>=5
|
|
47
|
+
Requires-Dist: pytest>=7
|
|
48
|
+
Provides-Extra: dev
|
|
49
|
+
Requires-Dist: pytest-xdist>=3; extra == 'dev'
|
|
50
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
51
|
+
Requires-Dist: ruff<0.16,>=0.15; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# pytest-xdist-memory
|
|
55
|
+
|
|
56
|
+
Size [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) by **available
|
|
57
|
+
RAM**, not by CPU count.
|
|
58
|
+
|
|
59
|
+
On a developer laptop the bottleneck of a parallel test run is usually memory.
|
|
60
|
+
`pytest -n auto` starts one worker per core; with heavy workers (Django and
|
|
61
|
+
a database, a browser per worker for Playwright, a live server...) a 14-core,
|
|
62
|
+
14 GB laptop goes into swap thrashing and freezes. This plugin measures how
|
|
63
|
+
much a worker of *your* test suite needs on *this* machine and answers
|
|
64
|
+
`-n auto` with as many workers as fit into the memory available right now.
|
|
65
|
+
|
|
66
|
+
> Status: alpha.
|
|
67
|
+
|
|
68
|
+
## What it does
|
|
69
|
+
|
|
70
|
+
With `--xdist-memory` (or `xdist_memory = true` in the ini file):
|
|
71
|
+
|
|
72
|
+
- **Measures** the peak memory of every worker **together with all its child
|
|
73
|
+
processes**: browsers, live servers, subprocesses. Linux uses PSS (a fair
|
|
74
|
+
share of pages shared between processes, so the several processes of one
|
|
75
|
+
Chromium are not counted several times over); other systems use RSS.
|
|
76
|
+
- **Measures containers** too, if you ask it to: a test database started by
|
|
77
|
+
testcontainers is not a child of pytest. Containers can be matched by name
|
|
78
|
+
(`--xdist-memory-docker SUBSTR`) or, more precisely, by the host port they
|
|
79
|
+
publish (`--xdist-memory-docker-port-env VAR`, where `VAR` is the
|
|
80
|
+
environment variable your fixtures export with the port). Matching by port
|
|
81
|
+
is exact even when parallel runs from other checkouts have containers with
|
|
82
|
+
similar names. The peak is divided by the number of workers.
|
|
83
|
+
- **Records** the peaks in a per-machine profile, `.pytest-xdist-memory.json`.
|
|
84
|
+
Each `-m` marker expression is a separate "suite": `-m playwright` workers
|
|
85
|
+
are much heavier than `-m "not playwright"` ones. The last 5 runs are kept,
|
|
86
|
+
and the estimate uses the maximum. Runs with fewer than 20 tests, or
|
|
87
|
+
interrupted runs, are not recorded.
|
|
88
|
+
- **Answers `-n auto` / `-n logical`**:
|
|
89
|
+
`workers = (available - reserve - controller) / (worker_peak × safety + extra)`,
|
|
90
|
+
capped by the CPU count. With `--xdist-memory-performance-cores`, Apple
|
|
91
|
+
Silicon is capped at the number of performance cores.
|
|
92
|
+
- **Optional watchdog** (`--xdist-memory-watchdog`): kills the run when the
|
|
93
|
+
whole process tree, plus the measured containers, exceeds the memory
|
|
94
|
+
available at start minus the reserve.
|
|
95
|
+
|
|
96
|
+
Nothing changes until you enable it. `PYTEST_XDIST_AUTO_NUM_WORKERS` still
|
|
97
|
+
wins over the estimate.
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install pytest-xdist-memory
|
|
103
|
+
|
|
104
|
+
# First run: no profile yet, so it assumes 1024 MB per worker; the run is measured.
|
|
105
|
+
pytest -n auto --xdist-memory
|
|
106
|
+
|
|
107
|
+
# Later runs use what was measured (per -m expression):
|
|
108
|
+
pytest -n auto --xdist-memory -m "not playwright"
|
|
109
|
+
pytest -n auto --xdist-memory -m playwright --xdist-memory-performance-cores
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The terminal summary shows the measured peaks and the calculation:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
================================ xdist-memory =================================
|
|
116
|
+
-n auto [default]: 3 workers: available 7282 MB - reserve 2048 - controller 215 = 5019 MB; per worker 1357 MB (910 x safety + extra 220); cpu cap 14; from profile (4 runs)
|
|
117
|
+
peak per worker 905 MB (all: [905, 871, 860]), controller 210 MB, containers 214 MB/worker
|
|
118
|
+
recorded in .pytest-xdist-memory.json [default]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Add `.pytest-xdist-memory.json` to `.gitignore`: the profile describes one
|
|
122
|
+
machine.
|
|
123
|
+
|
|
124
|
+
## Options
|
|
125
|
+
|
|
126
|
+
| Option | ini | Default | |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| `--xdist-memory` | `xdist_memory` | off | enable |
|
|
129
|
+
| `--xdist-memory-profile PATH` | `xdist_memory_profile` | `.pytest-xdist-memory.json` | relative to rootdir |
|
|
130
|
+
| `--xdist-memory-reserve MB` | `xdist_memory_reserve` | 2048 | left for the desktop/system |
|
|
131
|
+
| `--xdist-memory-safety F` | `xdist_memory_safety` | 1.25 | multiplier on the worker peak |
|
|
132
|
+
| `--xdist-memory-default-worker MB` | `xdist_memory_default_worker` | 1024 | used until a run is recorded |
|
|
133
|
+
| `--xdist-memory-extra MB` | `xdist_memory_extra` | 0 | fixed extra per worker (e.g. its DB) |
|
|
134
|
+
| `--xdist-memory-docker SUBSTR` | `xdist_memory_docker` | | measure containers by name |
|
|
135
|
+
| `--xdist-memory-docker-port-env VAR` | `xdist_memory_docker_port_env` | | measure the container publishing port `$VAR` |
|
|
136
|
+
| `--xdist-memory-performance-cores` | `xdist_memory_performance_cores` | off | Apple Silicon: P-cores only |
|
|
137
|
+
| `--xdist-memory-min-tests N` | `xdist_memory_min_tests` | 20 | smaller runs are not recorded |
|
|
138
|
+
| `--xdist-memory-watchdog` | `xdist_memory_watchdog` | off | kill the run over budget |
|
|
139
|
+
| `--xdist-memory-interval S` | `xdist_memory_interval` | 2 | sampling interval |
|
|
140
|
+
|
|
141
|
+
## Watchdog vs. a cgroup limit
|
|
142
|
+
|
|
143
|
+
The watchdog is portable but **best effort**: it checks once per sampling
|
|
144
|
+
interval, so a very fast allocation spike can get past it. When it fires, it
|
|
145
|
+
kills the workers and exits with code 3, without pytest's teardown: containers
|
|
146
|
+
are left to their own reaper (Ryuk for testcontainers).
|
|
147
|
+
|
|
148
|
+
On Linux with systemd, a cgroup limit is the hard guarantee: the kernel kills
|
|
149
|
+
the run instead of swapping the desktop to death. You can use both:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
systemd-run --user --scope -q -p MemoryMax=6G -p MemorySwapMax=0 \
|
|
153
|
+
pytest -n auto --xdist-memory
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Neither is needed for measuring.** Measuring uses only `psutil` (and
|
|
157
|
+
`docker stats`, if you ask for containers).
|
|
158
|
+
|
|
159
|
+
## Requirements
|
|
160
|
+
|
|
161
|
+
Python ≥ 3.10, pytest ≥ 7, psutil. pytest-xdist is optional: without it, the
|
|
162
|
+
plugin only reports the peak of the whole process tree.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
MIT
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# pytest-xdist-memory
|
|
2
|
+
|
|
3
|
+
Size [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) by **available
|
|
4
|
+
RAM**, not by CPU count.
|
|
5
|
+
|
|
6
|
+
On a developer laptop the bottleneck of a parallel test run is usually memory.
|
|
7
|
+
`pytest -n auto` starts one worker per core; with heavy workers (Django and
|
|
8
|
+
a database, a browser per worker for Playwright, a live server...) a 14-core,
|
|
9
|
+
14 GB laptop goes into swap thrashing and freezes. This plugin measures how
|
|
10
|
+
much a worker of *your* test suite needs on *this* machine and answers
|
|
11
|
+
`-n auto` with as many workers as fit into the memory available right now.
|
|
12
|
+
|
|
13
|
+
> Status: alpha.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
With `--xdist-memory` (or `xdist_memory = true` in the ini file):
|
|
18
|
+
|
|
19
|
+
- **Measures** the peak memory of every worker **together with all its child
|
|
20
|
+
processes**: browsers, live servers, subprocesses. Linux uses PSS (a fair
|
|
21
|
+
share of pages shared between processes, so the several processes of one
|
|
22
|
+
Chromium are not counted several times over); other systems use RSS.
|
|
23
|
+
- **Measures containers** too, if you ask it to: a test database started by
|
|
24
|
+
testcontainers is not a child of pytest. Containers can be matched by name
|
|
25
|
+
(`--xdist-memory-docker SUBSTR`) or, more precisely, by the host port they
|
|
26
|
+
publish (`--xdist-memory-docker-port-env VAR`, where `VAR` is the
|
|
27
|
+
environment variable your fixtures export with the port). Matching by port
|
|
28
|
+
is exact even when parallel runs from other checkouts have containers with
|
|
29
|
+
similar names. The peak is divided by the number of workers.
|
|
30
|
+
- **Records** the peaks in a per-machine profile, `.pytest-xdist-memory.json`.
|
|
31
|
+
Each `-m` marker expression is a separate "suite": `-m playwright` workers
|
|
32
|
+
are much heavier than `-m "not playwright"` ones. The last 5 runs are kept,
|
|
33
|
+
and the estimate uses the maximum. Runs with fewer than 20 tests, or
|
|
34
|
+
interrupted runs, are not recorded.
|
|
35
|
+
- **Answers `-n auto` / `-n logical`**:
|
|
36
|
+
`workers = (available - reserve - controller) / (worker_peak × safety + extra)`,
|
|
37
|
+
capped by the CPU count. With `--xdist-memory-performance-cores`, Apple
|
|
38
|
+
Silicon is capped at the number of performance cores.
|
|
39
|
+
- **Optional watchdog** (`--xdist-memory-watchdog`): kills the run when the
|
|
40
|
+
whole process tree, plus the measured containers, exceeds the memory
|
|
41
|
+
available at start minus the reserve.
|
|
42
|
+
|
|
43
|
+
Nothing changes until you enable it. `PYTEST_XDIST_AUTO_NUM_WORKERS` still
|
|
44
|
+
wins over the estimate.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install pytest-xdist-memory
|
|
50
|
+
|
|
51
|
+
# First run: no profile yet, so it assumes 1024 MB per worker; the run is measured.
|
|
52
|
+
pytest -n auto --xdist-memory
|
|
53
|
+
|
|
54
|
+
# Later runs use what was measured (per -m expression):
|
|
55
|
+
pytest -n auto --xdist-memory -m "not playwright"
|
|
56
|
+
pytest -n auto --xdist-memory -m playwright --xdist-memory-performance-cores
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The terminal summary shows the measured peaks and the calculation:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
================================ xdist-memory =================================
|
|
63
|
+
-n auto [default]: 3 workers: available 7282 MB - reserve 2048 - controller 215 = 5019 MB; per worker 1357 MB (910 x safety + extra 220); cpu cap 14; from profile (4 runs)
|
|
64
|
+
peak per worker 905 MB (all: [905, 871, 860]), controller 210 MB, containers 214 MB/worker
|
|
65
|
+
recorded in .pytest-xdist-memory.json [default]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add `.pytest-xdist-memory.json` to `.gitignore`: the profile describes one
|
|
69
|
+
machine.
|
|
70
|
+
|
|
71
|
+
## Options
|
|
72
|
+
|
|
73
|
+
| Option | ini | Default | |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| `--xdist-memory` | `xdist_memory` | off | enable |
|
|
76
|
+
| `--xdist-memory-profile PATH` | `xdist_memory_profile` | `.pytest-xdist-memory.json` | relative to rootdir |
|
|
77
|
+
| `--xdist-memory-reserve MB` | `xdist_memory_reserve` | 2048 | left for the desktop/system |
|
|
78
|
+
| `--xdist-memory-safety F` | `xdist_memory_safety` | 1.25 | multiplier on the worker peak |
|
|
79
|
+
| `--xdist-memory-default-worker MB` | `xdist_memory_default_worker` | 1024 | used until a run is recorded |
|
|
80
|
+
| `--xdist-memory-extra MB` | `xdist_memory_extra` | 0 | fixed extra per worker (e.g. its DB) |
|
|
81
|
+
| `--xdist-memory-docker SUBSTR` | `xdist_memory_docker` | | measure containers by name |
|
|
82
|
+
| `--xdist-memory-docker-port-env VAR` | `xdist_memory_docker_port_env` | | measure the container publishing port `$VAR` |
|
|
83
|
+
| `--xdist-memory-performance-cores` | `xdist_memory_performance_cores` | off | Apple Silicon: P-cores only |
|
|
84
|
+
| `--xdist-memory-min-tests N` | `xdist_memory_min_tests` | 20 | smaller runs are not recorded |
|
|
85
|
+
| `--xdist-memory-watchdog` | `xdist_memory_watchdog` | off | kill the run over budget |
|
|
86
|
+
| `--xdist-memory-interval S` | `xdist_memory_interval` | 2 | sampling interval |
|
|
87
|
+
|
|
88
|
+
## Watchdog vs. a cgroup limit
|
|
89
|
+
|
|
90
|
+
The watchdog is portable but **best effort**: it checks once per sampling
|
|
91
|
+
interval, so a very fast allocation spike can get past it. When it fires, it
|
|
92
|
+
kills the workers and exits with code 3, without pytest's teardown: containers
|
|
93
|
+
are left to their own reaper (Ryuk for testcontainers).
|
|
94
|
+
|
|
95
|
+
On Linux with systemd, a cgroup limit is the hard guarantee: the kernel kills
|
|
96
|
+
the run instead of swapping the desktop to death. You can use both:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
systemd-run --user --scope -q -p MemoryMax=6G -p MemorySwapMax=0 \
|
|
100
|
+
pytest -n auto --xdist-memory
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Neither is needed for measuring.** Measuring uses only `psutil` (and
|
|
104
|
+
`docker stats`, if you ask for containers).
|
|
105
|
+
|
|
106
|
+
## Requirements
|
|
107
|
+
|
|
108
|
+
Python ≥ 3.10, pytest ≥ 7, psutil. pytest-xdist is optional: without it, the
|
|
109
|
+
plugin only reports the peak of the whole process tree.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-xdist-memory"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Size pytest-xdist by available RAM: measure worker memory (with browsers and other child processes), keep a per-machine profile, answer -n auto from it, and optionally kill a run before it freezes the machine."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Michał Pasternak", email = "michal.dtz@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["pytest", "xdist", "memory", "RAM", "parallel", "playwright", "oom"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: Pytest",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Software Development :: Testing",
|
|
30
|
+
]
|
|
31
|
+
dependencies = ["pytest>=7", "psutil>=5"]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8",
|
|
36
|
+
"pytest-xdist>=3",
|
|
37
|
+
"ruff>=0.15,<0.16",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.entry-points.pytest11]
|
|
41
|
+
xdist_memory = "pytest_xdist_memory.plugin"
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/iplweb/pytest-xdist-memory"
|
|
45
|
+
Source = "https://github.com/iplweb/pytest-xdist-memory"
|
|
46
|
+
Issues = "https://github.com/iplweb/pytest-xdist-memory/issues"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/pytest_xdist_memory"]
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.sdist]
|
|
52
|
+
include = ["src/", "tests/", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
addopts = "-p pytester"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 88
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "W", "I", "B", "UP"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Measuring memory: process subtrees, available RAM, docker containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import psutil
|
|
10
|
+
|
|
11
|
+
MB = 1024 * 1024
|
|
12
|
+
|
|
13
|
+
_UNITS = (("GiB", 1024.0), ("MiB", 1.0), ("KiB", 1 / 1024), ("GB", 1000.0))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def process_mb(proc: psutil.Process) -> int:
|
|
17
|
+
"""Memory of one process in MB.
|
|
18
|
+
|
|
19
|
+
PSS on Linux: a fair share of pages shared with other processes. Browsers
|
|
20
|
+
(Chromium runs several processes sharing most of their pages) would be
|
|
21
|
+
counted several times over by summing RSS. Elsewhere RSS.
|
|
22
|
+
"""
|
|
23
|
+
if sys.platform.startswith("linux"):
|
|
24
|
+
try:
|
|
25
|
+
return proc.memory_full_info().pss // MB
|
|
26
|
+
except psutil.AccessDenied:
|
|
27
|
+
pass # no access to smaps -- fall back to RSS below
|
|
28
|
+
return proc.memory_info().rss // MB
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def subtree_mb(proc: psutil.Process) -> int:
|
|
32
|
+
"""Memory of a process and ALL its descendants (browsers, servers...)."""
|
|
33
|
+
try:
|
|
34
|
+
procs = [proc, *proc.children(recursive=True)]
|
|
35
|
+
except psutil.NoSuchProcess:
|
|
36
|
+
return 0
|
|
37
|
+
total = 0
|
|
38
|
+
for p in procs:
|
|
39
|
+
try:
|
|
40
|
+
total += process_mb(p)
|
|
41
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
42
|
+
continue # exited between listing and measuring
|
|
43
|
+
return total
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def available_mb() -> int:
|
|
47
|
+
return psutil.virtual_memory().available // MB
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_usage(usage: str) -> int | None:
|
|
51
|
+
used = usage.split("/")[0].strip()
|
|
52
|
+
for unit, mult in _UNITS:
|
|
53
|
+
if used.endswith(unit):
|
|
54
|
+
try:
|
|
55
|
+
return int(float(used[: -len(unit)]) * mult)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _docker(*args: str) -> str | None:
|
|
62
|
+
try:
|
|
63
|
+
return subprocess.run(
|
|
64
|
+
["docker", *args], capture_output=True, text=True, timeout=20
|
|
65
|
+
).stdout
|
|
66
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def docker_mb(match: str = "", port: str | None = None) -> int | None:
|
|
71
|
+
"""Total memory of running containers whose name contains ``match``
|
|
72
|
+
and/or which publish host ``port``.
|
|
73
|
+
|
|
74
|
+
``None`` when docker is unavailable or nothing matches. Containers are
|
|
75
|
+
not children of pytest (e.g. a database started by testcontainers), so
|
|
76
|
+
they cannot be found by walking the process tree. Matching by the
|
|
77
|
+
published port picks exactly THIS run's container even when parallel
|
|
78
|
+
runs (other checkouts) have containers with similar names.
|
|
79
|
+
"""
|
|
80
|
+
if not shutil.which("docker") or not (match or port):
|
|
81
|
+
return None
|
|
82
|
+
names: list[str] = []
|
|
83
|
+
if port:
|
|
84
|
+
out = _docker("ps", "--filter", f"publish={port}", "--format", "{{.Names}}")
|
|
85
|
+
names = (out or "").split()
|
|
86
|
+
if not names:
|
|
87
|
+
return None
|
|
88
|
+
out = _docker("stats", "--no-stream", "--format", "{{.Name}} {{.MemUsage}}", *names)
|
|
89
|
+
if out is None:
|
|
90
|
+
return None
|
|
91
|
+
total = None
|
|
92
|
+
for line in out.splitlines():
|
|
93
|
+
name, _, usage = line.partition(" ")
|
|
94
|
+
if match not in name:
|
|
95
|
+
continue
|
|
96
|
+
mb = _parse_usage(usage)
|
|
97
|
+
if mb is not None:
|
|
98
|
+
total = (total or 0) + mb
|
|
99
|
+
return total
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cpu_cap(performance_cores: bool) -> int:
|
|
103
|
+
"""Upper bound on workers from the CPU.
|
|
104
|
+
|
|
105
|
+
With ``performance_cores`` on Apple Silicon: only the performance cores.
|
|
106
|
+
Efficiency cores are too slow to drive e.g. their own browser and only
|
|
107
|
+
add contention.
|
|
108
|
+
"""
|
|
109
|
+
cpus = psutil.cpu_count(logical=True) or 1
|
|
110
|
+
if performance_cores and sys.platform == "darwin":
|
|
111
|
+
try:
|
|
112
|
+
out = subprocess.run(
|
|
113
|
+
["sysctl", "-n", "hw.perflevel0.logicalcpu"],
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
timeout=5,
|
|
117
|
+
).stdout.strip()
|
|
118
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
119
|
+
return cpus
|
|
120
|
+
if out.isdigit():
|
|
121
|
+
return int(out)
|
|
122
|
+
return cpus
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""pytest plugin: size pytest-xdist by RAM, not by CPU count.
|
|
2
|
+
|
|
3
|
+
Why this exists: on a developer machine (laptop with a desktop) the
|
|
4
|
+
bottleneck of a parallel test run is usually memory, not CPU. ``-n auto``
|
|
5
|
+
spawns one worker per core; with heavy workers (Django + a database,
|
|
6
|
+
a browser per worker for Playwright) that is enough to push the machine
|
|
7
|
+
into swap thrashing and freeze it.
|
|
8
|
+
|
|
9
|
+
The plugin, once enabled with ``--xdist-memory``:
|
|
10
|
+
|
|
11
|
+
* measures, in every worker, the peak memory of the worker AND all of its
|
|
12
|
+
descendants (browsers, live servers...) -- PSS on Linux, RSS elsewhere;
|
|
13
|
+
* optionally measures docker containers by name or by published port (a test
|
|
14
|
+
database started by testcontainers is not a child of pytest);
|
|
15
|
+
* records the peaks in a per-machine profile file, separately per ``-m``
|
|
16
|
+
marker expression ("suite");
|
|
17
|
+
* answers ``-n auto`` / ``-n logical`` with as many workers as fit into the
|
|
18
|
+
currently available RAM (capped by CPU count);
|
|
19
|
+
* optionally (``--xdist-memory-watchdog``) aborts the run when the whole
|
|
20
|
+
process tree exceeds the budget -- a portable, best-effort stand-in for a
|
|
21
|
+
cgroup limit (``systemd-run --scope -p MemoryMax=...``).
|
|
22
|
+
|
|
23
|
+
Nothing changes until you enable it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import psutil
|
|
35
|
+
import pytest
|
|
36
|
+
|
|
37
|
+
from . import memory, profile
|
|
38
|
+
|
|
39
|
+
_KEY = "xdist_memory"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
43
|
+
group = parser.getgroup("xdist-memory", "size pytest-xdist by available RAM")
|
|
44
|
+
group.addoption(
|
|
45
|
+
"--xdist-memory",
|
|
46
|
+
action="store_true",
|
|
47
|
+
default=None,
|
|
48
|
+
help="measure worker memory, record it in the profile and answer "
|
|
49
|
+
"-n auto from it (ini: xdist_memory).",
|
|
50
|
+
)
|
|
51
|
+
group.addoption(
|
|
52
|
+
"--xdist-memory-profile",
|
|
53
|
+
default=None,
|
|
54
|
+
metavar="PATH",
|
|
55
|
+
help="profile file, relative to rootdir; keep it out of version "
|
|
56
|
+
"control (ini: xdist_memory_profile; default .pytest-xdist-memory.json).",
|
|
57
|
+
)
|
|
58
|
+
group.addoption(
|
|
59
|
+
"--xdist-memory-reserve",
|
|
60
|
+
type=int,
|
|
61
|
+
default=None,
|
|
62
|
+
metavar="MB",
|
|
63
|
+
help="memory to leave for the desktop / system "
|
|
64
|
+
"(ini: xdist_memory_reserve; default 2048).",
|
|
65
|
+
)
|
|
66
|
+
group.addoption(
|
|
67
|
+
"--xdist-memory-safety",
|
|
68
|
+
type=float,
|
|
69
|
+
default=None,
|
|
70
|
+
metavar="FACTOR",
|
|
71
|
+
help="multiplier on the measured worker peak "
|
|
72
|
+
"(ini: xdist_memory_safety; default 1.25).",
|
|
73
|
+
)
|
|
74
|
+
group.addoption(
|
|
75
|
+
"--xdist-memory-default-worker",
|
|
76
|
+
type=int,
|
|
77
|
+
default=None,
|
|
78
|
+
metavar="MB",
|
|
79
|
+
help="worker peak assumed while the profile has no runs for this suite "
|
|
80
|
+
"(ini: xdist_memory_default_worker; default 1024).",
|
|
81
|
+
)
|
|
82
|
+
group.addoption(
|
|
83
|
+
"--xdist-memory-extra",
|
|
84
|
+
type=int,
|
|
85
|
+
default=None,
|
|
86
|
+
metavar="MB",
|
|
87
|
+
help="fixed extra memory per worker outside the process tree, e.g. "
|
|
88
|
+
"its database (ini: xdist_memory_extra; default 0).",
|
|
89
|
+
)
|
|
90
|
+
group.addoption(
|
|
91
|
+
"--xdist-memory-docker",
|
|
92
|
+
default=None,
|
|
93
|
+
metavar="SUBSTRING",
|
|
94
|
+
help="also measure running docker containers whose name contains "
|
|
95
|
+
"SUBSTRING and count their peak / workers as extra per worker "
|
|
96
|
+
"(ini: xdist_memory_docker).",
|
|
97
|
+
)
|
|
98
|
+
group.addoption(
|
|
99
|
+
"--xdist-memory-docker-port-env",
|
|
100
|
+
default=None,
|
|
101
|
+
metavar="VAR",
|
|
102
|
+
help="also measure the docker container publishing the host port in "
|
|
103
|
+
"environment variable VAR, e.g. the one testcontainers exports for "
|
|
104
|
+
"the test database; exact even with parallel runs "
|
|
105
|
+
"(ini: xdist_memory_docker_port_env).",
|
|
106
|
+
)
|
|
107
|
+
group.addoption(
|
|
108
|
+
"--xdist-memory-performance-cores",
|
|
109
|
+
action="store_true",
|
|
110
|
+
default=None,
|
|
111
|
+
help="on Apple Silicon cap workers at the number of performance cores "
|
|
112
|
+
"(ini: xdist_memory_performance_cores).",
|
|
113
|
+
)
|
|
114
|
+
group.addoption(
|
|
115
|
+
"--xdist-memory-min-tests",
|
|
116
|
+
type=int,
|
|
117
|
+
default=None,
|
|
118
|
+
metavar="N",
|
|
119
|
+
help="record a run in the profile only if at least N tests ran "
|
|
120
|
+
"(ini: xdist_memory_min_tests; default 20).",
|
|
121
|
+
)
|
|
122
|
+
group.addoption(
|
|
123
|
+
"--xdist-memory-watchdog",
|
|
124
|
+
action="store_true",
|
|
125
|
+
default=None,
|
|
126
|
+
help="kill the run when the process tree (+ measured containers) "
|
|
127
|
+
"exceeds the memory available at start minus the reserve "
|
|
128
|
+
"(ini: xdist_memory_watchdog).",
|
|
129
|
+
)
|
|
130
|
+
group.addoption(
|
|
131
|
+
"--xdist-memory-interval",
|
|
132
|
+
type=float,
|
|
133
|
+
default=None,
|
|
134
|
+
metavar="SECONDS",
|
|
135
|
+
help="sampling interval (ini: xdist_memory_interval; default 2).",
|
|
136
|
+
)
|
|
137
|
+
for name, kind, default in (
|
|
138
|
+
("xdist_memory", "bool", False),
|
|
139
|
+
("xdist_memory_profile", "string", ".pytest-xdist-memory.json"),
|
|
140
|
+
("xdist_memory_reserve", "string", "2048"),
|
|
141
|
+
("xdist_memory_safety", "string", "1.25"),
|
|
142
|
+
("xdist_memory_default_worker", "string", "1024"),
|
|
143
|
+
("xdist_memory_extra", "string", "0"),
|
|
144
|
+
("xdist_memory_docker", "string", ""),
|
|
145
|
+
("xdist_memory_docker_port_env", "string", ""),
|
|
146
|
+
("xdist_memory_performance_cores", "bool", False),
|
|
147
|
+
("xdist_memory_min_tests", "string", "20"),
|
|
148
|
+
("xdist_memory_watchdog", "bool", False),
|
|
149
|
+
("xdist_memory_interval", "string", "2"),
|
|
150
|
+
):
|
|
151
|
+
help_text = f"see --{name.replace('_', '-')}"
|
|
152
|
+
parser.addini(name, help_text, type=kind, default=default)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class Settings:
|
|
156
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
157
|
+
def opt(name: str, cast):
|
|
158
|
+
value = config.getoption(name)
|
|
159
|
+
if value is None:
|
|
160
|
+
value = config.getini(name)
|
|
161
|
+
return cast(value)
|
|
162
|
+
|
|
163
|
+
self.enabled = opt("xdist_memory", bool)
|
|
164
|
+
self.profile = config.rootpath / opt("xdist_memory_profile", str)
|
|
165
|
+
self.reserve_mb = opt("xdist_memory_reserve", int)
|
|
166
|
+
self.safety = opt("xdist_memory_safety", float)
|
|
167
|
+
self.default_worker_mb = opt("xdist_memory_default_worker", int)
|
|
168
|
+
self.extra_mb = opt("xdist_memory_extra", int)
|
|
169
|
+
self.docker = opt("xdist_memory_docker", str)
|
|
170
|
+
self.docker_port_env = opt("xdist_memory_docker_port_env", str)
|
|
171
|
+
self.performance_cores = opt("xdist_memory_performance_cores", bool)
|
|
172
|
+
self.min_tests = opt("xdist_memory_min_tests", int)
|
|
173
|
+
self.watchdog = opt("xdist_memory_watchdog", bool)
|
|
174
|
+
self.interval = opt("xdist_memory_interval", float)
|
|
175
|
+
self.suite = profile.suite_key(config.getoption("markexpr", None))
|
|
176
|
+
|
|
177
|
+
def estimate(self) -> profile.Estimate:
|
|
178
|
+
return profile.estimate(
|
|
179
|
+
profile.load(self.profile),
|
|
180
|
+
self.suite,
|
|
181
|
+
available_mb=memory.available_mb(),
|
|
182
|
+
reserve_mb=self.reserve_mb,
|
|
183
|
+
safety=self.safety,
|
|
184
|
+
cpu_cap=memory.cpu_cap(self.performance_cores),
|
|
185
|
+
default_worker_mb=self.default_worker_mb,
|
|
186
|
+
# Unknown before the first measured run; the controller is a
|
|
187
|
+
# plain pytest process, rarely more than a few hundred MB.
|
|
188
|
+
default_controller_mb=250,
|
|
189
|
+
extra_per_worker_mb=self.extra_mb,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.hookimpl(tryfirst=True, optionalhook=True)
|
|
194
|
+
def pytest_xdist_auto_num_workers(config: pytest.Config) -> int | None:
|
|
195
|
+
if os.environ.get("PYTEST_XDIST_AUTO_NUM_WORKERS"):
|
|
196
|
+
return None # explicit override wins; let xdist's own hook read it
|
|
197
|
+
settings = Settings(config)
|
|
198
|
+
if not settings.enabled:
|
|
199
|
+
return None
|
|
200
|
+
est = settings.estimate()
|
|
201
|
+
config.stash[_ESTIMATE] = est
|
|
202
|
+
return est.workers
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Sampler:
|
|
206
|
+
"""Background thread recording the peak memory of a process."""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
interval: float,
|
|
211
|
+
subtree: bool,
|
|
212
|
+
docker: str = "",
|
|
213
|
+
docker_port_env: str = "",
|
|
214
|
+
) -> None:
|
|
215
|
+
self.proc = psutil.Process()
|
|
216
|
+
self.interval = interval
|
|
217
|
+
self.subtree = subtree
|
|
218
|
+
self.docker = docker
|
|
219
|
+
self.docker_port_env = docker_port_env
|
|
220
|
+
self.peak_mb = 0
|
|
221
|
+
self.docker_peak_mb = 0
|
|
222
|
+
self.on_sample = None
|
|
223
|
+
self._stop = threading.Event()
|
|
224
|
+
self._thread = threading.Thread(
|
|
225
|
+
target=self._run, name="xdist-memory", daemon=True
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def start(self) -> None:
|
|
229
|
+
self.sample()
|
|
230
|
+
self._thread.start()
|
|
231
|
+
|
|
232
|
+
def stop(self) -> None:
|
|
233
|
+
self._stop.set()
|
|
234
|
+
if self._thread.is_alive():
|
|
235
|
+
self._thread.join(timeout=5)
|
|
236
|
+
self.sample()
|
|
237
|
+
|
|
238
|
+
def sample(self) -> int:
|
|
239
|
+
if self.subtree:
|
|
240
|
+
mb = memory.subtree_mb(self.proc)
|
|
241
|
+
else:
|
|
242
|
+
mb = memory.process_mb(self.proc)
|
|
243
|
+
self.peak_mb = max(self.peak_mb, mb)
|
|
244
|
+
return mb
|
|
245
|
+
|
|
246
|
+
def sample_docker(self) -> None:
|
|
247
|
+
# Read the port at sample time: plugins (testcontainers) may export
|
|
248
|
+
# it after we were configured.
|
|
249
|
+
port = os.environ.get(self.docker_port_env) if self.docker_port_env else None
|
|
250
|
+
if self.docker_port_env and not port:
|
|
251
|
+
return
|
|
252
|
+
mb = memory.docker_mb(self.docker, port) or 0
|
|
253
|
+
self.docker_peak_mb = max(self.docker_peak_mb, mb)
|
|
254
|
+
|
|
255
|
+
def _run(self) -> None:
|
|
256
|
+
# docker stats is slow (~1 s); sample containers less often.
|
|
257
|
+
docker_every = max(1, int(10 / max(self.interval, 0.1)))
|
|
258
|
+
tick = 0
|
|
259
|
+
while not self._stop.wait(self.interval):
|
|
260
|
+
mb = self.sample()
|
|
261
|
+
if (self.docker or self.docker_port_env) and tick % docker_every == 0:
|
|
262
|
+
self.sample_docker()
|
|
263
|
+
tick += 1
|
|
264
|
+
if self.on_sample is not None:
|
|
265
|
+
self.on_sample(mb)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class Watchdog:
|
|
269
|
+
"""Kill the run before it takes the machine down.
|
|
270
|
+
|
|
271
|
+
Best effort: it reacts within one sampling interval, so a very fast
|
|
272
|
+
allocation spike can still get through. A cgroup limit (``systemd-run
|
|
273
|
+
--user --scope -p MemoryMax=...``) is the hard guarantee on Linux.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def __init__(self, settings: Settings, sampler: Sampler) -> None:
|
|
277
|
+
self.sampler = sampler
|
|
278
|
+
own = memory.process_mb(sampler.proc)
|
|
279
|
+
self.limit_mb = max(memory.available_mb() - settings.reserve_mb + own, 512)
|
|
280
|
+
|
|
281
|
+
def __call__(self, _own_mb: int) -> None:
|
|
282
|
+
total = memory.subtree_mb(self.sampler.proc) + self.sampler.docker_peak_mb
|
|
283
|
+
if total <= self.limit_mb:
|
|
284
|
+
return
|
|
285
|
+
sys.stderr.write(
|
|
286
|
+
f"\npytest-xdist-memory: process tree uses {total} MB, over the "
|
|
287
|
+
f"limit of {self.limit_mb} MB -- killing the test run.\n"
|
|
288
|
+
)
|
|
289
|
+
sys.stderr.flush()
|
|
290
|
+
for child in self.sampler.proc.children(recursive=True):
|
|
291
|
+
try:
|
|
292
|
+
child.kill()
|
|
293
|
+
except psutil.NoSuchProcess:
|
|
294
|
+
continue # already gone
|
|
295
|
+
os._exit(3)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_worker(config: pytest.Config) -> bool:
|
|
299
|
+
return hasattr(config, "workerinput")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _is_controller(config: pytest.Config) -> bool:
|
|
303
|
+
# Same test as xdist's own is_xdist_controller(); the "dsession" plugin is
|
|
304
|
+
# not registered yet when our pytest_configure runs.
|
|
305
|
+
return (
|
|
306
|
+
not _is_worker(config)
|
|
307
|
+
and getattr(config.option, "dist", "no") != "no"
|
|
308
|
+
and bool(getattr(config.option, "tx", None))
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Runtime:
|
|
313
|
+
def __init__(self, config: pytest.Config, settings: Settings) -> None:
|
|
314
|
+
self.config = config
|
|
315
|
+
self.settings = settings
|
|
316
|
+
self.worker = _is_worker(config)
|
|
317
|
+
self.controller = _is_controller(config)
|
|
318
|
+
# A worker (or a plain non-xdist run) measures its whole subtree; the
|
|
319
|
+
# controller only itself, its children are the workers.
|
|
320
|
+
self.sampler = Sampler(
|
|
321
|
+
settings.interval,
|
|
322
|
+
subtree=not self.controller,
|
|
323
|
+
docker=settings.docker if not self.worker else "",
|
|
324
|
+
docker_port_env=settings.docker_port_env if not self.worker else "",
|
|
325
|
+
)
|
|
326
|
+
self.workers: dict[str, dict[str, Any]] = {}
|
|
327
|
+
self.tests = 0
|
|
328
|
+
self.watchdog = None
|
|
329
|
+
if settings.watchdog and not self.worker:
|
|
330
|
+
self.watchdog = Watchdog(settings, self.sampler)
|
|
331
|
+
self.sampler.on_sample = self.watchdog
|
|
332
|
+
|
|
333
|
+
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
|
334
|
+
if report.when == "call" or (report.when == "setup" and report.skipped):
|
|
335
|
+
self.tests += 1
|
|
336
|
+
|
|
337
|
+
@pytest.hookimpl(optionalhook=True)
|
|
338
|
+
def pytest_testnodedown(self, node, error) -> None:
|
|
339
|
+
data = getattr(node, "workeroutput", {}).get(_KEY)
|
|
340
|
+
if data:
|
|
341
|
+
self.workers[node.gateway.id] = data
|
|
342
|
+
|
|
343
|
+
def finish(self, session: pytest.Session) -> None:
|
|
344
|
+
self.sampler.stop()
|
|
345
|
+
if self.worker:
|
|
346
|
+
self.config.workeroutput[_KEY] = {"peak_mb": self.sampler.peak_mb}
|
|
347
|
+
return
|
|
348
|
+
if not self.controller:
|
|
349
|
+
return # plain run: nothing to size, just report in the summary
|
|
350
|
+
peaks = sorted((w["peak_mb"] for w in self.workers.values()), reverse=True)
|
|
351
|
+
if not peaks:
|
|
352
|
+
return
|
|
353
|
+
n = len(peaks)
|
|
354
|
+
self.run = {
|
|
355
|
+
"host": os.uname().nodename if hasattr(os, "uname") else "",
|
|
356
|
+
"metric": "pss" if sys.platform.startswith("linux") else "rss",
|
|
357
|
+
"workers": n,
|
|
358
|
+
"tests": self.tests,
|
|
359
|
+
"worker_peak_mb": peaks[0],
|
|
360
|
+
"worker_peaks_mb": peaks,
|
|
361
|
+
"controller_peak_mb": self.sampler.peak_mb,
|
|
362
|
+
"extra_per_worker_mb": (self.sampler.docker_peak_mb // n) or None,
|
|
363
|
+
}
|
|
364
|
+
interrupted = session.shouldstop or session.shouldfail
|
|
365
|
+
if self.tests >= self.settings.min_tests and not interrupted:
|
|
366
|
+
profile.record_run(self.settings.profile, self.settings.suite, self.run)
|
|
367
|
+
self.recorded = True
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
_RUNTIME = pytest.StashKey[Runtime]()
|
|
371
|
+
_ESTIMATE = pytest.StashKey[profile.Estimate]()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.hookimpl(trylast=True)
|
|
375
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
376
|
+
settings = Settings(config)
|
|
377
|
+
if not settings.enabled:
|
|
378
|
+
return
|
|
379
|
+
runtime = Runtime(config, settings)
|
|
380
|
+
config.stash[_RUNTIME] = runtime
|
|
381
|
+
config.pluginmanager.register(runtime, "xdist-memory-runtime")
|
|
382
|
+
runtime.sampler.start()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def pytest_sessionfinish(session: pytest.Session) -> None:
|
|
386
|
+
runtime = session.config.stash.get(_RUNTIME, None)
|
|
387
|
+
if runtime is not None:
|
|
388
|
+
runtime.finish(session)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config: pytest.Config):
|
|
392
|
+
runtime = config.stash.get(_RUNTIME, None)
|
|
393
|
+
if runtime is None or runtime.worker:
|
|
394
|
+
return
|
|
395
|
+
tr = terminalreporter
|
|
396
|
+
tr.write_sep("=", "xdist-memory")
|
|
397
|
+
est = config.stash.get(_ESTIMATE, None)
|
|
398
|
+
if est is not None:
|
|
399
|
+
tr.write_line(f"-n auto [{runtime.settings.suite}]: {est.describe()}")
|
|
400
|
+
run = getattr(runtime, "run", None)
|
|
401
|
+
if run is None:
|
|
402
|
+
tr.write_line(f"peak (whole tree, no xdist): {runtime.sampler.peak_mb} MB")
|
|
403
|
+
return
|
|
404
|
+
extra = run["extra_per_worker_mb"]
|
|
405
|
+
tr.write_line(
|
|
406
|
+
f"peak per worker {run['worker_peak_mb']} MB (all: {run['worker_peaks_mb']}),"
|
|
407
|
+
f" controller {run['controller_peak_mb']} MB"
|
|
408
|
+
+ (f", containers {extra} MB/worker" if extra else "")
|
|
409
|
+
)
|
|
410
|
+
path = Path(runtime.settings.profile)
|
|
411
|
+
if getattr(runtime, "recorded", False):
|
|
412
|
+
tr.write_line(f"recorded in {path.name} [{runtime.settings.suite}]")
|
|
413
|
+
else:
|
|
414
|
+
tr.write_line(
|
|
415
|
+
f"not recorded ({runtime.tests} tests < "
|
|
416
|
+
f"{runtime.settings.min_tests} or run interrupted)"
|
|
417
|
+
)
|
|
418
|
+
after = runtime.settings.estimate()
|
|
419
|
+
tr.write_line(f"next -n auto would give: {after.describe()}")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The memory profile file and the worker-count calculation.
|
|
2
|
+
|
|
3
|
+
The profile is per machine (keep it out of version control). It holds, per
|
|
4
|
+
"suite" (the ``-m`` marker expression, or ``default``), the last few runs.
|
|
5
|
+
The estimate takes the MAXIMUM over them: one lucky light run should not let
|
|
6
|
+
the next one spawn too many workers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
VERSION = 1
|
|
19
|
+
KEEP_RUNS = 5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def suite_key(markexpr: str | None) -> str:
|
|
23
|
+
return (markexpr or "").strip() or "default"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(path: Path) -> dict[str, Any]:
|
|
27
|
+
try:
|
|
28
|
+
data = json.loads(path.read_text())
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return {"version": VERSION, "suites": {}}
|
|
31
|
+
except (OSError, ValueError) as exc:
|
|
32
|
+
raise ProfileError(f"cannot read memory profile {path}: {exc}") from exc
|
|
33
|
+
if data.get("version") != VERSION:
|
|
34
|
+
return {"version": VERSION, "suites": {}}
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProfileError(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def record_run(path: Path, key: str, run: dict[str, Any]) -> None:
|
|
43
|
+
data = load(path)
|
|
44
|
+
runs = data["suites"].setdefault(key, {"runs": []})["runs"]
|
|
45
|
+
runs.append({"measured_at": time.strftime("%Y-%m-%d %H:%M:%S"), **run})
|
|
46
|
+
del runs[:-KEEP_RUNS]
|
|
47
|
+
tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
|
|
48
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
49
|
+
tmp.replace(path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Estimate:
|
|
54
|
+
workers: int
|
|
55
|
+
from_profile: bool
|
|
56
|
+
worker_mb: int
|
|
57
|
+
controller_mb: int
|
|
58
|
+
extra_per_worker_mb: int
|
|
59
|
+
per_worker_mb: int
|
|
60
|
+
available_mb: int
|
|
61
|
+
reserve_mb: int
|
|
62
|
+
budget_mb: int
|
|
63
|
+
cpu_cap: int
|
|
64
|
+
runs: int = 0
|
|
65
|
+
notes: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
def describe(self) -> str:
|
|
68
|
+
src = f"profile ({self.runs} runs)" if self.from_profile else "defaults"
|
|
69
|
+
return (
|
|
70
|
+
f"{self.workers} workers: available {self.available_mb} MB"
|
|
71
|
+
f" - reserve {self.reserve_mb} - controller {self.controller_mb}"
|
|
72
|
+
f" = {self.budget_mb} MB; per worker {self.per_worker_mb} MB"
|
|
73
|
+
f" ({self.worker_mb} x safety + extra {self.extra_per_worker_mb});"
|
|
74
|
+
f" cpu cap {self.cpu_cap}; from {src}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _peak(runs: list[dict[str, Any]], name: str) -> int | None:
|
|
79
|
+
values = [r[name] for r in runs if r.get(name)]
|
|
80
|
+
return max(values) if values else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def estimate(
|
|
84
|
+
data: dict[str, Any],
|
|
85
|
+
key: str,
|
|
86
|
+
*,
|
|
87
|
+
available_mb: int,
|
|
88
|
+
reserve_mb: int,
|
|
89
|
+
safety: float,
|
|
90
|
+
cpu_cap: int,
|
|
91
|
+
default_worker_mb: int,
|
|
92
|
+
default_controller_mb: int,
|
|
93
|
+
extra_per_worker_mb: int,
|
|
94
|
+
) -> Estimate:
|
|
95
|
+
runs = data.get("suites", {}).get(key, {}).get("runs", [])
|
|
96
|
+
worker = _peak(runs, "worker_peak_mb") or default_worker_mb
|
|
97
|
+
controller = _peak(runs, "controller_peak_mb") or default_controller_mb
|
|
98
|
+
measured_extra = _peak(runs, "extra_per_worker_mb")
|
|
99
|
+
extra = max(extra_per_worker_mb, measured_extra or 0)
|
|
100
|
+
per_worker = int(worker * safety) + extra
|
|
101
|
+
budget = available_mb - reserve_mb - controller
|
|
102
|
+
n = max(1, min(budget // max(per_worker, 1), cpu_cap))
|
|
103
|
+
return Estimate(
|
|
104
|
+
workers=n,
|
|
105
|
+
from_profile=bool(runs),
|
|
106
|
+
worker_mb=worker,
|
|
107
|
+
controller_mb=controller,
|
|
108
|
+
extra_per_worker_mb=extra,
|
|
109
|
+
per_worker_mb=per_worker,
|
|
110
|
+
available_mb=available_mb,
|
|
111
|
+
reserve_mb=reserve_mb,
|
|
112
|
+
budget_mb=budget,
|
|
113
|
+
cpu_cap=cpu_cap,
|
|
114
|
+
runs=len(runs),
|
|
115
|
+
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pytest_xdist_memory import profile
|
|
6
|
+
|
|
7
|
+
TESTS = """
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
@pytest.mark.parametrize("i", range(6))
|
|
11
|
+
def test_many(i):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@pytest.mark.heavy
|
|
15
|
+
@pytest.mark.parametrize("i", range(3))
|
|
16
|
+
def test_heavy(i):
|
|
17
|
+
pass
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
INI = """
|
|
21
|
+
[pytest]
|
|
22
|
+
markers = heavy
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _profile(pytester) -> dict:
|
|
27
|
+
return json.loads((pytester.path / ".pytest-xdist-memory.json").read_text())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_inactive_by_default(pytester):
|
|
31
|
+
pytester.makepyfile(TESTS)
|
|
32
|
+
pytester.makeini(INI)
|
|
33
|
+
result = pytester.runpytest("-n", "2")
|
|
34
|
+
result.assert_outcomes(passed=9)
|
|
35
|
+
result.stdout.no_fnmatch_line("*= xdist-memory =*")
|
|
36
|
+
assert not (pytester.path / ".pytest-xdist-memory.json").exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_records_worker_peaks_per_suite(pytester):
|
|
40
|
+
pytester.makepyfile(TESTS)
|
|
41
|
+
pytester.makeini(INI)
|
|
42
|
+
args = ("-n", "2", "--xdist-memory", "--xdist-memory-min-tests", "1")
|
|
43
|
+
pytester.runpytest(*args).assert_outcomes(passed=9)
|
|
44
|
+
# (xdist's controller does not report the deselected count)
|
|
45
|
+
pytester.runpytest(*args, "-m", "heavy").assert_outcomes(passed=3)
|
|
46
|
+
|
|
47
|
+
suites = _profile(pytester)["suites"]
|
|
48
|
+
assert set(suites) == {"default", "heavy"}
|
|
49
|
+
run = suites["default"]["runs"][0]
|
|
50
|
+
assert run["workers"] == 2
|
|
51
|
+
assert run["tests"] == 9
|
|
52
|
+
assert len(run["worker_peaks_mb"]) == 2
|
|
53
|
+
# A Python interpreter with pytest loaded: tens of MB at the very least.
|
|
54
|
+
assert run["worker_peak_mb"] > 10
|
|
55
|
+
assert run["controller_peak_mb"] > 10
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_too_few_tests_are_not_recorded(pytester):
|
|
59
|
+
pytester.makepyfile(TESTS)
|
|
60
|
+
pytester.makeini(INI)
|
|
61
|
+
result = pytester.runpytest("-n", "2", "--xdist-memory") # min-tests 20
|
|
62
|
+
result.assert_outcomes(passed=9)
|
|
63
|
+
result.stdout.fnmatch_lines(["*= xdist-memory =*", "*not recorded (9 tests < 20*"])
|
|
64
|
+
assert not (pytester.path / ".pytest-xdist-memory.json").exists()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_auto_uses_profile(pytester, monkeypatch):
|
|
68
|
+
monkeypatch.delenv("PYTEST_XDIST_AUTO_NUM_WORKERS", raising=False)
|
|
69
|
+
pytester.makepyfile(TESTS)
|
|
70
|
+
pytester.makeini(INI)
|
|
71
|
+
# A worker "needing" more than this machine has: -n auto must give 1.
|
|
72
|
+
profile.record_run(
|
|
73
|
+
pytester.path / ".pytest-xdist-memory.json",
|
|
74
|
+
"default",
|
|
75
|
+
{"worker_peak_mb": 10_000_000, "controller_peak_mb": 100},
|
|
76
|
+
)
|
|
77
|
+
result = pytester.runpytest("-n", "auto", "--xdist-memory")
|
|
78
|
+
result.assert_outcomes(passed=9)
|
|
79
|
+
result.stdout.fnmatch_lines(["*-n auto [[]default[]]: 1 workers*from profile*"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_env_override_wins(pytester, monkeypatch):
|
|
83
|
+
monkeypatch.setenv("PYTEST_XDIST_AUTO_NUM_WORKERS", "2")
|
|
84
|
+
pytester.makepyfile(TESTS)
|
|
85
|
+
pytester.makeini(INI)
|
|
86
|
+
result = pytester.runpytest("-n", "auto", "--xdist-memory")
|
|
87
|
+
result.assert_outcomes(passed=9)
|
|
88
|
+
result.stdout.fnmatch_lines(["*2 workers*"])
|
|
89
|
+
result.stdout.no_fnmatch_line("*-n auto [[]default[]]*")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_child_processes_count_towards_the_worker(pytester):
|
|
93
|
+
# Each test keeps a ~200 MB child process alive for a while: the worker
|
|
94
|
+
# peak must include it (that is what Chromium / Daphne look like).
|
|
95
|
+
pytester.makepyfile(
|
|
96
|
+
"""
|
|
97
|
+
import subprocess, sys, time
|
|
98
|
+
|
|
99
|
+
# Touch every page so it is really resident, then hold it.
|
|
100
|
+
CHILD = (
|
|
101
|
+
"b = bytearray(200 * 1024 * 1024)\\n"
|
|
102
|
+
"for i in range(0, len(b), 4096): b[i] = 1\\n"
|
|
103
|
+
"import time; time.sleep(3)"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_child():
|
|
107
|
+
p = subprocess.Popen([sys.executable, "-c", CHILD])
|
|
108
|
+
time.sleep(2.5)
|
|
109
|
+
p.wait()
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
result = pytester.runpytest(
|
|
113
|
+
"-n",
|
|
114
|
+
"1",
|
|
115
|
+
"--xdist-memory",
|
|
116
|
+
"--xdist-memory-min-tests",
|
|
117
|
+
"1",
|
|
118
|
+
"--xdist-memory-interval",
|
|
119
|
+
"0.5",
|
|
120
|
+
)
|
|
121
|
+
result.assert_outcomes(passed=1)
|
|
122
|
+
run = _profile(pytester)["suites"]["default"]["runs"][0]
|
|
123
|
+
assert run["worker_peak_mb"] >= 200
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_watchdog_kills_run_over_budget(pytester):
|
|
127
|
+
# Reserve > all RAM: the limit drops to its 512 MB floor, and a 700 MB
|
|
128
|
+
# child process must trip it.
|
|
129
|
+
pytester.makepyfile(
|
|
130
|
+
"""
|
|
131
|
+
import subprocess, sys, time
|
|
132
|
+
|
|
133
|
+
CHILD = (
|
|
134
|
+
"b = bytearray(700 * 1024 * 1024)\\n"
|
|
135
|
+
"for i in range(0, len(b), 4096): b[i] = 1\\n"
|
|
136
|
+
"import time; time.sleep(30)"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def test_hog():
|
|
140
|
+
subprocess.Popen([sys.executable, "-c", CHILD]).wait()
|
|
141
|
+
"""
|
|
142
|
+
)
|
|
143
|
+
result = pytester.runpytest_subprocess(
|
|
144
|
+
"-n",
|
|
145
|
+
"1",
|
|
146
|
+
"--xdist-memory",
|
|
147
|
+
"--xdist-memory-watchdog",
|
|
148
|
+
"--xdist-memory-reserve",
|
|
149
|
+
"100000000",
|
|
150
|
+
"--xdist-memory-interval",
|
|
151
|
+
"0.5",
|
|
152
|
+
timeout=60,
|
|
153
|
+
)
|
|
154
|
+
assert result.ret == 3
|
|
155
|
+
result.stderr.fnmatch_lines(["*over the limit of 512 MB*killing the test run*"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_estimate_takes_max_of_recent_runs():
|
|
159
|
+
data = {
|
|
160
|
+
"suites": {
|
|
161
|
+
"s": {
|
|
162
|
+
"runs": [
|
|
163
|
+
{"worker_peak_mb": 500, "controller_peak_mb": 100},
|
|
164
|
+
{"worker_peak_mb": 900, "controller_peak_mb": 150},
|
|
165
|
+
{"worker_peak_mb": 600, "controller_peak_mb": 120},
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
est = profile.estimate(
|
|
171
|
+
data,
|
|
172
|
+
"s",
|
|
173
|
+
available_mb=10_000,
|
|
174
|
+
reserve_mb=2_000,
|
|
175
|
+
safety=1.0,
|
|
176
|
+
cpu_cap=64,
|
|
177
|
+
default_worker_mb=1,
|
|
178
|
+
default_controller_mb=1,
|
|
179
|
+
extra_per_worker_mb=100,
|
|
180
|
+
)
|
|
181
|
+
assert est.worker_mb == 900
|
|
182
|
+
assert est.controller_mb == 150
|
|
183
|
+
assert est.per_worker_mb == 1000
|
|
184
|
+
assert est.workers == (10_000 - 2_000 - 150) // 1000
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.parametrize(
|
|
188
|
+
"available, cap, expected", [(100, 8, 1), (100_000, 3, 3), (6_000, 64, 3)]
|
|
189
|
+
)
|
|
190
|
+
def test_estimate_bounds(available, cap, expected):
|
|
191
|
+
est = profile.estimate(
|
|
192
|
+
{},
|
|
193
|
+
"default",
|
|
194
|
+
available_mb=available,
|
|
195
|
+
reserve_mb=1_000,
|
|
196
|
+
safety=1.0,
|
|
197
|
+
cpu_cap=cap,
|
|
198
|
+
default_worker_mb=1_500,
|
|
199
|
+
default_controller_mb=0,
|
|
200
|
+
extra_per_worker_mb=0,
|
|
201
|
+
)
|
|
202
|
+
assert est.workers == expected
|
|
203
|
+
assert not est.from_profile
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_profile_keeps_last_runs(tmp_path):
|
|
207
|
+
path = tmp_path / "p.json"
|
|
208
|
+
for i in range(profile.KEEP_RUNS + 3):
|
|
209
|
+
profile.record_run(path, "default", {"worker_peak_mb": i})
|
|
210
|
+
runs = profile.load(path)["suites"]["default"]["runs"]
|
|
211
|
+
assert [r["worker_peak_mb"] for r in runs] == list(range(3, profile.KEEP_RUNS + 3))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_docker_matching_by_port_and_name(monkeypatch):
|
|
215
|
+
from pytest_xdist_memory import memory
|
|
216
|
+
|
|
217
|
+
calls = []
|
|
218
|
+
|
|
219
|
+
def fake_docker(*args):
|
|
220
|
+
calls.append(args)
|
|
221
|
+
if args[0] == "ps":
|
|
222
|
+
return "mine-psql\n" if args[2] == "publish=5432" else ""
|
|
223
|
+
names = args[4:] or ("mine-psql", "other-psql", "redis")
|
|
224
|
+
usage = {"mine-psql": "300MiB", "other-psql": "1.5GiB", "redis": "20MiB"}
|
|
225
|
+
return "".join(f"{n} {usage[n]} / 14GiB\n" for n in names)
|
|
226
|
+
|
|
227
|
+
monkeypatch.setattr(memory.shutil, "which", lambda _: "/usr/bin/docker")
|
|
228
|
+
monkeypatch.setattr(memory, "_docker", fake_docker)
|
|
229
|
+
|
|
230
|
+
assert memory.docker_mb(port="5432") == 300
|
|
231
|
+
assert calls[-1][4:] == ("mine-psql",) # stats only for that container
|
|
232
|
+
assert memory.docker_mb(match="psql") == 300 + 1536
|
|
233
|
+
assert memory.docker_mb(port="9999") is None
|
|
234
|
+
assert memory.docker_mb() is None
|