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.
@@ -1,20 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robotframework-parallelrunner
3
- Version: 0.4.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.9
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.9+ and Robot Framework 5.0+ (installed automatically).
57
- See [docs/INSTALLATION.md](https://github.com/cristiangarciavd/robotframework-parallelrunner/blob/main/docs/INSTALLATION.md) for development installs
58
- and upgrading from the pre-release import path.
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.7 s to 4.6 s with
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 (52 tests;
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.7 s | ×1.0 |
196
- | robot + ParallelRunner (8 workers) | 4.6 s | ×7.1 |
197
- | pabot (4 processes), sequential loop | 10.6 s | ×3.1 |
198
- | pabot (4 processes) + ParallelRunner (8 workers) | 3.5 s | ×9.2 |
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
- 2.5 s here) is most of what's left, so the combination pays off more as
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.9+ 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
- and upgrading from the pre-release import path.
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.7 s to 4.6 s with
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 (52 tests;
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.7 s | ×1.0 |
162
- | robot + ParallelRunner (8 workers) | 4.6 s | ×7.1 |
163
- | pabot (4 processes), sequential loop | 10.6 s | ×3.1 |
164
- | pabot (4 processes) + ParallelRunner (8 workers) | 3.5 s | ×9.2 |
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
- 2.5 s here) is most of what's left, so the combination pays off more as
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.4.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.9"
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. A worker writes `started_at[i]` when call i
651
- # starts and `finished[i]` when it has returned - unless the main
652
- # thread has timed the call out first. `claims` decides which of the
653
- # two wins: dict.setdefault is atomic, so the first claim sticks.
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] = {"status": "FAIL", "item": item, "logs": log_buffers[index], "error": message}
790
+ finished[index] = {
791
+ "status": "FAIL", "item": item, "logs": log_buffers[index], "error": message, "attempts": 0,
792
+ }
676
793
  continue
677
- pending.put(index)
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
- while not stop.is_set():
683
- try:
684
- index = pending.get_nowait()
685
- except queue.Empty:
686
- return
687
- call_kwargs = kwargs if cancel_events is None else {**kwargs, "_cancelled": cancel_events[index]}
688
- started_at[index] = time.monotonic()
689
- result = self._execute_and_capture(
690
- method, items[index], logger_mapper, log_buffers[index], call_kwargs, arguments[index]
691
- )
692
- if claims.setdefault(index, _WORKER) != _WORKER:
693
- # The call timed out and a new worker took this thread's
694
- # place: stop here, so no more than max_workers calls run.
695
- return
696
- finished[index] = result
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(min(self.max_workers, count)):
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
- elif (
725
- call_timeout is not None
726
- and now - started_at[index] >= call_timeout
727
- and claims.setdefault(index, _MAIN) == _MAIN
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
- results[index] = self._timed_out_result(items[index], log_buffers[index], call_timeout)
730
- remaining -= 1
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].set()
733
- if not pending.empty():
734
- start_worker()
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(item, log_buffer: list, call_timeout: float) -> Dict[str, Any]:
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
- return {"status": "FAIL", "item": item, "logs": logs, "error": f"Timed out after {duration}"}
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
- thread_log(f"Thread failed for item {item}: {str(e)}", "ERROR")
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()