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