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.
Files changed (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. 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
+ ]