qplotter 1.6.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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1919 @@
|
|
|
1
|
+
"""Qt-independent Stage 4 broker for one trusted QCoDeS database instance.
|
|
2
|
+
|
|
3
|
+
The dispatcher is the sole normal query path and serialises all operations for
|
|
4
|
+
one persistent :class:`TrustedLiveReaderSupervisor`. A separate control
|
|
5
|
+
thread is allowed to call the supervisor's synchronous exact-job cancellation
|
|
6
|
+
while the dispatcher is blocked in ``wait``. Public cancellation and close
|
|
7
|
+
only mutate bounded broker state and wake that control path, so they return
|
|
8
|
+
promptly and are safe to call from the Qt GUI thread. Results are consumed
|
|
9
|
+
through request handles; the broker deliberately exposes no callback facility
|
|
10
|
+
whose arbitrary lifetime could hold service retirement open.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import heapq
|
|
16
|
+
import logging
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable, Generator
|
|
22
|
+
from dataclasses import dataclass, field, replace
|
|
23
|
+
from enum import IntEnum, StrEnum
|
|
24
|
+
from typing import Any, Generic, TypeVar, cast
|
|
25
|
+
|
|
26
|
+
from qplot.datahandling.file_identity import DatabaseInstance, database_instance
|
|
27
|
+
from qplot.datahandling.plot_progress import PlotProgress
|
|
28
|
+
from qplot.datahandling.trusted_live import (
|
|
29
|
+
SqliteBindings,
|
|
30
|
+
TrustedLiveBusyTimeoutError,
|
|
31
|
+
TrustedLiveCancelledError,
|
|
32
|
+
TrustedLiveQueryError,
|
|
33
|
+
TrustedLiveResultLimitError,
|
|
34
|
+
TrustedLiveSqlRejectedError,
|
|
35
|
+
TrustedQuery,
|
|
36
|
+
TrustedQueryResult,
|
|
37
|
+
)
|
|
38
|
+
from qplot.datahandling.trusted_live_queries import (
|
|
39
|
+
TrustedBootstrapResult,
|
|
40
|
+
TrustedDatabaseInfo,
|
|
41
|
+
TrustedDerivedSourceObservation,
|
|
42
|
+
TrustedMetadataQueryAdapter,
|
|
43
|
+
TrustedRefreshResult,
|
|
44
|
+
TrustedRunPage,
|
|
45
|
+
TrustedRunRecord,
|
|
46
|
+
TrustedSelectedRunDetail,
|
|
47
|
+
TrustedSourceRevisionNamespace,
|
|
48
|
+
)
|
|
49
|
+
from qplot.datahandling.trusted_live_supervisor import (
|
|
50
|
+
TrustedLiveJob,
|
|
51
|
+
TrustedLiveReaderSupervisor,
|
|
52
|
+
TrustedLiveSupervisorLiveness,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_LOG = logging.getLogger(__name__)
|
|
56
|
+
_ResultT = TypeVar("_ResultT")
|
|
57
|
+
_NO_RESULT = object()
|
|
58
|
+
_OPERATION_YIELDED = object()
|
|
59
|
+
_DEFAULT_REQUEST_TIMEOUT_SECONDS = 30.0
|
|
60
|
+
_MAX_REQUEST_TIMEOUT_SECONDS = 300.0
|
|
61
|
+
# Large plots can share the broker for many bounded scans. This wall-clock
|
|
62
|
+
# horizon includes time spent yielding to other plots; it is not a SQLite
|
|
63
|
+
# transaction timeout (those remain capped at five seconds).
|
|
64
|
+
_PLOT_CAPTURE_TIMEOUT_SECONDS = 3600.0
|
|
65
|
+
_DEFAULT_QUEUE_CAPACITY = 512
|
|
66
|
+
_MAX_FOREGROUND_DISPATCH_BURST = 8
|
|
67
|
+
|
|
68
|
+
TRUSTED_LIVE_MODE = "trusted_live"
|
|
69
|
+
SNAPSHOT_FALLBACK_MODE = "snapshot_fallback"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TrustedReadOperation(StrEnum):
|
|
73
|
+
BOOTSTRAP = "bootstrap"
|
|
74
|
+
DATABASE_INFO = "database-info"
|
|
75
|
+
BASIC_PAGE = "basic-page"
|
|
76
|
+
REFRESH = "refresh"
|
|
77
|
+
CHEAP_RUN = "cheap-run"
|
|
78
|
+
EXPENSIVE_RUN = "expensive-run"
|
|
79
|
+
SELECTED_RUN = "selected-run"
|
|
80
|
+
DERIVED_SOURCE = "derived-source"
|
|
81
|
+
PLOT_DATASET = "plot-dataset"
|
|
82
|
+
PLOT_PREFIX = "plot-prefix"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TrustedReadPriority(IntEnum):
|
|
86
|
+
BOOTSTRAP = 0
|
|
87
|
+
REFRESH = 10
|
|
88
|
+
SELECTED_CHEAP = 20
|
|
89
|
+
SELECTED_EXPENSIVE = 30
|
|
90
|
+
VISIBLE_CHEAP = 40
|
|
91
|
+
VISIBLE_EXPENSIVE = 50
|
|
92
|
+
REMAINING_CHEAP = 60
|
|
93
|
+
REMAINING_EXPENSIVE = 70
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TrustedReadServiceError(RuntimeError):
|
|
97
|
+
"""Base class for failures introduced by the Stage 4 broker."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TrustedReadServiceClosedError(TrustedReadServiceError):
|
|
101
|
+
"""A request targeted a closing or closed broker session."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TrustedReadQueueFullError(TrustedReadServiceError):
|
|
105
|
+
"""The bounded broker queue rejected additional work."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TrustedReadRequestCancelledError(
|
|
109
|
+
InterruptedError,
|
|
110
|
+
TrustedReadServiceError,
|
|
111
|
+
):
|
|
112
|
+
"""One exact public broker request was cancelled."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TrustedReadRequestDeadlineError(TimeoutError, TrustedReadServiceError):
|
|
116
|
+
"""A broker request expired before it could publish a result."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TrustedReadSessionFailedError(TrustedReadServiceError):
|
|
120
|
+
"""A terminal helper/session failure invalidated queued work."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True)
|
|
124
|
+
class TrustedReadRequestIdentity:
|
|
125
|
+
"""Immutable identity and scheduling data for one public request."""
|
|
126
|
+
|
|
127
|
+
session_generation: int
|
|
128
|
+
request_id: int
|
|
129
|
+
database_instance: DatabaseInstance
|
|
130
|
+
operation: TrustedReadOperation
|
|
131
|
+
initial_priority: int
|
|
132
|
+
deadline: float
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class TrustedReadServiceLiveness:
|
|
137
|
+
dispatcher_alive: bool
|
|
138
|
+
control_alive: bool
|
|
139
|
+
helper_alive: bool
|
|
140
|
+
helper_pid: int | None
|
|
141
|
+
receiver_alive: bool
|
|
142
|
+
open_supervisor_endpoints: int
|
|
143
|
+
unreaped_incarnations: int
|
|
144
|
+
resource_cleanup_pending: bool
|
|
145
|
+
outstanding_requests: int
|
|
146
|
+
closing: bool
|
|
147
|
+
closed: bool
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(slots=True)
|
|
151
|
+
class _RequestState:
|
|
152
|
+
identity: TrustedReadRequestIdentity
|
|
153
|
+
priority: int
|
|
154
|
+
operation_id: int
|
|
155
|
+
done: threading.Event = field(default_factory=threading.Event)
|
|
156
|
+
result: object = _NO_RESULT
|
|
157
|
+
error: BaseException | None = None
|
|
158
|
+
cancelled: bool = False
|
|
159
|
+
request: TrustedReadRequest[Any] | None = None
|
|
160
|
+
slot_released: bool = False
|
|
161
|
+
progress: PlotProgress | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(slots=True)
|
|
165
|
+
class _OperationState:
|
|
166
|
+
operation_id: int
|
|
167
|
+
kind: TrustedReadOperation
|
|
168
|
+
payload: tuple[object, ...]
|
|
169
|
+
coalesce_key: tuple[object, ...]
|
|
170
|
+
priority: int
|
|
171
|
+
sequence: int
|
|
172
|
+
deadline: float
|
|
173
|
+
subscribers: set[int] = field(default_factory=set)
|
|
174
|
+
revision: int = 0
|
|
175
|
+
status: str = "queued"
|
|
176
|
+
supervisor_job: TrustedLiveJob[Any] | None = None
|
|
177
|
+
cancel_underlying: bool = False
|
|
178
|
+
force_next_transaction: bool = False
|
|
179
|
+
plot_steps: Generator[None, None, Any] | None = None
|
|
180
|
+
progress: PlotProgress | None = None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TrustedReadRequest(Generic[_ResultT]):
|
|
184
|
+
"""One exact cancellable handle returned by :class:`TrustedLiveReadService`."""
|
|
185
|
+
|
|
186
|
+
__slots__ = ("_service", "_state")
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
service: TrustedLiveReadService,
|
|
191
|
+
state: _RequestState,
|
|
192
|
+
) -> None:
|
|
193
|
+
self._service = service
|
|
194
|
+
self._state = state
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def identity(self) -> TrustedReadRequestIdentity:
|
|
198
|
+
return self._state.identity
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def request_id(self) -> int:
|
|
202
|
+
return self._state.identity.request_id
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def priority(self) -> int:
|
|
206
|
+
return self._service._request_priority(self._state)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def deadline(self) -> float:
|
|
210
|
+
return self._state.identity.deadline
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def done(self) -> bool:
|
|
214
|
+
return self._state.done.is_set()
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def cancelled(self) -> bool:
|
|
218
|
+
return self._state.cancelled
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def progress(self) -> PlotProgress | None:
|
|
222
|
+
"""Latest immutable observation; polling never waits for a query."""
|
|
223
|
+
return self._state.progress
|
|
224
|
+
|
|
225
|
+
def cancel(self) -> bool:
|
|
226
|
+
"""Mark only this public request; never block on supervisor cleanup."""
|
|
227
|
+
|
|
228
|
+
return self._service.cancel(self)
|
|
229
|
+
|
|
230
|
+
def promote(self, priority: int | TrustedReadPriority) -> bool:
|
|
231
|
+
return self._service.promote(self, priority)
|
|
232
|
+
|
|
233
|
+
def reprioritize(self, priority: int | TrustedReadPriority) -> bool:
|
|
234
|
+
"""Replace this subscriber's priority, including a demotion."""
|
|
235
|
+
|
|
236
|
+
return self._service.reprioritize(self, priority)
|
|
237
|
+
|
|
238
|
+
def wait(self, timeout: float | None = None) -> _ResultT:
|
|
239
|
+
"""Wait for publication; callers must keep this off the Qt GUI thread."""
|
|
240
|
+
|
|
241
|
+
if timeout is not None:
|
|
242
|
+
timeout = _finite_duration(timeout, allow_zero=True)
|
|
243
|
+
if not self._state.done.wait(timeout):
|
|
244
|
+
raise TimeoutError("The broker request wait timed out.")
|
|
245
|
+
error = self._state.error
|
|
246
|
+
if error is not None:
|
|
247
|
+
raise error
|
|
248
|
+
if self._state.result is _NO_RESULT:
|
|
249
|
+
raise TrustedReadServiceError(
|
|
250
|
+
"The broker request completed without a result or error."
|
|
251
|
+
)
|
|
252
|
+
return cast(_ResultT, self._state.result)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class _BrokerQueryExecutor:
|
|
256
|
+
"""Expose supervisor operations while publishing the exact active job."""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
service: TrustedLiveReadService,
|
|
261
|
+
operation: _OperationState,
|
|
262
|
+
) -> None:
|
|
263
|
+
self._service = service
|
|
264
|
+
self._operation = operation
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def incarnation(self) -> int:
|
|
268
|
+
return self._service._required_supervisor().incarnation
|
|
269
|
+
|
|
270
|
+
def check_cancelled(self):
|
|
271
|
+
with self._service._condition:
|
|
272
|
+
if self._service._closing or self._operation.cancel_underlying:
|
|
273
|
+
raise TrustedReadRequestCancelledError("Plot capture cancelled.")
|
|
274
|
+
self._service._operation_remaining(self._operation)
|
|
275
|
+
|
|
276
|
+
def report_progress(self, phase, completed=0, total=None, *, unit="scan", stage=1):
|
|
277
|
+
progress = PlotProgress(phase, completed, total, unit, stage)
|
|
278
|
+
with self._service._condition:
|
|
279
|
+
self._operation.progress = progress
|
|
280
|
+
for request_id in self._operation.subscribers:
|
|
281
|
+
state = self._service._requests.get(request_id)
|
|
282
|
+
if state is not None and not state.done.is_set():
|
|
283
|
+
state.progress = progress
|
|
284
|
+
|
|
285
|
+
def query(
|
|
286
|
+
self,
|
|
287
|
+
sql: str,
|
|
288
|
+
bindings: SqliteBindings = None,
|
|
289
|
+
*,
|
|
290
|
+
timeout: float | None = None,
|
|
291
|
+
wait_timeout: float | None = None,
|
|
292
|
+
) -> TrustedQueryResult:
|
|
293
|
+
del timeout, wait_timeout
|
|
294
|
+
supervisor = self._service._required_supervisor()
|
|
295
|
+
remaining = self._service._prepare_supervisor_transaction(
|
|
296
|
+
self._operation,
|
|
297
|
+
self,
|
|
298
|
+
)
|
|
299
|
+
if self._operation.kind in (TrustedReadOperation.PLOT_DATASET, TrustedReadOperation.PLOT_PREFIX):
|
|
300
|
+
remaining = min(remaining, 5.0)
|
|
301
|
+
job = supervisor.submit_query(sql, bindings, timeout=remaining)
|
|
302
|
+
return self._service._wait_supervisor_job(
|
|
303
|
+
self._operation,
|
|
304
|
+
job,
|
|
305
|
+
remaining,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def query_batch(
|
|
309
|
+
self,
|
|
310
|
+
queries: tuple[TrustedQuery, ...],
|
|
311
|
+
*,
|
|
312
|
+
timeout: float | None = None,
|
|
313
|
+
wait_timeout: float | None = None,
|
|
314
|
+
) -> tuple[TrustedQueryResult, ...]:
|
|
315
|
+
del timeout, wait_timeout
|
|
316
|
+
supervisor = self._service._required_supervisor()
|
|
317
|
+
remaining = self._service._prepare_supervisor_transaction(
|
|
318
|
+
self._operation,
|
|
319
|
+
self,
|
|
320
|
+
)
|
|
321
|
+
if self._operation.kind in (TrustedReadOperation.PLOT_DATASET, TrustedReadOperation.PLOT_PREFIX):
|
|
322
|
+
remaining = min(remaining, 5.0)
|
|
323
|
+
job = supervisor.submit_query_batch(queries, timeout=remaining)
|
|
324
|
+
return self._service._wait_supervisor_job(
|
|
325
|
+
self._operation,
|
|
326
|
+
job,
|
|
327
|
+
remaining,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def data_version(
|
|
331
|
+
self,
|
|
332
|
+
*,
|
|
333
|
+
timeout: float | None = None,
|
|
334
|
+
wait_timeout: float | None = None,
|
|
335
|
+
) -> int:
|
|
336
|
+
del timeout, wait_timeout
|
|
337
|
+
supervisor = self._service._required_supervisor()
|
|
338
|
+
remaining = self._service._prepare_supervisor_transaction(
|
|
339
|
+
self._operation,
|
|
340
|
+
self,
|
|
341
|
+
)
|
|
342
|
+
job = supervisor.submit_data_version(timeout=remaining)
|
|
343
|
+
return self._service._wait_supervisor_job(
|
|
344
|
+
self._operation,
|
|
345
|
+
job,
|
|
346
|
+
remaining,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _finite_duration(value: object, *, allow_zero: bool) -> float:
|
|
351
|
+
if isinstance(value, bool):
|
|
352
|
+
raise ValueError("A request timeout must be a finite number of seconds.")
|
|
353
|
+
try:
|
|
354
|
+
duration = float(cast(Any, value))
|
|
355
|
+
except (TypeError, ValueError) as error:
|
|
356
|
+
raise ValueError(
|
|
357
|
+
"A request timeout must be a finite number of seconds."
|
|
358
|
+
) from error
|
|
359
|
+
if (
|
|
360
|
+
not math.isfinite(duration)
|
|
361
|
+
or duration < 0
|
|
362
|
+
or (not allow_zero and duration == 0)
|
|
363
|
+
or duration > _MAX_REQUEST_TIMEOUT_SECONDS
|
|
364
|
+
):
|
|
365
|
+
raise ValueError(
|
|
366
|
+
"A request timeout must be finite, non-negative, and no greater "
|
|
367
|
+
f"than {_MAX_REQUEST_TIMEOUT_SECONDS:g} seconds."
|
|
368
|
+
)
|
|
369
|
+
return duration
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _priority(value: object) -> int:
|
|
373
|
+
if isinstance(value, bool):
|
|
374
|
+
raise ValueError("priority must be an integer.")
|
|
375
|
+
try:
|
|
376
|
+
priority = int(cast(Any, value))
|
|
377
|
+
except (TypeError, ValueError) as error:
|
|
378
|
+
raise ValueError("priority must be an integer.") from error
|
|
379
|
+
if priority < -1_000_000 or priority > 1_000_000:
|
|
380
|
+
raise ValueError("priority is outside the bounded broker range.")
|
|
381
|
+
return priority
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class TrustedLiveReadService:
|
|
385
|
+
"""Own exactly one persistent trusted helper for one database instance."""
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
database_path: str | os.PathLike[str],
|
|
390
|
+
*,
|
|
391
|
+
expected_database_instance: DatabaseInstance | None = None,
|
|
392
|
+
session_generation: int = 1,
|
|
393
|
+
queue_capacity: int = _DEFAULT_QUEUE_CAPACITY,
|
|
394
|
+
request_timeout_seconds: float = _DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
395
|
+
supervisor_factory: Callable[..., TrustedLiveReaderSupervisor] | None = None,
|
|
396
|
+
supervisor_options: dict[str, Any] | None = None,
|
|
397
|
+
) -> None:
|
|
398
|
+
if type(session_generation) is not int or session_generation <= 0:
|
|
399
|
+
raise ValueError("session_generation must be a positive integer.")
|
|
400
|
+
if type(queue_capacity) is not int or queue_capacity <= 0:
|
|
401
|
+
raise ValueError("queue_capacity must be a positive integer.")
|
|
402
|
+
selected_path = os.fspath(database_path)
|
|
403
|
+
if expected_database_instance is None:
|
|
404
|
+
expected_database_instance = database_instance(selected_path)
|
|
405
|
+
elif not isinstance(expected_database_instance, DatabaseInstance):
|
|
406
|
+
raise TypeError(
|
|
407
|
+
"expected_database_instance must be a DatabaseInstance or None."
|
|
408
|
+
)
|
|
409
|
+
self._database_path = selected_path
|
|
410
|
+
self._database_instance = expected_database_instance
|
|
411
|
+
self._session_generation = session_generation
|
|
412
|
+
self._capacity = queue_capacity
|
|
413
|
+
self._request_timeout = _finite_duration(
|
|
414
|
+
request_timeout_seconds,
|
|
415
|
+
allow_zero=False,
|
|
416
|
+
)
|
|
417
|
+
self._supervisor_factory = (
|
|
418
|
+
supervisor_factory or TrustedLiveReaderSupervisor.open
|
|
419
|
+
)
|
|
420
|
+
self._supervisor_options = dict(supervisor_options or {})
|
|
421
|
+
self._supervisor: TrustedLiveReaderSupervisor | None = None
|
|
422
|
+
self._closing_supervisor: TrustedLiveReaderSupervisor | None = None
|
|
423
|
+
self._retained_supervisors: list[TrustedLiveReaderSupervisor] = []
|
|
424
|
+
self._adapter: TrustedMetadataQueryAdapter | None = None
|
|
425
|
+
self._refresh_status_retry_pending = False
|
|
426
|
+
# Accessed only by the dispatcher; never owned by a GUI dataset.
|
|
427
|
+
self._plot_prefix_cache: dict[tuple, Any] = {}
|
|
428
|
+
self._retired_plot_steps: list[Generator[None, None, Any]] = []
|
|
429
|
+
self._source_revision_namespace = TrustedSourceRevisionNamespace.create()
|
|
430
|
+
self._accepted_database_instance: DatabaseInstance | None = None
|
|
431
|
+
|
|
432
|
+
self._condition = threading.Condition(threading.RLock())
|
|
433
|
+
self._control_wakeup = threading.Event()
|
|
434
|
+
self._control_stop = threading.Event()
|
|
435
|
+
self._dispatcher_done = threading.Event()
|
|
436
|
+
self._control_done = threading.Event()
|
|
437
|
+
self._closed_event = threading.Event()
|
|
438
|
+
self._requests: dict[int, _RequestState] = {}
|
|
439
|
+
self._request_slots_in_use = 0
|
|
440
|
+
self._operations: dict[int, _OperationState] = {}
|
|
441
|
+
self._coalesced: dict[tuple[object, ...], int] = {}
|
|
442
|
+
self._heap: list[tuple[int, int, int, int]] = []
|
|
443
|
+
self._next_request_id = 1
|
|
444
|
+
self._next_operation_id = 1
|
|
445
|
+
self._next_sequence = 1
|
|
446
|
+
self._foreground_dispatch_burst = 0
|
|
447
|
+
self._active_operation: _OperationState | None = None
|
|
448
|
+
self._control_cancel_job: TrustedLiveJob[Any] | None = None
|
|
449
|
+
self._closing = False
|
|
450
|
+
self._closed = False
|
|
451
|
+
self._fatal_error: BaseException | None = None
|
|
452
|
+
self._close_error: BaseException | None = None
|
|
453
|
+
|
|
454
|
+
self._control_thread = threading.Thread(
|
|
455
|
+
target=self._control_loop,
|
|
456
|
+
name=f"qplot-trusted-read-control-{session_generation}",
|
|
457
|
+
daemon=True,
|
|
458
|
+
)
|
|
459
|
+
self._dispatcher_thread = threading.Thread(
|
|
460
|
+
target=self._dispatcher_loop,
|
|
461
|
+
name=f"qplot-trusted-read-dispatcher-{session_generation}",
|
|
462
|
+
daemon=True,
|
|
463
|
+
)
|
|
464
|
+
self._control_thread.start()
|
|
465
|
+
self._dispatcher_thread.start()
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def database_instance(self) -> DatabaseInstance:
|
|
469
|
+
with self._condition:
|
|
470
|
+
return self._accepted_database_instance or self._database_instance
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def session_generation(self) -> int:
|
|
474
|
+
return self._session_generation
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def source_revision_namespace(self) -> TrustedSourceRevisionNamespace:
|
|
478
|
+
"""Restart-unique identity used by all derived observations in this service."""
|
|
479
|
+
|
|
480
|
+
return self._source_revision_namespace
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def mode(self) -> str:
|
|
484
|
+
return TRUSTED_LIVE_MODE
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def accepted(self) -> bool:
|
|
488
|
+
"""Whether Stage 3 startup accepted and bound the source instance."""
|
|
489
|
+
|
|
490
|
+
with self._condition:
|
|
491
|
+
return self._accepted_database_instance is not None
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def closed(self) -> bool:
|
|
495
|
+
with self._condition:
|
|
496
|
+
return self._closed
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def closing(self) -> bool:
|
|
500
|
+
"""Whether asynchronous retirement has begun but is not yet complete."""
|
|
501
|
+
|
|
502
|
+
with self._condition:
|
|
503
|
+
return self._closing
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def close_error(self) -> BaseException | None:
|
|
507
|
+
with self._condition:
|
|
508
|
+
return self._close_error
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def fatal_error(self) -> BaseException | None:
|
|
512
|
+
"""Return the exact terminal dispatcher/control failure, if any."""
|
|
513
|
+
|
|
514
|
+
with self._condition:
|
|
515
|
+
return self._fatal_error
|
|
516
|
+
|
|
517
|
+
def liveness(self) -> TrustedReadServiceLiveness:
|
|
518
|
+
with self._condition:
|
|
519
|
+
supervisors = self._owned_supervisors_locked()
|
|
520
|
+
outstanding_requests = self._request_slots_in_use
|
|
521
|
+
closing = self._closing
|
|
522
|
+
closed = self._closed
|
|
523
|
+
helper_alive = False
|
|
524
|
+
helper_pid = None
|
|
525
|
+
receiver_alive = False
|
|
526
|
+
open_endpoints = 0
|
|
527
|
+
unreaped_incarnations = 0
|
|
528
|
+
resource_cleanup_pending = False
|
|
529
|
+
for supervisor in supervisors:
|
|
530
|
+
snapshot = self._supervisor_resource_liveness(
|
|
531
|
+
supervisor,
|
|
532
|
+
conservative_on_error=False,
|
|
533
|
+
)
|
|
534
|
+
helper_alive = helper_alive or snapshot.process_alive
|
|
535
|
+
if helper_pid is None:
|
|
536
|
+
helper_pid = snapshot.helper_pid
|
|
537
|
+
receiver_alive = receiver_alive or snapshot.receiver_alive
|
|
538
|
+
open_endpoints += snapshot.open_endpoints
|
|
539
|
+
unreaped_incarnations += int(snapshot.unreaped_incarnation)
|
|
540
|
+
resource_cleanup_pending = (
|
|
541
|
+
resource_cleanup_pending or snapshot.resources_owned
|
|
542
|
+
)
|
|
543
|
+
return TrustedReadServiceLiveness(
|
|
544
|
+
dispatcher_alive=self._dispatcher_thread.is_alive(),
|
|
545
|
+
control_alive=self._control_thread.is_alive(),
|
|
546
|
+
helper_alive=helper_alive,
|
|
547
|
+
helper_pid=helper_pid,
|
|
548
|
+
receiver_alive=receiver_alive,
|
|
549
|
+
open_supervisor_endpoints=open_endpoints,
|
|
550
|
+
unreaped_incarnations=unreaped_incarnations,
|
|
551
|
+
resource_cleanup_pending=resource_cleanup_pending,
|
|
552
|
+
outstanding_requests=outstanding_requests,
|
|
553
|
+
closing=closing,
|
|
554
|
+
closed=closed,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def submit_bootstrap(
|
|
558
|
+
self,
|
|
559
|
+
*,
|
|
560
|
+
deadline: float | None = None,
|
|
561
|
+
) -> TrustedReadRequest[TrustedBootstrapResult]:
|
|
562
|
+
return cast(
|
|
563
|
+
TrustedReadRequest[TrustedBootstrapResult],
|
|
564
|
+
self._submit(
|
|
565
|
+
TrustedReadOperation.BOOTSTRAP,
|
|
566
|
+
(),
|
|
567
|
+
TrustedReadPriority.BOOTSTRAP,
|
|
568
|
+
deadline,
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def submit_refresh(
|
|
573
|
+
self,
|
|
574
|
+
accepted_run_id: int | None = None,
|
|
575
|
+
*,
|
|
576
|
+
deadline: float | None = None,
|
|
577
|
+
) -> TrustedReadRequest[TrustedRefreshResult]:
|
|
578
|
+
if accepted_run_id is not None and (
|
|
579
|
+
type(accepted_run_id) is not int or accepted_run_id < 0
|
|
580
|
+
):
|
|
581
|
+
raise ValueError("accepted_run_id must be a non-negative integer or None.")
|
|
582
|
+
return cast(
|
|
583
|
+
TrustedReadRequest[TrustedRefreshResult],
|
|
584
|
+
self._submit(
|
|
585
|
+
TrustedReadOperation.REFRESH,
|
|
586
|
+
(accepted_run_id,),
|
|
587
|
+
TrustedReadPriority.REFRESH,
|
|
588
|
+
deadline,
|
|
589
|
+
),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def submit_database_info(
|
|
593
|
+
self,
|
|
594
|
+
*,
|
|
595
|
+
deadline: float | None = None,
|
|
596
|
+
) -> TrustedReadRequest[TrustedDatabaseInfo]:
|
|
597
|
+
"""Read current diagnostic metadata without a snapshot or GUI wait."""
|
|
598
|
+
|
|
599
|
+
return cast(
|
|
600
|
+
TrustedReadRequest[TrustedDatabaseInfo],
|
|
601
|
+
self._submit(
|
|
602
|
+
TrustedReadOperation.DATABASE_INFO,
|
|
603
|
+
(),
|
|
604
|
+
TrustedReadPriority.SELECTED_CHEAP,
|
|
605
|
+
deadline,
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def retry_refresh_statuses(self) -> None:
|
|
610
|
+
"""Retain status reconciliation when a refresh worker did not finish."""
|
|
611
|
+
|
|
612
|
+
with self._condition:
|
|
613
|
+
self._refresh_status_retry_pending = True
|
|
614
|
+
|
|
615
|
+
def submit_basic_page(
|
|
616
|
+
self,
|
|
617
|
+
after_run_id: int,
|
|
618
|
+
through_run_id: int,
|
|
619
|
+
*,
|
|
620
|
+
priority: int | TrustedReadPriority = TrustedReadPriority.BOOTSTRAP,
|
|
621
|
+
deadline: float | None = None,
|
|
622
|
+
) -> TrustedReadRequest[TrustedRunPage]:
|
|
623
|
+
if type(after_run_id) is not int or after_run_id < 0:
|
|
624
|
+
raise ValueError("after_run_id must be a non-negative integer.")
|
|
625
|
+
if type(through_run_id) is not int or through_run_id < after_run_id:
|
|
626
|
+
raise ValueError(
|
|
627
|
+
"through_run_id must be an integer no smaller than after_run_id."
|
|
628
|
+
)
|
|
629
|
+
return cast(
|
|
630
|
+
TrustedReadRequest[TrustedRunPage],
|
|
631
|
+
self._submit(
|
|
632
|
+
TrustedReadOperation.BASIC_PAGE,
|
|
633
|
+
(after_run_id, through_run_id),
|
|
634
|
+
priority,
|
|
635
|
+
deadline,
|
|
636
|
+
),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def submit_cheap_run(
|
|
640
|
+
self,
|
|
641
|
+
run_id: int,
|
|
642
|
+
*,
|
|
643
|
+
priority: int | TrustedReadPriority = TrustedReadPriority.REMAINING_CHEAP,
|
|
644
|
+
deadline: float | None = None,
|
|
645
|
+
) -> TrustedReadRequest[TrustedRunRecord]:
|
|
646
|
+
return cast(
|
|
647
|
+
TrustedReadRequest[TrustedRunRecord],
|
|
648
|
+
self._submit(
|
|
649
|
+
TrustedReadOperation.CHEAP_RUN,
|
|
650
|
+
(self._validated_run_id(run_id),),
|
|
651
|
+
priority,
|
|
652
|
+
deadline,
|
|
653
|
+
),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def submit_expensive_run(
|
|
657
|
+
self,
|
|
658
|
+
run_id: int,
|
|
659
|
+
*,
|
|
660
|
+
priority: int | TrustedReadPriority = (TrustedReadPriority.REMAINING_EXPENSIVE),
|
|
661
|
+
deadline: float | None = None,
|
|
662
|
+
) -> TrustedReadRequest[TrustedRunRecord]:
|
|
663
|
+
return cast(
|
|
664
|
+
TrustedReadRequest[TrustedRunRecord],
|
|
665
|
+
self._submit(
|
|
666
|
+
TrustedReadOperation.EXPENSIVE_RUN,
|
|
667
|
+
(self._validated_run_id(run_id),),
|
|
668
|
+
priority,
|
|
669
|
+
deadline,
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
def submit_selected_run(
|
|
674
|
+
self,
|
|
675
|
+
run_id: int,
|
|
676
|
+
*,
|
|
677
|
+
priority: int | TrustedReadPriority = TrustedReadPriority.SELECTED_EXPENSIVE,
|
|
678
|
+
deadline: float | None = None,
|
|
679
|
+
) -> TrustedReadRequest[TrustedSelectedRunDetail]:
|
|
680
|
+
return cast(
|
|
681
|
+
TrustedReadRequest[TrustedSelectedRunDetail],
|
|
682
|
+
self._submit(
|
|
683
|
+
TrustedReadOperation.SELECTED_RUN,
|
|
684
|
+
(self._validated_run_id(run_id),),
|
|
685
|
+
priority,
|
|
686
|
+
deadline,
|
|
687
|
+
),
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
def submit_derived_source(
|
|
691
|
+
self,
|
|
692
|
+
run_id: int,
|
|
693
|
+
*,
|
|
694
|
+
priority: int | TrustedReadPriority = TrustedReadPriority.REMAINING_EXPENSIVE,
|
|
695
|
+
deadline: float | None = None,
|
|
696
|
+
) -> TrustedReadRequest[TrustedDerivedSourceObservation]:
|
|
697
|
+
"""Capture one bounded immutable prefix through the persistent broker."""
|
|
698
|
+
|
|
699
|
+
return cast(
|
|
700
|
+
TrustedReadRequest[TrustedDerivedSourceObservation],
|
|
701
|
+
self._submit(
|
|
702
|
+
TrustedReadOperation.DERIVED_SOURCE,
|
|
703
|
+
(self._validated_run_id(run_id),),
|
|
704
|
+
priority,
|
|
705
|
+
deadline,
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
def submit_plot_dataset(self, guid, *, deadline=None):
|
|
710
|
+
if not isinstance(guid, str) or not 0 < len(guid) <= 256:
|
|
711
|
+
raise ValueError("Invalid plot GUID.")
|
|
712
|
+
return self._submit(TrustedReadOperation.PLOT_DATASET, (guid,),
|
|
713
|
+
TrustedReadPriority.SELECTED_CHEAP, deadline)
|
|
714
|
+
|
|
715
|
+
def submit_plot_prefix(self, dataset, *, deadline=None, heatmap_plan=None):
|
|
716
|
+
if dataset.service is not self:
|
|
717
|
+
raise ValueError("The plot belongs to another trusted session.")
|
|
718
|
+
payload = (dataset,) if heatmap_plan is None else (dataset, heatmap_plan)
|
|
719
|
+
return self._submit(TrustedReadOperation.PLOT_PREFIX, payload,
|
|
720
|
+
TrustedReadPriority.SELECTED_EXPENSIVE, deadline)
|
|
721
|
+
|
|
722
|
+
@staticmethod
|
|
723
|
+
def _validated_run_id(run_id: object) -> int:
|
|
724
|
+
if type(run_id) is not int or run_id <= 0:
|
|
725
|
+
raise ValueError("run_id must be a positive integer.")
|
|
726
|
+
return run_id
|
|
727
|
+
|
|
728
|
+
def _submit(
|
|
729
|
+
self,
|
|
730
|
+
kind: TrustedReadOperation,
|
|
731
|
+
payload: tuple[object, ...],
|
|
732
|
+
priority: int | TrustedReadPriority,
|
|
733
|
+
deadline: float | None,
|
|
734
|
+
) -> TrustedReadRequest[Any]:
|
|
735
|
+
requested_priority = _priority(priority)
|
|
736
|
+
now = time.monotonic()
|
|
737
|
+
default_deadline = deadline is None
|
|
738
|
+
if deadline is None:
|
|
739
|
+
request_deadline = now + (
|
|
740
|
+
_PLOT_CAPTURE_TIMEOUT_SECONDS
|
|
741
|
+
if kind is TrustedReadOperation.PLOT_PREFIX else self._request_timeout
|
|
742
|
+
)
|
|
743
|
+
else:
|
|
744
|
+
if isinstance(deadline, bool):
|
|
745
|
+
raise ValueError("deadline must be a finite monotonic timestamp.")
|
|
746
|
+
request_deadline = float(deadline)
|
|
747
|
+
if not math.isfinite(request_deadline):
|
|
748
|
+
raise ValueError("deadline must be a finite monotonic timestamp.")
|
|
749
|
+
if request_deadline - now > _MAX_REQUEST_TIMEOUT_SECONDS:
|
|
750
|
+
raise ValueError("deadline exceeds the bounded broker request horizon.")
|
|
751
|
+
|
|
752
|
+
with self._condition:
|
|
753
|
+
if self._closing or self._closed:
|
|
754
|
+
raise TrustedReadServiceClosedError(
|
|
755
|
+
"The trusted read service is closing or closed."
|
|
756
|
+
)
|
|
757
|
+
if self._request_slots_in_use >= self._capacity:
|
|
758
|
+
raise TrustedReadQueueFullError(
|
|
759
|
+
f"The trusted read queue reached its {self._capacity}-request cap."
|
|
760
|
+
)
|
|
761
|
+
request_id = self._next_request_id
|
|
762
|
+
self._next_request_id += 1
|
|
763
|
+
# Ordinary duplicate submissions share the first operation's
|
|
764
|
+
# bounded default deadline; an active supervisor wait is therefore
|
|
765
|
+
# never extended. Explicit deadlines coalesce only when exactly
|
|
766
|
+
# equal, preserving every caller's requested bound.
|
|
767
|
+
coalesce_key = (
|
|
768
|
+
(kind, payload, "default")
|
|
769
|
+
if default_deadline
|
|
770
|
+
else (kind, payload, "explicit", request_deadline)
|
|
771
|
+
)
|
|
772
|
+
operation_id = self._coalesced.get(coalesce_key)
|
|
773
|
+
operation = (
|
|
774
|
+
self._operations.get(operation_id) if operation_id is not None else None
|
|
775
|
+
)
|
|
776
|
+
coalesce_eligible = bool(
|
|
777
|
+
operation is not None
|
|
778
|
+
and operation.status in {"queued", "active"}
|
|
779
|
+
and not operation.cancel_underlying
|
|
780
|
+
and operation.deadline > now
|
|
781
|
+
)
|
|
782
|
+
if not coalesce_eligible:
|
|
783
|
+
operation_id = self._next_operation_id
|
|
784
|
+
self._next_operation_id += 1
|
|
785
|
+
sequence = self._next_sequence
|
|
786
|
+
self._next_sequence += 1
|
|
787
|
+
operation = _OperationState(
|
|
788
|
+
operation_id,
|
|
789
|
+
kind,
|
|
790
|
+
payload,
|
|
791
|
+
coalesce_key,
|
|
792
|
+
requested_priority,
|
|
793
|
+
sequence,
|
|
794
|
+
request_deadline,
|
|
795
|
+
)
|
|
796
|
+
self._operations[operation_id] = operation
|
|
797
|
+
self._coalesced[coalesce_key] = operation_id
|
|
798
|
+
heapq.heappush(
|
|
799
|
+
self._heap,
|
|
800
|
+
(
|
|
801
|
+
operation.priority,
|
|
802
|
+
operation.sequence,
|
|
803
|
+
operation.revision,
|
|
804
|
+
operation.operation_id,
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
assert operation is not None
|
|
809
|
+
if default_deadline:
|
|
810
|
+
request_deadline = operation.deadline
|
|
811
|
+
assert operation is not None
|
|
812
|
+
identity = TrustedReadRequestIdentity(
|
|
813
|
+
self._session_generation,
|
|
814
|
+
request_id,
|
|
815
|
+
self._database_instance,
|
|
816
|
+
kind,
|
|
817
|
+
requested_priority,
|
|
818
|
+
request_deadline,
|
|
819
|
+
)
|
|
820
|
+
state = _RequestState(
|
|
821
|
+
identity,
|
|
822
|
+
requested_priority,
|
|
823
|
+
operation.operation_id,
|
|
824
|
+
)
|
|
825
|
+
state.progress = operation.progress
|
|
826
|
+
request: TrustedReadRequest[Any] = TrustedReadRequest(self, state)
|
|
827
|
+
state.request = request
|
|
828
|
+
self._requests[request_id] = state
|
|
829
|
+
self._request_slots_in_use += 1
|
|
830
|
+
operation.subscribers.add(request_id)
|
|
831
|
+
# A coalesced operation is always scheduled from the complete set
|
|
832
|
+
# of live subscribers. This is also the single path used after
|
|
833
|
+
# cancellation and request-level reprioritization.
|
|
834
|
+
self._recompute_operation_schedule_locked(operation)
|
|
835
|
+
self._condition.notify()
|
|
836
|
+
self._control_wakeup.set()
|
|
837
|
+
return request
|
|
838
|
+
|
|
839
|
+
def _request_priority(self, state: _RequestState) -> int:
|
|
840
|
+
with self._condition:
|
|
841
|
+
return state.priority
|
|
842
|
+
|
|
843
|
+
def promote(
|
|
844
|
+
self,
|
|
845
|
+
request: TrustedReadRequest[Any],
|
|
846
|
+
priority: int | TrustedReadPriority,
|
|
847
|
+
) -> bool:
|
|
848
|
+
requested_priority = _priority(priority)
|
|
849
|
+
with self._condition:
|
|
850
|
+
state = self._owned_request_state(request)
|
|
851
|
+
if state.done.is_set() or requested_priority >= state.priority:
|
|
852
|
+
return False
|
|
853
|
+
return self._reprioritize_request_locked(state, requested_priority)
|
|
854
|
+
|
|
855
|
+
def reprioritize(
|
|
856
|
+
self,
|
|
857
|
+
request: TrustedReadRequest[Any],
|
|
858
|
+
priority: int | TrustedReadPriority,
|
|
859
|
+
) -> bool:
|
|
860
|
+
"""Replace one live subscriber priority and reschedule its operation."""
|
|
861
|
+
|
|
862
|
+
requested_priority = _priority(priority)
|
|
863
|
+
with self._condition:
|
|
864
|
+
state = self._owned_request_state(request)
|
|
865
|
+
if state.done.is_set() or requested_priority == state.priority:
|
|
866
|
+
return False
|
|
867
|
+
return self._reprioritize_request_locked(state, requested_priority)
|
|
868
|
+
|
|
869
|
+
def _reprioritize_request_locked(
|
|
870
|
+
self,
|
|
871
|
+
state: _RequestState,
|
|
872
|
+
requested_priority: int,
|
|
873
|
+
) -> bool:
|
|
874
|
+
operation = self._operations.get(state.operation_id)
|
|
875
|
+
if operation is None:
|
|
876
|
+
return False
|
|
877
|
+
state.priority = requested_priority
|
|
878
|
+
self._recompute_operation_schedule_locked(operation)
|
|
879
|
+
self._condition.notify_all()
|
|
880
|
+
return True
|
|
881
|
+
|
|
882
|
+
def cancel(self, request: TrustedReadRequest[Any]) -> bool:
|
|
883
|
+
with self._condition:
|
|
884
|
+
state = self._owned_request_state(request)
|
|
885
|
+
if state.done.is_set():
|
|
886
|
+
return False
|
|
887
|
+
state.cancelled = True
|
|
888
|
+
self._complete_request_locked(
|
|
889
|
+
state,
|
|
890
|
+
error=TrustedReadRequestCancelledError(
|
|
891
|
+
f"Trusted request {state.identity.request_id} was cancelled."
|
|
892
|
+
),
|
|
893
|
+
)
|
|
894
|
+
operation = self._operations.get(state.operation_id)
|
|
895
|
+
if operation is not None:
|
|
896
|
+
operation.subscribers.discard(state.identity.request_id)
|
|
897
|
+
if not operation.subscribers:
|
|
898
|
+
if operation.status == "queued":
|
|
899
|
+
self._discard_operation_locked(operation)
|
|
900
|
+
elif operation.status == "active":
|
|
901
|
+
operation.cancel_underlying = True
|
|
902
|
+
else:
|
|
903
|
+
self._recompute_operation_schedule_locked(operation)
|
|
904
|
+
self._condition.notify_all()
|
|
905
|
+
# Wake the deadline/cancellation control path even for a queued
|
|
906
|
+
# cancellation so it can recompute its earliest timed wait.
|
|
907
|
+
self._control_wakeup.set()
|
|
908
|
+
return True
|
|
909
|
+
|
|
910
|
+
def _owned_request_state(
|
|
911
|
+
self,
|
|
912
|
+
request: TrustedReadRequest[Any],
|
|
913
|
+
) -> _RequestState:
|
|
914
|
+
if not isinstance(request, TrustedReadRequest) or request._service is not self:
|
|
915
|
+
raise ValueError("The request does not belong to this trusted service.")
|
|
916
|
+
return request._state
|
|
917
|
+
|
|
918
|
+
def close_async(self) -> bool:
|
|
919
|
+
"""Begin cancellation/retirement and return without waiting or joining."""
|
|
920
|
+
|
|
921
|
+
with self._condition:
|
|
922
|
+
if self._closing or self._closed:
|
|
923
|
+
return False
|
|
924
|
+
self._closing = True
|
|
925
|
+
error = TrustedReadServiceClosedError(
|
|
926
|
+
"The trusted read service closed before the request completed."
|
|
927
|
+
)
|
|
928
|
+
for state in list(self._requests.values()):
|
|
929
|
+
state.cancelled = True
|
|
930
|
+
self._complete_request_locked(state, error=error)
|
|
931
|
+
for operation in list(self._operations.values()):
|
|
932
|
+
operation.subscribers.clear()
|
|
933
|
+
if operation.status == "queued":
|
|
934
|
+
self._discard_operation_locked(operation)
|
|
935
|
+
elif operation.status == "active":
|
|
936
|
+
operation.cancel_underlying = True
|
|
937
|
+
self._condition.notify_all()
|
|
938
|
+
self._control_wakeup.set()
|
|
939
|
+
return True
|
|
940
|
+
|
|
941
|
+
def escalate_cleanup_async(self) -> None:
|
|
942
|
+
"""Repeat retirement signals and perform one zero-wait resource reap.
|
|
943
|
+
|
|
944
|
+
This method is safe for a Qt shutdown timer: it never waits for a
|
|
945
|
+
broker thread or helper. The dispatcher/control paths retain sole
|
|
946
|
+
ownership of blocking Stage 3 cancellation and close operations.
|
|
947
|
+
"""
|
|
948
|
+
|
|
949
|
+
self.close_async()
|
|
950
|
+
with self._condition:
|
|
951
|
+
operation = self._active_operation
|
|
952
|
+
if operation is not None:
|
|
953
|
+
operation.cancel_underlying = True
|
|
954
|
+
self._condition.notify_all()
|
|
955
|
+
self._control_wakeup.set()
|
|
956
|
+
self._reap_retained_supervisors_once()
|
|
957
|
+
|
|
958
|
+
def close(self, *, timeout: float | None = None) -> None:
|
|
959
|
+
"""Blocking convenience for non-GUI owners and tests."""
|
|
960
|
+
|
|
961
|
+
self.close_async()
|
|
962
|
+
if not self.wait_closed(timeout):
|
|
963
|
+
raise TimeoutError("The trusted read service did not close in time.")
|
|
964
|
+
error = self.close_error
|
|
965
|
+
if error is not None:
|
|
966
|
+
raise error
|
|
967
|
+
|
|
968
|
+
def wait_closed(self, timeout: float | None = None) -> bool:
|
|
969
|
+
if timeout is not None:
|
|
970
|
+
timeout = _finite_duration(timeout, allow_zero=True)
|
|
971
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
972
|
+
threads = (
|
|
973
|
+
self._dispatcher_thread,
|
|
974
|
+
self._control_thread,
|
|
975
|
+
)
|
|
976
|
+
current = threading.current_thread()
|
|
977
|
+
if current in threads:
|
|
978
|
+
return False
|
|
979
|
+
for event in (
|
|
980
|
+
self._dispatcher_done,
|
|
981
|
+
self._control_done,
|
|
982
|
+
self._closed_event,
|
|
983
|
+
):
|
|
984
|
+
remaining = (
|
|
985
|
+
None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
986
|
+
)
|
|
987
|
+
if not event.wait(remaining):
|
|
988
|
+
return False
|
|
989
|
+
for thread in threads:
|
|
990
|
+
remaining = (
|
|
991
|
+
None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
992
|
+
)
|
|
993
|
+
thread.join(remaining)
|
|
994
|
+
if thread.is_alive():
|
|
995
|
+
return False
|
|
996
|
+
while self._reap_retained_supervisors_once():
|
|
997
|
+
if deadline is not None:
|
|
998
|
+
remaining = deadline - time.monotonic()
|
|
999
|
+
if remaining <= 0:
|
|
1000
|
+
return False
|
|
1001
|
+
time.sleep(min(0.01, remaining))
|
|
1002
|
+
else:
|
|
1003
|
+
time.sleep(0.01)
|
|
1004
|
+
return True
|
|
1005
|
+
|
|
1006
|
+
def _dispatcher_loop(self) -> None:
|
|
1007
|
+
try:
|
|
1008
|
+
while True:
|
|
1009
|
+
self._close_retired_plot_steps()
|
|
1010
|
+
with self._condition:
|
|
1011
|
+
operation = self._next_operation_locked()
|
|
1012
|
+
if operation is None:
|
|
1013
|
+
should_stop = self._closing
|
|
1014
|
+
if not should_stop:
|
|
1015
|
+
self._condition.wait(timeout=0.1)
|
|
1016
|
+
continue
|
|
1017
|
+
else:
|
|
1018
|
+
should_stop = False
|
|
1019
|
+
operation.status = "active"
|
|
1020
|
+
self._active_operation = operation
|
|
1021
|
+
# Fairness may select an operation directly rather than
|
|
1022
|
+
# popping its live heap entry. Keep the physical heap
|
|
1023
|
+
# exactly bounded to the remaining queued operations.
|
|
1024
|
+
self._compact_heap_locked()
|
|
1025
|
+
if operation is None:
|
|
1026
|
+
if should_stop:
|
|
1027
|
+
break
|
|
1028
|
+
continue
|
|
1029
|
+
|
|
1030
|
+
result: object = _NO_RESULT
|
|
1031
|
+
error: BaseException | None = None
|
|
1032
|
+
try:
|
|
1033
|
+
result = self._execute_operation(operation)
|
|
1034
|
+
except BaseException as operation_error:
|
|
1035
|
+
error = operation_error
|
|
1036
|
+
|
|
1037
|
+
with self._condition:
|
|
1038
|
+
self._finish_operation_locked(
|
|
1039
|
+
operation,
|
|
1040
|
+
result,
|
|
1041
|
+
error,
|
|
1042
|
+
)
|
|
1043
|
+
except BaseException as dispatcher_error:
|
|
1044
|
+
with self._condition:
|
|
1045
|
+
self._fatal_error = dispatcher_error
|
|
1046
|
+
self._closing = True
|
|
1047
|
+
self._fail_all_locked(dispatcher_error)
|
|
1048
|
+
self._condition.notify_all()
|
|
1049
|
+
finally:
|
|
1050
|
+
self._close_retired_plot_steps()
|
|
1051
|
+
self._plot_prefix_cache.clear()
|
|
1052
|
+
self._close_supervisor_from_dispatcher()
|
|
1053
|
+
with self._condition:
|
|
1054
|
+
self._closed = True
|
|
1055
|
+
self._closing = False
|
|
1056
|
+
self._condition.notify_all()
|
|
1057
|
+
self._dispatcher_done.set()
|
|
1058
|
+
self._control_stop.set()
|
|
1059
|
+
self._control_wakeup.set()
|
|
1060
|
+
with self._condition:
|
|
1061
|
+
self._condition.notify_all()
|
|
1062
|
+
self._closed_event.set()
|
|
1063
|
+
|
|
1064
|
+
def _next_operation_locked(self) -> _OperationState | None:
|
|
1065
|
+
while True:
|
|
1066
|
+
now = time.monotonic()
|
|
1067
|
+
background = min(
|
|
1068
|
+
(
|
|
1069
|
+
operation
|
|
1070
|
+
for operation in self._operations.values()
|
|
1071
|
+
if operation.status == "queued"
|
|
1072
|
+
and operation.priority >= TrustedReadPriority.VISIBLE_CHEAP
|
|
1073
|
+
),
|
|
1074
|
+
key=lambda operation: operation.sequence,
|
|
1075
|
+
default=None,
|
|
1076
|
+
)
|
|
1077
|
+
fairness_forced = (
|
|
1078
|
+
background is not None
|
|
1079
|
+
and self._foreground_dispatch_burst >= _MAX_FOREGROUND_DISPATCH_BURST
|
|
1080
|
+
)
|
|
1081
|
+
if fairness_forced:
|
|
1082
|
+
operation = background
|
|
1083
|
+
else:
|
|
1084
|
+
operation = self._pop_priority_operation_locked()
|
|
1085
|
+
if operation is None:
|
|
1086
|
+
self._foreground_dispatch_burst = 0
|
|
1087
|
+
return None
|
|
1088
|
+
if not self._prepare_queued_operation_locked(
|
|
1089
|
+
operation,
|
|
1090
|
+
now,
|
|
1091
|
+
):
|
|
1092
|
+
continue
|
|
1093
|
+
if operation.priority >= TrustedReadPriority.VISIBLE_CHEAP:
|
|
1094
|
+
self._foreground_dispatch_burst = 0
|
|
1095
|
+
elif background is not None:
|
|
1096
|
+
self._foreground_dispatch_burst += 1
|
|
1097
|
+
else:
|
|
1098
|
+
self._foreground_dispatch_burst = 0
|
|
1099
|
+
operation.force_next_transaction = fairness_forced
|
|
1100
|
+
return operation
|
|
1101
|
+
|
|
1102
|
+
def _pop_priority_operation_locked(self) -> _OperationState | None:
|
|
1103
|
+
while self._heap:
|
|
1104
|
+
priority, sequence, revision, operation_id = heapq.heappop(self._heap)
|
|
1105
|
+
operation = self._operations.get(operation_id)
|
|
1106
|
+
if (
|
|
1107
|
+
operation is not None
|
|
1108
|
+
and operation.status == "queued"
|
|
1109
|
+
and operation.priority == priority
|
|
1110
|
+
and operation.sequence == sequence
|
|
1111
|
+
and operation.revision == revision
|
|
1112
|
+
):
|
|
1113
|
+
return operation
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
def _compact_heap_locked(self) -> None:
|
|
1117
|
+
"""Retain exactly one heap entry for every queued operation.
|
|
1118
|
+
|
|
1119
|
+
Promotions and queued cancellation used to leave lazy tombstones in
|
|
1120
|
+
``_heap``. Because completed requests immediately release queue
|
|
1121
|
+
capacity, a producer could create an unbounded number of those physical
|
|
1122
|
+
entries behind one blocked active job. Rebuilding at the bounded
|
|
1123
|
+
operation cardinality keeps backpressure true for the actual storage.
|
|
1124
|
+
"""
|
|
1125
|
+
|
|
1126
|
+
self._heap = [
|
|
1127
|
+
(
|
|
1128
|
+
operation.priority,
|
|
1129
|
+
operation.sequence,
|
|
1130
|
+
operation.revision,
|
|
1131
|
+
operation.operation_id,
|
|
1132
|
+
)
|
|
1133
|
+
for operation in self._operations.values()
|
|
1134
|
+
if operation.status == "queued"
|
|
1135
|
+
]
|
|
1136
|
+
heapq.heapify(self._heap)
|
|
1137
|
+
|
|
1138
|
+
def _recompute_operation_schedule_locked(
|
|
1139
|
+
self,
|
|
1140
|
+
operation: _OperationState,
|
|
1141
|
+
) -> None:
|
|
1142
|
+
"""Recompute a shared operation from only its live subscribers."""
|
|
1143
|
+
|
|
1144
|
+
live_states: list[_RequestState] = []
|
|
1145
|
+
for request_id in tuple(operation.subscribers):
|
|
1146
|
+
state = self._requests.get(request_id)
|
|
1147
|
+
if state is None or state.done.is_set():
|
|
1148
|
+
operation.subscribers.discard(request_id)
|
|
1149
|
+
else:
|
|
1150
|
+
live_states.append(state)
|
|
1151
|
+
if not live_states:
|
|
1152
|
+
if operation.status == "queued":
|
|
1153
|
+
self._discard_operation_locked(operation)
|
|
1154
|
+
elif operation.status == "active":
|
|
1155
|
+
operation.cancel_underlying = True
|
|
1156
|
+
self._control_wakeup.set()
|
|
1157
|
+
return
|
|
1158
|
+
|
|
1159
|
+
priority = min(state.priority for state in live_states)
|
|
1160
|
+
operation.deadline = min(state.identity.deadline for state in live_states)
|
|
1161
|
+
if priority == operation.priority:
|
|
1162
|
+
return
|
|
1163
|
+
operation.priority = priority
|
|
1164
|
+
operation.revision += 1
|
|
1165
|
+
if operation.status == "queued":
|
|
1166
|
+
self._compact_heap_locked()
|
|
1167
|
+
|
|
1168
|
+
def _expire_queued_requests_locked(self, now: float) -> None:
|
|
1169
|
+
"""Complete queued subscribers at their own monotonic deadlines."""
|
|
1170
|
+
|
|
1171
|
+
for operation in tuple(self._operations.values()):
|
|
1172
|
+
if operation.status != "queued":
|
|
1173
|
+
continue
|
|
1174
|
+
expired = tuple(
|
|
1175
|
+
request_id
|
|
1176
|
+
for request_id in operation.subscribers
|
|
1177
|
+
if (
|
|
1178
|
+
(state := self._requests.get(request_id)) is not None
|
|
1179
|
+
and state.identity.deadline <= now
|
|
1180
|
+
)
|
|
1181
|
+
)
|
|
1182
|
+
for request_id in expired:
|
|
1183
|
+
state = self._requests.get(request_id)
|
|
1184
|
+
if state is None:
|
|
1185
|
+
operation.subscribers.discard(request_id)
|
|
1186
|
+
continue
|
|
1187
|
+
operation.subscribers.discard(request_id)
|
|
1188
|
+
self._complete_request_locked(
|
|
1189
|
+
state,
|
|
1190
|
+
error=TrustedReadRequestDeadlineError(
|
|
1191
|
+
f"Trusted request {request_id} expired in the queue."
|
|
1192
|
+
),
|
|
1193
|
+
)
|
|
1194
|
+
if expired:
|
|
1195
|
+
self._recompute_operation_schedule_locked(operation)
|
|
1196
|
+
self._condition.notify_all()
|
|
1197
|
+
|
|
1198
|
+
def _next_queued_deadline_locked(self) -> float | None:
|
|
1199
|
+
return min(
|
|
1200
|
+
(
|
|
1201
|
+
state.identity.deadline
|
|
1202
|
+
for operation in self._operations.values()
|
|
1203
|
+
if operation.status == "queued"
|
|
1204
|
+
for request_id in operation.subscribers
|
|
1205
|
+
if (state := self._requests.get(request_id)) is not None
|
|
1206
|
+
),
|
|
1207
|
+
default=None,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
def _next_higher_priority_operation_locked(
|
|
1211
|
+
self,
|
|
1212
|
+
current_priority: int,
|
|
1213
|
+
) -> _OperationState | None:
|
|
1214
|
+
# Captures already time-slice at the outer dispatcher. Nesting their
|
|
1215
|
+
# slow pages between every transaction of a finite metadata job can
|
|
1216
|
+
# consume that job's deadline and retire the shared helper. Cheap
|
|
1217
|
+
# interactive metadata still preempts here; captures run between jobs.
|
|
1218
|
+
candidates = sorted(
|
|
1219
|
+
(operation for operation in self._operations.values()
|
|
1220
|
+
if operation.status == "queued" and operation.priority < current_priority
|
|
1221
|
+
and operation.kind is not TrustedReadOperation.PLOT_PREFIX),
|
|
1222
|
+
key=lambda operation: (operation.priority, operation.sequence),
|
|
1223
|
+
)
|
|
1224
|
+
for operation in candidates:
|
|
1225
|
+
if self._prepare_queued_operation_locked(
|
|
1226
|
+
operation,
|
|
1227
|
+
time.monotonic(),
|
|
1228
|
+
):
|
|
1229
|
+
return operation
|
|
1230
|
+
return None
|
|
1231
|
+
|
|
1232
|
+
def _prepare_queued_operation_locked(
|
|
1233
|
+
self,
|
|
1234
|
+
operation: _OperationState,
|
|
1235
|
+
now: float,
|
|
1236
|
+
) -> bool:
|
|
1237
|
+
for request_id in tuple(operation.subscribers):
|
|
1238
|
+
state = self._requests.get(request_id)
|
|
1239
|
+
if state is None:
|
|
1240
|
+
operation.subscribers.discard(request_id)
|
|
1241
|
+
continue
|
|
1242
|
+
if state.identity.deadline <= now:
|
|
1243
|
+
operation.subscribers.discard(request_id)
|
|
1244
|
+
self._complete_request_locked(
|
|
1245
|
+
state,
|
|
1246
|
+
error=TrustedReadRequestDeadlineError(
|
|
1247
|
+
f"Trusted request {request_id} expired in the queue."
|
|
1248
|
+
),
|
|
1249
|
+
)
|
|
1250
|
+
if not operation.subscribers:
|
|
1251
|
+
self._discard_operation_locked(operation)
|
|
1252
|
+
return False
|
|
1253
|
+
self._recompute_operation_schedule_locked(operation)
|
|
1254
|
+
return True
|
|
1255
|
+
|
|
1256
|
+
def _execute_operation(self, operation: _OperationState) -> object:
|
|
1257
|
+
startup_remaining = self._operation_remaining(operation)
|
|
1258
|
+
supervisor = self._ensure_supervisor(startup_remaining)
|
|
1259
|
+
with self._condition:
|
|
1260
|
+
if self._closing or operation.cancel_underlying:
|
|
1261
|
+
raise TrustedReadRequestCancelledError(
|
|
1262
|
+
"The trusted operation was cancelled before execution."
|
|
1263
|
+
)
|
|
1264
|
+
adapter = self._adapter
|
|
1265
|
+
if adapter is None:
|
|
1266
|
+
adapter = TrustedMetadataQueryAdapter(
|
|
1267
|
+
_BrokerQueryExecutor(self, operation),
|
|
1268
|
+
self.database_instance.resolved_path,
|
|
1269
|
+
)
|
|
1270
|
+
self._adapter = adapter
|
|
1271
|
+
else:
|
|
1272
|
+
# The facade is operation-bound so exact cancellation always
|
|
1273
|
+
# targets the currently executing broker attempt.
|
|
1274
|
+
adapter.bind_executor(_BrokerQueryExecutor(self, operation))
|
|
1275
|
+
del supervisor
|
|
1276
|
+
if operation.kind in (TrustedReadOperation.PLOT_DATASET, TrustedReadOperation.PLOT_PREFIX):
|
|
1277
|
+
from .trusted_plot import plot_dataset, plot_prefix
|
|
1278
|
+
executor = _BrokerQueryExecutor(self, operation)
|
|
1279
|
+
if operation.kind is TrustedReadOperation.PLOT_DATASET:
|
|
1280
|
+
return plot_dataset(executor, self, operation.payload[0])
|
|
1281
|
+
if operation.plot_steps is None:
|
|
1282
|
+
operation.plot_steps = plot_prefix(executor, *operation.payload)
|
|
1283
|
+
# Amortise tiny pages into a short time slice. Otherwise a small
|
|
1284
|
+
# run pays for another large run's much slower page after *each*
|
|
1285
|
+
# of its own millisecond-scale pages. All yields end a source
|
|
1286
|
+
# transaction; higher-priority jobs still preempt before queries.
|
|
1287
|
+
slice_end = time.monotonic() + 0.05
|
|
1288
|
+
while True:
|
|
1289
|
+
executor.check_cancelled()
|
|
1290
|
+
try:
|
|
1291
|
+
next(operation.plot_steps)
|
|
1292
|
+
except StopIteration as complete:
|
|
1293
|
+
operation.plot_steps = None
|
|
1294
|
+
return complete.value
|
|
1295
|
+
if time.monotonic() >= slice_end:
|
|
1296
|
+
return _OPERATION_YIELDED
|
|
1297
|
+
if operation.kind is TrustedReadOperation.BOOTSTRAP:
|
|
1298
|
+
return adapter.bootstrap()
|
|
1299
|
+
if operation.kind is TrustedReadOperation.BASIC_PAGE:
|
|
1300
|
+
return adapter.basic_run_page(
|
|
1301
|
+
cast(int, operation.payload[0]),
|
|
1302
|
+
cast(int, operation.payload[1]),
|
|
1303
|
+
)
|
|
1304
|
+
if operation.kind is TrustedReadOperation.REFRESH:
|
|
1305
|
+
return adapter.refresh_new_runs(cast(int | None, operation.payload[0]))
|
|
1306
|
+
if operation.kind is TrustedReadOperation.DATABASE_INFO:
|
|
1307
|
+
return adapter.database_info()
|
|
1308
|
+
run_id = cast(int, operation.payload[0])
|
|
1309
|
+
if operation.kind is TrustedReadOperation.CHEAP_RUN:
|
|
1310
|
+
return adapter.cheap_run(run_id)
|
|
1311
|
+
if operation.kind is TrustedReadOperation.EXPENSIVE_RUN:
|
|
1312
|
+
return adapter.expensive_run(run_id)
|
|
1313
|
+
if operation.kind is TrustedReadOperation.SELECTED_RUN:
|
|
1314
|
+
return adapter.selected_run_detail(run_id)
|
|
1315
|
+
if operation.kind is TrustedReadOperation.DERIVED_SOURCE:
|
|
1316
|
+
return adapter.derived_source_observation(
|
|
1317
|
+
run_id,
|
|
1318
|
+
database_instance=self.database_instance,
|
|
1319
|
+
namespace=self._source_revision_namespace,
|
|
1320
|
+
)
|
|
1321
|
+
raise TrustedReadServiceError(
|
|
1322
|
+
f"Unsupported trusted broker operation {operation.kind!r}."
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
def _ensure_supervisor(
|
|
1326
|
+
self,
|
|
1327
|
+
startup_remaining: float,
|
|
1328
|
+
) -> TrustedLiveReaderSupervisor:
|
|
1329
|
+
with self._condition:
|
|
1330
|
+
existing = self._supervisor
|
|
1331
|
+
if existing is not None:
|
|
1332
|
+
return existing
|
|
1333
|
+
supervisor_options = dict(self._supervisor_options)
|
|
1334
|
+
startup_key = "startup_timeout_seconds"
|
|
1335
|
+
if startup_key not in supervisor_options:
|
|
1336
|
+
supervisor_options["startup_timeout_seconds"] = startup_remaining
|
|
1337
|
+
else:
|
|
1338
|
+
configured_startup_timeout = supervisor_options[startup_key]
|
|
1339
|
+
try:
|
|
1340
|
+
configured_duration = (
|
|
1341
|
+
math.nan
|
|
1342
|
+
if isinstance(configured_startup_timeout, bool)
|
|
1343
|
+
else float(cast(Any, configured_startup_timeout))
|
|
1344
|
+
)
|
|
1345
|
+
except (TypeError, ValueError):
|
|
1346
|
+
configured_duration = math.nan
|
|
1347
|
+
if (
|
|
1348
|
+
math.isfinite(configured_duration)
|
|
1349
|
+
and configured_duration > 0
|
|
1350
|
+
and startup_remaining < configured_duration
|
|
1351
|
+
):
|
|
1352
|
+
supervisor_options[startup_key] = startup_remaining
|
|
1353
|
+
supervisor = self._supervisor_factory(
|
|
1354
|
+
self._database_path,
|
|
1355
|
+
expected_database_instance=self._database_instance,
|
|
1356
|
+
**supervisor_options,
|
|
1357
|
+
)
|
|
1358
|
+
with self._condition:
|
|
1359
|
+
if self._supervisor is not None:
|
|
1360
|
+
# This should be unreachable because only the dispatcher opens,
|
|
1361
|
+
# but fail closed without leaking an unexpected second owner.
|
|
1362
|
+
duplicate = supervisor
|
|
1363
|
+
supervisor = self._supervisor
|
|
1364
|
+
else:
|
|
1365
|
+
duplicate = None
|
|
1366
|
+
self._supervisor = supervisor
|
|
1367
|
+
self._accepted_database_instance = supervisor.database_instance
|
|
1368
|
+
if duplicate is not None:
|
|
1369
|
+
try:
|
|
1370
|
+
duplicate.close()
|
|
1371
|
+
finally:
|
|
1372
|
+
self._retain_supervisor_if_needed(duplicate)
|
|
1373
|
+
return supervisor
|
|
1374
|
+
|
|
1375
|
+
def _required_supervisor(self) -> TrustedLiveReaderSupervisor:
|
|
1376
|
+
with self._condition:
|
|
1377
|
+
supervisor = self._supervisor
|
|
1378
|
+
if supervisor is None:
|
|
1379
|
+
raise TrustedReadServiceError(
|
|
1380
|
+
"The trusted supervisor has not completed startup."
|
|
1381
|
+
)
|
|
1382
|
+
return supervisor
|
|
1383
|
+
|
|
1384
|
+
def _operation_remaining(self, operation: _OperationState) -> float:
|
|
1385
|
+
remaining = operation.deadline - time.monotonic()
|
|
1386
|
+
if remaining <= 0:
|
|
1387
|
+
raise TrustedReadRequestDeadlineError(
|
|
1388
|
+
f"Trusted operation {operation.operation_id} reached its deadline."
|
|
1389
|
+
)
|
|
1390
|
+
return min(remaining, _MAX_REQUEST_TIMEOUT_SECONDS)
|
|
1391
|
+
|
|
1392
|
+
def _prepare_supervisor_transaction(
|
|
1393
|
+
self,
|
|
1394
|
+
operation: _OperationState,
|
|
1395
|
+
resume_executor: _BrokerQueryExecutor,
|
|
1396
|
+
) -> float:
|
|
1397
|
+
"""Yield at a job-free boundary to one strictly higher-priority request."""
|
|
1398
|
+
|
|
1399
|
+
with self._condition:
|
|
1400
|
+
if self._active_operation is not operation:
|
|
1401
|
+
raise TrustedReadServiceError(
|
|
1402
|
+
"The supervisor transaction does not match the active operation."
|
|
1403
|
+
)
|
|
1404
|
+
if self._closing or operation.cancel_underlying:
|
|
1405
|
+
raise TrustedReadRequestCancelledError(
|
|
1406
|
+
"The trusted operation was cancelled before its next transaction."
|
|
1407
|
+
)
|
|
1408
|
+
if operation.supervisor_job is not None:
|
|
1409
|
+
raise TrustedReadServiceError(
|
|
1410
|
+
"Cooperative scheduling requires a job-free transaction boundary."
|
|
1411
|
+
)
|
|
1412
|
+
if operation.force_next_transaction:
|
|
1413
|
+
operation.force_next_transaction = False
|
|
1414
|
+
nested = None
|
|
1415
|
+
else:
|
|
1416
|
+
nested = self._next_higher_priority_operation_locked(
|
|
1417
|
+
operation.priority,
|
|
1418
|
+
)
|
|
1419
|
+
if nested is not None:
|
|
1420
|
+
if (
|
|
1421
|
+
operation.priority >= TrustedReadPriority.VISIBLE_CHEAP
|
|
1422
|
+
and nested.priority < TrustedReadPriority.VISIBLE_CHEAP
|
|
1423
|
+
):
|
|
1424
|
+
self._foreground_dispatch_burst += 1
|
|
1425
|
+
nested.status = "active"
|
|
1426
|
+
self._active_operation = nested
|
|
1427
|
+
self._compact_heap_locked()
|
|
1428
|
+
|
|
1429
|
+
if nested is not None:
|
|
1430
|
+
result: object = _NO_RESULT
|
|
1431
|
+
nested_error: BaseException | None = None
|
|
1432
|
+
try:
|
|
1433
|
+
result = self._execute_operation(nested)
|
|
1434
|
+
except BaseException as error:
|
|
1435
|
+
nested_error = error
|
|
1436
|
+
with self._condition:
|
|
1437
|
+
self._finish_operation_locked(
|
|
1438
|
+
nested,
|
|
1439
|
+
result,
|
|
1440
|
+
nested_error,
|
|
1441
|
+
)
|
|
1442
|
+
self._active_operation = operation
|
|
1443
|
+
self._condition.notify_all()
|
|
1444
|
+
adapter = self._adapter
|
|
1445
|
+
if adapter is not None:
|
|
1446
|
+
adapter.bind_executor(resume_executor)
|
|
1447
|
+
if nested_error is not None and self._terminal_session_error(nested_error):
|
|
1448
|
+
raise nested_error
|
|
1449
|
+
|
|
1450
|
+
with self._condition:
|
|
1451
|
+
if self._active_operation is not operation:
|
|
1452
|
+
raise TrustedReadServiceError(
|
|
1453
|
+
"The original operation was not restored after scheduling."
|
|
1454
|
+
)
|
|
1455
|
+
if self._closing or operation.cancel_underlying:
|
|
1456
|
+
raise TrustedReadRequestCancelledError(
|
|
1457
|
+
"The trusted operation was cancelled before its next transaction."
|
|
1458
|
+
)
|
|
1459
|
+
return self._operation_remaining(operation)
|
|
1460
|
+
|
|
1461
|
+
def _finish_operation_locked(
|
|
1462
|
+
self,
|
|
1463
|
+
operation: _OperationState,
|
|
1464
|
+
result: object,
|
|
1465
|
+
error: BaseException | None,
|
|
1466
|
+
) -> None:
|
|
1467
|
+
operation.supervisor_job = None
|
|
1468
|
+
if self._active_operation is operation:
|
|
1469
|
+
self._active_operation = None
|
|
1470
|
+
if result is _OPERATION_YIELDED and error is None:
|
|
1471
|
+
if self._closing or operation.cancel_underlying:
|
|
1472
|
+
error = TrustedReadRequestCancelledError("The plot capture was cancelled.")
|
|
1473
|
+
else:
|
|
1474
|
+
# Move to the tail of this priority, without recursive calls.
|
|
1475
|
+
# Other plots get a turn at every page/BLOB boundary.
|
|
1476
|
+
operation.status = "queued"
|
|
1477
|
+
operation.sequence = self._next_sequence
|
|
1478
|
+
self._next_sequence += 1
|
|
1479
|
+
self._compact_heap_locked()
|
|
1480
|
+
self._condition.notify_all()
|
|
1481
|
+
return
|
|
1482
|
+
self._retire_plot_steps_locked(operation)
|
|
1483
|
+
operation.status = "finished"
|
|
1484
|
+
if self._coalesced.get(operation.coalesce_key) == operation.operation_id:
|
|
1485
|
+
self._coalesced.pop(operation.coalesce_key, None)
|
|
1486
|
+
self._operations.pop(operation.operation_id, None)
|
|
1487
|
+
now = time.monotonic()
|
|
1488
|
+
if (
|
|
1489
|
+
operation.kind is TrustedReadOperation.REFRESH
|
|
1490
|
+
and error is None
|
|
1491
|
+
and self._refresh_status_retry_pending
|
|
1492
|
+
):
|
|
1493
|
+
# The previous header may have accepted the writer's final commit
|
|
1494
|
+
# before a watched status failed. Reconcile those statuses even
|
|
1495
|
+
# when SQLite's data_version has not changed again.
|
|
1496
|
+
result = replace(cast(TrustedRefreshResult, result), data_version_changed=True)
|
|
1497
|
+
for request_id in tuple(operation.subscribers):
|
|
1498
|
+
state = self._requests.get(request_id)
|
|
1499
|
+
if state is None or state.done.is_set():
|
|
1500
|
+
continue
|
|
1501
|
+
publication_error = error
|
|
1502
|
+
publication_result = result
|
|
1503
|
+
if publication_error is None and now >= state.identity.deadline:
|
|
1504
|
+
publication_result = _NO_RESULT
|
|
1505
|
+
publication_error = TrustedReadRequestDeadlineError(
|
|
1506
|
+
f"Trusted request {request_id} reached its deadline."
|
|
1507
|
+
)
|
|
1508
|
+
self._complete_request_locked(
|
|
1509
|
+
state,
|
|
1510
|
+
result=publication_result,
|
|
1511
|
+
error=publication_error,
|
|
1512
|
+
)
|
|
1513
|
+
if error is not None and self._terminal_session_error(error):
|
|
1514
|
+
self._fatal_error = error
|
|
1515
|
+
self._closing = True
|
|
1516
|
+
self._fail_queued_locked(error)
|
|
1517
|
+
self._condition.notify_all()
|
|
1518
|
+
|
|
1519
|
+
def _wait_supervisor_job(
|
|
1520
|
+
self,
|
|
1521
|
+
operation: _OperationState,
|
|
1522
|
+
job: TrustedLiveJob[_ResultT],
|
|
1523
|
+
wait_timeout: float,
|
|
1524
|
+
) -> _ResultT:
|
|
1525
|
+
wake_control = False
|
|
1526
|
+
with self._condition:
|
|
1527
|
+
if self._active_operation is not operation:
|
|
1528
|
+
raise TrustedReadServiceError(
|
|
1529
|
+
"The supervisor job does not match the active broker operation."
|
|
1530
|
+
)
|
|
1531
|
+
operation.supervisor_job = job
|
|
1532
|
+
wake_control = operation.cancel_underlying or self._closing
|
|
1533
|
+
if wake_control:
|
|
1534
|
+
self._control_wakeup.set()
|
|
1535
|
+
try:
|
|
1536
|
+
return self._required_supervisor().wait(job, timeout=wait_timeout)
|
|
1537
|
+
finally:
|
|
1538
|
+
with self._condition:
|
|
1539
|
+
if operation.supervisor_job is job:
|
|
1540
|
+
operation.supervisor_job = None
|
|
1541
|
+
if operation.priority >= TrustedReadPriority.VISIBLE_CHEAP:
|
|
1542
|
+
# A nested yield runs at most one complete foreground
|
|
1543
|
+
# operation before returning here for a real suspended
|
|
1544
|
+
# background transaction. That progress ends the burst.
|
|
1545
|
+
self._foreground_dispatch_burst = 0
|
|
1546
|
+
|
|
1547
|
+
def _control_loop(self) -> None:
|
|
1548
|
+
fatal_supervisor: TrustedLiveReaderSupervisor | None = None
|
|
1549
|
+
fatal_job: TrustedLiveJob[Any] | None = None
|
|
1550
|
+
try:
|
|
1551
|
+
while True:
|
|
1552
|
+
# Clear before observing broker state: a mutation after this
|
|
1553
|
+
# snapshot leaves the event set and cannot be lost between an
|
|
1554
|
+
# Event.wait() return and a subsequent clear().
|
|
1555
|
+
self._control_wakeup.clear()
|
|
1556
|
+
with self._condition:
|
|
1557
|
+
self._expire_queued_requests_locked(time.monotonic())
|
|
1558
|
+
if self._control_stop.is_set():
|
|
1559
|
+
return
|
|
1560
|
+
operation = self._active_operation
|
|
1561
|
+
supervisor = self._supervisor
|
|
1562
|
+
job = None if operation is None else operation.supervisor_job
|
|
1563
|
+
should_cancel = bool(
|
|
1564
|
+
operation is not None
|
|
1565
|
+
and job is not None
|
|
1566
|
+
and (operation.cancel_underlying or self._closing)
|
|
1567
|
+
and job is not self._control_cancel_job
|
|
1568
|
+
)
|
|
1569
|
+
if should_cancel:
|
|
1570
|
+
self._control_cancel_job = job
|
|
1571
|
+
next_deadline = self._next_queued_deadline_locked()
|
|
1572
|
+
if should_cancel and supervisor is not None and job is not None:
|
|
1573
|
+
try:
|
|
1574
|
+
supervisor.cancel(job)
|
|
1575
|
+
except TrustedLiveCancelledError:
|
|
1576
|
+
pass
|
|
1577
|
+
except BaseException as error:
|
|
1578
|
+
with self._condition:
|
|
1579
|
+
# The exact job can finish after the unlocked
|
|
1580
|
+
# snapshot above, and the dispatcher can then close
|
|
1581
|
+
# its supervisor before cancel() obtains the Stage 3
|
|
1582
|
+
# lock. A resulting closed/stale-job exception says
|
|
1583
|
+
# nothing about the current session. Terminalise
|
|
1584
|
+
# only while this service still owns the same live
|
|
1585
|
+
# supervisor operation and is not already retiring.
|
|
1586
|
+
still_current = bool(
|
|
1587
|
+
not self._closing
|
|
1588
|
+
and not self._closed
|
|
1589
|
+
and self._supervisor is supervisor
|
|
1590
|
+
and operation is not None
|
|
1591
|
+
and self._active_operation is operation
|
|
1592
|
+
and operation.supervisor_job is job
|
|
1593
|
+
)
|
|
1594
|
+
if still_current:
|
|
1595
|
+
if self._fatal_error is None:
|
|
1596
|
+
self._fatal_error = error
|
|
1597
|
+
self._closing = True
|
|
1598
|
+
self._fail_all_locked(error)
|
|
1599
|
+
self._condition.notify_all()
|
|
1600
|
+
continue
|
|
1601
|
+
|
|
1602
|
+
timeout = (
|
|
1603
|
+
None
|
|
1604
|
+
if next_deadline is None
|
|
1605
|
+
else max(0.0, next_deadline - time.monotonic())
|
|
1606
|
+
)
|
|
1607
|
+
self._control_wakeup.wait(timeout)
|
|
1608
|
+
except BaseException as control_error:
|
|
1609
|
+
with self._condition:
|
|
1610
|
+
if self._fatal_error is None:
|
|
1611
|
+
self._fatal_error = control_error
|
|
1612
|
+
self._closing = True
|
|
1613
|
+
operation = self._active_operation
|
|
1614
|
+
fatal_supervisor = self._supervisor
|
|
1615
|
+
fatal_job = None if operation is None else operation.supervisor_job
|
|
1616
|
+
self._fail_all_locked(control_error)
|
|
1617
|
+
self._condition.notify_all()
|
|
1618
|
+
# A control-loop fault must publish terminal request outcomes before
|
|
1619
|
+
# attempting bounded Stage 3 cancellation. Cancellation remains
|
|
1620
|
+
# off the GUI thread and any second failure is diagnostic only.
|
|
1621
|
+
if fatal_supervisor is not None and fatal_job is not None:
|
|
1622
|
+
try:
|
|
1623
|
+
fatal_supervisor.cancel(fatal_job)
|
|
1624
|
+
except BaseException:
|
|
1625
|
+
_LOG.exception(
|
|
1626
|
+
"Trusted read-service control failure cleanup also failed"
|
|
1627
|
+
)
|
|
1628
|
+
finally:
|
|
1629
|
+
self._control_done.set()
|
|
1630
|
+
|
|
1631
|
+
def _close_supervisor_from_dispatcher(self) -> None:
|
|
1632
|
+
with self._condition:
|
|
1633
|
+
supervisor, self._supervisor = self._supervisor, None
|
|
1634
|
+
self._closing_supervisor = supervisor
|
|
1635
|
+
if supervisor is None:
|
|
1636
|
+
return
|
|
1637
|
+
try:
|
|
1638
|
+
supervisor.close()
|
|
1639
|
+
except BaseException as error:
|
|
1640
|
+
with self._condition:
|
|
1641
|
+
self._close_error = error
|
|
1642
|
+
finally:
|
|
1643
|
+
self._retain_supervisor_if_needed(supervisor)
|
|
1644
|
+
with self._condition:
|
|
1645
|
+
if self._closing_supervisor is supervisor:
|
|
1646
|
+
self._closing_supervisor = None
|
|
1647
|
+
|
|
1648
|
+
def _owned_supervisors_locked(self) -> tuple[TrustedLiveReaderSupervisor, ...]:
|
|
1649
|
+
supervisors: list[TrustedLiveReaderSupervisor] = []
|
|
1650
|
+
for supervisor in (
|
|
1651
|
+
self._supervisor,
|
|
1652
|
+
self._closing_supervisor,
|
|
1653
|
+
*self._retained_supervisors,
|
|
1654
|
+
):
|
|
1655
|
+
if supervisor is not None and all(
|
|
1656
|
+
supervisor is not owned for owned in supervisors
|
|
1657
|
+
):
|
|
1658
|
+
supervisors.append(supervisor)
|
|
1659
|
+
return tuple(supervisors)
|
|
1660
|
+
|
|
1661
|
+
@staticmethod
|
|
1662
|
+
def _supervisor_resource_liveness(
|
|
1663
|
+
supervisor: TrustedLiveReaderSupervisor,
|
|
1664
|
+
*,
|
|
1665
|
+
conservative_on_error: bool = True,
|
|
1666
|
+
) -> TrustedLiveSupervisorLiveness:
|
|
1667
|
+
probe = getattr(supervisor, "resource_liveness", None)
|
|
1668
|
+
if callable(probe):
|
|
1669
|
+
try:
|
|
1670
|
+
snapshot = probe()
|
|
1671
|
+
if not isinstance(snapshot, TrustedLiveSupervisorLiveness):
|
|
1672
|
+
raise TypeError("Invalid supervisor resource-liveness snapshot.")
|
|
1673
|
+
return snapshot
|
|
1674
|
+
except BaseException:
|
|
1675
|
+
if not conservative_on_error:
|
|
1676
|
+
raise
|
|
1677
|
+
# Losing observability must never be mistaken for successful
|
|
1678
|
+
# cleanup: retain the supervisor and report every dimension
|
|
1679
|
+
# conservatively alive.
|
|
1680
|
+
return TrustedLiveSupervisorLiveness(
|
|
1681
|
+
helper_pid=None,
|
|
1682
|
+
process_alive=True,
|
|
1683
|
+
receiver_alive=True,
|
|
1684
|
+
open_endpoints=1,
|
|
1685
|
+
active_incarnation=True,
|
|
1686
|
+
unreaped_incarnation=True,
|
|
1687
|
+
active_job=True,
|
|
1688
|
+
closing=True,
|
|
1689
|
+
closed=False,
|
|
1690
|
+
)
|
|
1691
|
+
if conservative_on_error:
|
|
1692
|
+
try:
|
|
1693
|
+
helper_alive = supervisor.helper_alive
|
|
1694
|
+
except BaseException:
|
|
1695
|
+
helper_alive = True
|
|
1696
|
+
try:
|
|
1697
|
+
helper_pid = supervisor.helper_pid
|
|
1698
|
+
except BaseException:
|
|
1699
|
+
helper_pid = None
|
|
1700
|
+
try:
|
|
1701
|
+
closed = supervisor.closed
|
|
1702
|
+
except BaseException:
|
|
1703
|
+
closed = not helper_alive
|
|
1704
|
+
else:
|
|
1705
|
+
helper_alive = supervisor.helper_alive
|
|
1706
|
+
helper_pid = supervisor.helper_pid
|
|
1707
|
+
closed = supervisor.closed
|
|
1708
|
+
# Compatibility for supervisor test doubles predating the resource
|
|
1709
|
+
# API. Production supervisors always take the exact path above.
|
|
1710
|
+
return TrustedLiveSupervisorLiveness(
|
|
1711
|
+
helper_pid=helper_pid,
|
|
1712
|
+
process_alive=helper_alive,
|
|
1713
|
+
receiver_alive=False,
|
|
1714
|
+
open_endpoints=0,
|
|
1715
|
+
active_incarnation=helper_alive,
|
|
1716
|
+
unreaped_incarnation=False,
|
|
1717
|
+
active_job=False,
|
|
1718
|
+
closing=False,
|
|
1719
|
+
closed=closed,
|
|
1720
|
+
)
|
|
1721
|
+
|
|
1722
|
+
def _retain_supervisor_if_needed(
|
|
1723
|
+
self,
|
|
1724
|
+
supervisor: TrustedLiveReaderSupervisor,
|
|
1725
|
+
) -> bool:
|
|
1726
|
+
snapshot = self._supervisor_resource_liveness(supervisor)
|
|
1727
|
+
with self._condition:
|
|
1728
|
+
retained = any(supervisor is item for item in self._retained_supervisors)
|
|
1729
|
+
if snapshot.resources_owned and not retained:
|
|
1730
|
+
self._retained_supervisors.append(supervisor)
|
|
1731
|
+
elif not snapshot.resources_owned and retained:
|
|
1732
|
+
self._retained_supervisors = [
|
|
1733
|
+
item
|
|
1734
|
+
for item in self._retained_supervisors
|
|
1735
|
+
if item is not supervisor
|
|
1736
|
+
]
|
|
1737
|
+
return snapshot.resources_owned
|
|
1738
|
+
|
|
1739
|
+
def _reap_retained_supervisors_once(self) -> bool:
|
|
1740
|
+
with self._condition:
|
|
1741
|
+
supervisors = tuple(self._retained_supervisors)
|
|
1742
|
+
pending = False
|
|
1743
|
+
for supervisor in supervisors:
|
|
1744
|
+
reap = getattr(supervisor, "reap_closed_resources", None)
|
|
1745
|
+
if callable(reap):
|
|
1746
|
+
try:
|
|
1747
|
+
snapshot = reap()
|
|
1748
|
+
if not isinstance(snapshot, TrustedLiveSupervisorLiveness):
|
|
1749
|
+
raise TypeError("Invalid supervisor resource-reap snapshot.")
|
|
1750
|
+
except BaseException:
|
|
1751
|
+
snapshot = self._supervisor_resource_liveness(supervisor)
|
|
1752
|
+
else:
|
|
1753
|
+
snapshot = self._supervisor_resource_liveness(supervisor)
|
|
1754
|
+
if snapshot.resources_owned:
|
|
1755
|
+
pending = True
|
|
1756
|
+
continue
|
|
1757
|
+
with self._condition:
|
|
1758
|
+
self._retained_supervisors = [
|
|
1759
|
+
item
|
|
1760
|
+
for item in self._retained_supervisors
|
|
1761
|
+
if item is not supervisor
|
|
1762
|
+
]
|
|
1763
|
+
return pending
|
|
1764
|
+
|
|
1765
|
+
def _complete_request_locked(
|
|
1766
|
+
self,
|
|
1767
|
+
state: _RequestState,
|
|
1768
|
+
*,
|
|
1769
|
+
result: object = _NO_RESULT,
|
|
1770
|
+
error: BaseException | None = None,
|
|
1771
|
+
) -> None:
|
|
1772
|
+
if state.done.is_set():
|
|
1773
|
+
return
|
|
1774
|
+
if error is not None and state.identity.operation in {
|
|
1775
|
+
TrustedReadOperation.CHEAP_RUN,
|
|
1776
|
+
TrustedReadOperation.EXPENSIVE_RUN,
|
|
1777
|
+
}:
|
|
1778
|
+
self._refresh_status_retry_pending = True
|
|
1779
|
+
elif error is None and state.identity.operation is TrustedReadOperation.REFRESH:
|
|
1780
|
+
# Clear only when a successful header reaches a live subscriber.
|
|
1781
|
+
# Queue expiry, cancellation and failed refreshes must retain it.
|
|
1782
|
+
self._refresh_status_retry_pending = False
|
|
1783
|
+
state.result = result
|
|
1784
|
+
state.error = error
|
|
1785
|
+
state.done.set()
|
|
1786
|
+
self._requests.pop(state.identity.request_id, None)
|
|
1787
|
+
state.request = None
|
|
1788
|
+
self._release_request_slot_locked(state)
|
|
1789
|
+
|
|
1790
|
+
def _release_request_slot_locked(self, state: _RequestState) -> None:
|
|
1791
|
+
if state.slot_released:
|
|
1792
|
+
return
|
|
1793
|
+
state.slot_released = True
|
|
1794
|
+
if self._request_slots_in_use <= 0:
|
|
1795
|
+
raise TrustedReadServiceError(
|
|
1796
|
+
"The broker request-capacity accounting became inconsistent."
|
|
1797
|
+
)
|
|
1798
|
+
self._request_slots_in_use -= 1
|
|
1799
|
+
|
|
1800
|
+
def _discard_operation_locked(self, operation: _OperationState) -> None:
|
|
1801
|
+
self._retire_plot_steps_locked(operation)
|
|
1802
|
+
operation.status = "discarded"
|
|
1803
|
+
self._operations.pop(operation.operation_id, None)
|
|
1804
|
+
if self._coalesced.get(operation.coalesce_key) == operation.operation_id:
|
|
1805
|
+
self._coalesced.pop(operation.coalesce_key, None)
|
|
1806
|
+
self._compact_heap_locked()
|
|
1807
|
+
|
|
1808
|
+
def _retire_plot_steps_locked(self, operation: _OperationState) -> None:
|
|
1809
|
+
if operation.plot_steps is not None:
|
|
1810
|
+
self._retired_plot_steps.append(operation.plot_steps)
|
|
1811
|
+
operation.plot_steps = None
|
|
1812
|
+
|
|
1813
|
+
def _close_retired_plot_steps(self) -> None:
|
|
1814
|
+
# Cancellation/expiry may originate on the GUI or control thread.
|
|
1815
|
+
# Private SQLite handles must be closed on their creating dispatcher.
|
|
1816
|
+
with self._condition:
|
|
1817
|
+
retired, self._retired_plot_steps = self._retired_plot_steps, []
|
|
1818
|
+
for steps in retired:
|
|
1819
|
+
try:
|
|
1820
|
+
steps.close()
|
|
1821
|
+
except BaseException as error:
|
|
1822
|
+
# A private-spool cleanup error must not strand the helper or
|
|
1823
|
+
# prevent other suspended captures from releasing resources.
|
|
1824
|
+
with self._condition:
|
|
1825
|
+
if self._close_error is None:
|
|
1826
|
+
self._close_error = error
|
|
1827
|
+
_LOG.exception("Trusted plot spool cleanup failed")
|
|
1828
|
+
self.close_async()
|
|
1829
|
+
|
|
1830
|
+
def _fail_queued_locked(
|
|
1831
|
+
self,
|
|
1832
|
+
cause: BaseException,
|
|
1833
|
+
) -> None:
|
|
1834
|
+
for operation in list(self._operations.values()):
|
|
1835
|
+
if operation.status != "queued":
|
|
1836
|
+
continue
|
|
1837
|
+
for request_id in tuple(operation.subscribers):
|
|
1838
|
+
state = self._requests.get(request_id)
|
|
1839
|
+
if state is None:
|
|
1840
|
+
continue
|
|
1841
|
+
error = TrustedReadSessionFailedError(
|
|
1842
|
+
"A terminal trusted-reader failure invalidated this queued "
|
|
1843
|
+
f"request ({type(cause).__name__}: {cause})."
|
|
1844
|
+
)
|
|
1845
|
+
error.__cause__ = cause
|
|
1846
|
+
self._complete_request_locked(state, error=error)
|
|
1847
|
+
operation.subscribers.clear()
|
|
1848
|
+
self._discard_operation_locked(operation)
|
|
1849
|
+
|
|
1850
|
+
def _fail_all_locked(self, cause: BaseException) -> None:
|
|
1851
|
+
"""Terminally complete every request and clear all broker accounting."""
|
|
1852
|
+
|
|
1853
|
+
for state in tuple(self._requests.values()):
|
|
1854
|
+
error = TrustedReadSessionFailedError(
|
|
1855
|
+
"A fatal trusted read-service loop failure invalidated this "
|
|
1856
|
+
f"request ({type(cause).__name__}: {cause})."
|
|
1857
|
+
)
|
|
1858
|
+
error.__cause__ = cause
|
|
1859
|
+
self._complete_request_locked(state, error=error)
|
|
1860
|
+
|
|
1861
|
+
for operation in self._operations.values():
|
|
1862
|
+
self._retire_plot_steps_locked(operation)
|
|
1863
|
+
operation.cancel_underlying = True
|
|
1864
|
+
operation.supervisor_job = None
|
|
1865
|
+
operation.subscribers.clear()
|
|
1866
|
+
operation.status = "failed"
|
|
1867
|
+
self._operations.clear()
|
|
1868
|
+
self._coalesced.clear()
|
|
1869
|
+
self._heap.clear()
|
|
1870
|
+
self._active_operation = None
|
|
1871
|
+
self._foreground_dispatch_burst = 0
|
|
1872
|
+
self._control_cancel_job = None
|
|
1873
|
+
if self._requests or self._request_slots_in_use:
|
|
1874
|
+
raise TrustedReadServiceError(
|
|
1875
|
+
"Fatal broker cleanup left live request-capacity accounting."
|
|
1876
|
+
)
|
|
1877
|
+
|
|
1878
|
+
@staticmethod
|
|
1879
|
+
def _terminal_session_error(error: BaseException) -> bool:
|
|
1880
|
+
# Broker-local cancellation/deadline outcomes do not describe helper
|
|
1881
|
+
# health. For errors returned by Stage 3, mirror its protocol
|
|
1882
|
+
# taxonomy exactly: these five operation failures are the complete
|
|
1883
|
+
# reusable set and every other outcome retires this broker session.
|
|
1884
|
+
if isinstance(
|
|
1885
|
+
error,
|
|
1886
|
+
(
|
|
1887
|
+
TrustedReadRequestCancelledError,
|
|
1888
|
+
TrustedReadRequestDeadlineError,
|
|
1889
|
+
),
|
|
1890
|
+
):
|
|
1891
|
+
return False
|
|
1892
|
+
return not isinstance(
|
|
1893
|
+
error,
|
|
1894
|
+
(
|
|
1895
|
+
TrustedLiveSqlRejectedError,
|
|
1896
|
+
TrustedLiveQueryError,
|
|
1897
|
+
TrustedLiveResultLimitError,
|
|
1898
|
+
TrustedLiveBusyTimeoutError,
|
|
1899
|
+
TrustedLiveCancelledError,
|
|
1900
|
+
),
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
__all__ = [
|
|
1905
|
+
"SNAPSHOT_FALLBACK_MODE",
|
|
1906
|
+
"TRUSTED_LIVE_MODE",
|
|
1907
|
+
"TrustedLiveReadService",
|
|
1908
|
+
"TrustedReadOperation",
|
|
1909
|
+
"TrustedReadPriority",
|
|
1910
|
+
"TrustedReadQueueFullError",
|
|
1911
|
+
"TrustedReadRequest",
|
|
1912
|
+
"TrustedReadRequestCancelledError",
|
|
1913
|
+
"TrustedReadRequestDeadlineError",
|
|
1914
|
+
"TrustedReadRequestIdentity",
|
|
1915
|
+
"TrustedReadServiceClosedError",
|
|
1916
|
+
"TrustedReadServiceError",
|
|
1917
|
+
"TrustedReadServiceLiveness",
|
|
1918
|
+
"TrustedReadSessionFailedError",
|
|
1919
|
+
]
|