behave-pool 1.0.0__py3-none-any.whl
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.
- behave_pool/__init__.py +21 -0
- behave_pool/config.py +131 -0
- behave_pool/iterator.py +81 -0
- behave_pool/py.typed +0 -0
- behave_pool/result.py +32 -0
- behave_pool/runner.py +343 -0
- behave_pool/timing.py +148 -0
- behave_pool/work_unit.py +42 -0
- behave_pool/worker.py +364 -0
- behave_pool-1.0.0.dist-info/METADATA +167 -0
- behave_pool-1.0.0.dist-info/RECORD +14 -0
- behave_pool-1.0.0.dist-info/WHEEL +4 -0
- behave_pool-1.0.0.dist-info/entry_points.txt +2 -0
- behave_pool-1.0.0.dist-info/licenses/LICENSE +21 -0
behave_pool/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""behave-pool: parallel test execution for Behave BDD via native ITestRunner.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
from behave_pool import ParallelRunner
|
|
6
|
+
|
|
7
|
+
# Register in behave.ini:
|
|
8
|
+
# [behave.runners]
|
|
9
|
+
# parallel = behave_pool:ParallelRunner
|
|
10
|
+
|
|
11
|
+
# Then run:
|
|
12
|
+
# behave --runner=parallel --parallel 4 --parallel-scheme feature features/
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
from behave_pool.runner import ParallelRunner
|
|
20
|
+
|
|
21
|
+
__all__ = ["ParallelRunner", "__version__"]
|
behave_pool/config.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Parallel configuration options for behave-pool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from behave.configuration import Configuration
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _register_parallel_options() -> None:
|
|
13
|
+
"""Register --parallel-scheme and --parallel-balance in behave's OPTIONS list (once)."""
|
|
14
|
+
from behave.configuration import OPTIONS
|
|
15
|
+
|
|
16
|
+
existing: set[str] = set()
|
|
17
|
+
for fixed, _ in OPTIONS:
|
|
18
|
+
if fixed:
|
|
19
|
+
existing.add(fixed[0])
|
|
20
|
+
|
|
21
|
+
if "--parallel-scheme" not in existing:
|
|
22
|
+
OPTIONS.append(
|
|
23
|
+
(
|
|
24
|
+
("--parallel-scheme",),
|
|
25
|
+
{
|
|
26
|
+
"dest": "parallel_scheme",
|
|
27
|
+
"default": "feature",
|
|
28
|
+
"help": "Parallelization scheme: feature (default: %(default)s).",
|
|
29
|
+
},
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if "--parallel-balance" not in existing:
|
|
34
|
+
OPTIONS.append(
|
|
35
|
+
(
|
|
36
|
+
("--parallel-balance",),
|
|
37
|
+
{
|
|
38
|
+
"dest": "parallel_balance",
|
|
39
|
+
"default": "lpt",
|
|
40
|
+
"choices": ["lpt", "fifo"],
|
|
41
|
+
"help": (
|
|
42
|
+
"Work unit ordering: lpt (longest first) or fifo (default: %(default)s)."
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if "--parallel-timing-file" not in existing:
|
|
49
|
+
OPTIONS.append(
|
|
50
|
+
(
|
|
51
|
+
("--parallel-timing-file",),
|
|
52
|
+
{
|
|
53
|
+
"dest": "parallel_timing_file",
|
|
54
|
+
"default": ".behave-pool-timing.json",
|
|
55
|
+
"help": "Path to timing file for LPT balancing (default: %(default)s).",
|
|
56
|
+
},
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_register_parallel_options()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ConfigSnapshot:
|
|
66
|
+
"""Picklable snapshot of essential Configuration fields for worker processes.
|
|
67
|
+
|
|
68
|
+
The full behave Configuration contains non-picklable objects (file
|
|
69
|
+
handles, reporters). This snapshot captures only the fields needed
|
|
70
|
+
by WorkerRunner to execute features.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
base_dir: str
|
|
74
|
+
steps_dir: str
|
|
75
|
+
environment_file: str
|
|
76
|
+
lang: str | None
|
|
77
|
+
stop: bool
|
|
78
|
+
paths: list[str] = field(default_factory=list)
|
|
79
|
+
parallel: int = 1
|
|
80
|
+
parallel_scheme: str = "feature"
|
|
81
|
+
parallel_balance: str = "lpt"
|
|
82
|
+
parallel_timing_file: str = ".behave-pool-timing.json"
|
|
83
|
+
dry_run: bool = False
|
|
84
|
+
use_nested_step_modules: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def snapshot_config(config: Configuration) -> ConfigSnapshot:
|
|
88
|
+
"""Create a picklable snapshot from a Configuration instance."""
|
|
89
|
+
return ConfigSnapshot(
|
|
90
|
+
base_dir=str(getattr(config, "base_dir", None) or "features"),
|
|
91
|
+
steps_dir=str(getattr(config, "steps_dir", None) or "steps"),
|
|
92
|
+
environment_file=str(getattr(config, "environment_file", None) or "environment.py"),
|
|
93
|
+
lang=config.lang,
|
|
94
|
+
stop=config.stop,
|
|
95
|
+
paths=[str(p) for p in config.paths] if config.paths else [],
|
|
96
|
+
parallel=getattr(config, "parallel", None) or getattr(config, "jobs", None) or 1,
|
|
97
|
+
parallel_scheme=getattr(config, "parallel_scheme", "feature"),
|
|
98
|
+
parallel_balance=getattr(config, "parallel_balance", "lpt"),
|
|
99
|
+
parallel_timing_file=str(
|
|
100
|
+
getattr(config, "parallel_timing_file", None) or ".behave-pool-timing.json"
|
|
101
|
+
),
|
|
102
|
+
dry_run=config.dry_run,
|
|
103
|
+
use_nested_step_modules=getattr(config, "use_nested_step_modules", False),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def add_parallel_options(config: Configuration) -> None:
|
|
108
|
+
"""Add parallel-related attributes to a Configuration instance.
|
|
109
|
+
|
|
110
|
+
Maps behave's ``config.jobs`` (from ``--parallel``/``--jobs``) to
|
|
111
|
+
``config.parallel`` and ensures ``config.parallel_scheme`` exists.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
config: Behave Configuration instance to augment.
|
|
115
|
+
"""
|
|
116
|
+
jobs = getattr(config, "jobs", 1)
|
|
117
|
+
if jobs is None:
|
|
118
|
+
jobs = 1
|
|
119
|
+
config.parallel = jobs
|
|
120
|
+
|
|
121
|
+
if not hasattr(config, "parallel_scheme") or config.parallel_scheme is None:
|
|
122
|
+
config.parallel_scheme = "feature"
|
|
123
|
+
|
|
124
|
+
if not hasattr(config, "parallel_balance") or config.parallel_balance is None:
|
|
125
|
+
config.parallel_balance = "lpt"
|
|
126
|
+
|
|
127
|
+
if not hasattr(config, "parallel_timing_file") or config.parallel_timing_file is None:
|
|
128
|
+
config.parallel_timing_file = ".behave-pool-timing.json"
|
|
129
|
+
|
|
130
|
+
if not hasattr(config, "use_nested_step_modules"):
|
|
131
|
+
config.use_nested_step_modules = False
|
behave_pool/iterator.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Strategy pattern for iterating work units from Behave features."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from behave_pool.config import snapshot_config
|
|
10
|
+
from behave_pool.work_unit import WorkUnit
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from behave.configuration import Configuration
|
|
14
|
+
from behave.model import Feature
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkUnitIterator(ABC):
|
|
18
|
+
"""Abstract strategy for generating WorkUnits from parsed features."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def iterate(self) -> Iterator[WorkUnit]:
|
|
22
|
+
"""Yield WorkUnit instances one at a time."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def for_scheme(
|
|
27
|
+
scheme: str,
|
|
28
|
+
features: list[Feature],
|
|
29
|
+
config: Configuration,
|
|
30
|
+
) -> WorkUnitIterator:
|
|
31
|
+
"""Factory: return the iterator for the given parallel scheme.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
scheme: "feature" or "scenario".
|
|
35
|
+
features: Parsed Behave Feature objects.
|
|
36
|
+
config: Coordinator's Configuration (will be deep-copied per unit).
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A WorkUnitIterator instance for the requested scheme.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If scheme is not recognised.
|
|
43
|
+
NotImplementedError: If scheme is "scenario" (not yet implemented).
|
|
44
|
+
"""
|
|
45
|
+
if scheme == "feature":
|
|
46
|
+
return FeatureIterator(features, config)
|
|
47
|
+
if scheme == "scenario":
|
|
48
|
+
raise NotImplementedError("ScenarioIterator is not yet implemented")
|
|
49
|
+
msg = f"Unknown parallel scheme: {scheme!r}. Use 'feature' or 'scenario'."
|
|
50
|
+
raise ValueError(msg)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FeatureIterator(WorkUnitIterator):
|
|
54
|
+
"""Generate one WorkUnit per feature file.
|
|
55
|
+
|
|
56
|
+
Each WorkUnit contains an isolated deep copy of the Configuration
|
|
57
|
+
so that workers can execute independently without shared mutable state.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, features: list[Feature], config: Configuration) -> None:
|
|
61
|
+
self._features = features
|
|
62
|
+
self._config = config
|
|
63
|
+
|
|
64
|
+
def iterate(self) -> Iterator[WorkUnit]:
|
|
65
|
+
"""Yield one WorkUnit per feature.
|
|
66
|
+
|
|
67
|
+
Tags are collected from both the feature and its scenarios.
|
|
68
|
+
If any scenario has the ``serial`` tag, the work unit is
|
|
69
|
+
marked serial so it runs in the serial phase.
|
|
70
|
+
"""
|
|
71
|
+
for feature in self._features:
|
|
72
|
+
tags = set(str(t) for t in (getattr(feature, "tags", None) or []))
|
|
73
|
+
for scenario in getattr(feature, "scenarios", None) or []:
|
|
74
|
+
tags.update(str(t) for t in (getattr(scenario, "tags", None) or []))
|
|
75
|
+
yield WorkUnit(
|
|
76
|
+
id=f"feature:{feature.filename}",
|
|
77
|
+
config=snapshot_config(self._config),
|
|
78
|
+
feature_path=feature.filename,
|
|
79
|
+
scenario_line=None,
|
|
80
|
+
tags=list(tags),
|
|
81
|
+
)
|
behave_pool/py.typed
ADDED
|
File without changes
|
behave_pool/result.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""WorkerResult: the outcome of executing a WorkUnit in a worker process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class WorkerResult:
|
|
10
|
+
"""Result of a worker executing a single WorkUnit.
|
|
11
|
+
|
|
12
|
+
Produced by WorkerRunner.run_work_unit() and sent back to the
|
|
13
|
+
coordinator via the result queue for aggregation.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
worker_id: Identifier of the worker process that produced this result.
|
|
17
|
+
work_unit_id: ID of the WorkUnit that was executed.
|
|
18
|
+
failed: True if any scenario or step in the work unit failed.
|
|
19
|
+
duration: Wall-clock execution time in seconds.
|
|
20
|
+
report_path: Path to the temporary JSON report file, or None if
|
|
21
|
+
no report was written.
|
|
22
|
+
undefined_steps: List of undefined step text patterns encountered.
|
|
23
|
+
error: Error message if the worker process crashed, None otherwise.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
worker_id: int
|
|
27
|
+
work_unit_id: str
|
|
28
|
+
failed: bool
|
|
29
|
+
duration: float
|
|
30
|
+
report_path: str | None = None
|
|
31
|
+
undefined_steps: list[str] = field(default_factory=list)
|
|
32
|
+
error: str | None = None
|
behave_pool/runner.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""ParallelRunner: coordinator that orchestrates parallel feature execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import multiprocessing
|
|
7
|
+
import os
|
|
8
|
+
import queue
|
|
9
|
+
import shutil
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from behave.runner import Runner, make_formatters, parse_features
|
|
15
|
+
|
|
16
|
+
from behave_pool.config import add_parallel_options, snapshot_config
|
|
17
|
+
from behave_pool.iterator import WorkUnitIterator
|
|
18
|
+
from behave_pool.result import WorkerResult
|
|
19
|
+
from behave_pool.timing import TimingStore
|
|
20
|
+
from behave_pool.worker import WorkerProcess
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from behave.configuration import Configuration
|
|
26
|
+
|
|
27
|
+
from behave_pool.work_unit import WorkUnit
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ParallelRunner(Runner): # type: ignore[misc]
|
|
33
|
+
"""Coordinator that dispatches work units to worker processes.
|
|
34
|
+
|
|
35
|
+
When ``config.parallel <= 1`` it falls back to the standard Behave
|
|
36
|
+
sequential runner. Otherwise it plans, dispatches, and collects
|
|
37
|
+
results from N worker processes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: Configuration) -> None:
|
|
41
|
+
super().__init__(config)
|
|
42
|
+
add_parallel_options(config)
|
|
43
|
+
|
|
44
|
+
def run(self) -> bool:
|
|
45
|
+
"""Run the test suite — parallel or sequential depending on config."""
|
|
46
|
+
with self.path_manager:
|
|
47
|
+
self.setup_paths()
|
|
48
|
+
return self.run_with_paths()
|
|
49
|
+
|
|
50
|
+
def run_with_paths(self) -> bool:
|
|
51
|
+
"""Run tests with configured paths.
|
|
52
|
+
|
|
53
|
+
If ``config.parallel <= 1`` delegates to the standard sequential
|
|
54
|
+
runner. Otherwise enters the parallel pipeline.
|
|
55
|
+
"""
|
|
56
|
+
if self.config.parallel <= 1:
|
|
57
|
+
return self._run_sequential()
|
|
58
|
+
|
|
59
|
+
return self._run_parallel()
|
|
60
|
+
|
|
61
|
+
def _run_sequential(self) -> bool:
|
|
62
|
+
"""Standard Behave sequential execution."""
|
|
63
|
+
from behave.runner import Context
|
|
64
|
+
|
|
65
|
+
self.context = Context(self)
|
|
66
|
+
self.load_hooks()
|
|
67
|
+
self.load_step_definitions()
|
|
68
|
+
|
|
69
|
+
feature_locations = [
|
|
70
|
+
filename for filename in self.feature_locations() if not self.config.exclude(filename)
|
|
71
|
+
]
|
|
72
|
+
features = parse_features(feature_locations, language=self.config.lang)
|
|
73
|
+
self.features.extend(features)
|
|
74
|
+
|
|
75
|
+
stream_openers = self.config.outputs
|
|
76
|
+
self.formatters = make_formatters(self.config, stream_openers)
|
|
77
|
+
failed: bool = self.run_model()
|
|
78
|
+
return failed
|
|
79
|
+
|
|
80
|
+
def _run_parallel(self) -> bool:
|
|
81
|
+
"""Execute the parallel pipeline: plan -> split -> dispatch -> collect."""
|
|
82
|
+
ctx = multiprocessing.get_context("spawn")
|
|
83
|
+
task_queue: Any = ctx.JoinableQueue()
|
|
84
|
+
result_queue: Any = ctx.Queue()
|
|
85
|
+
stop_event: Any = ctx.Event()
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
work_units = self._plan()
|
|
89
|
+
parallel_batch, serial_batch = self._split_by_serial_tag(work_units)
|
|
90
|
+
dispatched = self._dispatch(
|
|
91
|
+
task_queue, result_queue, stop_event, parallel_batch, serial_batch
|
|
92
|
+
)
|
|
93
|
+
return self._collect(result_queue, dispatched)
|
|
94
|
+
finally:
|
|
95
|
+
stop_event.set()
|
|
96
|
+
task_queue.close()
|
|
97
|
+
result_queue.close()
|
|
98
|
+
|
|
99
|
+
def _plan(self) -> list[WorkUnit]:
|
|
100
|
+
"""Parse features and create work units.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of work units to execute.
|
|
104
|
+
"""
|
|
105
|
+
from behave.runner import Context
|
|
106
|
+
|
|
107
|
+
self.context = Context(self)
|
|
108
|
+
self.load_hooks()
|
|
109
|
+
|
|
110
|
+
feature_locations = [
|
|
111
|
+
filename for filename in self.feature_locations() if not self.config.exclude(filename)
|
|
112
|
+
]
|
|
113
|
+
features = parse_features(feature_locations, language=self.config.lang)
|
|
114
|
+
self.features.extend(features)
|
|
115
|
+
|
|
116
|
+
iterator = WorkUnitIterator.for_scheme(
|
|
117
|
+
scheme=self.config.parallel_scheme,
|
|
118
|
+
features=features,
|
|
119
|
+
config=self.config,
|
|
120
|
+
)
|
|
121
|
+
work_units = list(iterator.iterate())
|
|
122
|
+
work_units = self._sort_by_duration(work_units)
|
|
123
|
+
|
|
124
|
+
return work_units
|
|
125
|
+
|
|
126
|
+
def _sort_by_duration(self, units: list[WorkUnit]) -> list[WorkUnit]:
|
|
127
|
+
"""Sort work units by historical duration (LPT) or keep FIFO order.
|
|
128
|
+
|
|
129
|
+
When ``config.parallel_balance`` is ``"lpt"``, units are sorted
|
|
130
|
+
descending by their stored duration in the TimingStore so that
|
|
131
|
+
the longest jobs start first, improving overall wall-clock time.
|
|
132
|
+
|
|
133
|
+
When ``config.parallel_balance`` is ``"fifo"``, the original
|
|
134
|
+
order is preserved.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
units: Work units to sort.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Sorted list of work units.
|
|
141
|
+
"""
|
|
142
|
+
balance = getattr(self.config, "parallel_balance", "lpt")
|
|
143
|
+
if balance == "fifo":
|
|
144
|
+
return units
|
|
145
|
+
|
|
146
|
+
timing_file = (
|
|
147
|
+
getattr(self.config, "parallel_timing_file", None) or ".behave-pool-timing.json"
|
|
148
|
+
)
|
|
149
|
+
store = TimingStore(path=Path(timing_file))
|
|
150
|
+
store.load()
|
|
151
|
+
return sorted(units, key=lambda u: store.get_duration(u.id), reverse=True)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _split_by_serial_tag(
|
|
155
|
+
units: list[WorkUnit],
|
|
156
|
+
) -> tuple[list[WorkUnit], list[WorkUnit]]:
|
|
157
|
+
"""Split work units into parallel and serial batches.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
units: All work units to split.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Tuple of (parallel_batch, serial_batch).
|
|
164
|
+
"""
|
|
165
|
+
parallel_batch = [u for u in units if not u.is_serial]
|
|
166
|
+
serial_batch = [u for u in units if u.is_serial]
|
|
167
|
+
return parallel_batch, serial_batch
|
|
168
|
+
|
|
169
|
+
def _dispatch(
|
|
170
|
+
self,
|
|
171
|
+
task_queue: Any,
|
|
172
|
+
result_queue: Any,
|
|
173
|
+
stop_event: Any,
|
|
174
|
+
parallel_batch: list[WorkUnit],
|
|
175
|
+
serial_batch: list[WorkUnit],
|
|
176
|
+
) -> list[WorkUnit]:
|
|
177
|
+
"""Two-phase dispatch: parallel first, then serial.
|
|
178
|
+
|
|
179
|
+
Phase 1: enqueue parallel_batch, launch N workers, wait for completion.
|
|
180
|
+
Phase 2: enqueue serial_batch one at a time, launch 1 worker, wait.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of work units that were actually enqueued (dispatched).
|
|
184
|
+
"""
|
|
185
|
+
n_workers = self.config.parallel
|
|
186
|
+
config_snapshot = snapshot_config(self.config)
|
|
187
|
+
dispatched: list[WorkUnit] = []
|
|
188
|
+
ctx = multiprocessing.get_context("spawn")
|
|
189
|
+
|
|
190
|
+
# -- Phase 1: parallel batch with N workers.
|
|
191
|
+
if parallel_batch:
|
|
192
|
+
for unit in parallel_batch:
|
|
193
|
+
task_queue.put(unit)
|
|
194
|
+
for _ in range(n_workers):
|
|
195
|
+
task_queue.put(None)
|
|
196
|
+
dispatched.extend(parallel_batch)
|
|
197
|
+
|
|
198
|
+
workers: list[WorkerProcess] = []
|
|
199
|
+
for worker_id in range(n_workers):
|
|
200
|
+
worker = WorkerProcess(
|
|
201
|
+
worker_id=worker_id,
|
|
202
|
+
task_queue=task_queue,
|
|
203
|
+
result_queue=result_queue,
|
|
204
|
+
stop_event=stop_event,
|
|
205
|
+
config_snapshot=config_snapshot,
|
|
206
|
+
ctx=ctx,
|
|
207
|
+
)
|
|
208
|
+
worker.start()
|
|
209
|
+
workers.append(worker)
|
|
210
|
+
|
|
211
|
+
for worker in workers:
|
|
212
|
+
worker.join(timeout=300)
|
|
213
|
+
if worker.is_alive():
|
|
214
|
+
logger.warning(
|
|
215
|
+
"Worker %d did not terminate within 300s; "
|
|
216
|
+
"setting stop event and terminating.",
|
|
217
|
+
worker.worker_id,
|
|
218
|
+
)
|
|
219
|
+
stop_event.set()
|
|
220
|
+
worker.terminate()
|
|
221
|
+
|
|
222
|
+
# Drain any unconsumed items so the queue is empty for Phase 2.
|
|
223
|
+
while not task_queue.empty():
|
|
224
|
+
try:
|
|
225
|
+
task_queue.get_nowait()
|
|
226
|
+
task_queue.task_done()
|
|
227
|
+
except queue.Empty:
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
# -- Phase 2: serial batch with 1 worker.
|
|
231
|
+
if serial_batch and not stop_event.is_set():
|
|
232
|
+
for unit in serial_batch:
|
|
233
|
+
task_queue.put(unit)
|
|
234
|
+
task_queue.put(None)
|
|
235
|
+
dispatched.extend(serial_batch)
|
|
236
|
+
|
|
237
|
+
serial_worker = WorkerProcess(
|
|
238
|
+
worker_id=0,
|
|
239
|
+
task_queue=task_queue,
|
|
240
|
+
result_queue=result_queue,
|
|
241
|
+
stop_event=stop_event,
|
|
242
|
+
config_snapshot=config_snapshot,
|
|
243
|
+
ctx=ctx,
|
|
244
|
+
)
|
|
245
|
+
serial_worker.start()
|
|
246
|
+
serial_worker.join(timeout=300)
|
|
247
|
+
if serial_worker.is_alive():
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Serial worker did not terminate within 300s; "
|
|
250
|
+
"setting stop event and terminating."
|
|
251
|
+
)
|
|
252
|
+
stop_event.set()
|
|
253
|
+
serial_worker.terminate()
|
|
254
|
+
|
|
255
|
+
# Drain any unconsumed items.
|
|
256
|
+
while not task_queue.empty():
|
|
257
|
+
try:
|
|
258
|
+
task_queue.get_nowait()
|
|
259
|
+
task_queue.task_done()
|
|
260
|
+
except queue.Empty:
|
|
261
|
+
break
|
|
262
|
+
|
|
263
|
+
return dispatched
|
|
264
|
+
|
|
265
|
+
def _collect(
|
|
266
|
+
self,
|
|
267
|
+
result_queue: Any,
|
|
268
|
+
work_units: list[WorkUnit],
|
|
269
|
+
deadline_seconds: float = 30,
|
|
270
|
+
) -> bool:
|
|
271
|
+
"""Drain result queue, merge results, and compute exit code.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
True if any test failed (Behave convention).
|
|
275
|
+
"""
|
|
276
|
+
expected = len(work_units)
|
|
277
|
+
results: list[WorkerResult] = []
|
|
278
|
+
received_ids: set[str] = set()
|
|
279
|
+
|
|
280
|
+
# Drain all available results, waiting up to deadline_seconds for late arrivals.
|
|
281
|
+
deadline = time.monotonic() + deadline_seconds
|
|
282
|
+
while len(results) < expected and time.monotonic() < deadline:
|
|
283
|
+
try:
|
|
284
|
+
result = result_queue.get(timeout=1)
|
|
285
|
+
except queue.Empty:
|
|
286
|
+
continue
|
|
287
|
+
except (EOFError, OSError):
|
|
288
|
+
break
|
|
289
|
+
results.append(result)
|
|
290
|
+
received_ids.add(result.work_unit_id)
|
|
291
|
+
|
|
292
|
+
# Detect missing results from crashed or timed-out workers.
|
|
293
|
+
missing = [u.id for u in work_units if u.id not in received_ids]
|
|
294
|
+
if missing:
|
|
295
|
+
logger.warning(
|
|
296
|
+
"Missing %d result(s) from worker(s): %s",
|
|
297
|
+
len(missing),
|
|
298
|
+
", ".join(missing),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
any_failed = any(r.failed for r in results)
|
|
302
|
+
|
|
303
|
+
# Missing results indicate worker crashes — treat as failures.
|
|
304
|
+
if missing:
|
|
305
|
+
any_failed = True
|
|
306
|
+
|
|
307
|
+
self._update_timings(results)
|
|
308
|
+
|
|
309
|
+
tmp_dir = os.path.join(os.getcwd(), "tmp")
|
|
310
|
+
if os.path.isdir(tmp_dir):
|
|
311
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
312
|
+
|
|
313
|
+
logger.info(
|
|
314
|
+
"Parallel run complete: %d work units, %d results, failed=%s",
|
|
315
|
+
len(work_units),
|
|
316
|
+
len(results),
|
|
317
|
+
any_failed,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return any_failed
|
|
321
|
+
|
|
322
|
+
def _update_timings(self, results: list[WorkerResult]) -> None:
|
|
323
|
+
"""Update the TimingStore with observed durations from results.
|
|
324
|
+
|
|
325
|
+
Timing persistence is best-effort: any failure is logged and
|
|
326
|
+
does not affect the test run outcome.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
results: Worker results containing durations to persist.
|
|
330
|
+
"""
|
|
331
|
+
timing_file = (
|
|
332
|
+
getattr(self.config, "parallel_timing_file", None) or ".behave-pool-timing.json"
|
|
333
|
+
)
|
|
334
|
+
try:
|
|
335
|
+
store = TimingStore(path=Path(timing_file))
|
|
336
|
+
store.load()
|
|
337
|
+
for result in results:
|
|
338
|
+
store.update(result.work_unit_id, result.duration)
|
|
339
|
+
store.save_if_changed()
|
|
340
|
+
except Exception:
|
|
341
|
+
logger.warning(
|
|
342
|
+
"Failed to update timing file %s; timings will not persist.", timing_file
|
|
343
|
+
)
|