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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
"""Qt-independent scheduling and cache identity for trusted-live derived work.
|
|
2
|
+
|
|
3
|
+
Stage 5A deliberately stops at ownership and scheduling. This module does not
|
|
4
|
+
open databases, render images, or access a disk cache. An owner pulls one
|
|
5
|
+
bounded :class:`ScheduledWork` claim at a time and completes or abandons it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import IntEnum, StrEnum
|
|
18
|
+
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
|
19
|
+
from types import MappingProxyType
|
|
20
|
+
from typing import TypeAlias
|
|
21
|
+
|
|
22
|
+
from qplot.datahandling.file_identity import DatabaseInstance
|
|
23
|
+
from qplot.datahandling.trusted_live_queries import TrustedSourceRevision
|
|
24
|
+
|
|
25
|
+
RenderingOptionValue: TypeAlias = None | bool | int | float | str
|
|
26
|
+
CanonicalRenderingOptionValue: TypeAlias = tuple[str, RenderingOptionValue]
|
|
27
|
+
PriorityKey: TypeAlias = tuple[int, int, int]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TrustedWorkKind(IntEnum):
|
|
31
|
+
"""Information order within every run-priority tier."""
|
|
32
|
+
|
|
33
|
+
METADATA = 0
|
|
34
|
+
THUMBNAIL = 1
|
|
35
|
+
PREVIEW = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TrustedRunTier(IntEnum):
|
|
39
|
+
"""Run order, from interactive foreground to stable background."""
|
|
40
|
+
|
|
41
|
+
SELECTED = 0
|
|
42
|
+
VISIBLE = 1
|
|
43
|
+
REMAINING = 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TrustedWorkState(StrEnum):
|
|
47
|
+
PENDING = "pending"
|
|
48
|
+
RUNNING = "running"
|
|
49
|
+
COMPLETED = "completed"
|
|
50
|
+
CANCELLED = "cancelled"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SchedulerLifecycle(StrEnum):
|
|
54
|
+
ACTIVE = "active"
|
|
55
|
+
CLOSED = "closed"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CompletionDisposition(StrEnum):
|
|
59
|
+
ACCEPTED = "accepted"
|
|
60
|
+
STALE = "stale"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class RenderingOptions:
|
|
65
|
+
"""Canonical, immutable rendering options that participate in identity."""
|
|
66
|
+
|
|
67
|
+
values: tuple[tuple[str, RenderingOptionValue], ...] = field(
|
|
68
|
+
default=(),
|
|
69
|
+
compare=False,
|
|
70
|
+
hash=False,
|
|
71
|
+
)
|
|
72
|
+
_canonical_values: tuple[tuple[str, CanonicalRenderingOptionValue], ...] = field(
|
|
73
|
+
init=False,
|
|
74
|
+
repr=False,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def __post_init__(self) -> None:
|
|
78
|
+
names = tuple(name for name, _value in self.values)
|
|
79
|
+
if any(not isinstance(name, str) or not name for name in names):
|
|
80
|
+
raise ValueError("Rendering option names must be non-empty strings.")
|
|
81
|
+
if names != tuple(sorted(names)) or len(names) != len(set(names)):
|
|
82
|
+
raise ValueError("Rendering options must have unique, sorted names.")
|
|
83
|
+
canonical = []
|
|
84
|
+
for name, value in self.values:
|
|
85
|
+
if value is None:
|
|
86
|
+
tagged: CanonicalRenderingOptionValue = ("none", None)
|
|
87
|
+
elif isinstance(value, bool):
|
|
88
|
+
tagged = ("boolean", value)
|
|
89
|
+
elif isinstance(value, int):
|
|
90
|
+
tagged = ("integer", value)
|
|
91
|
+
elif isinstance(value, float):
|
|
92
|
+
if not math.isfinite(value):
|
|
93
|
+
raise ValueError("Floating rendering options must be finite.")
|
|
94
|
+
tagged = ("float", value)
|
|
95
|
+
elif isinstance(value, str):
|
|
96
|
+
tagged = ("string", value)
|
|
97
|
+
else:
|
|
98
|
+
raise TypeError("A rendering option value is not cache-key safe.")
|
|
99
|
+
canonical.append((name, tagged))
|
|
100
|
+
object.__setattr__(self, "_canonical_values", tuple(canonical))
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def canonical_values(
|
|
104
|
+
self,
|
|
105
|
+
) -> tuple[tuple[str, CanonicalRenderingOptionValue], ...]:
|
|
106
|
+
"""Type-tagged deterministic representation used by equality/hashing."""
|
|
107
|
+
|
|
108
|
+
return self._canonical_values
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_mapping(
|
|
112
|
+
cls,
|
|
113
|
+
values: Mapping[str, RenderingOptionValue],
|
|
114
|
+
) -> RenderingOptions:
|
|
115
|
+
return cls(tuple(sorted(values.items())))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class WorkFormat:
|
|
120
|
+
"""Renderer/format namespace for one kind of derived result."""
|
|
121
|
+
|
|
122
|
+
renderer_version: str
|
|
123
|
+
options: RenderingOptions = RenderingOptions()
|
|
124
|
+
|
|
125
|
+
def __post_init__(self) -> None:
|
|
126
|
+
if not isinstance(self.renderer_version, str) or not self.renderer_version:
|
|
127
|
+
raise ValueError("renderer_version must be a non-empty string.")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class TrustedCacheWorkKey:
|
|
132
|
+
"""Complete immutable identity for one cached or in-flight result."""
|
|
133
|
+
|
|
134
|
+
database_instance: DatabaseInstance
|
|
135
|
+
run_guid: str
|
|
136
|
+
kind: TrustedWorkKind
|
|
137
|
+
source_revision: TrustedSourceRevision
|
|
138
|
+
renderer_version: str
|
|
139
|
+
rendering_options: RenderingOptions = RenderingOptions()
|
|
140
|
+
|
|
141
|
+
def __post_init__(self) -> None:
|
|
142
|
+
if self.database_instance.identity is None:
|
|
143
|
+
raise ValueError("A cache key requires an exact database file identity.")
|
|
144
|
+
if not isinstance(self.run_guid, str) or not self.run_guid:
|
|
145
|
+
raise ValueError("run_guid must be a non-empty string.")
|
|
146
|
+
if not isinstance(self.kind, TrustedWorkKind):
|
|
147
|
+
raise TypeError("kind must be a TrustedWorkKind.")
|
|
148
|
+
if not isinstance(self.source_revision, TrustedSourceRevision):
|
|
149
|
+
raise TypeError("source_revision must be a TrustedSourceRevision.")
|
|
150
|
+
if not isinstance(self.renderer_version, str) or not self.renderer_version:
|
|
151
|
+
raise ValueError("renderer_version must be a non-empty string.")
|
|
152
|
+
if not isinstance(self.rendering_options, RenderingOptions):
|
|
153
|
+
raise TypeError("rendering_options must be RenderingOptions.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class TrustedRunWorkSource:
|
|
158
|
+
"""Minimal table-order input; no derived task is allocated here."""
|
|
159
|
+
|
|
160
|
+
run_guid: str
|
|
161
|
+
source_revision: TrustedSourceRevision
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
if not isinstance(self.run_guid, str) or not self.run_guid:
|
|
165
|
+
raise ValueError("run_guid must be a non-empty string.")
|
|
166
|
+
if not isinstance(self.source_revision, TrustedSourceRevision):
|
|
167
|
+
raise TypeError("source_revision must be a TrustedSourceRevision.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CancellationToken:
|
|
171
|
+
"""Thread-safe cooperative cancellation flag owned by one work claim."""
|
|
172
|
+
|
|
173
|
+
__slots__ = ("_event",)
|
|
174
|
+
|
|
175
|
+
def __init__(self) -> None:
|
|
176
|
+
self._event = threading.Event()
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def cancelled(self) -> bool:
|
|
180
|
+
return self._event.is_set()
|
|
181
|
+
|
|
182
|
+
def cancel(self) -> None:
|
|
183
|
+
self._event.set()
|
|
184
|
+
|
|
185
|
+
def raise_if_cancelled(self) -> None:
|
|
186
|
+
if self.cancelled:
|
|
187
|
+
raise InterruptedError("The scheduled work was cancelled.")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class ScheduledWork:
|
|
192
|
+
"""One bounded owner claim, safe to pass to a controllable executor."""
|
|
193
|
+
|
|
194
|
+
claim_id: int
|
|
195
|
+
generation: int
|
|
196
|
+
run_index: int
|
|
197
|
+
priority_key: PriorityKey
|
|
198
|
+
key: TrustedCacheWorkKey
|
|
199
|
+
cancellation: CancellationToken
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class WorkTransition:
|
|
204
|
+
generation: int
|
|
205
|
+
key: TrustedCacheWorkKey
|
|
206
|
+
previous: TrustedWorkState
|
|
207
|
+
current: TrustedWorkState
|
|
208
|
+
timestamp: float
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True, slots=True)
|
|
212
|
+
class WorkPublication:
|
|
213
|
+
"""A current-instance result, explicitly tagged for safe UI routing."""
|
|
214
|
+
|
|
215
|
+
generation: int
|
|
216
|
+
key: TrustedCacheWorkKey
|
|
217
|
+
result: object
|
|
218
|
+
is_current_selection: bool
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True, slots=True)
|
|
222
|
+
class SchedulerSnapshot:
|
|
223
|
+
lifecycle: SchedulerLifecycle
|
|
224
|
+
generation: int
|
|
225
|
+
database_instance: DatabaseInstance
|
|
226
|
+
run_count: int
|
|
227
|
+
selected_index: int | None
|
|
228
|
+
visible_range: tuple[int, int]
|
|
229
|
+
visible_indices: tuple[int, ...]
|
|
230
|
+
pending_count: int
|
|
231
|
+
running: tuple[ScheduledWork, ...]
|
|
232
|
+
completed_count: int
|
|
233
|
+
next_priority_key: PriorityKey | None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
TransitionCallback: TypeAlias = Callable[[WorkTransition], None]
|
|
237
|
+
PublicationCallback: TypeAlias = Callable[[WorkPublication], None]
|
|
238
|
+
|
|
239
|
+
_WORK_KINDS = tuple(TrustedWorkKind)
|
|
240
|
+
_RUN_TIERS = tuple(TrustedRunTier)
|
|
241
|
+
_WORK_COMPLETION_MASK = sum(1 << int(kind) for kind in _WORK_KINDS)
|
|
242
|
+
_PROGRESSIVE_METADATA_PARKED_BIT = 1 << len(_WORK_KINDS)
|
|
243
|
+
_METADATA_CONCLUSIVE_BIT = 1 << (len(_WORK_KINDS) + 1)
|
|
244
|
+
_DEFAULT_FORMATS = MappingProxyType(
|
|
245
|
+
{
|
|
246
|
+
TrustedWorkKind.METADATA: WorkFormat("metadata-v1"),
|
|
247
|
+
TrustedWorkKind.THUMBNAIL: WorkFormat("thumbnail-v1"),
|
|
248
|
+
TrustedWorkKind.PREVIEW: WorkFormat("preview-v1"),
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def trusted_derived_cache_root(
|
|
254
|
+
*,
|
|
255
|
+
environment: Mapping[str, str] | None = None,
|
|
256
|
+
home: str | PurePath | None = None,
|
|
257
|
+
platform: str | None = None,
|
|
258
|
+
) -> PurePath:
|
|
259
|
+
"""Return the future application-cache namespace without creating it."""
|
|
260
|
+
|
|
261
|
+
environment = os.environ if environment is None else environment
|
|
262
|
+
platform = sys.platform if platform is None else platform
|
|
263
|
+
path_type: type[PurePath]
|
|
264
|
+
home_path: PurePath
|
|
265
|
+
candidate: PurePath | None
|
|
266
|
+
base: PurePath
|
|
267
|
+
if platform == "win32":
|
|
268
|
+
path_type = PureWindowsPath
|
|
269
|
+
home_path = path_type(str(Path.home()) if home is None else str(home))
|
|
270
|
+
if not home_path.is_absolute():
|
|
271
|
+
raise ValueError("home must be absolute under Windows path rules.")
|
|
272
|
+
configured = environment.get("LOCALAPPDATA", "")
|
|
273
|
+
candidate = path_type(configured) if configured else None
|
|
274
|
+
base = (
|
|
275
|
+
candidate
|
|
276
|
+
if candidate is not None and candidate.is_absolute()
|
|
277
|
+
else home_path / "AppData" / "Local"
|
|
278
|
+
)
|
|
279
|
+
elif platform == "darwin":
|
|
280
|
+
path_type = PurePosixPath
|
|
281
|
+
home_path = path_type(str(Path.home()) if home is None else str(home))
|
|
282
|
+
if not home_path.is_absolute():
|
|
283
|
+
raise ValueError("home must be absolute under POSIX path rules.")
|
|
284
|
+
base = home_path / "Library" / "Caches"
|
|
285
|
+
else:
|
|
286
|
+
path_type = PurePosixPath
|
|
287
|
+
home_path = path_type(str(Path.home()) if home is None else str(home))
|
|
288
|
+
if not home_path.is_absolute():
|
|
289
|
+
raise ValueError("home must be absolute under POSIX path rules.")
|
|
290
|
+
configured = environment.get("XDG_CACHE_HOME", "")
|
|
291
|
+
candidate = path_type(configured) if configured else None
|
|
292
|
+
base = (
|
|
293
|
+
candidate
|
|
294
|
+
if candidate is not None and candidate.is_absolute()
|
|
295
|
+
else home_path / ".cache"
|
|
296
|
+
)
|
|
297
|
+
return base / "qplot" / "trusted-derived"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class TrustedWorkScheduler:
|
|
301
|
+
"""Deterministic, lazy owner for one exact database generation.
|
|
302
|
+
|
|
303
|
+
The exact priority key is ``(run tier, work kind, stable table index)``.
|
|
304
|
+
No ready queue is materialised. Nine small lane cursors lazily find the
|
|
305
|
+
next claim, while one completion byte per run records all three kinds.
|
|
306
|
+
|
|
307
|
+
The constructing thread is the sole scheduler owner. Every scheduler
|
|
308
|
+
method and property must be used on that thread; only a claim's
|
|
309
|
+
:class:`CancellationToken` is cross-thread safe. Executors must marshal
|
|
310
|
+
completion back to the owner thread. Owner-thread callbacks may re-enter.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(
|
|
314
|
+
self,
|
|
315
|
+
database_instance: DatabaseInstance,
|
|
316
|
+
runs: Sequence[TrustedRunWorkSource],
|
|
317
|
+
*,
|
|
318
|
+
formats: Mapping[TrustedWorkKind, WorkFormat] | None = None,
|
|
319
|
+
clock: Callable[[], float] = time.monotonic,
|
|
320
|
+
on_transition: TransitionCallback | None = None,
|
|
321
|
+
on_publish: PublicationCallback | None = None,
|
|
322
|
+
on_selected_publish: PublicationCallback | None = None,
|
|
323
|
+
) -> None:
|
|
324
|
+
self._owner_thread_id = threading.get_ident()
|
|
325
|
+
self._validate_database_instance(database_instance)
|
|
326
|
+
run_table = self._validated_run_table(runs)
|
|
327
|
+
self._database_instance = database_instance
|
|
328
|
+
self._runs = run_table
|
|
329
|
+
self._formats = self._validated_formats(formats or _DEFAULT_FORMATS)
|
|
330
|
+
self._clock = clock
|
|
331
|
+
self._on_transition = on_transition
|
|
332
|
+
self._on_publish = on_publish
|
|
333
|
+
self._on_selected_publish = on_selected_publish
|
|
334
|
+
self._lifecycle = SchedulerLifecycle.ACTIVE
|
|
335
|
+
self._generation = 1
|
|
336
|
+
self._selected_index: int | None = None
|
|
337
|
+
self._visible_start = 0
|
|
338
|
+
self._visible_stop = 0
|
|
339
|
+
self._visible_indices: tuple[int, ...] = ()
|
|
340
|
+
self._visible_index_set: frozenset[int] = frozenset()
|
|
341
|
+
self._completion_masks = bytearray(len(runs))
|
|
342
|
+
# Spare bits in the existing one-byte-per-run completion table encode
|
|
343
|
+
# the progressive dependency and conclusive-metadata state. This
|
|
344
|
+
# avoids an unbounded Python set alongside the structurally bounded
|
|
345
|
+
# scheduler arrays.
|
|
346
|
+
self._revision_overrides: dict[int, TrustedSourceRevision] = {}
|
|
347
|
+
self._completed_count = 0
|
|
348
|
+
self._running: dict[int, ScheduledWork] = {}
|
|
349
|
+
self._running_slots: set[tuple[int, TrustedWorkKind]] = set()
|
|
350
|
+
self._next_claim_id = 1
|
|
351
|
+
self._lane_cursors: dict[tuple[TrustedRunTier, TrustedWorkKind], int] = {}
|
|
352
|
+
self._reset_cursors()
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def lifecycle(self) -> SchedulerLifecycle:
|
|
356
|
+
self._require_owner()
|
|
357
|
+
return self._lifecycle
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def generation(self) -> int:
|
|
361
|
+
self._require_owner()
|
|
362
|
+
return self._generation
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def database_instance(self) -> DatabaseInstance:
|
|
366
|
+
self._require_owner()
|
|
367
|
+
return self._database_instance
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def allocated_work_count(self) -> int:
|
|
371
|
+
"""Number of actual derived-work claims, bounded to zero or one."""
|
|
372
|
+
|
|
373
|
+
self._require_owner()
|
|
374
|
+
return len(self._running)
|
|
375
|
+
|
|
376
|
+
def select_run(self, run_index: int | None) -> None:
|
|
377
|
+
self._require_owner()
|
|
378
|
+
self._require_active()
|
|
379
|
+
if run_index is not None:
|
|
380
|
+
self._validate_run_index(run_index)
|
|
381
|
+
if run_index != self._selected_index:
|
|
382
|
+
self._selected_index = run_index
|
|
383
|
+
self._reset_cursors()
|
|
384
|
+
|
|
385
|
+
def set_visible_range(self, start: int, stop: int) -> None:
|
|
386
|
+
"""Set the half-open stable table range currently visible."""
|
|
387
|
+
|
|
388
|
+
self._require_owner()
|
|
389
|
+
self._require_active()
|
|
390
|
+
if (
|
|
391
|
+
type(start) is not int
|
|
392
|
+
or type(stop) is not int
|
|
393
|
+
or start < 0
|
|
394
|
+
or stop < start
|
|
395
|
+
or stop > len(self._runs)
|
|
396
|
+
):
|
|
397
|
+
raise ValueError("The visible range must be within the run table.")
|
|
398
|
+
exact = tuple(range(start, stop))
|
|
399
|
+
if (start, stop) != (
|
|
400
|
+
self._visible_start,
|
|
401
|
+
self._visible_stop,
|
|
402
|
+
) or exact != self._visible_indices:
|
|
403
|
+
self._visible_start = start
|
|
404
|
+
self._visible_stop = stop
|
|
405
|
+
self._visible_indices = exact
|
|
406
|
+
self._visible_index_set = frozenset(self._visible_indices)
|
|
407
|
+
self._reset_cursors()
|
|
408
|
+
|
|
409
|
+
def set_visible_indices(self, indices: Sequence[int]) -> None:
|
|
410
|
+
"""Set the exact stable run slots currently intersecting the viewport.
|
|
411
|
+
|
|
412
|
+
Qt sorting and filtering can make visible rows non-contiguous in the
|
|
413
|
+
scheduler's stable run order. The original range API remains for
|
|
414
|
+
simple consumers; Stage 5C uses this exact bounded set.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
self._require_owner()
|
|
418
|
+
self._require_active()
|
|
419
|
+
if not isinstance(indices, Sequence):
|
|
420
|
+
raise TypeError("visible indices must be a stable Sequence.")
|
|
421
|
+
exact = tuple(indices)
|
|
422
|
+
if any(type(index) is not int for index in exact):
|
|
423
|
+
raise TypeError("visible indices must contain integers.")
|
|
424
|
+
if any(not 0 <= index < len(self._runs) for index in exact):
|
|
425
|
+
raise ValueError("A visible index is outside the run table.")
|
|
426
|
+
if len(exact) != len(set(exact)):
|
|
427
|
+
raise ValueError("visible indices must be unique.")
|
|
428
|
+
exact = tuple(sorted(exact))
|
|
429
|
+
if exact == self._visible_indices:
|
|
430
|
+
return
|
|
431
|
+
self._visible_indices = exact
|
|
432
|
+
self._visible_index_set = frozenset(exact)
|
|
433
|
+
self._visible_start = exact[0] if exact else 0
|
|
434
|
+
self._visible_stop = exact[-1] + 1 if exact else 0
|
|
435
|
+
self._reset_cursors()
|
|
436
|
+
|
|
437
|
+
def set_priority(
|
|
438
|
+
self,
|
|
439
|
+
selected_index: int | None,
|
|
440
|
+
visible_indices: Sequence[int],
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Atomically adopt selection and the exact visible stable indices."""
|
|
443
|
+
|
|
444
|
+
self._require_owner()
|
|
445
|
+
self._require_active()
|
|
446
|
+
if selected_index is not None:
|
|
447
|
+
self._validate_run_index(selected_index)
|
|
448
|
+
if not isinstance(visible_indices, Sequence):
|
|
449
|
+
raise TypeError("visible indices must be a stable Sequence.")
|
|
450
|
+
exact = tuple(visible_indices)
|
|
451
|
+
if any(type(index) is not int for index in exact):
|
|
452
|
+
raise TypeError("visible indices must contain integers.")
|
|
453
|
+
if any(not 0 <= index < len(self._runs) for index in exact):
|
|
454
|
+
raise ValueError("A visible index is outside the run table.")
|
|
455
|
+
if len(exact) != len(set(exact)):
|
|
456
|
+
raise ValueError("visible indices must be unique.")
|
|
457
|
+
exact = tuple(sorted(exact))
|
|
458
|
+
if selected_index == self._selected_index and exact == self._visible_indices:
|
|
459
|
+
return
|
|
460
|
+
self._selected_index = selected_index
|
|
461
|
+
self._visible_indices = exact
|
|
462
|
+
self._visible_index_set = frozenset(exact)
|
|
463
|
+
self._visible_start = exact[0] if exact else 0
|
|
464
|
+
self._visible_stop = exact[-1] + 1 if exact else 0
|
|
465
|
+
self._reset_cursors()
|
|
466
|
+
|
|
467
|
+
def claim_next(
|
|
468
|
+
self,
|
|
469
|
+
*,
|
|
470
|
+
age_remaining_metadata: bool = False,
|
|
471
|
+
) -> ScheduledWork | None:
|
|
472
|
+
"""Claim the deterministic next item, with at most one in flight.
|
|
473
|
+
|
|
474
|
+
``age_remaining_metadata`` admits one stable remaining-tier metadata
|
|
475
|
+
quantum ahead of the base order. The coordinator uses this bounded
|
|
476
|
+
exception only after its tested foreground-claim limit; normal callers
|
|
477
|
+
always receive the exact tier-first priority order.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
self._require_owner()
|
|
481
|
+
self._require_active()
|
|
482
|
+
if type(age_remaining_metadata) is not bool:
|
|
483
|
+
raise TypeError("age_remaining_metadata must be a boolean.")
|
|
484
|
+
if self._running:
|
|
485
|
+
return None
|
|
486
|
+
candidate = None
|
|
487
|
+
if age_remaining_metadata:
|
|
488
|
+
run_index = self._lane_candidate(
|
|
489
|
+
TrustedRunTier.REMAINING,
|
|
490
|
+
TrustedWorkKind.METADATA,
|
|
491
|
+
advance=True,
|
|
492
|
+
)
|
|
493
|
+
if run_index is not None:
|
|
494
|
+
candidate = (
|
|
495
|
+
TrustedRunTier.REMAINING,
|
|
496
|
+
TrustedWorkKind.METADATA,
|
|
497
|
+
run_index,
|
|
498
|
+
)
|
|
499
|
+
if candidate is None:
|
|
500
|
+
candidate = self._next_candidate(advance=True)
|
|
501
|
+
if candidate is None:
|
|
502
|
+
return None
|
|
503
|
+
return self._claim_candidate(candidate)
|
|
504
|
+
|
|
505
|
+
def claim_pending(
|
|
506
|
+
self,
|
|
507
|
+
run_index: int,
|
|
508
|
+
kind: TrustedWorkKind,
|
|
509
|
+
) -> ScheduledWork | None:
|
|
510
|
+
"""Claim one exact pending slot after an owner-side priority decision."""
|
|
511
|
+
|
|
512
|
+
self._require_owner()
|
|
513
|
+
self._require_active()
|
|
514
|
+
self._validate_run_index(run_index)
|
|
515
|
+
if not isinstance(kind, TrustedWorkKind):
|
|
516
|
+
raise TypeError("kind must be a TrustedWorkKind.")
|
|
517
|
+
if self._running or (run_index, kind) in self._running_slots:
|
|
518
|
+
return None
|
|
519
|
+
if self._completion_masks[run_index] & self._kind_bit(kind):
|
|
520
|
+
return None
|
|
521
|
+
return self._claim_candidate((self._tier_for_index(run_index), kind, run_index))
|
|
522
|
+
|
|
523
|
+
def next_pending_priority(
|
|
524
|
+
self,
|
|
525
|
+
*,
|
|
526
|
+
admissible: Callable[[int, TrustedWorkKind], bool] | None = None,
|
|
527
|
+
) -> PriorityKey | None:
|
|
528
|
+
"""Peek the first pending base-order slot accepted by ``admissible``.
|
|
529
|
+
|
|
530
|
+
This owner-side view lets the coordinator bypass an independently
|
|
531
|
+
retry-delayed slot without moving a lane cursor past it. Once that
|
|
532
|
+
slot's deadline arrives it therefore resumes at its original stable
|
|
533
|
+
priority rather than being stranded behind later work.
|
|
534
|
+
"""
|
|
535
|
+
|
|
536
|
+
self._require_owner()
|
|
537
|
+
self._require_active()
|
|
538
|
+
if admissible is not None and not callable(admissible):
|
|
539
|
+
raise TypeError("admissible must be callable.")
|
|
540
|
+
candidate = self._next_candidate(
|
|
541
|
+
advance=False,
|
|
542
|
+
admissible=admissible,
|
|
543
|
+
)
|
|
544
|
+
if candidate is None:
|
|
545
|
+
return None
|
|
546
|
+
tier, kind, run_index = candidate
|
|
547
|
+
return int(tier), int(kind), run_index
|
|
548
|
+
|
|
549
|
+
def next_pending_remaining_metadata_index(
|
|
550
|
+
self,
|
|
551
|
+
*,
|
|
552
|
+
exclude_index: int | None = None,
|
|
553
|
+
admissible: Callable[[int], bool] | None = None,
|
|
554
|
+
) -> int | None:
|
|
555
|
+
"""Peek the stable ordinary background-metadata lane without claiming."""
|
|
556
|
+
|
|
557
|
+
self._require_owner()
|
|
558
|
+
self._require_active()
|
|
559
|
+
if exclude_index is not None:
|
|
560
|
+
self._validate_run_index(exclude_index)
|
|
561
|
+
cursor = self._lane_cursors[
|
|
562
|
+
(TrustedRunTier.REMAINING, TrustedWorkKind.METADATA)
|
|
563
|
+
]
|
|
564
|
+
bit = self._kind_bit(TrustedWorkKind.METADATA)
|
|
565
|
+
for run_index in range(cursor, len(self._runs)):
|
|
566
|
+
if (
|
|
567
|
+
run_index == exclude_index
|
|
568
|
+
or (admissible is not None and not admissible(run_index))
|
|
569
|
+
or self._tier_for_index(run_index) is not TrustedRunTier.REMAINING
|
|
570
|
+
or self._completion_masks[run_index] & bit
|
|
571
|
+
or (run_index, TrustedWorkKind.METADATA) in self._running_slots
|
|
572
|
+
):
|
|
573
|
+
continue
|
|
574
|
+
return run_index
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
def _claim_candidate(
|
|
578
|
+
self,
|
|
579
|
+
candidate: tuple[TrustedRunTier, TrustedWorkKind, int],
|
|
580
|
+
) -> ScheduledWork:
|
|
581
|
+
tier, kind, run_index = candidate
|
|
582
|
+
source = self._runs[run_index]
|
|
583
|
+
source_revision = self._revision_overrides.get(
|
|
584
|
+
run_index,
|
|
585
|
+
source.source_revision,
|
|
586
|
+
)
|
|
587
|
+
work_format = self._formats[kind]
|
|
588
|
+
key = TrustedCacheWorkKey(
|
|
589
|
+
self._database_instance,
|
|
590
|
+
source.run_guid,
|
|
591
|
+
kind,
|
|
592
|
+
source_revision,
|
|
593
|
+
work_format.renderer_version,
|
|
594
|
+
work_format.options,
|
|
595
|
+
)
|
|
596
|
+
work = ScheduledWork(
|
|
597
|
+
claim_id=self._next_claim_id,
|
|
598
|
+
generation=self._generation,
|
|
599
|
+
run_index=run_index,
|
|
600
|
+
priority_key=(int(tier), int(kind), run_index),
|
|
601
|
+
key=key,
|
|
602
|
+
cancellation=CancellationToken(),
|
|
603
|
+
)
|
|
604
|
+
self._next_claim_id += 1
|
|
605
|
+
self._running[work.claim_id] = work
|
|
606
|
+
self._running_slots.add((run_index, kind))
|
|
607
|
+
self._transition(work, TrustedWorkState.PENDING, TrustedWorkState.RUNNING)
|
|
608
|
+
return work
|
|
609
|
+
|
|
610
|
+
def complete(
|
|
611
|
+
self,
|
|
612
|
+
work: ScheduledWork,
|
|
613
|
+
result: object = None,
|
|
614
|
+
) -> CompletionDisposition:
|
|
615
|
+
"""Accept only the exact current claim and publish only a keyed result."""
|
|
616
|
+
|
|
617
|
+
self._require_owner()
|
|
618
|
+
current = self._running.get(work.claim_id)
|
|
619
|
+
if current is not work:
|
|
620
|
+
return CompletionDisposition.STALE
|
|
621
|
+
if not self._is_current(work):
|
|
622
|
+
transitions = self._remove_claims((work,))
|
|
623
|
+
self._emit_transitions(transitions)
|
|
624
|
+
return CompletionDisposition.STALE
|
|
625
|
+
self._running.pop(work.claim_id)
|
|
626
|
+
self._running_slots.remove((work.run_index, work.key.kind))
|
|
627
|
+
bit = self._kind_bit(work.key.kind)
|
|
628
|
+
if not self._completion_masks[work.run_index] & bit:
|
|
629
|
+
self._completion_masks[work.run_index] |= bit
|
|
630
|
+
self._completed_count += 1
|
|
631
|
+
if work.key.kind is TrustedWorkKind.METADATA:
|
|
632
|
+
self._completion_masks[work.run_index] |= _METADATA_CONCLUSIVE_BIT
|
|
633
|
+
self._transition(work, TrustedWorkState.RUNNING, TrustedWorkState.COMPLETED)
|
|
634
|
+
if not self._is_current(work):
|
|
635
|
+
return CompletionDisposition.STALE
|
|
636
|
+
publication = WorkPublication(
|
|
637
|
+
generation=work.generation,
|
|
638
|
+
key=work.key,
|
|
639
|
+
result=result,
|
|
640
|
+
is_current_selection=(work.run_index == self._selected_index),
|
|
641
|
+
)
|
|
642
|
+
if self._on_publish is not None:
|
|
643
|
+
self._on_publish(publication)
|
|
644
|
+
if (
|
|
645
|
+
self._on_selected_publish is not None
|
|
646
|
+
and self._is_current(work)
|
|
647
|
+
and work.run_index == self._selected_index
|
|
648
|
+
):
|
|
649
|
+
self._on_selected_publish(
|
|
650
|
+
WorkPublication(
|
|
651
|
+
work.generation,
|
|
652
|
+
work.key,
|
|
653
|
+
result,
|
|
654
|
+
True,
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
return CompletionDisposition.ACCEPTED
|
|
658
|
+
|
|
659
|
+
def abandon(self, work: ScheduledWork) -> CompletionDisposition:
|
|
660
|
+
"""Return current work to pending; obsolete claims remain harmless."""
|
|
661
|
+
|
|
662
|
+
self._require_owner()
|
|
663
|
+
current = self._running.get(work.claim_id)
|
|
664
|
+
if current is not work:
|
|
665
|
+
return CompletionDisposition.STALE
|
|
666
|
+
if not self._is_current(work):
|
|
667
|
+
transitions = self._remove_claims((work,))
|
|
668
|
+
self._emit_transitions(transitions)
|
|
669
|
+
return CompletionDisposition.STALE
|
|
670
|
+
self._running.pop(work.claim_id)
|
|
671
|
+
self._running_slots.remove((work.run_index, work.key.kind))
|
|
672
|
+
tier = self._tier_for_index(work.run_index)
|
|
673
|
+
lane = (tier, work.key.kind)
|
|
674
|
+
self._lane_cursors[lane] = min(self._lane_cursors[lane], work.run_index)
|
|
675
|
+
self._transition(work, TrustedWorkState.RUNNING, TrustedWorkState.PENDING)
|
|
676
|
+
return CompletionDisposition.ACCEPTED
|
|
677
|
+
|
|
678
|
+
def is_current_claim(self, work: ScheduledWork) -> bool:
|
|
679
|
+
"""Prove an exact claim token still owns its complete scheduler identity."""
|
|
680
|
+
|
|
681
|
+
self._require_owner()
|
|
682
|
+
return self._running.get(work.claim_id) is work and self._is_current(work)
|
|
683
|
+
|
|
684
|
+
def refine_claim_source_revision(
|
|
685
|
+
self,
|
|
686
|
+
work: ScheduledWork,
|
|
687
|
+
source_revision: TrustedSourceRevision,
|
|
688
|
+
) -> CompletionDisposition:
|
|
689
|
+
"""Atomically adopt an authoritative revision from one current claim.
|
|
690
|
+
|
|
691
|
+
No state is changed until the exact claim token, generation, database,
|
|
692
|
+
stable run slot/GUID, kind, prior source identity, and render format are
|
|
693
|
+
all proven current. The refining claim is retired without publication;
|
|
694
|
+
its exact-key payload may then be reused by the owner for the replacement
|
|
695
|
+
claim.
|
|
696
|
+
"""
|
|
697
|
+
|
|
698
|
+
self._require_owner()
|
|
699
|
+
if not isinstance(source_revision, TrustedSourceRevision):
|
|
700
|
+
raise TypeError("source_revision must be a TrustedSourceRevision.")
|
|
701
|
+
if not self.is_current_claim(work):
|
|
702
|
+
return CompletionDisposition.STALE
|
|
703
|
+
if work.key.source_revision == source_revision:
|
|
704
|
+
return CompletionDisposition.ACCEPTED
|
|
705
|
+
self._running.pop(work.claim_id)
|
|
706
|
+
self._running_slots.remove((work.run_index, work.key.kind))
|
|
707
|
+
self._completion_masks[work.run_index] &= ~(
|
|
708
|
+
_PROGRESSIVE_METADATA_PARKED_BIT | _METADATA_CONCLUSIVE_BIT
|
|
709
|
+
)
|
|
710
|
+
self._revision_overrides[work.run_index] = source_revision
|
|
711
|
+
self._completed_count -= (
|
|
712
|
+
self._completion_masks[work.run_index] & _WORK_COMPLETION_MASK
|
|
713
|
+
).bit_count()
|
|
714
|
+
self._completion_masks[work.run_index] = 0
|
|
715
|
+
self._reset_cursors()
|
|
716
|
+
self._transition(work, TrustedWorkState.RUNNING, TrustedWorkState.CANCELLED)
|
|
717
|
+
return CompletionDisposition.ACCEPTED
|
|
718
|
+
|
|
719
|
+
def park_progressive_metadata(
|
|
720
|
+
self,
|
|
721
|
+
work: ScheduledWork,
|
|
722
|
+
source_revision: TrustedSourceRevision,
|
|
723
|
+
) -> CompletionDisposition:
|
|
724
|
+
"""Retire an inconclusive metadata page without publishing any output.
|
|
725
|
+
|
|
726
|
+
All three output kinds are marked complete temporarily so ordinary
|
|
727
|
+
thumbnail and preview lanes cannot amplify a metadata-only progressive
|
|
728
|
+
inspection. The owner later clears only the metadata bit with
|
|
729
|
+
:meth:`request_completed_work`; a conclusive replacement claim refines
|
|
730
|
+
the source revision normally and makes all real outputs pending again.
|
|
731
|
+
"""
|
|
732
|
+
|
|
733
|
+
self._require_owner()
|
|
734
|
+
if not isinstance(source_revision, TrustedSourceRevision):
|
|
735
|
+
raise TypeError("source_revision must be a TrustedSourceRevision.")
|
|
736
|
+
if work.key.kind is not TrustedWorkKind.METADATA:
|
|
737
|
+
raise ValueError("Only metadata work can be parked progressively.")
|
|
738
|
+
if not self.is_current_claim(work):
|
|
739
|
+
return CompletionDisposition.STALE
|
|
740
|
+
|
|
741
|
+
self._running.pop(work.claim_id)
|
|
742
|
+
self._running_slots.remove((work.run_index, work.key.kind))
|
|
743
|
+
self._revision_overrides[work.run_index] = source_revision
|
|
744
|
+
prior_mask = self._completion_masks[work.run_index]
|
|
745
|
+
self._completed_count -= (prior_mask & _WORK_COMPLETION_MASK).bit_count()
|
|
746
|
+
parked_mask = _WORK_COMPLETION_MASK | _PROGRESSIVE_METADATA_PARKED_BIT
|
|
747
|
+
self._completion_masks[work.run_index] = parked_mask
|
|
748
|
+
self._completed_count += len(_WORK_KINDS)
|
|
749
|
+
self._reset_cursors()
|
|
750
|
+
self._transition(work, TrustedWorkState.RUNNING, TrustedWorkState.COMPLETED)
|
|
751
|
+
return CompletionDisposition.ACCEPTED
|
|
752
|
+
|
|
753
|
+
def unpark_progressive_metadata(
|
|
754
|
+
self,
|
|
755
|
+
work: ScheduledWork,
|
|
756
|
+
) -> CompletionDisposition:
|
|
757
|
+
"""Release only this run's image dependency after conclusive metadata."""
|
|
758
|
+
|
|
759
|
+
self._require_owner()
|
|
760
|
+
if work.key.kind is not TrustedWorkKind.METADATA:
|
|
761
|
+
raise ValueError("Only metadata work can finish a progressive proof.")
|
|
762
|
+
if not self.is_current_claim(work):
|
|
763
|
+
return CompletionDisposition.STALE
|
|
764
|
+
if not (
|
|
765
|
+
self._completion_masks[work.run_index] & _PROGRESSIVE_METADATA_PARKED_BIT
|
|
766
|
+
):
|
|
767
|
+
return CompletionDisposition.ACCEPTED
|
|
768
|
+
self._completion_masks[work.run_index] &= ~_PROGRESSIVE_METADATA_PARKED_BIT
|
|
769
|
+
for kind in (TrustedWorkKind.THUMBNAIL, TrustedWorkKind.PREVIEW):
|
|
770
|
+
bit = self._kind_bit(kind)
|
|
771
|
+
if self._completion_masks[work.run_index] & bit:
|
|
772
|
+
self._completion_masks[work.run_index] &= ~bit
|
|
773
|
+
self._completed_count -= 1
|
|
774
|
+
self._reset_cursors()
|
|
775
|
+
return CompletionDisposition.ACCEPTED
|
|
776
|
+
|
|
777
|
+
def update_source_revision(
|
|
778
|
+
self,
|
|
779
|
+
run_index: int,
|
|
780
|
+
source_revision: TrustedSourceRevision,
|
|
781
|
+
) -> None:
|
|
782
|
+
"""Invalidate all derived kinds for a live run's new source revision."""
|
|
783
|
+
|
|
784
|
+
self._require_owner()
|
|
785
|
+
self._require_active()
|
|
786
|
+
self._validate_run_index(run_index)
|
|
787
|
+
if not isinstance(source_revision, TrustedSourceRevision):
|
|
788
|
+
raise TypeError("source_revision must be a TrustedSourceRevision.")
|
|
789
|
+
prior = self._revision_overrides.get(
|
|
790
|
+
run_index,
|
|
791
|
+
self._runs[run_index].source_revision,
|
|
792
|
+
)
|
|
793
|
+
if prior == source_revision:
|
|
794
|
+
return
|
|
795
|
+
affected = tuple(
|
|
796
|
+
work for work in self._running.values() if work.run_index == run_index
|
|
797
|
+
)
|
|
798
|
+
self._revision_overrides[run_index] = source_revision
|
|
799
|
+
self._completed_count -= (
|
|
800
|
+
self._completion_masks[run_index] & _WORK_COMPLETION_MASK
|
|
801
|
+
).bit_count()
|
|
802
|
+
self._completion_masks[run_index] = 0
|
|
803
|
+
transitions = self._remove_claims(affected)
|
|
804
|
+
self._reset_cursors()
|
|
805
|
+
self._emit_transitions(transitions)
|
|
806
|
+
|
|
807
|
+
def update_format(self, kind: TrustedWorkKind, work_format: WorkFormat) -> None:
|
|
808
|
+
"""Invalidate one output kind after renderer/options identity changes."""
|
|
809
|
+
|
|
810
|
+
self._require_owner()
|
|
811
|
+
self._require_active()
|
|
812
|
+
if not isinstance(kind, TrustedWorkKind):
|
|
813
|
+
raise TypeError("kind must be a TrustedWorkKind.")
|
|
814
|
+
if not isinstance(work_format, WorkFormat):
|
|
815
|
+
raise TypeError("work_format must be a WorkFormat.")
|
|
816
|
+
if self._formats[kind] == work_format:
|
|
817
|
+
return
|
|
818
|
+
affected = tuple(
|
|
819
|
+
work for work in self._running.values() if work.key.kind is kind
|
|
820
|
+
)
|
|
821
|
+
self._formats[kind] = work_format
|
|
822
|
+
bit = self._kind_bit(kind)
|
|
823
|
+
for index, mask in enumerate(self._completion_masks):
|
|
824
|
+
if (
|
|
825
|
+
mask & _PROGRESSIVE_METADATA_PARKED_BIT
|
|
826
|
+
and kind is not TrustedWorkKind.METADATA
|
|
827
|
+
):
|
|
828
|
+
continue
|
|
829
|
+
if mask & bit:
|
|
830
|
+
self._completion_masks[index] &= ~bit
|
|
831
|
+
self._completed_count -= 1
|
|
832
|
+
transitions = self._remove_claims(affected)
|
|
833
|
+
self._reset_cursors()
|
|
834
|
+
self._emit_transitions(transitions)
|
|
835
|
+
|
|
836
|
+
def request_completed_work(
|
|
837
|
+
self,
|
|
838
|
+
run_index: int,
|
|
839
|
+
kind: TrustedWorkKind,
|
|
840
|
+
*,
|
|
841
|
+
database_instance: DatabaseInstance,
|
|
842
|
+
generation: int,
|
|
843
|
+
run_guid: str,
|
|
844
|
+
) -> bool:
|
|
845
|
+
"""Return one exact completed item to pending for bounded replay.
|
|
846
|
+
|
|
847
|
+
This is intentionally narrower than source or format invalidation: it
|
|
848
|
+
retains the exact cache identity and resets only one completion bit.
|
|
849
|
+
Stale GUI requests are harmless because the complete database,
|
|
850
|
+
generation, stable run slot/GUID, and work-kind identity must still
|
|
851
|
+
match. Repeated requests coalesce while that item is pending/running.
|
|
852
|
+
"""
|
|
853
|
+
|
|
854
|
+
self._require_owner()
|
|
855
|
+
self._require_active()
|
|
856
|
+
if not isinstance(kind, TrustedWorkKind):
|
|
857
|
+
raise TypeError("kind must be a TrustedWorkKind.")
|
|
858
|
+
if type(run_index) is not int:
|
|
859
|
+
raise TypeError("run_index must be an integer.")
|
|
860
|
+
if not isinstance(database_instance, DatabaseInstance):
|
|
861
|
+
raise TypeError("database_instance must be a DatabaseInstance.")
|
|
862
|
+
if type(generation) is not int:
|
|
863
|
+
raise TypeError("generation must be an integer.")
|
|
864
|
+
if not isinstance(run_guid, str) or not run_guid:
|
|
865
|
+
raise ValueError("run_guid must be a non-empty string.")
|
|
866
|
+
if (
|
|
867
|
+
database_instance != self._database_instance
|
|
868
|
+
or generation != self._generation
|
|
869
|
+
or not 0 <= run_index < len(self._runs)
|
|
870
|
+
or self._runs[run_index].run_guid != run_guid
|
|
871
|
+
or (run_index, kind) in self._running_slots
|
|
872
|
+
or (
|
|
873
|
+
self._completion_masks[run_index] & _PROGRESSIVE_METADATA_PARKED_BIT
|
|
874
|
+
and kind is not TrustedWorkKind.METADATA
|
|
875
|
+
)
|
|
876
|
+
):
|
|
877
|
+
return False
|
|
878
|
+
bit = self._kind_bit(kind)
|
|
879
|
+
if not self._completion_masks[run_index] & bit:
|
|
880
|
+
return False
|
|
881
|
+
self._completion_masks[run_index] &= ~bit
|
|
882
|
+
self._completed_count -= 1
|
|
883
|
+
self._reset_cursors()
|
|
884
|
+
return True
|
|
885
|
+
|
|
886
|
+
def reconcile_runs(self, runs: Sequence[TrustedRunWorkSource]) -> None:
|
|
887
|
+
"""Adopt an append-only same-database run table without replaying work.
|
|
888
|
+
|
|
889
|
+
Existing entries must be an exact stable prefix. Reordering, removal,
|
|
890
|
+
GUID replacement, source-revision replacement, and duplicate GUIDs are
|
|
891
|
+
rejected; source changes use :meth:`update_source_revision` instead.
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
self._require_owner()
|
|
895
|
+
self._require_active()
|
|
896
|
+
run_table = self._validated_run_table(runs)
|
|
897
|
+
old_count = len(self._runs)
|
|
898
|
+
if len(run_table) < old_count:
|
|
899
|
+
raise ValueError("Same-instance run reconciliation may not remove runs.")
|
|
900
|
+
effective_prefix = tuple(
|
|
901
|
+
TrustedRunWorkSource(
|
|
902
|
+
run.run_guid,
|
|
903
|
+
self._revision_overrides.get(index, run.source_revision),
|
|
904
|
+
)
|
|
905
|
+
for index, run in enumerate(self._runs)
|
|
906
|
+
)
|
|
907
|
+
prefix_is_stable = all(
|
|
908
|
+
incoming.run_guid == original.run_guid
|
|
909
|
+
and incoming.source_revision
|
|
910
|
+
in {original.source_revision, effective.source_revision}
|
|
911
|
+
for incoming, original, effective in zip(
|
|
912
|
+
run_table[:old_count],
|
|
913
|
+
self._runs,
|
|
914
|
+
effective_prefix,
|
|
915
|
+
strict=True,
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
if not prefix_is_stable:
|
|
919
|
+
raise ValueError(
|
|
920
|
+
"Existing runs must remain an exact stable prefix; use "
|
|
921
|
+
"update_source_revision for source changes."
|
|
922
|
+
)
|
|
923
|
+
if len(run_table) == old_count:
|
|
924
|
+
return
|
|
925
|
+
# Fold refined revisions into the stable prefix. Callers may present
|
|
926
|
+
# either their original baseline or the coordinator's already-refined
|
|
927
|
+
# view, but an append must never regress effective source identity.
|
|
928
|
+
self._runs = (*effective_prefix, *run_table[old_count:])
|
|
929
|
+
self._revision_overrides = {
|
|
930
|
+
index: revision
|
|
931
|
+
for index, revision in self._revision_overrides.items()
|
|
932
|
+
if index >= old_count
|
|
933
|
+
}
|
|
934
|
+
self._completion_masks.extend(b"\0" * (len(run_table) - old_count))
|
|
935
|
+
self._reset_cursors()
|
|
936
|
+
|
|
937
|
+
def switch_database(
|
|
938
|
+
self,
|
|
939
|
+
database_instance: DatabaseInstance,
|
|
940
|
+
runs: Sequence[TrustedRunWorkSource],
|
|
941
|
+
) -> None:
|
|
942
|
+
"""Cancel and invalidate the complete prior exact-instance namespace."""
|
|
943
|
+
|
|
944
|
+
self._require_owner()
|
|
945
|
+
self._require_active()
|
|
946
|
+
self._validate_database_instance(database_instance)
|
|
947
|
+
run_table = self._validated_run_table(runs)
|
|
948
|
+
affected = tuple(self._running.values())
|
|
949
|
+
self._generation += 1
|
|
950
|
+
self._database_instance = database_instance
|
|
951
|
+
self._runs = run_table
|
|
952
|
+
self._selected_index = None
|
|
953
|
+
self._visible_start = 0
|
|
954
|
+
self._visible_stop = 0
|
|
955
|
+
self._visible_indices = ()
|
|
956
|
+
self._visible_index_set = frozenset()
|
|
957
|
+
self._completion_masks = bytearray(len(runs))
|
|
958
|
+
self._revision_overrides = {}
|
|
959
|
+
self._completed_count = 0
|
|
960
|
+
transitions = self._remove_claims(affected)
|
|
961
|
+
self._reset_cursors()
|
|
962
|
+
self._emit_transitions(transitions)
|
|
963
|
+
|
|
964
|
+
def close(self) -> None:
|
|
965
|
+
self._require_owner()
|
|
966
|
+
if self._lifecycle is SchedulerLifecycle.CLOSED:
|
|
967
|
+
return
|
|
968
|
+
affected = tuple(self._running.values())
|
|
969
|
+
self._lifecycle = SchedulerLifecycle.CLOSED
|
|
970
|
+
transitions = self._remove_claims(affected)
|
|
971
|
+
self._emit_transitions(transitions)
|
|
972
|
+
|
|
973
|
+
def state_for(self, run_index: int, kind: TrustedWorkKind) -> TrustedWorkState:
|
|
974
|
+
self._require_owner()
|
|
975
|
+
self._validate_run_index(run_index)
|
|
976
|
+
if (run_index, kind) in self._running_slots:
|
|
977
|
+
return TrustedWorkState.RUNNING
|
|
978
|
+
if self._completion_masks[run_index] & self._kind_bit(kind):
|
|
979
|
+
return TrustedWorkState.COMPLETED
|
|
980
|
+
return TrustedWorkState.PENDING
|
|
981
|
+
|
|
982
|
+
def metadata_is_conclusive(self, run_index: int) -> bool:
|
|
983
|
+
"""Return whether this run's current source has unblocked image work."""
|
|
984
|
+
|
|
985
|
+
self._require_owner()
|
|
986
|
+
self._validate_run_index(run_index)
|
|
987
|
+
return bool(self._completion_masks[run_index] & _METADATA_CONCLUSIVE_BIT)
|
|
988
|
+
|
|
989
|
+
def metadata_is_progressively_parked(self, run_index: int) -> bool:
|
|
990
|
+
"""Return whether images remain hidden behind progressive metadata."""
|
|
991
|
+
|
|
992
|
+
self._require_owner()
|
|
993
|
+
self._validate_run_index(run_index)
|
|
994
|
+
return bool(
|
|
995
|
+
self._completion_masks[run_index] & _PROGRESSIVE_METADATA_PARKED_BIT
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
def snapshot(self) -> SchedulerSnapshot:
|
|
999
|
+
self._require_owner()
|
|
1000
|
+
next_candidate = None
|
|
1001
|
+
if self._lifecycle is SchedulerLifecycle.ACTIVE and not self._running:
|
|
1002
|
+
candidate = self._next_candidate(advance=False)
|
|
1003
|
+
if candidate is not None:
|
|
1004
|
+
tier, kind, run_index = candidate
|
|
1005
|
+
next_candidate = (int(tier), int(kind), run_index)
|
|
1006
|
+
total = len(self._runs) * len(_WORK_KINDS)
|
|
1007
|
+
return SchedulerSnapshot(
|
|
1008
|
+
lifecycle=self._lifecycle,
|
|
1009
|
+
generation=self._generation,
|
|
1010
|
+
database_instance=self._database_instance,
|
|
1011
|
+
run_count=len(self._runs),
|
|
1012
|
+
selected_index=self._selected_index,
|
|
1013
|
+
visible_range=(self._visible_start, self._visible_stop),
|
|
1014
|
+
visible_indices=self._visible_indices,
|
|
1015
|
+
pending_count=total - self._completed_count - len(self._running),
|
|
1016
|
+
running=tuple(self._running.values()),
|
|
1017
|
+
completed_count=self._completed_count,
|
|
1018
|
+
next_priority_key=next_candidate,
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
def _next_candidate(
|
|
1022
|
+
self,
|
|
1023
|
+
*,
|
|
1024
|
+
advance: bool,
|
|
1025
|
+
admissible: Callable[[int, TrustedWorkKind], bool] | None = None,
|
|
1026
|
+
) -> tuple[TrustedRunTier, TrustedWorkKind, int] | None:
|
|
1027
|
+
for tier in _RUN_TIERS:
|
|
1028
|
+
for kind in _WORK_KINDS:
|
|
1029
|
+
run_index = self._lane_candidate(
|
|
1030
|
+
tier,
|
|
1031
|
+
kind,
|
|
1032
|
+
advance=advance,
|
|
1033
|
+
admissible=admissible,
|
|
1034
|
+
)
|
|
1035
|
+
if run_index is not None:
|
|
1036
|
+
return tier, kind, run_index
|
|
1037
|
+
return None
|
|
1038
|
+
|
|
1039
|
+
def _lane_candidate(
|
|
1040
|
+
self,
|
|
1041
|
+
tier: TrustedRunTier,
|
|
1042
|
+
kind: TrustedWorkKind,
|
|
1043
|
+
*,
|
|
1044
|
+
advance: bool,
|
|
1045
|
+
admissible: Callable[[int, TrustedWorkKind], bool] | None = None,
|
|
1046
|
+
) -> int | None:
|
|
1047
|
+
cursor = self._lane_cursors[(tier, kind)]
|
|
1048
|
+
if tier is TrustedRunTier.SELECTED:
|
|
1049
|
+
candidates: range | tuple[int, ...] = (
|
|
1050
|
+
()
|
|
1051
|
+
if self._selected_index is None or self._selected_index < cursor
|
|
1052
|
+
else (self._selected_index,)
|
|
1053
|
+
)
|
|
1054
|
+
elif tier is TrustedRunTier.VISIBLE:
|
|
1055
|
+
candidates = tuple(
|
|
1056
|
+
index for index in self._visible_indices if index >= cursor
|
|
1057
|
+
)
|
|
1058
|
+
else:
|
|
1059
|
+
candidates = range(cursor, len(self._runs))
|
|
1060
|
+
|
|
1061
|
+
bit = self._kind_bit(kind)
|
|
1062
|
+
for index in candidates:
|
|
1063
|
+
if self._tier_for_index(index) is not tier:
|
|
1064
|
+
continue
|
|
1065
|
+
if admissible is not None and not admissible(index, kind):
|
|
1066
|
+
continue
|
|
1067
|
+
if self._completion_masks[index] & bit:
|
|
1068
|
+
continue
|
|
1069
|
+
if (index, kind) in self._running_slots:
|
|
1070
|
+
continue
|
|
1071
|
+
if advance:
|
|
1072
|
+
self._lane_cursors[(tier, kind)] = index + 1
|
|
1073
|
+
return index
|
|
1074
|
+
if advance:
|
|
1075
|
+
self._lane_cursors[(tier, kind)] = len(self._runs)
|
|
1076
|
+
return None
|
|
1077
|
+
|
|
1078
|
+
def _tier_for_index(self, run_index: int) -> TrustedRunTier:
|
|
1079
|
+
if run_index == self._selected_index:
|
|
1080
|
+
return TrustedRunTier.SELECTED
|
|
1081
|
+
if run_index in self._visible_index_set:
|
|
1082
|
+
return TrustedRunTier.VISIBLE
|
|
1083
|
+
return TrustedRunTier.REMAINING
|
|
1084
|
+
|
|
1085
|
+
def _remove_claims(
|
|
1086
|
+
self,
|
|
1087
|
+
claims: Sequence[ScheduledWork],
|
|
1088
|
+
) -> tuple[tuple[ScheduledWork, TrustedWorkState, TrustedWorkState], ...]:
|
|
1089
|
+
transitions = []
|
|
1090
|
+
for work in claims:
|
|
1091
|
+
if self._running.get(work.claim_id) is not work:
|
|
1092
|
+
continue
|
|
1093
|
+
work.cancellation.cancel()
|
|
1094
|
+
self._running.pop(work.claim_id)
|
|
1095
|
+
self._running_slots.discard((work.run_index, work.key.kind))
|
|
1096
|
+
transitions.append(
|
|
1097
|
+
(
|
|
1098
|
+
work,
|
|
1099
|
+
TrustedWorkState.RUNNING,
|
|
1100
|
+
TrustedWorkState.CANCELLED,
|
|
1101
|
+
)
|
|
1102
|
+
)
|
|
1103
|
+
return tuple(transitions)
|
|
1104
|
+
|
|
1105
|
+
def _emit_transitions(
|
|
1106
|
+
self,
|
|
1107
|
+
transitions: Sequence[tuple[ScheduledWork, TrustedWorkState, TrustedWorkState]],
|
|
1108
|
+
) -> None:
|
|
1109
|
+
for work, previous, current in transitions:
|
|
1110
|
+
self._transition(work, previous, current)
|
|
1111
|
+
|
|
1112
|
+
def _transition(
|
|
1113
|
+
self,
|
|
1114
|
+
work: ScheduledWork,
|
|
1115
|
+
previous: TrustedWorkState,
|
|
1116
|
+
current: TrustedWorkState,
|
|
1117
|
+
) -> None:
|
|
1118
|
+
if self._on_transition is not None:
|
|
1119
|
+
self._on_transition(
|
|
1120
|
+
WorkTransition(
|
|
1121
|
+
work.generation,
|
|
1122
|
+
work.key,
|
|
1123
|
+
previous,
|
|
1124
|
+
current,
|
|
1125
|
+
self._clock(),
|
|
1126
|
+
)
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
def _is_current(self, work: ScheduledWork) -> bool:
|
|
1130
|
+
if (
|
|
1131
|
+
self._lifecycle is not SchedulerLifecycle.ACTIVE
|
|
1132
|
+
or work.generation != self._generation
|
|
1133
|
+
or work.key.database_instance != self._database_instance
|
|
1134
|
+
or not 0 <= work.run_index < len(self._runs)
|
|
1135
|
+
):
|
|
1136
|
+
return False
|
|
1137
|
+
source = self._runs[work.run_index]
|
|
1138
|
+
source_revision = self._revision_overrides.get(
|
|
1139
|
+
work.run_index,
|
|
1140
|
+
source.source_revision,
|
|
1141
|
+
)
|
|
1142
|
+
work_format = self._formats[work.key.kind]
|
|
1143
|
+
return work.key == TrustedCacheWorkKey(
|
|
1144
|
+
self._database_instance,
|
|
1145
|
+
source.run_guid,
|
|
1146
|
+
work.key.kind,
|
|
1147
|
+
source_revision,
|
|
1148
|
+
work_format.renderer_version,
|
|
1149
|
+
work_format.options,
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
def _reset_cursors(self) -> None:
|
|
1153
|
+
self._lane_cursors = {
|
|
1154
|
+
(tier, kind): 0 for tier in _RUN_TIERS for kind in _WORK_KINDS
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
@staticmethod
|
|
1158
|
+
def _kind_bit(kind: TrustedWorkKind) -> int:
|
|
1159
|
+
return 1 << int(kind)
|
|
1160
|
+
|
|
1161
|
+
def _validate_run_index(self, run_index: int) -> None:
|
|
1162
|
+
if type(run_index) is not int or not 0 <= run_index < len(self._runs):
|
|
1163
|
+
raise IndexError("run_index is outside the stable run table.")
|
|
1164
|
+
|
|
1165
|
+
def _require_active(self) -> None:
|
|
1166
|
+
if self._lifecycle is SchedulerLifecycle.CLOSED:
|
|
1167
|
+
raise RuntimeError("The trusted work scheduler is closed.")
|
|
1168
|
+
|
|
1169
|
+
def _require_owner(self) -> None:
|
|
1170
|
+
if threading.get_ident() != self._owner_thread_id:
|
|
1171
|
+
raise RuntimeError(
|
|
1172
|
+
"TrustedWorkScheduler must be accessed on its owner thread; "
|
|
1173
|
+
"marshal executor completions back to the constructing thread."
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
@staticmethod
|
|
1177
|
+
def _validate_database_instance(instance: DatabaseInstance) -> None:
|
|
1178
|
+
if not isinstance(instance, DatabaseInstance) or instance.identity is None:
|
|
1179
|
+
raise ValueError("The scheduler requires an exact DatabaseInstance.")
|
|
1180
|
+
|
|
1181
|
+
@staticmethod
|
|
1182
|
+
def _validated_run_table(
|
|
1183
|
+
runs: Sequence[TrustedRunWorkSource],
|
|
1184
|
+
) -> tuple[TrustedRunWorkSource, ...]:
|
|
1185
|
+
if not isinstance(runs, Sequence):
|
|
1186
|
+
raise TypeError("runs must be a stable Sequence.")
|
|
1187
|
+
run_table = tuple(runs)
|
|
1188
|
+
if any(not isinstance(item, TrustedRunWorkSource) for item in run_table):
|
|
1189
|
+
raise TypeError("runs must contain TrustedRunWorkSource values.")
|
|
1190
|
+
guids = tuple(item.run_guid for item in run_table)
|
|
1191
|
+
if len(guids) != len(set(guids)):
|
|
1192
|
+
raise ValueError("run GUIDs must be unique within a scheduler table.")
|
|
1193
|
+
return run_table
|
|
1194
|
+
|
|
1195
|
+
@staticmethod
|
|
1196
|
+
def _validated_formats(
|
|
1197
|
+
formats: Mapping[TrustedWorkKind, WorkFormat],
|
|
1198
|
+
) -> dict[TrustedWorkKind, WorkFormat]:
|
|
1199
|
+
if set(formats) != set(_WORK_KINDS):
|
|
1200
|
+
raise ValueError("formats must define metadata, thumbnail, and preview.")
|
|
1201
|
+
output = dict(formats)
|
|
1202
|
+
if any(not isinstance(value, WorkFormat) for value in output.values()):
|
|
1203
|
+
raise TypeError("Every format must be a WorkFormat.")
|
|
1204
|
+
return output
|