dynamic-systems-testing-framework 1.0.1__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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
dstf/backends/pool.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""The simulation pool: a queue of tests that stays open.
|
|
2
|
+
|
|
3
|
+
``Backend.run_tests`` runs one fixed list and closes its workers. The
|
|
4
|
+
authoring tool queues tests while others run, takes one back before it
|
|
5
|
+
starts, stops one mid-simulation and wants each result the moment its test
|
|
6
|
+
ends — so this is the same dispatch kept open: first in, first out, a test
|
|
7
|
+
never queued twice, workers that live until :meth:`SimulationPool.close`.
|
|
8
|
+
|
|
9
|
+
How ONE test runs is not decided here. Each pool thread drives an
|
|
10
|
+
:class:`~dstf.backends.base.ItemRunner` it gets from the backend
|
|
11
|
+
(``Backend.item_runner``): a persistent worker with its restart accounting
|
|
12
|
+
for Dymola / OpenModelica / Julia — the object ``run_tests`` drives, so a
|
|
13
|
+
test fails, retries and restarts identically in both — and the backend's own
|
|
14
|
+
single-test path for a backend without workers. What happens around the run
|
|
15
|
+
on disk (the test's key, its wipe, its recorded verdict, its result read
|
|
16
|
+
back) is the :class:`ItemPath` the pool is given;
|
|
17
|
+
:func:`dstf.pipeline.open_pool` builds the pool with the run pipeline's.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import collections
|
|
23
|
+
import dataclasses
|
|
24
|
+
import logging
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from typing import TYPE_CHECKING, Protocol
|
|
31
|
+
|
|
32
|
+
from .base import ItemRunner, no_worker_message
|
|
33
|
+
from .types import Capability, TestResult, TestRunResult
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from ..discovery.library_state import LibraryState
|
|
37
|
+
from ..discovery.test_registry import TestModel
|
|
38
|
+
from .base import Backend
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ItemState(str, Enum):
|
|
44
|
+
"""Where a :class:`QueueItem` is. The last four are final."""
|
|
45
|
+
|
|
46
|
+
WAITING = "waiting"
|
|
47
|
+
RUNNING = "running"
|
|
48
|
+
#: Its simulation ran and its result was read back (``item.result``).
|
|
49
|
+
FINISHED = "finished"
|
|
50
|
+
#: Its simulation failed or timed out, its result could not be read, or
|
|
51
|
+
#: no worker could start (``item.message`` says which).
|
|
52
|
+
FAILED = "failed"
|
|
53
|
+
#: Stopped while running, by :meth:`SimulationPool.cancel` or by
|
|
54
|
+
#: :meth:`SimulationPool.close`. Recorded as cancelled; no result.
|
|
55
|
+
CANCELLED = "cancelled"
|
|
56
|
+
#: Taken out while waiting, by :meth:`SimulationPool.remove` or by
|
|
57
|
+
#: :meth:`SimulationPool.close`. It never ran and nothing was recorded.
|
|
58
|
+
REMOVED = "removed"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Refused(RuntimeError):
|
|
62
|
+
"""The pool did not do what was asked; ``str()`` is the sentence why."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(eq=False)
|
|
66
|
+
class QueueItem:
|
|
67
|
+
"""One submitted test and what became of it. The pool writes the fields
|
|
68
|
+
(``test`` too: :meth:`SimulationPool.update`); a caller reads them —
|
|
69
|
+
after ``on_end``, all of them are final."""
|
|
70
|
+
|
|
71
|
+
test: TestModel
|
|
72
|
+
#: The source files as swept at the Queue click that submitted it: it
|
|
73
|
+
#: runs on a worker that has read them as they were then, or later.
|
|
74
|
+
#: ``None``: nothing is asked of the worker (a source no worker keeps
|
|
75
|
+
#: loaded).
|
|
76
|
+
library: LibraryState | None = None
|
|
77
|
+
state: ItemState = ItemState.WAITING
|
|
78
|
+
#: The test's key in the work dir, once it started.
|
|
79
|
+
test_key: str | None = None
|
|
80
|
+
#: The worker (pool thread) it ran on.
|
|
81
|
+
worker_id: int | None = None
|
|
82
|
+
#: What the backend's run returned; ``None`` while it has not ended, and
|
|
83
|
+
#: for an item that never ran.
|
|
84
|
+
run_result: TestRunResult | None = None
|
|
85
|
+
#: The result read back; ``None`` unless the item FINISHED or its
|
|
86
|
+
#: simulation FAILED (then it carries the failure).
|
|
87
|
+
result: TestResult | None = None
|
|
88
|
+
#: The backend's message for a FAILED item, ``"Cancelled"`` for a
|
|
89
|
+
#: cancelled one.
|
|
90
|
+
message: str | None = None
|
|
91
|
+
#: Wall-clock (``time.time()``) stamps.
|
|
92
|
+
submitted_at: float = field(default_factory=time.time)
|
|
93
|
+
started_at: float | None = None
|
|
94
|
+
ended_at: float | None = None
|
|
95
|
+
# A stop was asked of it while RUNNING (cancel or close): a run that then
|
|
96
|
+
# does not succeed is cancelled, whatever the backend called it.
|
|
97
|
+
_stop_asked: bool = field(default=False, repr=False)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def model_id(self) -> str:
|
|
101
|
+
return self.test.model_id
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def stopping(self) -> bool:
|
|
105
|
+
"""A stop was asked of it and it has not ended: still RUNNING, on
|
|
106
|
+
its way to CANCELLED (or FINISHED, where its simulation got there
|
|
107
|
+
first). For as long as the backend takes — a simulation that does
|
|
108
|
+
not look at the request runs on until its worker's grace is over
|
|
109
|
+
and the worker is killed."""
|
|
110
|
+
return self._stop_asked and self.state is ItemState.RUNNING
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ItemPath(Protocol):
|
|
114
|
+
"""What happens around one item's run, on disk."""
|
|
115
|
+
|
|
116
|
+
def begin(self, test: TestModel) -> str:
|
|
117
|
+
"""Before the run: the test's key, with its state from an earlier
|
|
118
|
+
run cleared."""
|
|
119
|
+
...
|
|
120
|
+
|
|
121
|
+
def end(
|
|
122
|
+
self, test: TestModel, test_key: str, run_result: TestRunResult
|
|
123
|
+
) -> TestResult | None:
|
|
124
|
+
"""After the run: record what it came to, and read the result back
|
|
125
|
+
(``None`` for a cancelled run)."""
|
|
126
|
+
...
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class StartingWorker:
|
|
131
|
+
"""A worker that is not ready for a test right now: being brought up —
|
|
132
|
+
its first start, or a restart after a death — or up and reading the
|
|
133
|
+
library again for the item it is about to run (``reload``)."""
|
|
134
|
+
|
|
135
|
+
worker_id: int
|
|
136
|
+
#: The step the start (or the reload) is in, in the backend's own words
|
|
137
|
+
#: (``loadFile("/lib/package.mo")``, ``its launch``); ``""`` when the
|
|
138
|
+
#: backend names none.
|
|
139
|
+
phase: str
|
|
140
|
+
#: Seconds since this start (or reload) began.
|
|
141
|
+
seconds: float
|
|
142
|
+
#: Whether it is being brought BACK: a worker that died is restarted
|
|
143
|
+
#: under the item it is to run, which is running meanwhile; a first
|
|
144
|
+
#: start has no item yet.
|
|
145
|
+
restart: bool = False
|
|
146
|
+
#: Whether it is up and reading the library again: its files changed
|
|
147
|
+
#: since it read them, and its item is running meanwhile.
|
|
148
|
+
reload: bool = False
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(eq=False)
|
|
152
|
+
class _Slot:
|
|
153
|
+
"""One pool thread and the runner it drives."""
|
|
154
|
+
|
|
155
|
+
id: int
|
|
156
|
+
thread: threading.Thread | None = None
|
|
157
|
+
runner: ItemRunner | None = None
|
|
158
|
+
item: QueueItem | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SimulationPool:
|
|
162
|
+
"""A FIFO queue of tests over a backend's runners.
|
|
163
|
+
|
|
164
|
+
``submit`` appends and returns the item — with the state of the source
|
|
165
|
+
files it is to run against, which a live worker that read them earlier
|
|
166
|
+
reads again first; ``update`` hands a waiting item its test as it
|
|
167
|
+
stands now; ``remove`` takes a waiting item back;
|
|
168
|
+
``cancel`` stops a running one where the backend can
|
|
169
|
+
(:attr:`Capability.CANCEL`); ``close`` ends everything. All five are
|
|
170
|
+
safe from any thread and return promptly (``close`` waits for the
|
|
171
|
+
threads).
|
|
172
|
+
|
|
173
|
+
**Callbacks.** ``on_start(item)`` once when an item starts running,
|
|
174
|
+
``on_end(item)`` once when it has ended — FINISHED, FAILED or CANCELLED,
|
|
175
|
+
its result read back and every field final. Both run on a POOL thread,
|
|
176
|
+
never the caller's: the one that ran the item, ``on_start`` before
|
|
177
|
+
``on_end``, outside the pool's lock; callbacks of different items may
|
|
178
|
+
run at the same time on different threads. A callback may call
|
|
179
|
+
``submit`` / ``remove`` / ``cancel`` and set ``workers``; it must not
|
|
180
|
+
call ``close`` (which joins the thread it is on). An item that never
|
|
181
|
+
started gets no ``on_start``, and gets ``on_end`` only when the POOL
|
|
182
|
+
failed it (no worker could start; on the thread of the last worker to
|
|
183
|
+
leave) — not when the caller removed it or closed the pool.
|
|
184
|
+
|
|
185
|
+
**Workers.** None exists until the first ``submit``; then as many as
|
|
186
|
+
there are items to run, up to ``workers`` (``Config.parallel`` unless
|
|
187
|
+
given). Setting ``workers`` higher starts more as items need them;
|
|
188
|
+
lower lets the surplus go as each finishes its item. ``starting`` lists
|
|
189
|
+
the ones still coming up, and the ones reading the library again, with
|
|
190
|
+
the phase each is in. They live until
|
|
191
|
+
``close`` — which the owner must call: the pool's threads are daemons
|
|
192
|
+
and do not keep a dying process open, but nothing else ends the
|
|
193
|
+
workers' tool processes.
|
|
194
|
+
|
|
195
|
+
**A backend that cannot start.** A worker whose start fails or which
|
|
196
|
+
cannot be restarted leaves. When none is left, every waiting item FAILS
|
|
197
|
+
with the backend's own message (``No OpenModelica worker could start —
|
|
198
|
+
worker 0: ...``), ``stopped`` holds that message and ``submit`` refuses
|
|
199
|
+
with it: a new pool is the retry.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(
|
|
203
|
+
self,
|
|
204
|
+
backend: Backend,
|
|
205
|
+
path: ItemPath,
|
|
206
|
+
*,
|
|
207
|
+
on_start: Callable[[QueueItem], None] | None = None,
|
|
208
|
+
on_end: Callable[[QueueItem], None] | None = None,
|
|
209
|
+
workers: int | None = None,
|
|
210
|
+
):
|
|
211
|
+
"""Raises ``RuntimeError`` when *backend* cannot run one test at a
|
|
212
|
+
time (``Backend.prepare_item_runners``)."""
|
|
213
|
+
self.backend = backend
|
|
214
|
+
#: The pool's :class:`ItemPath`, for what it offers beyond the two
|
|
215
|
+
#: steps (the run pipeline's writes ``status.json`` when asked).
|
|
216
|
+
self.path = path
|
|
217
|
+
self._on_start = on_start
|
|
218
|
+
self._on_end = on_end
|
|
219
|
+
self._cond = threading.Condition()
|
|
220
|
+
self._waiting: collections.deque[QueueItem] = collections.deque()
|
|
221
|
+
#: Waiting or running, by model id — the "never queued twice" rule.
|
|
222
|
+
self._active: dict[str, QueueItem] = {}
|
|
223
|
+
self._slots: dict[int, _Slot] = {}
|
|
224
|
+
self._target = max(
|
|
225
|
+
1, int(backend.config.parallel if workers is None else workers)
|
|
226
|
+
)
|
|
227
|
+
self._closing = False
|
|
228
|
+
self._stopped: str | None = None
|
|
229
|
+
#: Why each worker that left for good did, by worker id.
|
|
230
|
+
self._failures: dict[int, str] = {}
|
|
231
|
+
#: The latest state an item was submitted with: what a worker that
|
|
232
|
+
#: starts NOW reads at least.
|
|
233
|
+
self._library: LibraryState | None = None
|
|
234
|
+
backend.prepare_item_runners()
|
|
235
|
+
|
|
236
|
+
# -- the caller's side ---------------------------------------------
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def workers(self) -> int:
|
|
240
|
+
"""How many workers the pool may run at once."""
|
|
241
|
+
return self._target
|
|
242
|
+
|
|
243
|
+
@workers.setter
|
|
244
|
+
def workers(self, count: int) -> None:
|
|
245
|
+
with self._cond:
|
|
246
|
+
self._target = max(1, int(count))
|
|
247
|
+
self._spawn_slots()
|
|
248
|
+
self._cond.notify_all()
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def can_cancel(self) -> bool:
|
|
252
|
+
"""Whether a running item can be stopped on this backend."""
|
|
253
|
+
return Capability.CANCEL in self.backend.capabilities
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def stopped(self) -> str | None:
|
|
257
|
+
"""The message the pool stopped with, or ``None`` while it takes
|
|
258
|
+
items."""
|
|
259
|
+
return self._stopped
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def waiting(self) -> tuple[QueueItem, ...]:
|
|
263
|
+
"""The waiting items, next to run first."""
|
|
264
|
+
with self._cond:
|
|
265
|
+
return tuple(self._waiting)
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def running(self) -> tuple[QueueItem, ...]:
|
|
269
|
+
"""The running items, by worker id."""
|
|
270
|
+
with self._cond:
|
|
271
|
+
return tuple(
|
|
272
|
+
slot.item
|
|
273
|
+
for _, slot in sorted(self._slots.items())
|
|
274
|
+
if slot.item is not None
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def starting(self) -> tuple[StartingWorker, ...]:
|
|
279
|
+
"""The workers being brought up right now and the ones reading the
|
|
280
|
+
library again, by worker id — empty once each is ready or has
|
|
281
|
+
failed. A large library is minutes of load in a live tool: this is
|
|
282
|
+
what says so while the items wait."""
|
|
283
|
+
with self._cond:
|
|
284
|
+
runners = [
|
|
285
|
+
(slot_id, slot.runner, slot.item is not None)
|
|
286
|
+
for slot_id, slot in sorted(self._slots.items())
|
|
287
|
+
if slot.runner is not None
|
|
288
|
+
]
|
|
289
|
+
found = []
|
|
290
|
+
for slot_id, runner, restart in runners:
|
|
291
|
+
state = runner.starting()
|
|
292
|
+
if state is not None:
|
|
293
|
+
found.append(StartingWorker(slot_id, *state, restart=restart))
|
|
294
|
+
continue
|
|
295
|
+
state = runner.reloading()
|
|
296
|
+
if state is not None:
|
|
297
|
+
found.append(StartingWorker(slot_id, *state, reload=True))
|
|
298
|
+
return tuple(found)
|
|
299
|
+
|
|
300
|
+
def submit(self, test: TestModel, library: LibraryState | None = None) -> QueueItem:
|
|
301
|
+
"""Append *test*. Raises :class:`Refused` when it is already waiting
|
|
302
|
+
or running, and when the pool is closed or stopped.
|
|
303
|
+
|
|
304
|
+
*library* is the state of the source files as the caller swept them
|
|
305
|
+
for this submit (one state per Queue click, shared by its items).
|
|
306
|
+
Before the item runs, a worker that read the files at an older
|
|
307
|
+
state reads them again (``ItemRunner.run``); one that read them at
|
|
308
|
+
this state or a later one does nothing."""
|
|
309
|
+
with self._cond:
|
|
310
|
+
if self._closing:
|
|
311
|
+
raise Refused("the simulation queue is closed")
|
|
312
|
+
if self._stopped is not None:
|
|
313
|
+
raise Refused(self._stopped)
|
|
314
|
+
active = self._active.get(test.model_id)
|
|
315
|
+
if active is not None:
|
|
316
|
+
doing = "running" if active.state is ItemState.RUNNING else "queued"
|
|
317
|
+
raise Refused(f"{test.model_id} is already {doing}")
|
|
318
|
+
item = QueueItem(test, library)
|
|
319
|
+
self._note(library)
|
|
320
|
+
self._active[test.model_id] = item
|
|
321
|
+
self._waiting.append(item)
|
|
322
|
+
self._spawn_slots()
|
|
323
|
+
self._cond.notify()
|
|
324
|
+
return item
|
|
325
|
+
|
|
326
|
+
def update(
|
|
327
|
+
self, item: QueueItem, test: TestModel, library: LibraryState | None = None
|
|
328
|
+
) -> bool:
|
|
329
|
+
"""Hand a waiting *item* the test as it stands now — the same model,
|
|
330
|
+
its settings as they are at this call — keeping its place: an item
|
|
331
|
+
runs with the last test it was given before it started. ``False``
|
|
332
|
+
once it has started (or ended): nothing is changed.
|
|
333
|
+
|
|
334
|
+
Its library state stays the one it was submitted with — a test's
|
|
335
|
+
settings follow the spec, the files it was queued against do not —
|
|
336
|
+
unless *library* is given and is a later one: a click that read
|
|
337
|
+
the test's file again hands the item what it read, and the state
|
|
338
|
+
it read it at. A state never moves back."""
|
|
339
|
+
if test.model_id != item.model_id:
|
|
340
|
+
raise ValueError(f"{test.model_id} is not {item.model_id}")
|
|
341
|
+
with self._cond:
|
|
342
|
+
if item.state is not ItemState.WAITING:
|
|
343
|
+
return False
|
|
344
|
+
item.test = test
|
|
345
|
+
if library is not None and (
|
|
346
|
+
item.library is None or library.serial > item.library.serial
|
|
347
|
+
):
|
|
348
|
+
item.library = library
|
|
349
|
+
self._note(library)
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
def _note(self, library: LibraryState | None) -> None:
|
|
353
|
+
"""Under the lock: *library* is a state an item carries."""
|
|
354
|
+
if library is not None and (
|
|
355
|
+
self._library is None or library.serial > self._library.serial
|
|
356
|
+
):
|
|
357
|
+
self._library = library
|
|
358
|
+
|
|
359
|
+
def remove(self, item: QueueItem) -> bool:
|
|
360
|
+
"""Take a waiting *item* out. ``False`` once it has started (or
|
|
361
|
+
ended): nothing is changed."""
|
|
362
|
+
with self._cond:
|
|
363
|
+
if item.state is not ItemState.WAITING:
|
|
364
|
+
return False
|
|
365
|
+
self._waiting.remove(item)
|
|
366
|
+
del self._active[item.model_id]
|
|
367
|
+
item.state, item.ended_at = ItemState.REMOVED, time.time()
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
def cancel(self, item: QueueItem) -> bool:
|
|
371
|
+
"""Ask a running *item*'s simulation to stop: ``True`` when it was
|
|
372
|
+
asked. It then ends CANCELLED — unless its simulation had already
|
|
373
|
+
finished, which stays the result it is, FINISHED (``on_end`` follows
|
|
374
|
+
from its thread either way). ``False`` when it is not running —
|
|
375
|
+
waiting (``remove`` it) or already ended. Raises :class:`Refused`
|
|
376
|
+
when it runs on a backend that cannot stop a test."""
|
|
377
|
+
with self._cond:
|
|
378
|
+
if item.state is not ItemState.RUNNING:
|
|
379
|
+
return False
|
|
380
|
+
if not self.can_cancel:
|
|
381
|
+
raise Refused(
|
|
382
|
+
f"{self.backend.label} cannot stop a running simulation: "
|
|
383
|
+
f"{item.model_id} runs to its end, or to its timeout"
|
|
384
|
+
)
|
|
385
|
+
item._stop_asked = True
|
|
386
|
+
slot = self._slots[item.worker_id] # type: ignore[index]
|
|
387
|
+
runner, test_key = slot.runner, item.test_key
|
|
388
|
+
# Before its key exists the item has not reached its runner: the
|
|
389
|
+
# flag set above is what its thread sees. After, the runner expects
|
|
390
|
+
# the item (announced under this lock), so the stop reaches its run
|
|
391
|
+
# — and no other: by now the item may have ended and the same test
|
|
392
|
+
# be running again, under the same key.
|
|
393
|
+
if runner is not None and test_key is not None:
|
|
394
|
+
runner.cancel(item)
|
|
395
|
+
return True
|
|
396
|
+
|
|
397
|
+
def close(self) -> None:
|
|
398
|
+
"""End the pool: waiting items are REMOVED, running ones stopped
|
|
399
|
+
(CANCELLED, with their ``on_end``), every worker closed and every
|
|
400
|
+
pool thread joined. A backend that cannot stop a test finishes the
|
|
401
|
+
one it is running first. Idempotent; not callable from a callback.
|
|
402
|
+
"""
|
|
403
|
+
with self._cond:
|
|
404
|
+
if any(
|
|
405
|
+
slot.thread is threading.current_thread()
|
|
406
|
+
for slot in self._slots.values()
|
|
407
|
+
):
|
|
408
|
+
raise RuntimeError("SimulationPool.close() called from a pool callback")
|
|
409
|
+
self._closing = True
|
|
410
|
+
for item in self._waiting:
|
|
411
|
+
del self._active[item.model_id]
|
|
412
|
+
item.state, item.ended_at = ItemState.REMOVED, time.time()
|
|
413
|
+
self._waiting.clear()
|
|
414
|
+
slots = list(self._slots.values())
|
|
415
|
+
stops = []
|
|
416
|
+
for slot in slots:
|
|
417
|
+
if slot.item is not None:
|
|
418
|
+
slot.item._stop_asked = True
|
|
419
|
+
if slot.runner is not None:
|
|
420
|
+
key = slot.item.test_key if slot.item is not None else None
|
|
421
|
+
stops.append((slot.runner, key))
|
|
422
|
+
self._cond.notify_all()
|
|
423
|
+
for runner, test_key in stops:
|
|
424
|
+
try:
|
|
425
|
+
runner.stop(test_key)
|
|
426
|
+
except Exception:
|
|
427
|
+
logger.exception("pool: stopping worker %s raised", runner.worker_id)
|
|
428
|
+
for slot in slots:
|
|
429
|
+
if slot.thread is not None:
|
|
430
|
+
slot.thread.join()
|
|
431
|
+
|
|
432
|
+
# -- the pool's threads --------------------------------------------
|
|
433
|
+
|
|
434
|
+
def _spawn_slots(self) -> None:
|
|
435
|
+
"""Under the lock: one thread per item there is to run, up to the
|
|
436
|
+
target."""
|
|
437
|
+
busy = sum(1 for slot in self._slots.values() if slot.item is not None)
|
|
438
|
+
want = min(self._target, len(self._waiting) + busy)
|
|
439
|
+
while len(self._slots) < want:
|
|
440
|
+
slot = _Slot(next(i for i in range(want) if i not in self._slots))
|
|
441
|
+
slot.thread = threading.Thread(
|
|
442
|
+
target=self._slot_loop,
|
|
443
|
+
args=(slot,),
|
|
444
|
+
name=f"{self.backend.label.lower().replace(' ', '-')}-pool-{slot.id}",
|
|
445
|
+
# An owner that dies without close() must not be held open
|
|
446
|
+
# by an idle queue. Its workers' tool processes are then
|
|
447
|
+
# left running: close() is what ends them.
|
|
448
|
+
daemon=True,
|
|
449
|
+
)
|
|
450
|
+
self._slots[slot.id] = slot
|
|
451
|
+
slot.thread.start()
|
|
452
|
+
|
|
453
|
+
def _slot_loop(self, slot: _Slot) -> None:
|
|
454
|
+
left_because: str | None = None
|
|
455
|
+
try:
|
|
456
|
+
try:
|
|
457
|
+
runner = self.backend.item_runner(slot.id)
|
|
458
|
+
with self._cond:
|
|
459
|
+
slot.runner = runner
|
|
460
|
+
if self._closing:
|
|
461
|
+
return
|
|
462
|
+
# Read BEFORE the start reads the files: each is then
|
|
463
|
+
# read as it was at this state or later. (A click
|
|
464
|
+
# during the start is a later state, and the worker
|
|
465
|
+
# reads the library again for its items.)
|
|
466
|
+
library = self._library
|
|
467
|
+
runner.start()
|
|
468
|
+
runner.library = library
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
left_because = str(exc) or type(exc).__name__
|
|
471
|
+
return
|
|
472
|
+
with self._cond:
|
|
473
|
+
self._failures.pop(slot.id, None)
|
|
474
|
+
while True:
|
|
475
|
+
item = self._take(slot)
|
|
476
|
+
if item is None:
|
|
477
|
+
return
|
|
478
|
+
self._run_item(slot, runner, item)
|
|
479
|
+
if runner.retired:
|
|
480
|
+
left_because = runner.retired
|
|
481
|
+
return
|
|
482
|
+
finally:
|
|
483
|
+
if slot.runner is not None:
|
|
484
|
+
try:
|
|
485
|
+
slot.runner.close()
|
|
486
|
+
except Exception:
|
|
487
|
+
logger.exception("pool: closing worker %s raised", slot.id)
|
|
488
|
+
self._slot_left(slot, left_because)
|
|
489
|
+
|
|
490
|
+
def _take(self, slot: _Slot) -> QueueItem | None:
|
|
491
|
+
"""The next item for *slot*, marked RUNNING — or ``None`` when the
|
|
492
|
+
slot is to leave (the pool closing or stopped, or more workers than
|
|
493
|
+
the target)."""
|
|
494
|
+
with self._cond:
|
|
495
|
+
while True:
|
|
496
|
+
if self._closing or self._stopped is not None:
|
|
497
|
+
return None
|
|
498
|
+
if len(self._slots) > self._target:
|
|
499
|
+
del self._slots[slot.id]
|
|
500
|
+
return None
|
|
501
|
+
if self._waiting:
|
|
502
|
+
item = self._waiting.popleft()
|
|
503
|
+
item.state, item.worker_id = ItemState.RUNNING, slot.id
|
|
504
|
+
item.started_at = time.time()
|
|
505
|
+
slot.item = item
|
|
506
|
+
return item
|
|
507
|
+
self._cond.wait()
|
|
508
|
+
|
|
509
|
+
def _slot_left(self, slot: _Slot, because: str | None) -> None:
|
|
510
|
+
"""A thread is gone. One that left because its worker could not
|
|
511
|
+
start, or could not be brought back, says why; when it was the last,
|
|
512
|
+
the pool stops and the waiting items fail with the reasons."""
|
|
513
|
+
with self._cond:
|
|
514
|
+
if self._slots.get(slot.id) is slot:
|
|
515
|
+
del self._slots[slot.id]
|
|
516
|
+
if because is not None:
|
|
517
|
+
self._failures[slot.id] = because
|
|
518
|
+
if self._slots or because is None or self._closing:
|
|
519
|
+
return
|
|
520
|
+
self._stopped = no_worker_message(self.backend.label, self._failures)
|
|
521
|
+
failed = list(self._waiting)
|
|
522
|
+
self._waiting.clear()
|
|
523
|
+
for item in failed:
|
|
524
|
+
del self._active[item.model_id]
|
|
525
|
+
item.state, item.message = ItemState.FAILED, self._stopped
|
|
526
|
+
item.ended_at = time.time()
|
|
527
|
+
self._cond.notify_all()
|
|
528
|
+
for item in failed:
|
|
529
|
+
self._fire(self._on_end, item)
|
|
530
|
+
|
|
531
|
+
def _run_item(self, slot: _Slot, runner: ItemRunner, item: QueueItem) -> None:
|
|
532
|
+
self._fire(self._on_start, item)
|
|
533
|
+
run_result: TestRunResult | None = None
|
|
534
|
+
result: TestResult | None = None
|
|
535
|
+
error: str | None = None
|
|
536
|
+
try:
|
|
537
|
+
test_key = self.path.begin(item.test)
|
|
538
|
+
with self._cond:
|
|
539
|
+
item.test_key = test_key
|
|
540
|
+
stopped_early = item._stop_asked
|
|
541
|
+
if not stopped_early:
|
|
542
|
+
runner.expect(test_key, item)
|
|
543
|
+
if stopped_early:
|
|
544
|
+
run_result = TestRunResult(
|
|
545
|
+
model_id=item.model_id,
|
|
546
|
+
test_key=test_key,
|
|
547
|
+
success=False,
|
|
548
|
+
error_message="Cancelled",
|
|
549
|
+
cancelled=True,
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
run_result = runner.run(item.test, test_key, item.library)
|
|
553
|
+
if item._stop_asked and not run_result.success:
|
|
554
|
+
# Whatever a stopped run reports — a failed record, a dead
|
|
555
|
+
# pipe — is the stop's doing, not a failure of the test.
|
|
556
|
+
run_result = dataclasses.replace(
|
|
557
|
+
run_result, cancelled=True, error_message="Cancelled"
|
|
558
|
+
)
|
|
559
|
+
result = self.path.end(item.test, test_key, run_result)
|
|
560
|
+
except Exception as exc:
|
|
561
|
+
logger.exception("pool: running %s raised", item.model_id)
|
|
562
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
563
|
+
with self._cond:
|
|
564
|
+
item.run_result, item.result = run_result, result
|
|
565
|
+
if error is not None or run_result is None:
|
|
566
|
+
item.state, item.message = ItemState.FAILED, error
|
|
567
|
+
elif run_result.cancelled:
|
|
568
|
+
item.state, item.message = ItemState.CANCELLED, "Cancelled"
|
|
569
|
+
elif run_result.success and result is not None and result.success:
|
|
570
|
+
item.state = ItemState.FINISHED
|
|
571
|
+
else:
|
|
572
|
+
item.state = ItemState.FAILED
|
|
573
|
+
item.message = (
|
|
574
|
+
(result.error_message if result is not None else None)
|
|
575
|
+
or run_result.error_message
|
|
576
|
+
or "Simulation failed"
|
|
577
|
+
)
|
|
578
|
+
item.ended_at = time.time()
|
|
579
|
+
del self._active[item.model_id]
|
|
580
|
+
slot.item = None
|
|
581
|
+
self._fire(self._on_end, item)
|
|
582
|
+
|
|
583
|
+
def _fire(
|
|
584
|
+
self, callback: Callable[[QueueItem], None] | None, item: QueueItem
|
|
585
|
+
) -> None:
|
|
586
|
+
"""A callback that raises costs its own effect, never the thread."""
|
|
587
|
+
if callback is None:
|
|
588
|
+
return
|
|
589
|
+
try:
|
|
590
|
+
callback(item)
|
|
591
|
+
except Exception:
|
|
592
|
+
logger.exception("pool: a callback raised for %s", item.model_id)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
__all__ = [
|
|
596
|
+
"ItemPath",
|
|
597
|
+
"ItemState",
|
|
598
|
+
"QueueItem",
|
|
599
|
+
"Refused",
|
|
600
|
+
"SimulationPool",
|
|
601
|
+
"StartingWorker",
|
|
602
|
+
]
|