robotframework-parallelrunner 0.4.0__tar.gz → 0.5.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.
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/PKG-INFO +39 -19
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/README.md +37 -16
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/pyproject.toml +2 -3
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/src/ParallelRunner/parallel_library.py +268 -39
- robotframework_parallelrunner-0.5.0/src/ParallelRunner/retry.py +114 -0
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/LICENSE +0 -0
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/src/ParallelRunner/__init__.py +0 -0
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/src/ParallelRunner/py.typed +0 -0
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/src/ParallelRunner/version.py +0 -0
- {robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/src/parallelrunner.py +0 -0
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robotframework-parallelrunner
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Keywords: robotframework,robot-framework,parallel,threading,testing,automation
|
|
8
8
|
Author: Cristian Garcia
|
|
9
9
|
Author-email: cristian.garcia.vd@gmail.com
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Framework :: Robot Framework
|
|
13
13
|
Classifier: Framework :: Robot Framework :: Library
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Programming Language :: Python :: 3
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -53,9 +52,10 @@ correctly ordered `log.html` plus a structured list of per-item results.
|
|
|
53
52
|
pip install robotframework-parallelrunner
|
|
54
53
|
```
|
|
55
54
|
|
|
56
|
-
Requires Python 3.
|
|
57
|
-
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
|
|
58
|
-
|
|
55
|
+
Requires Python 3.10+ and Robot Framework 5.0+ (installed automatically).
|
|
56
|
+
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs,
|
|
57
|
+
upgrading from the pre-release import path, and Python 3.8/3.9 (no longer
|
|
58
|
+
supported, but 0.5.0 was tested and works on them).
|
|
59
59
|
|
|
60
60
|
## Quickstart
|
|
61
61
|
|
|
@@ -113,8 +113,8 @@ threading, buffering, and replay logic stay out of your `.robot` files.
|
|
|
113
113
|
## Summary of Benefits
|
|
114
114
|
|
|
115
115
|
- **Speed.** Waiting time is overlapped instead of added up. In this repo's
|
|
116
|
-
examples, 9 HTTP checks with a 1 s delay each drop from 13 s to 2 s with
|
|
117
|
-
9 workers, and 4 suites × 8 items × 1 s drop from 32.
|
|
116
|
+
examples, 9 HTTP checks with a 1 s delay each drop from 13.9 s to 2.2 s with
|
|
117
|
+
9 workers, and 4 suites × 8 items × 1 s drop from 32.9 s to 5.1 s with
|
|
118
118
|
8 workers (see [Using It Together With pabot](#using-it-together-with-pabot)).
|
|
119
119
|
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
120
120
|
broken XML tags from concurrent writes.
|
|
@@ -183,7 +183,7 @@ pabot --processes 4 --pythonpath . atest/
|
|
|
183
183
|
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
Both commands pass the full acceptance suite of this repository (
|
|
186
|
+
Both commands pass the full acceptance suite of this repository (82 tests;
|
|
187
187
|
checked with pabot 5.2.2).
|
|
188
188
|
|
|
189
189
|
How much time each one saves, from the offline
|
|
@@ -192,14 +192,14 @@ How much time each one saves, from the offline
|
|
|
192
192
|
|
|
193
193
|
| Run | Wall-clock time | Speedup |
|
|
194
194
|
|---|---|---|
|
|
195
|
-
| robot, sequential loop | 32.
|
|
196
|
-
| robot + ParallelRunner (8 workers) |
|
|
197
|
-
| pabot (4 processes), sequential loop |
|
|
198
|
-
| pabot (4 processes) + ParallelRunner (8 workers) |
|
|
195
|
+
| robot, sequential loop | 32.9 s | ×1.0 |
|
|
196
|
+
| robot + ParallelRunner (8 workers) | 5.1 s | ×6.5 |
|
|
197
|
+
| pabot (4 processes), sequential loop | 12.4 s | ×2.7 |
|
|
198
|
+
| pabot (4 processes) + ParallelRunner (8 workers) | 4.6 s | ×7.2 |
|
|
199
199
|
|
|
200
200
|
ParallelRunner removes the waiting *inside* each test, and pabot removes it
|
|
201
201
|
*between* suites. With suites this short, pabot's process start-up (about
|
|
202
|
-
|
|
202
|
+
3.5 s here) is most of what's left, so the combination pays off more as
|
|
203
203
|
suites get longer.
|
|
204
204
|
|
|
205
205
|
Things to keep in mind:
|
|
@@ -221,6 +221,31 @@ Things to keep in mind:
|
|
|
221
221
|
could interfere with each other. Keep shared state per process, or split
|
|
222
222
|
by suite.
|
|
223
223
|
|
|
224
|
+
## How Is This Different From AsyncLibrary?
|
|
225
|
+
|
|
226
|
+
[AsyncLibrary](https://github.com/rlehfeld/robotframework-async-keyword)
|
|
227
|
+
(`pip install robotframework-async-keyword`) also runs keywords in threads
|
|
228
|
+
inside one test case. It runs *any* keyword, user keywords included: you
|
|
229
|
+
start each call with `Async Run` and collect it later with `Async Get`. It
|
|
230
|
+
buffers each call's output and replays it, so `log.html` keeps the full
|
|
231
|
+
nested keyword structure. To do that, it works with Robot Framework's
|
|
232
|
+
internal execution context and output.
|
|
233
|
+
|
|
234
|
+
| | **AsyncLibrary** | **ParallelRunner** |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| Runs | Any keyword, including user keywords | A method of a Python library that accepts `_logger` |
|
|
237
|
+
| Usage | `Async Run` per call, `Async Get` later | One `Run Parallel Scenarios` call over a list, or `repeat=N` |
|
|
238
|
+
| Concurrency | Python's default thread pool size | `ROBOT_THREAD_WORKERS` |
|
|
239
|
+
| Hung calls | `Async Get timeout=...` stops waiting for them | `call_timeout` fails only that call and frees its slot for the next item |
|
|
240
|
+
| Failures | `Async Get` raises the call's error | One failure listing every failed item, or a `PASS`/`FAIL` per item with `fail_on_error=False` |
|
|
241
|
+
| Robot internals | Works with the internal execution context and output | Not patched; logs only from the main thread, through the public logger API; tested on Robot Framework 5, 6 and 7 |
|
|
242
|
+
|
|
243
|
+
Use AsyncLibrary when the work lives in user keywords (or in library
|
|
244
|
+
keywords you can't change), or when you want to start something in the
|
|
245
|
+
background, run other steps, and collect it later. Use ParallelRunner to fan
|
|
246
|
+
one Python keyword out over many items as a single batch, with a bounded
|
|
247
|
+
worker pool, per-call timeouts and one failure report for the whole batch.
|
|
248
|
+
|
|
224
249
|
## When Not To Use This
|
|
225
250
|
|
|
226
251
|
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
@@ -230,11 +255,6 @@ Things to keep in mind:
|
|
|
230
255
|
made thread-safe for you; your own keyword's side effects are not. If it
|
|
231
256
|
writes to a shared variable, file, or object without a lock, running it
|
|
232
257
|
concurrently can race the same way any multi-threaded code can.
|
|
233
|
-
- **You need per-item retries.** Not implemented yet (see
|
|
234
|
-
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)). A per-call limit is: `call_timeout=10s` fails
|
|
235
|
-
only the call that hangs and lets the others finish. Note that a timed-out
|
|
236
|
-
call can't be killed - it keeps running in the background unless the
|
|
237
|
-
keyword declares `_cancelled` and stops when it is set.
|
|
238
258
|
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
239
259
|
able to affect another) or cross-machine parallelism — that's pabot's
|
|
240
260
|
domain, not this library's.
|
|
@@ -19,9 +19,10 @@ correctly ordered `log.html` plus a structured list of per-item results.
|
|
|
19
19
|
pip install robotframework-parallelrunner
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Requires Python 3.
|
|
23
|
-
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
|
|
24
|
-
|
|
22
|
+
Requires Python 3.10+ and Robot Framework 5.0+ (installed automatically).
|
|
23
|
+
See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs,
|
|
24
|
+
upgrading from the pre-release import path, and Python 3.8/3.9 (no longer
|
|
25
|
+
supported, but 0.5.0 was tested and works on them).
|
|
25
26
|
|
|
26
27
|
## Quickstart
|
|
27
28
|
|
|
@@ -79,8 +80,8 @@ threading, buffering, and replay logic stay out of your `.robot` files.
|
|
|
79
80
|
## Summary of Benefits
|
|
80
81
|
|
|
81
82
|
- **Speed.** Waiting time is overlapped instead of added up. In this repo's
|
|
82
|
-
examples, 9 HTTP checks with a 1 s delay each drop from 13 s to 2 s with
|
|
83
|
-
9 workers, and 4 suites × 8 items × 1 s drop from 32.
|
|
83
|
+
examples, 9 HTTP checks with a 1 s delay each drop from 13.9 s to 2.2 s with
|
|
84
|
+
9 workers, and 4 suites × 8 items × 1 s drop from 32.9 s to 5.1 s with
|
|
84
85
|
8 workers (see [Using It Together With pabot](#using-it-together-with-pabot)).
|
|
85
86
|
- **Integrity.** Your `log.html` remains the single source of truth — no
|
|
86
87
|
broken XML tags from concurrent writes.
|
|
@@ -149,7 +150,7 @@ pabot --processes 4 --pythonpath . atest/
|
|
|
149
150
|
pabot --processes 4 --testlevelsplit --pythonpath . atest/
|
|
150
151
|
```
|
|
151
152
|
|
|
152
|
-
Both commands pass the full acceptance suite of this repository (
|
|
153
|
+
Both commands pass the full acceptance suite of this repository (82 tests;
|
|
153
154
|
checked with pabot 5.2.2).
|
|
154
155
|
|
|
155
156
|
How much time each one saves, from the offline
|
|
@@ -158,14 +159,14 @@ How much time each one saves, from the offline
|
|
|
158
159
|
|
|
159
160
|
| Run | Wall-clock time | Speedup |
|
|
160
161
|
|---|---|---|
|
|
161
|
-
| robot, sequential loop | 32.
|
|
162
|
-
| robot + ParallelRunner (8 workers) |
|
|
163
|
-
| pabot (4 processes), sequential loop |
|
|
164
|
-
| pabot (4 processes) + ParallelRunner (8 workers) |
|
|
162
|
+
| robot, sequential loop | 32.9 s | ×1.0 |
|
|
163
|
+
| robot + ParallelRunner (8 workers) | 5.1 s | ×6.5 |
|
|
164
|
+
| pabot (4 processes), sequential loop | 12.4 s | ×2.7 |
|
|
165
|
+
| pabot (4 processes) + ParallelRunner (8 workers) | 4.6 s | ×7.2 |
|
|
165
166
|
|
|
166
167
|
ParallelRunner removes the waiting *inside* each test, and pabot removes it
|
|
167
168
|
*between* suites. With suites this short, pabot's process start-up (about
|
|
168
|
-
|
|
169
|
+
3.5 s here) is most of what's left, so the combination pays off more as
|
|
169
170
|
suites get longer.
|
|
170
171
|
|
|
171
172
|
Things to keep in mind:
|
|
@@ -187,6 +188,31 @@ Things to keep in mind:
|
|
|
187
188
|
could interfere with each other. Keep shared state per process, or split
|
|
188
189
|
by suite.
|
|
189
190
|
|
|
191
|
+
## How Is This Different From AsyncLibrary?
|
|
192
|
+
|
|
193
|
+
[AsyncLibrary](https://github.com/rlehfeld/robotframework-async-keyword)
|
|
194
|
+
(`pip install robotframework-async-keyword`) also runs keywords in threads
|
|
195
|
+
inside one test case. It runs *any* keyword, user keywords included: you
|
|
196
|
+
start each call with `Async Run` and collect it later with `Async Get`. It
|
|
197
|
+
buffers each call's output and replays it, so `log.html` keeps the full
|
|
198
|
+
nested keyword structure. To do that, it works with Robot Framework's
|
|
199
|
+
internal execution context and output.
|
|
200
|
+
|
|
201
|
+
| | **AsyncLibrary** | **ParallelRunner** |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| Runs | Any keyword, including user keywords | A method of a Python library that accepts `_logger` |
|
|
204
|
+
| Usage | `Async Run` per call, `Async Get` later | One `Run Parallel Scenarios` call over a list, or `repeat=N` |
|
|
205
|
+
| Concurrency | Python's default thread pool size | `ROBOT_THREAD_WORKERS` |
|
|
206
|
+
| Hung calls | `Async Get timeout=...` stops waiting for them | `call_timeout` fails only that call and frees its slot for the next item |
|
|
207
|
+
| Failures | `Async Get` raises the call's error | One failure listing every failed item, or a `PASS`/`FAIL` per item with `fail_on_error=False` |
|
|
208
|
+
| Robot internals | Works with the internal execution context and output | Not patched; logs only from the main thread, through the public logger API; tested on Robot Framework 5, 6 and 7 |
|
|
209
|
+
|
|
210
|
+
Use AsyncLibrary when the work lives in user keywords (or in library
|
|
211
|
+
keywords you can't change), or when you want to start something in the
|
|
212
|
+
background, run other steps, and collect it later. Use ParallelRunner to fan
|
|
213
|
+
one Python keyword out over many items as a single batch, with a bounded
|
|
214
|
+
worker pool, per-call timeouts and one failure report for the whole batch.
|
|
215
|
+
|
|
190
216
|
## When Not To Use This
|
|
191
217
|
|
|
192
218
|
- **CPU-bound work.** Threads share Python's GIL — number crunching won't
|
|
@@ -196,11 +222,6 @@ Things to keep in mind:
|
|
|
196
222
|
made thread-safe for you; your own keyword's side effects are not. If it
|
|
197
223
|
writes to a shared variable, file, or object without a lock, running it
|
|
198
224
|
concurrently can race the same way any multi-threaded code can.
|
|
199
|
-
- **You need per-item retries.** Not implemented yet (see
|
|
200
|
-
[ROADMAP.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/ROADMAP.md)). A per-call limit is: `call_timeout=10s` fails
|
|
201
|
-
only the call that hangs and lets the others finish. Note that a timed-out
|
|
202
|
-
call can't be killed - it keeps running in the background unless the
|
|
203
|
-
keyword declares `_cancelled` and stops when it is set.
|
|
204
225
|
- **You need process-level isolation** (a crash in one call shouldn't be
|
|
205
226
|
able to affect another) or cross-machine parallelism — that's pabot's
|
|
206
227
|
domain, not this library's.
|
|
@@ -6,12 +6,12 @@ build-backend = "poetry.core.masonry.api"
|
|
|
6
6
|
name = "robotframework-parallelrunner"
|
|
7
7
|
# Single source of truth for the version: bump with `poetry version patch|minor|major`.
|
|
8
8
|
# ParallelRunner.__version__ / ROBOT_LIBRARY_VERSION read it back from the installed metadata.
|
|
9
|
-
version = "0.
|
|
9
|
+
version = "0.5.0"
|
|
10
10
|
description = "Run a keyword in parallel inside a single Robot Framework test case, with one clean, ordered log.html."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
license = "MIT"
|
|
13
13
|
license-files = ["LICENSE"]
|
|
14
|
-
requires-python = ">=3.
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
15
|
authors = [
|
|
16
16
|
{ name = "Cristian Garcia", email = "cristian.garcia.vd@gmail.com" },
|
|
17
17
|
]
|
|
@@ -30,7 +30,6 @@ classifiers = [
|
|
|
30
30
|
"Intended Audience :: Developers",
|
|
31
31
|
"Operating System :: OS Independent",
|
|
32
32
|
"Programming Language :: Python :: 3",
|
|
33
|
-
"Programming Language :: Python :: 3.9",
|
|
34
33
|
"Programming Language :: Python :: 3.10",
|
|
35
34
|
"Programming Language :: Python :: 3.11",
|
|
36
35
|
"Programming Language :: Python :: 3.12",
|
|
@@ -15,6 +15,7 @@ from robot.libraries.BuiltIn import BuiltIn
|
|
|
15
15
|
from robot.running.arguments import PythonArgumentParser
|
|
16
16
|
from robot.utils import secs_to_timestr, timestr_to_secs
|
|
17
17
|
|
|
18
|
+
from .retry import NO_RETRIES, RetryPolicy
|
|
18
19
|
from .version import __version__
|
|
19
20
|
|
|
20
21
|
|
|
@@ -54,6 +55,18 @@ def _not_given(value: Any) -> bool:
|
|
|
54
55
|
_WORKER, _MAIN = "worker", "main"
|
|
55
56
|
_NOT_SET = object()
|
|
56
57
|
|
|
58
|
+
# Worker threads left running a call that was abandoned - timed out by
|
|
59
|
+
# `call_timeout` or interrupted by a Robot `[Timeout]` - across every keyword
|
|
60
|
+
# call of the run, so the limit (ROBOT_MAX_ABANDONED_THREADS) holds even when
|
|
61
|
+
# the hung calls come from earlier tests. Only the thread running the keywords
|
|
62
|
+
# changes it. Dead threads are dropped by `_abandoned_threads_alive`.
|
|
63
|
+
_abandoned_threads: List[threading.Thread] = []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _abandoned_threads_alive() -> int:
|
|
67
|
+
_abandoned_threads[:] = [thread for thread in _abandoned_threads if thread.is_alive()]
|
|
68
|
+
return len(_abandoned_threads)
|
|
69
|
+
|
|
57
70
|
|
|
58
71
|
def log_to_robot(msg: Any, level: str = "INFO") -> None:
|
|
59
72
|
"""Logs ``msg`` straight to Robot Framework, accepting the same levels as
|
|
@@ -179,6 +192,7 @@ class ParallelRunner:
|
|
|
179
192
|
| logs | List of ``(level, message)`` tuples captured during the call. |
|
|
180
193
|
| result | The return value of the call (only when ``status`` is ``PASS``). |
|
|
181
194
|
| error | The error message (only when ``status`` is ``FAIL``), e.g. ``Timed out after 10 seconds`` with ``call_timeout``. |
|
|
195
|
+
| attempts | How many times the call ran: ``1``, or more with ``call_retries`` (see `Retries`). ``0`` for a call that never ran. |
|
|
182
196
|
|
|
183
197
|
= Failures =
|
|
184
198
|
|
|
@@ -255,6 +269,18 @@ class ParallelRunner:
|
|
|
255
269
|
background, and a new worker thread takes its place so the remaining
|
|
256
270
|
calls still run ``ROBOT_THREAD_WORKERS`` at a time.
|
|
257
271
|
|
|
272
|
+
So that calls that hang can't pile up hundreds of threads (and open
|
|
273
|
+
connections), the abandoned threads still running are limited by
|
|
274
|
+
``ROBOT_MAX_ABANDONED_THREADS`` (default: twice ``ROBOT_THREAD_WORKERS``).
|
|
275
|
+
They are counted across the whole run - calls abandoned by earlier tests,
|
|
276
|
+
or interrupted by a Robot ``[Timeout]``, count too - and a thread stops
|
|
277
|
+
counting as soon as its call returns. Above the limit, a warning is logged
|
|
278
|
+
and timed-out workers are no longer replaced: the remaining calls run with
|
|
279
|
+
fewer workers, and if none is left they fail without starting, with error
|
|
280
|
+
``Not started: abandoned thread limit (8) reached``. Every keyword call
|
|
281
|
+
still starts with its normal number of workers, whatever earlier tests
|
|
282
|
+
left running.
|
|
283
|
+
|
|
258
284
|
A keyword that can stop early declares a ``_cancelled`` parameter.
|
|
259
285
|
`Run Parallel Scenarios` then passes it a ``threading.Event`` that is set
|
|
260
286
|
when that call times out, or when a Robot ``[Timeout]`` interrupts the
|
|
@@ -271,6 +297,32 @@ class ParallelRunner:
|
|
|
271
297
|
existing keywords keep working unchanged. It can't interrupt a call that
|
|
272
298
|
is blocked in a single operation (a network request without a timeout).
|
|
273
299
|
|
|
300
|
+
= Retries =
|
|
301
|
+
|
|
302
|
+
``call_retries=N`` runs a failed call up to ``N`` more times before it
|
|
303
|
+
counts as failed. Only that call is retried; the others are not affected.
|
|
304
|
+
|
|
305
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent | library=MyLibrary | for_loop_iterable=${agents} | call_retries=2 | retry_delay=1s | retry_backoff=2 |
|
|
306
|
+
|
|
307
|
+
- Between attempts the call waits ``retry_delay`` (default ``1s``), multiplied by
|
|
308
|
+
``retry_backoff`` after every failed retry: above, 1 second before the second
|
|
309
|
+
attempt and 2 seconds before the third. The wait happens in a worker
|
|
310
|
+
thread, after the calls that were already waiting.
|
|
311
|
+
- A call that exceeds ``call_timeout`` is retried too, and ``call_timeout``
|
|
312
|
+
applies to each attempt. The timed-out attempt keeps running in the
|
|
313
|
+
background, so it is only retried while the abandoned threads are within
|
|
314
|
+
``ROBOT_MAX_ABANDONED_THREADS``; otherwise it fails with its timeout error.
|
|
315
|
+
- Every attempt's logs are kept, separated by a ``WARN`` such as
|
|
316
|
+
``Attempt 1 of 3 failed: <error>; retrying in 1 second``, so ``log.html``
|
|
317
|
+
shows why a call needed retries even when it passed in the end. Only the
|
|
318
|
+
last failure is logged as ``ERROR``.
|
|
319
|
+
- The ``attempts`` key of the result tells how many attempts the call took,
|
|
320
|
+
to spot calls that are flaky even when the test passes.
|
|
321
|
+
- A Robot ``[Timeout]`` stops the retries along with the rest of the batch.
|
|
322
|
+
|
|
323
|
+
Retry only what is safe to repeat: a call that failed half way may already
|
|
324
|
+
have changed something (a created record, a sent message).
|
|
325
|
+
|
|
274
326
|
= Custom logger mappers =
|
|
275
327
|
|
|
276
328
|
A logger mapper replaces the default buffering logger: the target keyword
|
|
@@ -295,6 +347,7 @@ class ParallelRunner:
|
|
|
295
347
|
|
|
296
348
|
| =Variable= | =Description= |
|
|
297
349
|
| ROBOT_THREAD_WORKERS | Number of worker threads. Read when the library is imported. Default ``4``. An invalid value is ignored with a warning. |
|
|
350
|
+
| ROBOT_MAX_ABANDONED_THREADS | Maximum number of threads left running by timed-out calls before they are no longer replaced. Read when the library is imported. Default: twice ``ROBOT_THREAD_WORKERS``. ``0`` never replaces them. An invalid value is ignored with a warning. See `Call timeouts and cancellation`. |
|
|
298
351
|
| ROBOT_LOGGER_MAPPER | Default ``logger_mapper`` as a ``module.function`` path, used when the argument is not given. See `Custom logger mappers`. |
|
|
299
352
|
|
|
300
353
|
= When not to use it =
|
|
@@ -312,6 +365,8 @@ class ParallelRunner:
|
|
|
312
365
|
|
|
313
366
|
def __init__(self):
|
|
314
367
|
self.max_workers = self._workers_from_environment()
|
|
368
|
+
# None: twice the number of workers (see `_abandoned_thread_limit`).
|
|
369
|
+
self.max_abandoned_threads = self._abandoned_limit_from_environment()
|
|
315
370
|
|
|
316
371
|
def _workers_from_environment(self) -> int:
|
|
317
372
|
"""``ROBOT_THREAD_WORKERS``, or 4. An invalid value is ignored with a
|
|
@@ -332,6 +387,30 @@ class ParallelRunner:
|
|
|
332
387
|
return self.DEFAULT_WORKERS
|
|
333
388
|
return workers
|
|
334
389
|
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _abandoned_limit_from_environment() -> Optional[int]:
|
|
392
|
+
"""``ROBOT_MAX_ABANDONED_THREADS``, or None for the default. An invalid
|
|
393
|
+
value is ignored with a warning, like ``ROBOT_THREAD_WORKERS``."""
|
|
394
|
+
value = os.getenv("ROBOT_MAX_ABANDONED_THREADS")
|
|
395
|
+
if _not_given(value):
|
|
396
|
+
return None
|
|
397
|
+
try:
|
|
398
|
+
limit = int(value)
|
|
399
|
+
except ValueError:
|
|
400
|
+
limit = -1
|
|
401
|
+
if limit < 0:
|
|
402
|
+
logger.warn(
|
|
403
|
+
f"Ignoring ROBOT_MAX_ABANDONED_THREADS {value!r}: expected a whole number of at least 0. "
|
|
404
|
+
"Using twice the number of workers."
|
|
405
|
+
)
|
|
406
|
+
return None
|
|
407
|
+
return limit
|
|
408
|
+
|
|
409
|
+
def _abandoned_thread_limit(self) -> int:
|
|
410
|
+
if self.max_abandoned_threads is None:
|
|
411
|
+
return 2 * self.max_workers
|
|
412
|
+
return self.max_abandoned_threads
|
|
413
|
+
|
|
335
414
|
def run_parallel_scenarios(
|
|
336
415
|
self,
|
|
337
416
|
keyword: str,
|
|
@@ -344,6 +423,9 @@ class ParallelRunner:
|
|
|
344
423
|
return_values_only: bool = False,
|
|
345
424
|
fail_on_error: bool = True,
|
|
346
425
|
call_timeout: Union[str, float, None] = None,
|
|
426
|
+
call_retries: Union[int, str, None] = 0,
|
|
427
|
+
retry_delay: Union[str, float, None] = "1s",
|
|
428
|
+
retry_backoff: Union[float, str, None] = 1,
|
|
347
429
|
**kwargs
|
|
348
430
|
) -> Union[List[Dict[str, Any]], Tuple[Any, ...]]:
|
|
349
431
|
"""Runs ``keyword`` from ``library`` concurrently, once per item or N times.
|
|
@@ -388,6 +470,13 @@ class ParallelRunner:
|
|
|
388
470
|
calls keep running; calls that declare ``_cancelled`` are told to
|
|
389
471
|
stop. Not given (default): no limit. See
|
|
390
472
|
`Call timeouts and cancellation`.
|
|
473
|
+
- ``call_retries``: How many more times a failed call is tried
|
|
474
|
+
(``0``, default: none). A call that exceeds ``call_timeout`` is
|
|
475
|
+
retried too. See `Retries`.
|
|
476
|
+
- ``retry_delay``: Wait before the first retry, in Robot's time format.
|
|
477
|
+
Default ``1s``; ``0`` retries right away.
|
|
478
|
+
- ``retry_backoff``: Multiplies the wait after every failed retry:
|
|
479
|
+
``2`` waits 1 s, 2 s, 4 s... Default ``1`` (always the same wait).
|
|
391
480
|
- ``**kwargs``: Any other named argument is passed to every call,
|
|
392
481
|
converted by the method's type hints like in a direct call.
|
|
393
482
|
|
|
@@ -403,6 +492,7 @@ class ParallelRunner:
|
|
|
403
492
|
| ${id1} ${id2} ${id3}= | Run Parallel Scenarios | keyword=Seed Record | library=MyLibrary | repeat=3 | return_values_only=True |
|
|
404
493
|
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | fail_on_error=False |
|
|
405
494
|
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | call_timeout=30s |
|
|
495
|
+
| ${results}= | Run Parallel Scenarios | keyword=Verify Agent Data | library=MyLibrary | for_loop_iterable=${agents} | call_retries=2 | retry_delay=500ms |
|
|
406
496
|
"""
|
|
407
497
|
# Determine items to process. NOTE: `repeat or 1` would be wrong here -
|
|
408
498
|
# 0 is falsy in Python, so an explicit repeat=0 would silently fall
|
|
@@ -422,6 +512,7 @@ class ParallelRunner:
|
|
|
422
512
|
|
|
423
513
|
min_level = self._resolve_thread_log_level(thread_log_level)
|
|
424
514
|
timeout_seconds = self._resolve_call_timeout(call_timeout)
|
|
515
|
+
retry = RetryPolicy.from_arguments(call_retries, retry_delay, retry_backoff)
|
|
425
516
|
|
|
426
517
|
# Note: We must call the underlying Python method, not BuiltIn().run_keyword
|
|
427
518
|
# because run_keyword is not thread-safe.
|
|
@@ -429,7 +520,7 @@ class ParallelRunner:
|
|
|
429
520
|
method = self._get_keyword_method(lib_instance, keyword)
|
|
430
521
|
|
|
431
522
|
results = self._run_in_threads(
|
|
432
|
-
method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs, timeout_seconds
|
|
523
|
+
method, list(items), effective_mapper, remove_passing_logs, min_level, kwargs, timeout_seconds, retry
|
|
433
524
|
)
|
|
434
525
|
|
|
435
526
|
# Step: Re-play logs into Robot Framework sequentially
|
|
@@ -616,15 +707,25 @@ class ParallelRunner:
|
|
|
616
707
|
min_level: int,
|
|
617
708
|
kwargs: Dict[str, Any],
|
|
618
709
|
call_timeout: Optional[float] = None,
|
|
710
|
+
retry: RetryPolicy = NO_RETRIES,
|
|
619
711
|
) -> List[Dict[str, Any]]:
|
|
620
712
|
"""
|
|
621
713
|
Runs ``method`` once per item on up to ``max_workers`` daemon threads
|
|
622
714
|
and returns the result dicts in call order (NOT completion order).
|
|
623
715
|
|
|
716
|
+
A failed attempt is tried again while ``retry`` allows it: the worker
|
|
717
|
+
puts the call back in the queue with the time it may start again, and
|
|
718
|
+
whichever worker takes it waits until then. ``call_timeout`` applies
|
|
719
|
+
to each attempt, and a timed-out attempt is retried only while the
|
|
720
|
+
abandoned threads are within their limit.
|
|
721
|
+
|
|
624
722
|
With ``call_timeout``, a call still running after that many seconds
|
|
625
723
|
becomes a ``FAIL`` result: its thread is abandoned (it can't be
|
|
626
724
|
killed) and a new worker takes its place, so the other calls keep
|
|
627
|
-
running ``max_workers`` at a time
|
|
725
|
+
running ``max_workers`` at a time - while no more than the limit of
|
|
726
|
+
abandoned threads (``_abandoned_thread_limit``) are alive. Past it,
|
|
727
|
+
no replacements are started; if every worker is gone, the calls that
|
|
728
|
+
did not start fail right away.
|
|
628
729
|
|
|
629
730
|
If the wait is interrupted (mostly by a Robot ``[Timeout]``), the
|
|
630
731
|
workers stop starting new calls, the logs collected so far are
|
|
@@ -647,13 +748,27 @@ class ParallelRunner:
|
|
|
647
748
|
# Created here, not in the workers, so the logs of calls that are still
|
|
648
749
|
# running can be replayed if the keyword is interrupted or times out.
|
|
649
750
|
log_buffers: List[list] = [[] for _ in items]
|
|
650
|
-
# Written by the workers.
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
#
|
|
751
|
+
# Written by the workers. When an attempt of call i starts, a worker
|
|
752
|
+
# writes `threads[i]`, `attempts[i]` (its number, 1-based) and then
|
|
753
|
+
# `attempt_started[i]` - in that order. `attempt_started[i]` is None
|
|
754
|
+
# while the call waits for a retry (it is cleared before the retry is
|
|
755
|
+
# queued), and the main thread reads `attempts[i]` first: so it never
|
|
756
|
+
# pairs an attempt with the start time of an earlier one, which would
|
|
757
|
+
# time the new attempt out too soon. `started_at[i]` is the start of
|
|
758
|
+
# the first attempt. `finished[i]`
|
|
759
|
+
# is written when the last attempt has returned - unless the main
|
|
760
|
+
# thread has timed the attempt out first. `claims`, keyed by (call,
|
|
761
|
+
# attempt), decides which of the two wins: dict.setdefault is atomic,
|
|
762
|
+
# so the first claim sticks.
|
|
654
763
|
started_at: List[Optional[float]] = [None] * count
|
|
764
|
+
attempt_started: List[Optional[float]] = [None] * count
|
|
765
|
+
attempts: List[int] = [0] * count
|
|
766
|
+
threads: List[Optional[threading.Thread]] = [None] * count
|
|
655
767
|
finished: List[Optional[Dict[str, Any]]] = [None] * count
|
|
656
|
-
claims: Dict[int, str] = {}
|
|
768
|
+
claims: Dict[Tuple[int, int], str] = {}
|
|
769
|
+
# One entry per worker that ended because there was nothing left to
|
|
770
|
+
# run (or the batch was interrupted), not because it was abandoned.
|
|
771
|
+
exited: List[int] = []
|
|
657
772
|
# Written by the main thread only.
|
|
658
773
|
results: List[Optional[Dict[str, Any]]] = [None] * count
|
|
659
774
|
|
|
@@ -672,36 +787,67 @@ class ParallelRunner:
|
|
|
672
787
|
message = str(error)
|
|
673
788
|
log_buffers[index].append(("ERROR", f"Thread failed for item {item}: {message}"))
|
|
674
789
|
started_at[index] = time.monotonic()
|
|
675
|
-
finished[index] = {
|
|
790
|
+
finished[index] = {
|
|
791
|
+
"status": "FAIL", "item": item, "logs": log_buffers[index], "error": message, "attempts": 0,
|
|
792
|
+
}
|
|
676
793
|
continue
|
|
677
|
-
|
|
794
|
+
# (call, attempt, earliest start): retries go back in the queue too.
|
|
795
|
+
pending.put((index, 1, 0.0))
|
|
678
796
|
stop = threading.Event()
|
|
679
797
|
workers_started = 0
|
|
680
798
|
|
|
681
799
|
def worker():
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
800
|
+
abandoned = False
|
|
801
|
+
try:
|
|
802
|
+
while not stop.is_set():
|
|
803
|
+
try:
|
|
804
|
+
index, attempt, ready_at = pending.get_nowait()
|
|
805
|
+
except queue.Empty:
|
|
806
|
+
return
|
|
807
|
+
wait = ready_at - time.monotonic()
|
|
808
|
+
if wait > 0 and stop.wait(wait):
|
|
809
|
+
return
|
|
810
|
+
call_kwargs = kwargs if cancel_events is None else {**kwargs, "_cancelled": cancel_events[index]}
|
|
811
|
+
log_buffer = log_buffers[index]
|
|
812
|
+
threads[index] = threading.current_thread()
|
|
813
|
+
attempts[index] = attempt
|
|
814
|
+
now = time.monotonic()
|
|
815
|
+
attempt_started[index] = now
|
|
816
|
+
if started_at[index] is None:
|
|
817
|
+
started_at[index] = now
|
|
818
|
+
last = not retry.can_retry(attempt)
|
|
819
|
+
result = self._execute_and_capture(
|
|
820
|
+
method, items[index], logger_mapper, log_buffer, call_kwargs, arguments[index],
|
|
821
|
+
log_failure=last,
|
|
822
|
+
)
|
|
823
|
+
if claims.setdefault((index, attempt), _WORKER) != _WORKER:
|
|
824
|
+
# The attempt timed out and a new worker took this
|
|
825
|
+
# thread's place: stop here, so no more than
|
|
826
|
+
# max_workers calls run.
|
|
827
|
+
abandoned = True
|
|
828
|
+
return
|
|
829
|
+
if result["status"] == "FAIL" and not last:
|
|
830
|
+
attempt_started[index] = None
|
|
831
|
+
log_buffer.append(("WARN", retry.failed_message(attempt, result["error"])))
|
|
832
|
+
pending.put((index, attempt + 1, time.monotonic() + retry.delay_after(attempt)))
|
|
833
|
+
else:
|
|
834
|
+
result["attempts"] = attempt
|
|
835
|
+
finished[index] = result
|
|
836
|
+
finally:
|
|
837
|
+
if not abandoned:
|
|
838
|
+
exited.append(1)
|
|
697
839
|
|
|
698
840
|
def start_worker():
|
|
699
841
|
nonlocal workers_started
|
|
700
842
|
workers_started += 1
|
|
701
843
|
threading.Thread(target=worker, name=f"ParallelRunner-worker-{workers_started}", daemon=True).start()
|
|
702
844
|
|
|
845
|
+
workers_wanted = min(self.max_workers, count)
|
|
846
|
+
limit = self._abandoned_thread_limit()
|
|
847
|
+
abandoned_here = 0
|
|
848
|
+
limit_warned = False
|
|
703
849
|
try:
|
|
704
|
-
for _ in range(
|
|
850
|
+
for _ in range(workers_wanted):
|
|
705
851
|
start_worker()
|
|
706
852
|
# Deliberately no lock, Event or Future wait here: a timeout raised
|
|
707
853
|
# asynchronously while such a wait holds an internal lock could
|
|
@@ -721,20 +867,65 @@ class ParallelRunner:
|
|
|
721
867
|
if finished[index] is not None:
|
|
722
868
|
results[index] = finished[index]
|
|
723
869
|
remaining -= 1
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
870
|
+
continue
|
|
871
|
+
attempt = attempts[index] # before attempt_started: see above
|
|
872
|
+
began = attempt_started[index]
|
|
873
|
+
if (
|
|
874
|
+
call_timeout is None
|
|
875
|
+
or began is None
|
|
876
|
+
or now - began < call_timeout
|
|
877
|
+
or claims.setdefault((index, attempt), _MAIN) != _MAIN
|
|
728
878
|
):
|
|
729
|
-
|
|
730
|
-
|
|
879
|
+
still_running.append(index)
|
|
880
|
+
continue
|
|
881
|
+
if cancel_events is not None:
|
|
882
|
+
cancel_events[index].set()
|
|
883
|
+
_abandoned_threads.append(threads[index])
|
|
884
|
+
abandoned_here += 1
|
|
885
|
+
retrying = retry.can_retry(attempt)
|
|
886
|
+
if retrying and _abandoned_threads_alive() <= limit:
|
|
887
|
+
# The abandoned attempt keeps the old buffer and event:
|
|
888
|
+
# its late logs don't mix with the next attempt's.
|
|
889
|
+
duration = secs_to_timestr(call_timeout)
|
|
890
|
+
log_buffers[index] = log_buffers[index] + [
|
|
891
|
+
("WARN", retry.timed_out_message(attempt, duration))
|
|
892
|
+
]
|
|
731
893
|
if cancel_events is not None:
|
|
732
|
-
cancel_events[index].
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
else:
|
|
894
|
+
cancel_events[index] = threading.Event()
|
|
895
|
+
attempt_started[index] = None
|
|
896
|
+
pending.put((index, attempt + 1, now + retry.delay_after(attempt)))
|
|
736
897
|
still_running.append(index)
|
|
898
|
+
else:
|
|
899
|
+
results[index] = self._timed_out_result(
|
|
900
|
+
items[index], log_buffers[index], call_timeout, attempt,
|
|
901
|
+
not_retried_limit=limit if retrying else None,
|
|
902
|
+
)
|
|
903
|
+
remaining -= 1
|
|
737
904
|
running = still_running
|
|
905
|
+
# Replace the workers abandoned by timed-out calls (or ended
|
|
906
|
+
# while a timed-out call was put back in the queue) while
|
|
907
|
+
# calls are still waiting - unless too many abandoned threads
|
|
908
|
+
# are alive (from this keyword call or earlier ones). Checked
|
|
909
|
+
# on every round, so a slot freed by a hung call that finally
|
|
910
|
+
# returns is used again.
|
|
911
|
+
active = workers_started - abandoned_here - len(exited)
|
|
912
|
+
missing = workers_wanted - active
|
|
913
|
+
if missing > 0 and not pending.empty():
|
|
914
|
+
alive = _abandoned_threads_alive()
|
|
915
|
+
if alive <= limit:
|
|
916
|
+
for _ in range(missing):
|
|
917
|
+
start_worker()
|
|
918
|
+
else:
|
|
919
|
+
if not limit_warned:
|
|
920
|
+
limit_warned = True
|
|
921
|
+
logger.warn(
|
|
922
|
+
f"{alive} thread(s) abandoned by timed-out calls are still running, more than the "
|
|
923
|
+
f"limit of {limit} (ROBOT_MAX_ABANDONED_THREADS): timed-out workers are no longer "
|
|
924
|
+
"replaced, so the remaining calls run with fewer workers."
|
|
925
|
+
)
|
|
926
|
+
if active == 0:
|
|
927
|
+
# No worker left to run them: fail them now instead of waiting forever.
|
|
928
|
+
self._fail_not_started(pending, items, started_at, finished, log_buffers, limit)
|
|
738
929
|
if remaining:
|
|
739
930
|
time.sleep(_POLL_INTERVAL)
|
|
740
931
|
except BaseException as error:
|
|
@@ -742,6 +933,10 @@ class ParallelRunner:
|
|
|
742
933
|
if cancel_events is not None:
|
|
743
934
|
for event in cancel_events:
|
|
744
935
|
event.set()
|
|
936
|
+
# The calls still running are abandoned too: they count towards the limit.
|
|
937
|
+
for index in range(count):
|
|
938
|
+
if attempt_started[index] is not None and results[index] is None and finished[index] is None:
|
|
939
|
+
_abandoned_threads.append(threads[index])
|
|
745
940
|
self._replay_interrupted(
|
|
746
941
|
items, results, finished, started_at, log_buffers, error, remove_passing_logs, min_level
|
|
747
942
|
)
|
|
@@ -749,12 +944,42 @@ class ParallelRunner:
|
|
|
749
944
|
return results
|
|
750
945
|
|
|
751
946
|
@staticmethod
|
|
752
|
-
def _timed_out_result(
|
|
947
|
+
def _timed_out_result(
|
|
948
|
+
item, log_buffer: list, call_timeout: float, attempt: int = 1, not_retried_limit: Optional[int] = None
|
|
949
|
+
) -> Dict[str, Any]:
|
|
950
|
+
"""``not_retried_limit``: the abandoned thread limit, when it is what
|
|
951
|
+
kept the call from being retried (not given: no retries were left)."""
|
|
753
952
|
duration = secs_to_timestr(call_timeout)
|
|
754
953
|
logs = list(log_buffer)
|
|
755
954
|
logs.append(("ERROR", f"Call for item {item} timed out after {duration} (call_timeout); "
|
|
756
955
|
"it keeps running in the background"))
|
|
757
|
-
|
|
956
|
+
if not_retried_limit is not None:
|
|
957
|
+
logs.append(("ERROR", f"Call for item {item} not retried: the abandoned thread limit "
|
|
958
|
+
f"({not_retried_limit}, ROBOT_MAX_ABANDONED_THREADS) was reached"))
|
|
959
|
+
return {"status": "FAIL", "item": item, "logs": logs, "error": f"Timed out after {duration}", "attempts": attempt}
|
|
960
|
+
|
|
961
|
+
@staticmethod
|
|
962
|
+
def _fail_not_started(pending, items, started_at, finished, log_buffers, limit: int) -> None:
|
|
963
|
+
"""Fails every call still waiting in ``pending``. Only called when no
|
|
964
|
+
worker is left, so nothing else reads the queue. The results are
|
|
965
|
+
picked up by the main loop like those of finished calls."""
|
|
966
|
+
while True:
|
|
967
|
+
try:
|
|
968
|
+
index, attempt, _ = pending.get_nowait()
|
|
969
|
+
except queue.Empty:
|
|
970
|
+
return
|
|
971
|
+
item = items[index]
|
|
972
|
+
log_buffers[index].append((
|
|
973
|
+
"ERROR",
|
|
974
|
+
f"Call for item {item} not started: every worker was abandoned by a timed-out call and "
|
|
975
|
+
f"the abandoned thread limit ({limit}, ROBOT_MAX_ABANDONED_THREADS) was reached",
|
|
976
|
+
))
|
|
977
|
+
finished[index] = {
|
|
978
|
+
"status": "FAIL", "item": item, "logs": log_buffers[index],
|
|
979
|
+
"error": f"Not started: abandoned thread limit ({limit}) reached", "attempts": attempt - 1,
|
|
980
|
+
}
|
|
981
|
+
if started_at[index] is None:
|
|
982
|
+
started_at[index] = time.monotonic()
|
|
758
983
|
|
|
759
984
|
def _replay_interrupted(self, items, results, finished, started_at, log_buffers, error, remove_passing_logs, min_level):
|
|
760
985
|
"""Replays what the calls logged before the keyword was interrupted.
|
|
@@ -784,10 +1009,13 @@ class ParallelRunner:
|
|
|
784
1009
|
|
|
785
1010
|
def _execute_and_capture(
|
|
786
1011
|
self, method, item, logger_mapper: Optional[Any], log_buffer: list, kwargs: Dict[str, Any],
|
|
787
|
-
argument: Any = _NOT_SET,
|
|
1012
|
+
argument: Any = _NOT_SET, log_failure: bool = True,
|
|
788
1013
|
) -> Dict[str, Any]:
|
|
789
1014
|
"""Worker wrapper to capture logs and results.
|
|
790
1015
|
|
|
1016
|
+
``log_failure=False`` (an attempt that will be retried) leaves out the
|
|
1017
|
+
``ERROR`` line of a failure; the caller logs the attempt as a warning.
|
|
1018
|
+
|
|
791
1019
|
``argument`` is what the call receives as its first argument (``item``
|
|
792
1020
|
converted by its type hint), ``item`` what the result reports. ``kwargs``
|
|
793
1021
|
is a dict, not ``**kwargs``, so a keyword argument that happens to be
|
|
@@ -813,7 +1041,8 @@ class ParallelRunner:
|
|
|
813
1041
|
# SystemExit), the worker must report a result, or the main thread
|
|
814
1042
|
# would wait for this call forever.
|
|
815
1043
|
except BaseException as e:
|
|
816
|
-
|
|
1044
|
+
if log_failure:
|
|
1045
|
+
thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
|
|
817
1046
|
return {"status": "FAIL", "item": item, "logs": log_buffer, "error": str(e)}
|
|
818
1047
|
|
|
819
1048
|
def _replay_logs(self, results: List[Dict[str, Any]], remove_passing_logs: bool = False, min_level: int = 0):
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Retries of failed calls in `Run Parallel Scenarios`: the ``call_retries``,
|
|
2
|
+
``retry_delay`` and ``retry_backoff`` arguments.
|
|
3
|
+
|
|
4
|
+
This module only decides *whether* and *when* a call is retried, and what is
|
|
5
|
+
logged between attempts. Running the attempts on the worker threads (and
|
|
6
|
+
retrying calls that exceeded ``call_timeout``) is done by
|
|
7
|
+
``ParallelRunner._run_in_threads``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from robot.errors import DataError
|
|
15
|
+
from robot.utils import secs_to_timestr, timestr_to_secs
|
|
16
|
+
|
|
17
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _not_given(value: Any) -> bool:
|
|
21
|
+
"""None, an empty string or "None" (any case), like in parallel_library."""
|
|
22
|
+
return value is None or (isinstance(value, str) and value.strip().upper() in ("", "NONE"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RetryPolicy:
|
|
27
|
+
"""How often and how long apart a failed call is tried again.
|
|
28
|
+
|
|
29
|
+
``retries`` extra attempts after the first one; before attempt ``n + 1``
|
|
30
|
+
the call waits ``delay * backoff ** (n - 1)`` seconds (``backoff=1``: a
|
|
31
|
+
fixed delay; ``backoff=2``: 1 s, 2 s, 4 s...).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
retries: int = 0
|
|
35
|
+
delay: float = DEFAULT_RETRY_DELAY
|
|
36
|
+
backoff: float = 1.0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def attempts(self) -> int:
|
|
40
|
+
"""Maximum number of attempts per call."""
|
|
41
|
+
return self.retries + 1
|
|
42
|
+
|
|
43
|
+
def can_retry(self, attempt: int) -> bool:
|
|
44
|
+
"""Whether a call whose attempt number ``attempt`` (1-based) failed may run again."""
|
|
45
|
+
return attempt < self.attempts
|
|
46
|
+
|
|
47
|
+
def delay_after(self, attempt: int) -> float:
|
|
48
|
+
"""Seconds to wait between failed attempt ``attempt`` and the next one."""
|
|
49
|
+
return self.delay * self.backoff ** (attempt - 1)
|
|
50
|
+
|
|
51
|
+
def failed_message(self, attempt: int, error: str) -> str:
|
|
52
|
+
return f"Attempt {attempt} of {self.attempts} failed: {error}; {self._retrying(attempt)}"
|
|
53
|
+
|
|
54
|
+
def timed_out_message(self, attempt: int, duration: str) -> str:
|
|
55
|
+
return (
|
|
56
|
+
f"Attempt {attempt} of {self.attempts} timed out after {duration} (call_timeout), "
|
|
57
|
+
f"it keeps running in the background; {self._retrying(attempt)}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _retrying(self, attempt: int) -> str:
|
|
61
|
+
delay = self.delay_after(attempt)
|
|
62
|
+
return f"retrying in {secs_to_timestr(delay)}" if delay > 0 else "retrying now"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_arguments(cls, call_retries: Any = None, retry_delay: Any = None, retry_backoff: Any = None) -> "RetryPolicy":
|
|
66
|
+
"""Validates the keyword arguments, in Robot's formats: ``call_retries``
|
|
67
|
+
a whole number, ``retry_delay`` a Robot time (``1s``, ``500ms``),
|
|
68
|
+
``retry_backoff`` a number. Not given: no retries, a 1 second delay,
|
|
69
|
+
no backoff. An invalid value raises ``ValueError`` before anything runs."""
|
|
70
|
+
return cls(
|
|
71
|
+
retries=cls._resolve_retries(call_retries),
|
|
72
|
+
delay=cls._resolve_delay(retry_delay),
|
|
73
|
+
backoff=cls._resolve_backoff(retry_backoff),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _resolve_retries(value: Any) -> int:
|
|
78
|
+
if _not_given(value):
|
|
79
|
+
return 0
|
|
80
|
+
try:
|
|
81
|
+
# Through str: rejects 1.5 (and True) instead of truncating it.
|
|
82
|
+
retries = int(str(value).strip())
|
|
83
|
+
except ValueError:
|
|
84
|
+
retries = -1
|
|
85
|
+
if retries < 0:
|
|
86
|
+
raise ValueError(f"Invalid call_retries {value!r}: expected a whole number of at least 0.")
|
|
87
|
+
return retries
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _resolve_delay(value: Any) -> float:
|
|
91
|
+
if _not_given(value):
|
|
92
|
+
return DEFAULT_RETRY_DELAY
|
|
93
|
+
try:
|
|
94
|
+
seconds = timestr_to_secs(value)
|
|
95
|
+
except (ValueError, DataError) as error:
|
|
96
|
+
raise ValueError(f"Invalid retry_delay {value!r}: {error}") from None
|
|
97
|
+
if seconds < 0:
|
|
98
|
+
raise ValueError(f"retry_delay can't be negative, got {value!r}.")
|
|
99
|
+
return seconds
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _resolve_backoff(value: Any) -> float:
|
|
103
|
+
if _not_given(value):
|
|
104
|
+
return 1.0
|
|
105
|
+
try:
|
|
106
|
+
backoff = float(str(value).strip())
|
|
107
|
+
except ValueError:
|
|
108
|
+
raise ValueError(f"Invalid retry_backoff {value!r}: expected a number of at least 1.") from None
|
|
109
|
+
if not 1 <= backoff < math.inf: # also rejects NaN
|
|
110
|
+
raise ValueError(f"retry_backoff must be at least 1, got {value!r}.")
|
|
111
|
+
return backoff
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
NO_RETRIES = RetryPolicy()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{robotframework_parallelrunner-0.4.0 → robotframework_parallelrunner-0.5.0}/src/parallelrunner.py
RENAMED
|
File without changes
|