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,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