qplotter 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. qplotter-1.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2340 @@
1
+ """
2
+ Database access helpers used by the main window.
3
+
4
+ This module keeps blocking database probes, cloud-file hydration, background
5
+ load workers, and diagnostic report generation outside the GUI class.
6
+ """
7
+
8
+ import errno
9
+ import json
10
+ import os
11
+ import queue
12
+ import stat
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ from datetime import datetime
17
+ from time import monotonic, perf_counter
18
+
19
+ from PyQt6 import QtCore
20
+
21
+ from qplot.datahandling.file_identity import (
22
+ DatabaseInstance,
23
+ database_file_identity,
24
+ database_instance,
25
+ database_instances_differ,
26
+ )
27
+ from qplot.datahandling.readonly import (
28
+ DatabaseInstanceChangedError,
29
+ UnverifiableDatabaseWalError,
30
+ quarantine_wal_for_replaced_database,
31
+ replacement_wal_is_quarantined,
32
+ sqlite_read_only_connection,
33
+ )
34
+ from qplot.datahandling.readSQL import (
35
+ find_new_runs,
36
+ get_run_status,
37
+ get_runs_basic_via_sql,
38
+ get_snapshot_selected_run_detail,
39
+ iter_run_detail_batches_via_sql,
40
+ iter_run_shape_batches_via_sql,
41
+ iter_run_storage_batches_via_sql,
42
+ )
43
+ from qplot.datahandling.trusted_live import (
44
+ TrustedLiveReaderUnavailableError,
45
+ TrustedLiveSourceIOError,
46
+ TrustedLiveUnsupportedSourceError,
47
+ )
48
+ from qplot.datahandling.trusted_live_queries import run_records_as_dict
49
+ from qplot.datahandling.trusted_live_service import (
50
+ SNAPSHOT_FALLBACK_MODE,
51
+ TRUSTED_LIVE_MODE,
52
+ TrustedLiveReadService,
53
+ TrustedReadPriority,
54
+ TrustedReadRequestCancelledError,
55
+ )
56
+ from qplot.datahandling.trusted_presentation import (
57
+ bounded_presentation_error,
58
+ bounded_selected_run_fields,
59
+ )
60
+ from qplot.diagnostics import log_exception
61
+
62
+ DATABASE_ACCESS_TIMEOUT_SECONDS = 3
63
+ DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS = 120
64
+ DATABASE_CLOUD_SYNC_CHUNK_BYTES = 4 * 1024 * 1024
65
+ DATABASE_CLOUD_SYNC_STATUS_INTERVAL = 1.0
66
+ DATABASE_CLOUD_SYNC_RETRY_INTERVAL = 0.25
67
+ DATABASE_PREFETCH_STATUS_PREFIX = "QPLOT_PREFETCH_PROGRESS:"
68
+ CLOUD_PLACEHOLDER_XATTR_MARKERS = (
69
+ "com.apple.fileprovider",
70
+ "com.apple.fileutil.PlaceholderData",
71
+ "com.microsoft.OneDrive",
72
+ )
73
+ WINDOWS_CLOUD_PLACEHOLDER_ATTRIBUTES = (
74
+ getattr(stat, "FILE_ATTRIBUTE_OFFLINE", 0x00001000)
75
+ | getattr(stat, "FILE_ATTRIBUTE_RECALL_ON_OPEN", 0x00040000)
76
+ | getattr(stat, "FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS", 0x00400000)
77
+ )
78
+ _DATABASE_ACCESS_ERROR_PREFIX = "QPLOT_DATABASE_ACCESS_ERROR:"
79
+
80
+
81
+ def _bounded_run_publication(fields):
82
+ """Strip raw run payloads before any worker result crosses into Qt."""
83
+
84
+ return {
85
+ name: list(value) if isinstance(value, tuple) else value
86
+ for name, value in bounded_selected_run_fields(fields or {})
87
+ }
88
+
89
+
90
+ def _bounded_runs_publication(runs):
91
+ return {
92
+ run_id: _bounded_run_publication(fields)
93
+ for run_id, fields in (runs or {}).items()
94
+ }
95
+
96
+
97
+ def _bounded_worker_error(error):
98
+ """Detach tracebacks/contexts and cap every object queued into Qt."""
99
+
100
+ if error is None:
101
+ return None
102
+ message = bounded_presentation_error(error)
103
+ if isinstance(error, DatabaseInstanceChangedError):
104
+ return DatabaseInstanceChangedError(message)
105
+ if isinstance(error, UnverifiableDatabaseWalError):
106
+ return UnverifiableDatabaseWalError(message)
107
+ return message
108
+
109
+
110
+ def trusted_open_failure_allows_snapshot_fallback(error):
111
+ """Return true only for the two documented initial-open outcomes.
112
+
113
+ Several supervisor/process failures intentionally inherit
114
+ ``TrustedLiveReaderUnavailableError``. Exact-type checks prevent helper
115
+ crashes, protocol failures, cancellation, deadlines, and accepted-session
116
+ failures from being converted into a legacy snapshot replay.
117
+ """
118
+
119
+ return type(error) in {
120
+ TrustedLiveReaderUnavailableError,
121
+ TrustedLiveUnsupportedSourceError,
122
+ }
123
+
124
+
125
+ class _DatabaseAccessErrorMessage(str):
126
+ """Probe text retaining the remote exception type for the load worker."""
127
+
128
+ def __new__(cls, message, error_type=None):
129
+ instance = super().__new__(cls, message)
130
+ instance.error_type = error_type
131
+ return instance
132
+
133
+
134
+ def _database_access_error_message(output):
135
+ """Decode a probe error without exposing its transport marker to users."""
136
+ output = (output or "").strip()
137
+ for line in reversed(output.splitlines()):
138
+ if not line.startswith(_DATABASE_ACCESS_ERROR_PREFIX):
139
+ continue
140
+ try:
141
+ payload = json.loads(line.removeprefix(_DATABASE_ACCESS_ERROR_PREFIX))
142
+ except (json.JSONDecodeError, TypeError):
143
+ continue
144
+ if not isinstance(payload, dict):
145
+ continue
146
+ message = payload.get("message")
147
+ error_type = payload.get("type")
148
+ if isinstance(message, str) and isinstance(error_type, str):
149
+ return _DatabaseAccessErrorMessage(message, error_type)
150
+ return _DatabaseAccessErrorMessage(output)
151
+
152
+
153
+ def database_path_from_mime_data(mime_data):
154
+ """
155
+ Return a dropped local .db path, if the drop contains exactly one.
156
+
157
+ """
158
+ if not mime_data.hasUrls():
159
+ return None
160
+
161
+ urls = mime_data.urls()
162
+ if len(urls) != 1:
163
+ return None
164
+
165
+ url = urls[0]
166
+ if not url.isLocalFile():
167
+ return None
168
+
169
+ path = os.path.normpath(url.toLocalFile())
170
+ if os.path.isfile(path) and path.lower().endswith(".db"):
171
+ return path
172
+
173
+ return None
174
+
175
+
176
+ def database_access_error(
177
+ database_path,
178
+ timeout=DATABASE_ACCESS_TIMEOUT_SECONDS,
179
+ *,
180
+ expected_database_identity=None,
181
+ cancelled_callback=None,
182
+ deadline=None,
183
+ ):
184
+ """
185
+ Return an error message if a database cannot be opened promptly.
186
+
187
+ QCoDeS initialisation can block inside SQLite when another process or a
188
+ cloud-sync provider holds the database. Probe in a short-lived interpreter
189
+ first so a stuck access check can be timed out without freezing qPlot.
190
+
191
+ """
192
+ if cancelled_callback is not None and cancelled_callback():
193
+ raise InterruptedError("Database access check cancelled.")
194
+ if deadline is not None and monotonic() >= deadline:
195
+ raise TimeoutError("Database access check deadline exceeded.")
196
+
197
+ # The child has a fresh replacement registry. Carry the identity observed
198
+ # before its launch so it cannot combine a later main-file replacement
199
+ # with a retained WAL sidecar.
200
+ if expected_database_identity is None:
201
+ expected_database_identity = database_file_identity(database_path)
202
+ ignore_unpaired_wal = replacement_wal_is_quarantined(database_path)
203
+ timeout = float(timeout)
204
+ if deadline is not None:
205
+ timeout = min(timeout, max(0.0, float(deadline) - monotonic()))
206
+ probe = (
207
+ "import json\n"
208
+ "import sys\n"
209
+ "from time import monotonic\n"
210
+ "from qplot.datahandling.readonly import probe_read_only_database\n"
211
+ "try:\n"
212
+ " expected_database_identity = json.loads(sys.argv[4])\n"
213
+ " if expected_database_identity is not None:\n"
214
+ " expected_database_identity = tuple(expected_database_identity)\n"
215
+ " deadline = monotonic() + max(0.0, float(sys.argv[3]))\n"
216
+ " probe_read_only_database(\n"
217
+ " sys.argv[1],\n"
218
+ " ignore_unpaired_wal=(sys.argv[2] == '1'),\n"
219
+ " expected_database_identity=expected_database_identity,\n"
220
+ " deadline=deadline,\n"
221
+ " _check_sqlite_lock_bytes=True,\n"
222
+ " )\n"
223
+ "except Exception as err:\n"
224
+ " payload = {'type': type(err).__name__, 'message': str(err)}\n"
225
+ f" print('{_DATABASE_ACCESS_ERROR_PREFIX}' + json.dumps(payload), "
226
+ "file=sys.stderr)\n"
227
+ " raise SystemExit(1)\n"
228
+ )
229
+
230
+ command = [
231
+ sys.executable,
232
+ "-c",
233
+ probe,
234
+ database_path,
235
+ "1" if ignore_unpaired_wal else "0",
236
+ repr(timeout),
237
+ json.dumps(expected_database_identity),
238
+ ]
239
+
240
+ try:
241
+ if cancelled_callback is None:
242
+ result = subprocess.run(
243
+ command,
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=timeout,
247
+ check=False,
248
+ )
249
+ returncode = result.returncode
250
+ stdout = result.stdout
251
+ stderr = result.stderr
252
+ else:
253
+ process = subprocess.Popen(
254
+ command,
255
+ stdout=subprocess.PIPE,
256
+ stderr=subprocess.PIPE,
257
+ text=True,
258
+ )
259
+ probe_started_at = perf_counter()
260
+ while True:
261
+ if cancelled_callback():
262
+ process.kill()
263
+ process.communicate()
264
+ raise InterruptedError("Database access check cancelled.")
265
+ remaining = timeout - (perf_counter() - probe_started_at)
266
+ if remaining <= 0:
267
+ process.kill()
268
+ process.communicate()
269
+ raise subprocess.TimeoutExpired(command, timeout)
270
+ try:
271
+ stdout, stderr = process.communicate(
272
+ timeout=min(0.05, remaining),
273
+ )
274
+ except subprocess.TimeoutExpired:
275
+ continue
276
+ returncode = process.returncode
277
+ break
278
+ except InterruptedError:
279
+ raise
280
+ except subprocess.TimeoutExpired:
281
+ if deadline is not None and monotonic() >= deadline:
282
+ raise TimeoutError("Database access check deadline exceeded.") from None
283
+ return (
284
+ f"Timed out after {timeout:g} s while checking database access. "
285
+ "The database may be locked by another qPlot, QCoDeS, Python, or "
286
+ "notebook process, or blocked by cloud sync."
287
+ )
288
+ except OSError as err:
289
+ if cancelled_callback is not None and cancelled_callback():
290
+ raise InterruptedError("Database access check cancelled.") from err
291
+ return str(err)
292
+
293
+ if cancelled_callback is not None and cancelled_callback():
294
+ raise InterruptedError("Database access check cancelled.")
295
+
296
+ if returncode == 0:
297
+ return None
298
+
299
+ details = _database_access_error_message(stderr or stdout)
300
+ if not details:
301
+ details = _DatabaseAccessErrorMessage(
302
+ f"SQLite access probe exited with code {returncode}."
303
+ )
304
+ if (
305
+ deadline is not None
306
+ and details.error_type == TimeoutError.__name__
307
+ ):
308
+ raise TimeoutError(str(details))
309
+ return details
310
+
311
+
312
+ def database_cloud_storage_label(database_path):
313
+ """
314
+ Returns a user-facing cloud provider label when the path looks cloud-backed.
315
+
316
+ """
317
+ path = os.path.abspath(str(database_path or ""))
318
+ lower_path = path.lower()
319
+ if "onedrive" in lower_path:
320
+ return "OneDrive"
321
+ if "dropbox" in lower_path:
322
+ return "Dropbox"
323
+ if "google drive" in lower_path:
324
+ return "Google Drive"
325
+ if f"{os.sep}box{os.sep}" in lower_path:
326
+ return "Box"
327
+ if f"{os.sep}library{os.sep}cloudstorage{os.sep}" in lower_path:
328
+ return "cloud storage"
329
+ return None
330
+
331
+
332
+ def database_is_likely_cloud_placeholder(database_path):
333
+ """
334
+ Returns true when a database appears to be a cloud placeholder.
335
+
336
+ """
337
+ listxattr = getattr(os, "listxattr", None)
338
+ if listxattr is None:
339
+ attributes = []
340
+ else:
341
+ try:
342
+ attributes = listxattr(database_path)
343
+ except OSError:
344
+ attributes = []
345
+
346
+ for attribute in attributes:
347
+ if any(marker in attribute for marker in CLOUD_PLACEHOLDER_XATTR_MARKERS):
348
+ return True
349
+
350
+ if database_cloud_storage_label(database_path) is None:
351
+ return False
352
+
353
+ try:
354
+ info = os.stat(database_path)
355
+ except OSError:
356
+ return False
357
+
358
+ logical_size = getattr(info, "st_size", 0)
359
+ file_attributes = getattr(info, "st_file_attributes", None)
360
+ if file_attributes is not None:
361
+ return bool(file_attributes & WINDOWS_CLOUD_PLACEHOLDER_ATTRIBUTES)
362
+
363
+ blocks = getattr(info, "st_blocks", None)
364
+ if blocks is None:
365
+ return False
366
+
367
+ allocated_size = blocks * 512
368
+ return logical_size > 0 and allocated_size == 0
369
+
370
+
371
+ def prefetch_database_file(
372
+ database_path,
373
+ status_callback=None,
374
+ chunk_size=DATABASE_CLOUD_SYNC_CHUNK_BYTES,
375
+ status_interval=DATABASE_CLOUD_SYNC_STATUS_INTERVAL,
376
+ ):
377
+ """
378
+ Reads a database sequentially to trigger cloud Files-On-Demand hydration.
379
+
380
+ """
381
+ total_bytes = os.path.getsize(database_path)
382
+ if total_bytes <= 0:
383
+ return 0
384
+
385
+ provider = database_cloud_storage_label(database_path) or "cloud storage"
386
+ bytes_read = 0
387
+ last_status = 0.0
388
+
389
+ def emit_status(force=False):
390
+ nonlocal last_status
391
+ if status_callback is None:
392
+ return
393
+
394
+ now = perf_counter()
395
+ if not force and now - last_status < status_interval:
396
+ return
397
+
398
+ percent = min(100.0, (bytes_read / total_bytes) * 100.0)
399
+ status_callback(
400
+ f"Waiting for {provider} sync... {percent:.0f}% available"
401
+ )
402
+ last_status = now
403
+
404
+ emit_status(force=True)
405
+ with open(database_path, "rb") as handle:
406
+ while True:
407
+ chunk = handle.read(chunk_size)
408
+ if not chunk:
409
+ break
410
+ bytes_read += len(chunk)
411
+ emit_status()
412
+
413
+ emit_status(force=True)
414
+ return bytes_read
415
+
416
+
417
+ def prefetch_database_file_with_timeout(
418
+ database_path,
419
+ timeout=DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS,
420
+ status_callback=None,
421
+ cancelled_callback=None,
422
+ ):
423
+ """
424
+ Runs cloud prefetch in a subprocess so stalled providers can be timed out.
425
+
426
+ """
427
+ timeout = float(timeout)
428
+ provider = database_cloud_storage_label(database_path) or "cloud storage"
429
+ if status_callback is not None:
430
+ status_callback(f"Waiting for {provider} sync...")
431
+
432
+ script = _database_prefetch_script()
433
+
434
+ try:
435
+ process = subprocess.Popen(
436
+ [sys.executable, "-c", script, database_path],
437
+ stdout=subprocess.PIPE,
438
+ stderr=subprocess.PIPE,
439
+ text=True,
440
+ bufsize=1,
441
+ )
442
+ except OSError as err:
443
+ raise RuntimeError(str(err)) from err
444
+
445
+ output_queue = queue.Queue()
446
+ stdout_thread = threading.Thread(
447
+ target=_read_prefetch_pipe,
448
+ args=(process.stdout, "stdout", output_queue),
449
+ daemon=True,
450
+ )
451
+ stderr_thread = threading.Thread(
452
+ target=_read_prefetch_pipe,
453
+ args=(process.stderr, "stderr", output_queue),
454
+ daemon=True,
455
+ )
456
+ stdout_thread.start()
457
+ stderr_thread.start()
458
+
459
+ start = perf_counter()
460
+ stderr_lines = []
461
+ bytes_read = 0
462
+
463
+ try:
464
+ while True:
465
+ try:
466
+ stream, line = output_queue.get(timeout=0.1)
467
+ except queue.Empty:
468
+ stream = line = None
469
+
470
+ if stream == "stdout":
471
+ parsed = _handle_prefetch_stdout_line(line, provider, status_callback)
472
+ if parsed is not None:
473
+ bytes_read = parsed
474
+ elif stream == "stderr" and line:
475
+ stderr_lines.append(line)
476
+
477
+ if process.poll() is not None:
478
+ stdout_thread.join(timeout=0.2)
479
+ stderr_thread.join(timeout=0.2)
480
+ parsed = _drain_prefetch_queue(
481
+ output_queue,
482
+ provider,
483
+ status_callback,
484
+ stderr_lines,
485
+ )
486
+ if parsed is not None:
487
+ bytes_read = parsed
488
+ break
489
+
490
+ if cancelled_callback is not None and cancelled_callback():
491
+ process.kill()
492
+ process.wait()
493
+ raise InterruptedError("Database load cancelled.")
494
+
495
+ if perf_counter() - start > timeout:
496
+ process.kill()
497
+ process.wait()
498
+ raise TimeoutError(
499
+ f"Timed out after {timeout:g} s while waiting for "
500
+ f"{provider} to download the database. Check that "
501
+ f"{provider} is running and signed in, or mark the "
502
+ "database folder as always available on this device."
503
+ )
504
+ finally:
505
+ for pipe in (process.stdout, process.stderr):
506
+ if pipe is not None:
507
+ pipe.close()
508
+
509
+ stdout_thread.join(timeout=0.5)
510
+ stderr_thread.join(timeout=0.5)
511
+
512
+ if process.returncode != 0:
513
+ details = "\n".join(line for line in stderr_lines if line)
514
+ if not details:
515
+ details = f"Cloud sync prefetch exited with code {process.returncode}."
516
+ raise RuntimeError(details)
517
+
518
+ return bytes_read
519
+
520
+
521
+ def _database_prefetch_script():
522
+ """
523
+ Returns the isolated cloud-hydration script used by the load worker.
524
+
525
+ Cloud providers can report a transient OS-level TimeoutError while a
526
+ placeholder is being materialised. The subprocess keeps retrying those
527
+ reads; its parent remains responsible for the overall timeout and cancel.
528
+
529
+ """
530
+ return (
531
+ "import errno, os, sys, time\n"
532
+ f"prefix = {DATABASE_PREFETCH_STATUS_PREFIX!r}\n"
533
+ f"chunk_size = {DATABASE_CLOUD_SYNC_CHUNK_BYTES!r}\n"
534
+ f"status_interval = {DATABASE_CLOUD_SYNC_STATUS_INTERVAL!r}\n"
535
+ f"retry_interval = {DATABASE_CLOUD_SYNC_RETRY_INTERVAL!r}\n"
536
+ "transient_errors = {errno.ETIMEDOUT, errno.ECANCELED}\n"
537
+ "path = sys.argv[1]\n"
538
+ "total = os.path.getsize(path)\n"
539
+ "read = 0\n"
540
+ "last = 0.0\n"
541
+ "def report(force=False):\n"
542
+ " global last\n"
543
+ " if total <= 0:\n"
544
+ " percent = 100.0\n"
545
+ " else:\n"
546
+ " percent = min(100.0, (read / total) * 100.0)\n"
547
+ " now = time.perf_counter()\n"
548
+ " if force or now - last >= status_interval:\n"
549
+ " print(prefix + f'{percent:.0f}', flush=True)\n"
550
+ " last = now\n"
551
+ "report(True)\n"
552
+ "while True:\n"
553
+ " try:\n"
554
+ " handle = open(path, 'rb')\n"
555
+ " break\n"
556
+ " except OSError as error:\n"
557
+ " if error.errno not in transient_errors:\n"
558
+ " raise\n"
559
+ " report(True)\n"
560
+ " time.sleep(retry_interval)\n"
561
+ "with handle:\n"
562
+ " while True:\n"
563
+ " try:\n"
564
+ " chunk = handle.read(chunk_size)\n"
565
+ " except OSError as error:\n"
566
+ " if error.errno not in transient_errors:\n"
567
+ " raise\n"
568
+ " report(True)\n"
569
+ " time.sleep(retry_interval)\n"
570
+ " continue\n"
571
+ " if not chunk:\n"
572
+ " break\n"
573
+ " read += len(chunk)\n"
574
+ " report(False)\n"
575
+ "report(True)\n"
576
+ "print(read, flush=True)\n"
577
+ )
578
+
579
+
580
+ def _read_prefetch_pipe(pipe, stream_name, output_queue):
581
+ if pipe is None:
582
+ return
583
+
584
+ for line in pipe:
585
+ output_queue.put((stream_name, line.rstrip()))
586
+
587
+
588
+ def _handle_prefetch_stdout_line(line, provider, status_callback):
589
+ if not line:
590
+ return None
591
+
592
+ if line.startswith(DATABASE_PREFETCH_STATUS_PREFIX):
593
+ percent = line[len(DATABASE_PREFETCH_STATUS_PREFIX):]
594
+ if status_callback is not None:
595
+ status_callback(f"Waiting for {provider} sync... {percent}% available")
596
+ return None
597
+
598
+ try:
599
+ return int(line)
600
+ except ValueError:
601
+ return None
602
+
603
+
604
+ def _drain_prefetch_queue(output_queue, provider, status_callback, stderr_lines):
605
+ bytes_read = None
606
+ while True:
607
+ try:
608
+ stream, line = output_queue.get_nowait()
609
+ except queue.Empty:
610
+ break
611
+
612
+ if stream == "stdout":
613
+ parsed = _handle_prefetch_stdout_line(line, provider, status_callback)
614
+ if parsed is not None:
615
+ bytes_read = parsed
616
+ elif stream == "stderr" and line:
617
+ stderr_lines.append(line)
618
+
619
+ return bytes_read
620
+
621
+
622
+ class DatabaseLoadSignals(QtCore.QObject):
623
+ """
624
+ Signals emitted by a background database load.
625
+
626
+ """
627
+ status = QtCore.pyqtSignal(int, str)
628
+ finished = QtCore.pyqtSignal(int, str, object, object)
629
+
630
+
631
+ class DatabaseDetailSignals(QtCore.QObject):
632
+ """
633
+ Signals emitted by progressive run-detail loading.
634
+
635
+ """
636
+ status = QtCore.pyqtSignal(int, str)
637
+ batch_ready = QtCore.pyqtSignal(int, str, object)
638
+ finished = QtCore.pyqtSignal(int, str, object)
639
+
640
+
641
+ class DatabaseSelectedRunSignals(QtCore.QObject):
642
+ """Signals for one generation-bound plain selected-run view."""
643
+
644
+ progress = QtCore.pyqtSignal(int, str, str, object)
645
+ finished = QtCore.pyqtSignal(int, str, str, object, object)
646
+
647
+
648
+ class _InterruptibleSqlWorker:
649
+ """Allows the GUI thread to interrupt an active read-only SQLite query."""
650
+
651
+ def _init_database_binding(
652
+ self,
653
+ database_path,
654
+ expected_database_instance=None,
655
+ deadline=None,
656
+ ):
657
+ """Retain the exact database instance accepted by the scheduler."""
658
+ supplied_instance = expected_database_instance is not None
659
+ requested_database_path = os.fspath(database_path)
660
+ if expected_database_instance is None:
661
+ expected_database_instance = database_instance(requested_database_path)
662
+ elif not isinstance(expected_database_instance, DatabaseInstance):
663
+ raise TypeError(
664
+ "expected_database_instance must be a DatabaseInstance or None"
665
+ )
666
+
667
+ self.database_instance = expected_database_instance
668
+ self.database_path = expected_database_instance.logical_path
669
+ self.requested_database_path = requested_database_path
670
+ # Signals are matched against the paths stored by the UI, which are
671
+ # logical_database_path values. Keeping the original spelling here
672
+ # makes an otherwise identical Windows path (for example ``D:`` vs
673
+ # ``d:``) look like a different database to signal consumers.
674
+ self._signal_database_path = requested_database_path
675
+ self._read_database_path = (
676
+ expected_database_instance.logical_path
677
+ if supplied_instance
678
+ else requested_database_path
679
+ )
680
+ self.logical_database_path = expected_database_instance.logical_path
681
+ self.resolved_database_path = expected_database_instance.resolved_path
682
+ self.database_identity = expected_database_instance.identity
683
+ self.sidecar_identities = expected_database_instance.sidecar_identities
684
+ self.deadline = deadline
685
+
686
+
687
+ def _expected_database_identity_kwargs(self):
688
+ """Keep legacy direct worker callers working when identity is unavailable."""
689
+ if self.database_identity is None:
690
+ return {}
691
+ return {"expected_database_identity": self.database_identity}
692
+
693
+
694
+ def _read_control_kwargs(self):
695
+ """Forward cancellation, identity, and an optional absolute deadline."""
696
+ kwargs = self._expected_database_identity_kwargs()
697
+ kwargs["cancelled_callback"] = self._cancelled.is_set
698
+ if self.deadline is not None:
699
+ kwargs["deadline"] = self.deadline
700
+ return kwargs
701
+
702
+
703
+ def _require_current_database_instance(self):
704
+ """Reject results if the worker's full logical source was replaced."""
705
+ accepted_instance = self.database_instance
706
+ current_instance = database_instance(accepted_instance.logical_path)
707
+ if (
708
+ not database_instances_differ(accepted_instance, current_instance)
709
+ and accepted_instance.sidecar_identities.issubset(
710
+ current_instance.sidecar_identities
711
+ )
712
+ ):
713
+ # SQLite may create WAL/SHM after the controller first accepts the
714
+ # main file. Permit that first directional appearance, but bind it
715
+ # immediately so a later removal, rotation, or ABA substitution is
716
+ # rejected by the worker's next pre-read/pre-publication guard.
717
+ if (
718
+ accepted_instance.sidecar_identities
719
+ != current_instance.sidecar_identities
720
+ ):
721
+ self.database_instance = current_instance
722
+ self.sidecar_identities = current_instance.sidecar_identities
723
+ return
724
+
725
+ quarantine_wal_for_replaced_database(accepted_instance.logical_path)
726
+ raise DatabaseInstanceChangedError(
727
+ "The database or one of its SQLite sidecars was replaced while "
728
+ "qPlot was loading background metadata."
729
+ )
730
+
731
+
732
+ @staticmethod
733
+ def _close_batch_iterator(iterator):
734
+ """Close a suspended SQL generator immediately on every early exit."""
735
+ close = getattr(iterator, "close", None)
736
+ if callable(close):
737
+ close()
738
+
739
+ def _init_sql_interrupt(self):
740
+ self._sql_connection_lock = threading.Lock()
741
+ self._publication_lock = threading.RLock()
742
+ self._sql_connection = None
743
+ self._trusted_request_lock = threading.Lock()
744
+ self._trusted_request = None
745
+
746
+
747
+ def _cancel_read(self):
748
+ """Linearize cancellation against worker result publication."""
749
+ with self._publication_lock:
750
+ self._cancelled.set()
751
+ with self._trusted_request_lock:
752
+ trusted_request = self._trusted_request
753
+ if trusted_request is not None:
754
+ trusted_request.cancel()
755
+ self._interrupt_sql()
756
+
757
+
758
+ def _interrupt_sql(self):
759
+ with self._sql_connection_lock:
760
+ connection = self._sql_connection
761
+ if connection is not None:
762
+ try:
763
+ connection.interrupt()
764
+ except Exception:
765
+ pass
766
+
767
+
768
+ def _set_sql_connection(self, connection):
769
+ with self._sql_connection_lock:
770
+ self._sql_connection = connection
771
+ if connection is not None and self._cancelled.is_set():
772
+ self._interrupt_sql()
773
+
774
+
775
+ def _set_trusted_request(self, request):
776
+ with self._trusted_request_lock:
777
+ self._trusted_request = request
778
+ if request is not None and self._cancelled.is_set():
779
+ request.cancel()
780
+
781
+
782
+ def _wait_trusted_request(self, request):
783
+ self._set_trusted_request(request)
784
+ try:
785
+ return request.wait()
786
+ finally:
787
+ with self._trusted_request_lock:
788
+ if self._trusted_request is request:
789
+ self._trusted_request = None
790
+
791
+
792
+ class DatabaseRefreshSignals(QtCore.QObject):
793
+ """Signals emitted by a coalesced main-window refresh worker."""
794
+
795
+ new_runs_ready = QtCore.pyqtSignal(int, str, object)
796
+ finished = QtCore.pyqtSignal(int, str, object, object, object)
797
+
798
+
799
+ class DatabaseRefreshWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
800
+ """Fetch new runs and live-run status without blocking the GUI thread."""
801
+
802
+ def __init__(
803
+ self,
804
+ generation,
805
+ database_path,
806
+ last_run_id,
807
+ watched_runs,
808
+ *,
809
+ expected_database_instance=None,
810
+ deadline=None,
811
+ trusted_service=None,
812
+ require_publication_ack=False,
813
+ derived_coordinator_owned=False,
814
+ ):
815
+ super().__init__()
816
+ self.signals = DatabaseRefreshSignals()
817
+ self.generation = generation
818
+ self._init_database_binding(
819
+ database_path,
820
+ expected_database_instance,
821
+ deadline,
822
+ )
823
+ self.last_run_id = int(last_run_id or 0)
824
+ self.watched_runs = list(watched_runs or [])
825
+ self._cancelled = threading.Event()
826
+ self._init_sql_interrupt()
827
+ self.trusted_service = trusted_service
828
+ self._require_publication_ack = bool(require_publication_ack)
829
+ self._derived_coordinator_owned = bool(derived_coordinator_owned)
830
+ self._new_runs_publication_ack = threading.Event()
831
+ self._new_runs_publication_lock = threading.Lock()
832
+ self._new_runs_publication_error: BaseException | None = None
833
+
834
+
835
+ def cancel(self):
836
+ self._cancel_read()
837
+ self._new_runs_publication_ack.set()
838
+ if self.trusted_service is not None:
839
+ self.trusted_service.retry_refresh_statuses()
840
+
841
+
842
+ def acknowledge_new_runs_published(self):
843
+ """Release the off-GUI refresh only after its basic page is visible."""
844
+ with self._new_runs_publication_lock:
845
+ self._new_runs_publication_error = None
846
+ self._new_runs_publication_ack.set()
847
+
848
+
849
+ def reject_new_runs_publication(self, error):
850
+ """Fail closed when a trusted basic page could not reach the GUI."""
851
+ with self._new_runs_publication_lock:
852
+ self._new_runs_publication_error = error
853
+ # The adapter cursor already accepted this bounded page. Retire the
854
+ # session instead of letting a later refresh omit rows the GUI failed
855
+ # to publish. close_async is deliberately prompt and GUI-safe.
856
+ if self.trusted_service is not None:
857
+ self.trusted_service.close_async()
858
+ self._new_runs_publication_ack.set()
859
+
860
+
861
+ def run(self):
862
+ new_runs = {}
863
+ statuses = {}
864
+ trusted_refresh_succeeded = False
865
+ try:
866
+ if self._cancelled.is_set():
867
+ return
868
+
869
+ self._require_current_database_instance()
870
+ if self.trusted_service is not None:
871
+ new_runs, statuses = self._run_trusted_refresh()
872
+ self._require_current_database_instance()
873
+ self._emit_finished({}, statuses, None)
874
+ trusted_refresh_succeeded = not self._cancelled.is_set()
875
+ return
876
+ new_runs = find_new_runs(
877
+ self.last_run_id,
878
+ database_path=self._read_database_path,
879
+ connection_callback=self._set_sql_connection,
880
+ **self._read_control_kwargs(),
881
+ ) or {}
882
+ for guid in self.watched_runs:
883
+ if self._cancelled.is_set():
884
+ return
885
+ self._require_current_database_instance()
886
+ status = get_run_status(
887
+ guid,
888
+ database_path=self._read_database_path,
889
+ include_storage_bytes=False,
890
+ connection_callback=self._set_sql_connection,
891
+ **self._read_control_kwargs(),
892
+ )
893
+ if status:
894
+ statuses[guid] = status
895
+ self._require_current_database_instance()
896
+ except InterruptedError:
897
+ return
898
+ except DatabaseInstanceChangedError as err:
899
+ if self._cancelled.is_set():
900
+ return
901
+ self._emit_finished({}, {}, err)
902
+ return
903
+ except Exception as err:
904
+ if self._cancelled.is_set():
905
+ return
906
+ log_exception("Database refresh worker failed", err, __name__)
907
+ self._emit_finished(new_runs, statuses, err)
908
+ return
909
+ finally:
910
+ if self.trusted_service is not None and not trusted_refresh_succeeded:
911
+ # A header accepts data_version before pages and statuses are
912
+ # collected. Any later failure/cancellation must leave a retry
913
+ # even if the writer has made its final commit already.
914
+ self.trusted_service.retry_refresh_statuses()
915
+
916
+ self._emit_finished(new_runs, statuses, None)
917
+
918
+
919
+ def _run_trusted_refresh(self):
920
+ header = self._wait_trusted_request(
921
+ self.trusted_service.submit_refresh(
922
+ self.last_run_id,
923
+ deadline=self.deadline,
924
+ )
925
+ )
926
+ new_runs = {}
927
+ if header.data_version_changed:
928
+ cursor = header.prior_run_id_watermark
929
+ while True:
930
+ if self._cancelled.is_set():
931
+ raise InterruptedError("Database refresh cancelled.")
932
+ page = self._wait_trusted_request(
933
+ self.trusted_service.submit_basic_page(
934
+ cursor,
935
+ header.run_id_watermark,
936
+ priority=TrustedReadPriority.REFRESH,
937
+ deadline=self.deadline,
938
+ )
939
+ )
940
+ page_runs = run_records_as_dict(page.runs)
941
+ new_runs.update(page_runs)
942
+ if page_runs:
943
+ self._emit_new_runs_ready(page_runs)
944
+ if page.complete:
945
+ break
946
+ cursor = page.next_run_id
947
+
948
+ statuses = {}
949
+ if not header.data_version_changed:
950
+ return new_runs, statuses
951
+ for watched in self.watched_runs:
952
+ if self._cancelled.is_set():
953
+ raise InterruptedError("Database refresh cancelled.")
954
+ if (
955
+ isinstance(watched, (tuple, list))
956
+ and len(watched) in {2, 3}
957
+ ):
958
+ if len(watched) == 2:
959
+ run_id, guid = watched
960
+ category = "remaining"
961
+ else:
962
+ run_id, guid, category = watched
963
+ else:
964
+ continue
965
+ try:
966
+ run_id = int(run_id)
967
+ except (TypeError, ValueError):
968
+ continue
969
+ if category == "selected":
970
+ cheap_priority = TrustedReadPriority.SELECTED_CHEAP
971
+ elif category == "visible":
972
+ cheap_priority = TrustedReadPriority.VISIBLE_CHEAP
973
+ else:
974
+ cheap_priority = TrustedReadPriority.REMAINING_CHEAP
975
+ cheap = self._wait_trusted_request(
976
+ self.trusted_service.submit_cheap_run(
977
+ run_id,
978
+ priority=cheap_priority,
979
+ deadline=self.deadline,
980
+ )
981
+ )
982
+ status = cheap.as_dict()
983
+ if not self._derived_coordinator_owned:
984
+ if category == "selected":
985
+ expensive_priority = TrustedReadPriority.SELECTED_EXPENSIVE
986
+ elif category == "visible":
987
+ expensive_priority = TrustedReadPriority.VISIBLE_EXPENSIVE
988
+ else:
989
+ expensive_priority = TrustedReadPriority.REMAINING_EXPENSIVE
990
+ expensive = self._wait_trusted_request(
991
+ self.trusted_service.submit_expensive_run(
992
+ run_id,
993
+ priority=expensive_priority,
994
+ deadline=self.deadline,
995
+ )
996
+ )
997
+ status.update(expensive.as_dict())
998
+ statuses[str(guid)] = status
999
+ return new_runs, statuses
1000
+
1001
+
1002
+ def _emit_new_runs_ready(self, new_runs):
1003
+ self._require_current_database_instance()
1004
+ new_runs = _bounded_runs_publication(new_runs)
1005
+ if self._require_publication_ack:
1006
+ with self._new_runs_publication_lock:
1007
+ self._new_runs_publication_error = None
1008
+ self._new_runs_publication_ack.clear()
1009
+ with self._publication_lock:
1010
+ if self._cancelled.is_set():
1011
+ return
1012
+ self.signals.new_runs_ready.emit(
1013
+ self.generation,
1014
+ self._signal_database_path,
1015
+ new_runs,
1016
+ )
1017
+ if not self._require_publication_ack:
1018
+ return
1019
+
1020
+ acknowledgement_deadline = monotonic() + 10.0
1021
+ if self.deadline is not None:
1022
+ acknowledgement_deadline = min(
1023
+ acknowledgement_deadline,
1024
+ self.deadline,
1025
+ )
1026
+ while not self._new_runs_publication_ack.wait(0.05):
1027
+ if self._cancelled.is_set():
1028
+ raise InterruptedError("Database refresh cancelled.")
1029
+ if monotonic() >= acknowledgement_deadline:
1030
+ raise TimeoutError(
1031
+ "The GUI did not publish a trusted basic-run page in time."
1032
+ )
1033
+ with self._new_runs_publication_lock:
1034
+ publication_error = self._new_runs_publication_error
1035
+ if publication_error is not None:
1036
+ raise RuntimeError(
1037
+ "The GUI could not publish a trusted basic-run page; the "
1038
+ "accepted service retirement was requested to prevent an "
1039
+ "omitted run."
1040
+ ) from publication_error
1041
+ if self._cancelled.is_set():
1042
+ raise InterruptedError("Database refresh cancelled.")
1043
+
1044
+
1045
+ def _emit_finished(self, new_runs, statuses, error):
1046
+ if not isinstance(error, DatabaseInstanceChangedError):
1047
+ try:
1048
+ self._require_current_database_instance()
1049
+ except DatabaseInstanceChangedError as err:
1050
+ new_runs = {}
1051
+ statuses = {}
1052
+ error = err
1053
+ new_runs = _bounded_runs_publication(new_runs)
1054
+ statuses = _bounded_runs_publication(statuses)
1055
+ error = _bounded_worker_error(error)
1056
+ with self._publication_lock:
1057
+ if self._cancelled.is_set():
1058
+ return
1059
+ try:
1060
+ self.signals.finished.emit(
1061
+ self.generation,
1062
+ self._signal_database_path,
1063
+ new_runs,
1064
+ statuses,
1065
+ error,
1066
+ )
1067
+ except RuntimeError as err:
1068
+ message = str(err)
1069
+ if not (
1070
+ "wrapped C/C++ object" in message
1071
+ and "has been deleted" in message
1072
+ ):
1073
+ raise
1074
+
1075
+
1076
+ class DatabaseLoadWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
1077
+ """
1078
+ Loads database metadata away from the GUI thread.
1079
+
1080
+ The worker is responsible for the blocking parts of opening a database:
1081
+ access probing, QCoDeS initialisation, and collecting the run table
1082
+ metadata. Widget updates stay in MainWindow on the GUI thread.
1083
+
1084
+ """
1085
+
1086
+ def __init__(
1087
+ self,
1088
+ generation,
1089
+ database_path,
1090
+ cloud_sync_timeout=DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS,
1091
+ *,
1092
+ expected_database_instance=None,
1093
+ deadline=None,
1094
+ trusted_service=None,
1095
+ ):
1096
+ super().__init__()
1097
+ self.signals = DatabaseLoadSignals()
1098
+ self.generation = generation
1099
+ self._init_database_binding(
1100
+ database_path,
1101
+ expected_database_instance,
1102
+ deadline,
1103
+ )
1104
+ self.cloud_sync_timeout = cloud_sync_timeout
1105
+ self._cancelled = threading.Event()
1106
+ self._init_sql_interrupt()
1107
+ self._owns_trusted_service = trusted_service is None
1108
+ self.trusted_service = trusted_service or TrustedLiveReadService(
1109
+ self._read_database_path,
1110
+ expected_database_instance=self.database_instance,
1111
+ session_generation=max(1, int(generation)),
1112
+ )
1113
+ self.access_mode = None
1114
+ self.fallback_reason = None
1115
+
1116
+
1117
+ def cancel(self):
1118
+ """
1119
+ Marks this load as cancelled so later phases do not run.
1120
+
1121
+ """
1122
+ self._cancel_read()
1123
+ if self._owns_trusted_service:
1124
+ self.trusted_service.close_async()
1125
+
1126
+
1127
+ def run(self):
1128
+ try:
1129
+ if self._is_cancelled():
1130
+ return
1131
+
1132
+ self._require_current_database_instance()
1133
+ if database_is_likely_cloud_placeholder(self._read_database_path):
1134
+ self._prefetch_cloud_file()
1135
+ if self._is_cancelled():
1136
+ return
1137
+ self._require_current_database_instance()
1138
+ try:
1139
+ runs = self._load_trusted_runs()
1140
+ self.access_mode = TRUSTED_LIVE_MODE
1141
+ except Exception as trusted_error:
1142
+ if self._is_cancelled():
1143
+ return
1144
+ if (
1145
+ self.trusted_service.accepted
1146
+ or not trusted_open_failure_allows_snapshot_fallback(
1147
+ trusted_error
1148
+ )
1149
+ ):
1150
+ raise
1151
+ self.fallback_reason = type(trusted_error).__name__
1152
+ self._close_trusted_before_fallback()
1153
+ runs = self._load_snapshot_fallback()
1154
+ self.access_mode = SNAPSHOT_FALLBACK_MODE
1155
+ if self._is_cancelled():
1156
+ return
1157
+ self._require_current_database_instance()
1158
+ except (InterruptedError, TrustedReadRequestCancelledError):
1159
+ return
1160
+ except DatabaseInstanceChangedError as err:
1161
+ if self._is_cancelled():
1162
+ return
1163
+ self._emit_finished({}, err)
1164
+ return
1165
+ except Exception as err:
1166
+ if self._is_cancelled():
1167
+ return
1168
+ log_exception("Database load worker failed", err, __name__)
1169
+ provider = database_cloud_storage_label(self._read_database_path)
1170
+ if (
1171
+ provider
1172
+ and (
1173
+ isinstance(err, (TrustedLiveSourceIOError, TimeoutError))
1174
+ or (
1175
+ isinstance(err, OSError)
1176
+ and err.errno in {errno.EIO, errno.ETIMEDOUT}
1177
+ )
1178
+ )
1179
+ ):
1180
+ sync_app = provider if provider != "cloud storage" else "your cloud sync app"
1181
+ err = RuntimeError(
1182
+ f"The database is stored in {provider}, and qPlot could not "
1183
+ "read it or its auxiliary files in time. The cloud sync "
1184
+ "app may not be running, or some files may not be "
1185
+ "available on this device.\n\n"
1186
+ f"Start or restart {sync_app}, check that it is signed in, "
1187
+ "and wait for the database folder to finish downloading. "
1188
+ "Mark the whole folder as always available on this device, "
1189
+ "then open the database again in qPlot.\n\n"
1190
+ f"Technical details: {err}"
1191
+ )
1192
+ self._emit_finished({}, err)
1193
+ return
1194
+
1195
+ self._emit_finished(runs, None)
1196
+
1197
+
1198
+ def _load_trusted_runs(self):
1199
+ self._emit_status("Opening trusted live database...")
1200
+ header = self._wait_trusted_request(
1201
+ self.trusted_service.submit_bootstrap(deadline=self.deadline)
1202
+ )
1203
+ self._emit_status("Loading basic run list...")
1204
+ runs = {}
1205
+ cursor = 0
1206
+ while True:
1207
+ if self._is_cancelled():
1208
+ raise InterruptedError("Database load cancelled.")
1209
+ page = self._wait_trusted_request(
1210
+ self.trusted_service.submit_basic_page(
1211
+ cursor,
1212
+ header.run_id_watermark,
1213
+ priority=TrustedReadPriority.BOOTSTRAP,
1214
+ deadline=self.deadline,
1215
+ )
1216
+ )
1217
+ runs.update(run_records_as_dict(page.runs))
1218
+ if page.complete:
1219
+ break
1220
+ if page.next_run_id <= cursor:
1221
+ raise RuntimeError(
1222
+ "Trusted run-list pagination did not advance its cursor."
1223
+ )
1224
+ cursor = page.next_run_id
1225
+ return runs
1226
+
1227
+
1228
+ def _close_trusted_before_fallback(self):
1229
+ self.trusted_service.close_async()
1230
+ if not self.trusted_service.wait_closed(10):
1231
+ raise TimeoutError(
1232
+ "The unavailable trusted reader did not retire before fallback."
1233
+ )
1234
+ if self.trusted_service.close_error is not None:
1235
+ raise self.trusted_service.close_error
1236
+
1237
+
1238
+ def _load_snapshot_fallback(self):
1239
+ self._emit_status("Checking database access for snapshot fallback...")
1240
+ access_error = database_access_error(
1241
+ self._read_database_path,
1242
+ **self._read_control_kwargs(),
1243
+ )
1244
+ if self._is_cancelled():
1245
+ raise InterruptedError("Database load cancelled.")
1246
+
1247
+ if (
1248
+ access_error
1249
+ and getattr(access_error, "error_type", None) not in {
1250
+ DatabaseInstanceChangedError.__name__,
1251
+ UnverifiableDatabaseWalError.__name__,
1252
+ }
1253
+ and database_cloud_storage_label(self._read_database_path)
1254
+ and os.path.isfile(self._read_database_path)
1255
+ ):
1256
+ self._prefetch_cloud_file()
1257
+ if self._is_cancelled():
1258
+ raise InterruptedError("Database load cancelled.")
1259
+ self._emit_status("Checking database access for snapshot fallback...")
1260
+ access_error = database_access_error(
1261
+ self._read_database_path,
1262
+ **self._read_control_kwargs(),
1263
+ )
1264
+
1265
+ if access_error:
1266
+ error_type = getattr(access_error, "error_type", None)
1267
+ if error_type == DatabaseInstanceChangedError.__name__:
1268
+ raise DatabaseInstanceChangedError(access_error)
1269
+ if error_type == UnverifiableDatabaseWalError.__name__:
1270
+ raise UnverifiableDatabaseWalError(access_error)
1271
+ raise RuntimeError(access_error)
1272
+
1273
+ self._emit_status("Opening snapshot fallback read-only...")
1274
+ self._emit_status("Loading basic run list...")
1275
+ return get_runs_basic_via_sql(
1276
+ self._read_database_path,
1277
+ connection_callback=self._set_sql_connection,
1278
+ **self._read_control_kwargs(),
1279
+ ) or {}
1280
+
1281
+
1282
+ def _is_cancelled(self):
1283
+ return self._cancelled.is_set()
1284
+
1285
+
1286
+ def _emit_status(self, message):
1287
+ with self._publication_lock:
1288
+ if self._cancelled.is_set():
1289
+ return
1290
+ try:
1291
+ self.signals.status.emit(self.generation, message)
1292
+ except RuntimeError as err:
1293
+ if not self._qt_signal_was_deleted(err):
1294
+ raise
1295
+
1296
+
1297
+ def _emit_finished(self, runs, error):
1298
+ if not isinstance(error, DatabaseInstanceChangedError):
1299
+ try:
1300
+ self._require_current_database_instance()
1301
+ except DatabaseInstanceChangedError as err:
1302
+ runs = {}
1303
+ error = err
1304
+ if error is None:
1305
+ runs = _bounded_runs_publication(runs)
1306
+ else:
1307
+ runs = {}
1308
+ error = _bounded_worker_error(error)
1309
+ with self._publication_lock:
1310
+ if self._cancelled.is_set():
1311
+ return
1312
+ try:
1313
+ self.signals.finished.emit(
1314
+ self.generation,
1315
+ self._signal_database_path,
1316
+ runs,
1317
+ error,
1318
+ )
1319
+ except RuntimeError as err:
1320
+ if not self._qt_signal_was_deleted(err):
1321
+ raise
1322
+
1323
+
1324
+ def _qt_signal_was_deleted(self, err):
1325
+ message = str(err)
1326
+ return "wrapped C/C++ object" in message and "has been deleted" in message
1327
+
1328
+
1329
+ def _prefetch_cloud_file(self):
1330
+ timeout = self.cloud_sync_timeout
1331
+ if self.deadline is not None:
1332
+ remaining = self.deadline - monotonic()
1333
+ if remaining <= 0:
1334
+ raise TimeoutError("Database load deadline exceeded.")
1335
+ timeout = min(timeout, remaining)
1336
+ prefetch_database_file_with_timeout(
1337
+ self._read_database_path,
1338
+ timeout=timeout,
1339
+ status_callback=self._emit_status,
1340
+ cancelled_callback=self._is_cancelled,
1341
+ )
1342
+
1343
+
1344
+ class _PrioritizedRunWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
1345
+ """
1346
+ Base for background workers that need selected/visible run prioritisation.
1347
+
1348
+ """
1349
+
1350
+ _trusted_selected_priority = TrustedReadPriority.SELECTED_CHEAP
1351
+ _trusted_visible_priority = TrustedReadPriority.VISIBLE_CHEAP
1352
+ _trusted_remaining_priority = TrustedReadPriority.REMAINING_CHEAP
1353
+
1354
+ def __init__(
1355
+ self,
1356
+ generation,
1357
+ database_path,
1358
+ run_ids,
1359
+ *,
1360
+ expected_database_instance=None,
1361
+ deadline=None,
1362
+ trusted_service=None,
1363
+ ):
1364
+ super().__init__()
1365
+ self.signals = DatabaseDetailSignals()
1366
+ self.generation = generation
1367
+ self._init_database_binding(
1368
+ database_path,
1369
+ expected_database_instance,
1370
+ deadline,
1371
+ )
1372
+ self.run_ids = list(run_ids or [])
1373
+ self._default_run_order = {
1374
+ run_id: index
1375
+ for index, run_id in enumerate(self.run_ids)
1376
+ }
1377
+ self._cancelled = threading.Event()
1378
+ self._init_sql_interrupt()
1379
+ self._priority_lock = threading.Lock()
1380
+ self._priority_scores = {}
1381
+ self._promoted_run_ids = []
1382
+ self._trusted_active_run_id = None
1383
+ self._accepting_run_ids = True
1384
+ self.trusted_service = trusted_service
1385
+
1386
+
1387
+ def cancel(self):
1388
+ with self._priority_lock:
1389
+ self._accepting_run_ids = False
1390
+ self._cancel_read()
1391
+
1392
+
1393
+ def add_run_ids(self, run_ids):
1394
+ """Append trusted incremental work without replaying completed runs."""
1395
+ candidates = []
1396
+ for run_id in run_ids or ():
1397
+ try:
1398
+ candidate = int(run_id)
1399
+ except (TypeError, ValueError):
1400
+ continue
1401
+ if candidate > 0:
1402
+ candidates.append(candidate)
1403
+
1404
+ with self._priority_lock:
1405
+ if not self._accepting_run_ids or self._cancelled.is_set():
1406
+ return False
1407
+ known = {
1408
+ int(run_id)
1409
+ for run_id in self.run_ids
1410
+ if str(run_id).lstrip("-").isdigit()
1411
+ }
1412
+ additions = [run_id for run_id in candidates if run_id not in known]
1413
+ for run_id in additions:
1414
+ self._default_run_order[run_id] = len(self.run_ids)
1415
+ self.run_ids.append(run_id)
1416
+ known.add(run_id)
1417
+ return True
1418
+
1419
+
1420
+ def prioritize_run_ids(self, run_ids):
1421
+ normalised = []
1422
+ seen = set()
1423
+ for run_id in run_ids or []:
1424
+ run_id = self._normalise_run_id(run_id)
1425
+ if run_id is None or run_id in seen:
1426
+ continue
1427
+ normalised.append(run_id)
1428
+ seen.add(run_id)
1429
+
1430
+ with self._priority_lock:
1431
+ # This is a replacement snapshot of the selected/visible rows, not
1432
+ # an accumulating promotion log. Omitted rows immediately return
1433
+ # to their stable table order, and an empty snapshot clears every
1434
+ # prior viewport/selection promotion.
1435
+ base_score = -len(normalised)
1436
+ self._priority_scores = {
1437
+ run_id: base_score + offset
1438
+ for offset, run_id in enumerate(normalised)
1439
+ }
1440
+ self._promoted_run_ids = normalised
1441
+ active_run_id = self._trusted_active_run_id
1442
+ with self._trusted_request_lock:
1443
+ request = self._trusted_request
1444
+ if active_run_id is not None and request is not None:
1445
+ requested_priority = self._trusted_priority_for_run_locked(
1446
+ active_run_id,
1447
+ promoted_order=normalised,
1448
+ )
1449
+ reprioritize = getattr(request, "reprioritize", None)
1450
+ if callable(reprioritize):
1451
+ reprioritize(requested_priority)
1452
+ elif active_run_id in normalised:
1453
+ # Compatibility with narrow request doubles that predate
1454
+ # bidirectional trusted request priorities.
1455
+ request.promote(requested_priority)
1456
+
1457
+
1458
+ def _is_cancelled(self):
1459
+ return self._cancelled.is_set()
1460
+
1461
+
1462
+ def _normalise_run_id(self, run_id):
1463
+ if run_id in self._default_run_order:
1464
+ return run_id
1465
+
1466
+ try:
1467
+ int_run_id = int(run_id)
1468
+ except (TypeError, ValueError):
1469
+ return None
1470
+
1471
+ if int_run_id in self._default_run_order:
1472
+ return int_run_id
1473
+ text_run_id = str(int_run_id)
1474
+ if text_run_id in self._default_run_order:
1475
+ return text_run_id
1476
+ return None
1477
+
1478
+
1479
+ def _next_priority_batch(self, done, batch_size):
1480
+ with self._priority_lock:
1481
+ priority_scores = dict(self._priority_scores)
1482
+ run_ids = tuple(self.run_ids)
1483
+ default_run_order = dict(self._default_run_order)
1484
+
1485
+ candidates = [run_id for run_id in run_ids if run_id not in done]
1486
+ if not candidates:
1487
+ return []
1488
+
1489
+ def sort_key(run_id):
1490
+ return (
1491
+ priority_scores.get(run_id, 0),
1492
+ default_run_order.get(run_id, 0),
1493
+ )
1494
+
1495
+ candidates.sort(key=sort_key)
1496
+ return candidates[:max(1, int(batch_size or 1))]
1497
+
1498
+
1499
+ def _next_trusted_run(self, done):
1500
+ """Choose one run or atomically stop accepting dynamic additions."""
1501
+ with self._priority_lock:
1502
+ candidates = [run_id for run_id in self.run_ids if run_id not in done]
1503
+ if not candidates:
1504
+ self._accepting_run_ids = False
1505
+ return None
1506
+ priority_scores = dict(self._priority_scores)
1507
+ default_run_order = dict(self._default_run_order)
1508
+ candidates.sort(
1509
+ key=lambda run_id: (
1510
+ priority_scores.get(run_id, 0),
1511
+ default_run_order.get(run_id, 0),
1512
+ )
1513
+ )
1514
+ return candidates[0]
1515
+
1516
+
1517
+ def _trusted_priority_for_run(self, run_id, promoted_order=None):
1518
+ with self._priority_lock:
1519
+ return self._trusted_priority_for_run_locked(
1520
+ run_id,
1521
+ promoted_order=promoted_order,
1522
+ )
1523
+
1524
+
1525
+ def _trusted_priority_for_run_locked(self, run_id, promoted_order=None):
1526
+ """Return one trusted priority while ``_priority_lock`` is held."""
1527
+
1528
+ order = list(
1529
+ self._promoted_run_ids
1530
+ if promoted_order is None
1531
+ else promoted_order
1532
+ )
1533
+ if run_id in order:
1534
+ return (
1535
+ self._trusted_selected_priority
1536
+ if order.index(run_id) == 0
1537
+ else self._trusted_visible_priority
1538
+ )
1539
+ return self._trusted_remaining_priority
1540
+
1541
+
1542
+ def _submit_next_trusted_run(self, done, submit):
1543
+ """Choose, submit, and install one request as one priority transition."""
1544
+
1545
+ with self._priority_lock:
1546
+ candidates = [run_id for run_id in self.run_ids if run_id not in done]
1547
+ if not candidates:
1548
+ self._accepting_run_ids = False
1549
+ return None
1550
+ candidates.sort(
1551
+ key=lambda run_id: (
1552
+ self._priority_scores.get(run_id, 0),
1553
+ self._default_run_order.get(run_id, 0),
1554
+ )
1555
+ )
1556
+ run_id = candidates[0]
1557
+ priority = self._trusted_priority_for_run_locked(run_id)
1558
+ self._trusted_active_run_id = run_id
1559
+ try:
1560
+ request = submit(run_id, priority)
1561
+ except Exception:
1562
+ self._trusted_active_run_id = None
1563
+ raise
1564
+ # ``prioritize_run_ids`` takes these locks in the same order. It
1565
+ # therefore observes either no active run or an installed request;
1566
+ # promotion cannot fall into the former submit/install gap.
1567
+ with self._trusted_request_lock:
1568
+ self._trusted_request = request
1569
+ return run_id, request
1570
+
1571
+
1572
+ def _run_trusted_details(self, status_label, submit):
1573
+ with self._priority_lock:
1574
+ total = len(self.run_ids)
1575
+ if total == 0:
1576
+ self._emit_finished(None)
1577
+ return
1578
+ done = set()
1579
+ first_error = None
1580
+ self._emit_status(f"{status_label} 0/{total}")
1581
+ while True:
1582
+ if self._is_cancelled():
1583
+ return
1584
+ submitted = self._submit_next_trusted_run(done, submit)
1585
+ if submitted is None:
1586
+ break
1587
+ run_id, request = submitted
1588
+ try:
1589
+ record = self._wait_trusted_request(request)
1590
+ except Exception as error:
1591
+ if self._is_cancelled():
1592
+ return
1593
+ service = self.trusted_service
1594
+ if (
1595
+ service is None
1596
+ or isinstance(error, DatabaseInstanceChangedError)
1597
+ or TrustedLiveReadService._terminal_session_error(error)
1598
+ or bool(getattr(service, "closing", False))
1599
+ or bool(getattr(service, "closed", False))
1600
+ ):
1601
+ raise
1602
+ if first_error is None:
1603
+ first_error = error
1604
+ done.add(run_id)
1605
+ finally:
1606
+ with self._priority_lock:
1607
+ if self._trusted_active_run_id == run_id:
1608
+ self._trusted_active_run_id = None
1609
+ if self._is_cancelled():
1610
+ return
1611
+ if run_id not in done:
1612
+ self._emit_batch_ready({record.run_id: record.as_dict()})
1613
+ done.add(run_id)
1614
+ with self._priority_lock:
1615
+ total = len(self.run_ids)
1616
+ self._emit_status(f"{status_label} {len(done)}/{total}")
1617
+ self._emit_finished(first_error)
1618
+
1619
+
1620
+ def _emit_status(self, message):
1621
+ with self._publication_lock:
1622
+ if self._cancelled.is_set():
1623
+ return
1624
+ try:
1625
+ self.signals.status.emit(self.generation, message)
1626
+ except RuntimeError as err:
1627
+ if not self._qt_signal_was_deleted(err):
1628
+ raise
1629
+
1630
+
1631
+ def _emit_batch_ready(self, details):
1632
+ self._require_current_database_instance()
1633
+ details = _bounded_runs_publication(details)
1634
+ with self._publication_lock:
1635
+ if self._cancelled.is_set():
1636
+ return
1637
+ try:
1638
+ self.signals.batch_ready.emit(
1639
+ self.generation,
1640
+ self._signal_database_path,
1641
+ details,
1642
+ )
1643
+ except RuntimeError as err:
1644
+ if not self._qt_signal_was_deleted(err):
1645
+ raise
1646
+
1647
+
1648
+ def _emit_finished(self, error):
1649
+ with self._priority_lock:
1650
+ self._accepting_run_ids = False
1651
+ if not isinstance(error, DatabaseInstanceChangedError):
1652
+ try:
1653
+ self._require_current_database_instance()
1654
+ except DatabaseInstanceChangedError as err:
1655
+ error = err
1656
+ error = _bounded_worker_error(error)
1657
+ with self._publication_lock:
1658
+ if self._cancelled.is_set():
1659
+ return
1660
+ try:
1661
+ self.signals.finished.emit(
1662
+ self.generation,
1663
+ self._signal_database_path,
1664
+ error,
1665
+ )
1666
+ except RuntimeError as err:
1667
+ if not self._qt_signal_was_deleted(err):
1668
+ raise
1669
+
1670
+
1671
+ def _qt_signal_was_deleted(self, err):
1672
+ message = str(err)
1673
+ return "wrapped C/C++ object" in message and "has been deleted" in message
1674
+
1675
+
1676
+ class DatabaseDetailWorker(_PrioritizedRunWorker):
1677
+ """
1678
+ Loads cheap per-run metadata after the basic run table is visible.
1679
+
1680
+ """
1681
+
1682
+ def __init__(
1683
+ self,
1684
+ generation,
1685
+ database_path,
1686
+ run_ids,
1687
+ batch_size=1,
1688
+ *,
1689
+ expected_database_instance=None,
1690
+ deadline=None,
1691
+ trusted_service=None,
1692
+ ):
1693
+ super().__init__(
1694
+ generation,
1695
+ database_path,
1696
+ run_ids,
1697
+ expected_database_instance=expected_database_instance,
1698
+ deadline=deadline,
1699
+ trusted_service=trusted_service,
1700
+ )
1701
+ self.batch_size = max(1, int(batch_size or 1))
1702
+
1703
+
1704
+ def run(self):
1705
+ total = len(self.run_ids)
1706
+ completed = 0
1707
+ try:
1708
+ if self._is_cancelled():
1709
+ return
1710
+ if self.trusted_service is not None:
1711
+ self._run_trusted_details(
1712
+ "Loading cheap run metadata...",
1713
+ lambda run_id, priority:
1714
+ self.trusted_service.submit_cheap_run(
1715
+ int(run_id),
1716
+ priority=priority,
1717
+ deadline=self.deadline,
1718
+ ),
1719
+ )
1720
+ return
1721
+ if total == 0:
1722
+ self._emit_finished(None)
1723
+ return
1724
+
1725
+ self._emit_status(f"Loading run details... 0/{total}")
1726
+ done = set()
1727
+ while len(done) < total:
1728
+ if self._is_cancelled():
1729
+ return
1730
+
1731
+ batch = self._next_priority_batch(done, self.batch_size)
1732
+ if not batch:
1733
+ break
1734
+ self._require_current_database_instance()
1735
+ batches = iter_run_detail_batches_via_sql(
1736
+ self._read_database_path,
1737
+ batch,
1738
+ batch_size=self.batch_size,
1739
+ infer_missing_shapes=False,
1740
+ include_storage_bytes=False,
1741
+ include_storage_estimate=True,
1742
+ include_read_setpoint_count=True,
1743
+ connection_callback=self._set_sql_connection,
1744
+ **self._read_control_kwargs(),
1745
+ )
1746
+ try:
1747
+ for details in batches:
1748
+ if self._is_cancelled():
1749
+ return
1750
+ if details:
1751
+ self._emit_batch_ready(details)
1752
+ finally:
1753
+ self._close_batch_iterator(batches)
1754
+
1755
+ done.update(batch)
1756
+ completed = len(done)
1757
+ self._emit_status(
1758
+ f"Loading run details... {min(completed, total)}/{total}"
1759
+ )
1760
+ except InterruptedError:
1761
+ return
1762
+ except DatabaseInstanceChangedError as err:
1763
+ if self._is_cancelled():
1764
+ return
1765
+ self._emit_finished(err)
1766
+ return
1767
+ except Exception as err:
1768
+ if self._is_cancelled():
1769
+ return
1770
+ log_exception("Database detail worker failed", err, __name__)
1771
+ self._emit_finished(err)
1772
+ return
1773
+
1774
+ self._emit_finished(None)
1775
+
1776
+
1777
+ class DatabaseExpensiveDetailWorker(_PrioritizedRunWorker):
1778
+ """
1779
+ Loads expensive shape and storage metadata in priority order.
1780
+
1781
+ """
1782
+
1783
+ _trusted_selected_priority = TrustedReadPriority.SELECTED_EXPENSIVE
1784
+ _trusted_visible_priority = TrustedReadPriority.VISIBLE_EXPENSIVE
1785
+ _trusted_remaining_priority = TrustedReadPriority.REMAINING_EXPENSIVE
1786
+
1787
+ def __init__(
1788
+ self,
1789
+ generation,
1790
+ database_path,
1791
+ run_ids,
1792
+ batch_size=10,
1793
+ *,
1794
+ expected_database_instance=None,
1795
+ deadline=None,
1796
+ trusted_service=None,
1797
+ ):
1798
+ super().__init__(
1799
+ generation,
1800
+ database_path,
1801
+ run_ids,
1802
+ expected_database_instance=expected_database_instance,
1803
+ deadline=deadline,
1804
+ trusted_service=trusted_service,
1805
+ )
1806
+ self.batch_size = max(1, int(batch_size or 1))
1807
+
1808
+
1809
+ def run(self):
1810
+ total = len(self.run_ids)
1811
+ try:
1812
+ if self._is_cancelled():
1813
+ return
1814
+ if self.trusted_service is not None:
1815
+ self._run_trusted_details(
1816
+ "Loading counts, shapes, and sizes...",
1817
+ lambda run_id, priority:
1818
+ self.trusted_service.submit_expensive_run(
1819
+ int(run_id),
1820
+ priority=priority,
1821
+ deadline=self.deadline,
1822
+ ),
1823
+ )
1824
+ return
1825
+ if total == 0:
1826
+ self._emit_finished(None)
1827
+ return
1828
+
1829
+ shape_done = set()
1830
+ self._emit_status(f"Loading setpoint shapes... 0/{total}")
1831
+ while len(shape_done) < total:
1832
+ if self._is_cancelled():
1833
+ return
1834
+
1835
+ batch = self._next_priority_batch(
1836
+ shape_done,
1837
+ batch_size=self.batch_size,
1838
+ )
1839
+ if not batch:
1840
+ break
1841
+
1842
+ self._require_current_database_instance()
1843
+ batches = iter_run_shape_batches_via_sql(
1844
+ self._read_database_path,
1845
+ batch,
1846
+ batch_size=self.batch_size,
1847
+ connection_callback=self._set_sql_connection,
1848
+ **self._read_control_kwargs(),
1849
+ )
1850
+ try:
1851
+ for shapes in batches:
1852
+ if self._is_cancelled():
1853
+ return
1854
+
1855
+ if shapes:
1856
+ self._emit_batch_ready(shapes)
1857
+ finally:
1858
+ self._close_batch_iterator(batches)
1859
+
1860
+ shape_done.update(batch)
1861
+ self._emit_status(
1862
+ f"Loading setpoint shapes... {len(shape_done)}/{total}"
1863
+ )
1864
+
1865
+ storage_done = set()
1866
+ storage_batch_size = max(25, self.batch_size)
1867
+ self._emit_status(f"Loading exact run sizes... 0/{total}")
1868
+ while len(storage_done) < total:
1869
+ if self._is_cancelled():
1870
+ return
1871
+
1872
+ batch = self._next_priority_batch(
1873
+ storage_done,
1874
+ batch_size=storage_batch_size,
1875
+ )
1876
+ if not batch:
1877
+ break
1878
+
1879
+ self._require_current_database_instance()
1880
+ batches = iter_run_storage_batches_via_sql(
1881
+ self._read_database_path,
1882
+ batch,
1883
+ batch_size=storage_batch_size,
1884
+ connection_callback=self._set_sql_connection,
1885
+ **self._read_control_kwargs(),
1886
+ )
1887
+ try:
1888
+ for storage in batches:
1889
+ if self._is_cancelled():
1890
+ return
1891
+
1892
+ if storage:
1893
+ self._emit_batch_ready(storage)
1894
+ finally:
1895
+ self._close_batch_iterator(batches)
1896
+
1897
+ storage_done.update(batch)
1898
+ self._emit_status(
1899
+ f"Loading exact run sizes... {len(storage_done)}/{total}"
1900
+ )
1901
+ except InterruptedError:
1902
+ return
1903
+ except DatabaseInstanceChangedError as err:
1904
+ if self._is_cancelled():
1905
+ return
1906
+ self._emit_finished(err)
1907
+ return
1908
+ except Exception as err:
1909
+ if self._is_cancelled():
1910
+ return
1911
+ log_exception("Expensive database detail worker failed", err, __name__)
1912
+ self._emit_finished(err)
1913
+ return
1914
+
1915
+ self._emit_finished(None)
1916
+
1917
+
1918
+ class DatabaseSelectedRunWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
1919
+ """Load a plain selected-run view off-GUI for either accepted read mode."""
1920
+
1921
+ def __init__(
1922
+ self,
1923
+ generation,
1924
+ database_path,
1925
+ run_id,
1926
+ guid,
1927
+ trusted_service=None,
1928
+ *,
1929
+ run_metadata=None,
1930
+ expected_database_instance=None,
1931
+ deadline=None,
1932
+ ):
1933
+ super().__init__()
1934
+ if trusted_service is not None and not isinstance(
1935
+ trusted_service,
1936
+ TrustedLiveReadService,
1937
+ ):
1938
+ raise TypeError(
1939
+ "trusted_service must be a TrustedLiveReadService or None."
1940
+ )
1941
+ self.signals = DatabaseSelectedRunSignals()
1942
+ self.generation = generation
1943
+ self.run_id = int(run_id)
1944
+ self.guid = str(guid)
1945
+ self.trusted_service = trusted_service
1946
+ self.run_metadata = dict(run_metadata or {})
1947
+ self._init_database_binding(
1948
+ database_path,
1949
+ expected_database_instance,
1950
+ deadline,
1951
+ )
1952
+ self._cancelled = threading.Event()
1953
+ self._init_sql_interrupt()
1954
+
1955
+
1956
+ def cancel(self):
1957
+ self._cancel_read()
1958
+
1959
+
1960
+ def run(self):
1961
+ try:
1962
+ if self._cancelled.is_set():
1963
+ return
1964
+ self._require_current_database_instance()
1965
+ if self.trusted_service is None:
1966
+ detail = self._run_snapshot_detail()
1967
+ self._require_current_database_instance()
1968
+ self._emit_finished(detail, None)
1969
+ return
1970
+ self._wait_trusted_request(
1971
+ self.trusted_service.submit_cheap_run(
1972
+ self.run_id,
1973
+ priority=TrustedReadPriority.SELECTED_CHEAP,
1974
+ deadline=self.deadline,
1975
+ )
1976
+ )
1977
+ initial_detail = self._wait_trusted_request(
1978
+ self.trusted_service.submit_selected_run(
1979
+ self.run_id,
1980
+ priority=TrustedReadPriority.SELECTED_CHEAP,
1981
+ deadline=self.deadline,
1982
+ )
1983
+ )
1984
+ self._emit_progress(initial_detail)
1985
+ if self._cancelled.is_set():
1986
+ return
1987
+ self._wait_trusted_request(
1988
+ self.trusted_service.submit_expensive_run(
1989
+ self.run_id,
1990
+ priority=TrustedReadPriority.SELECTED_EXPENSIVE,
1991
+ deadline=self.deadline,
1992
+ )
1993
+ )
1994
+ detail = self._wait_trusted_request(
1995
+ self.trusted_service.submit_selected_run(
1996
+ self.run_id,
1997
+ priority=TrustedReadPriority.SELECTED_EXPENSIVE,
1998
+ deadline=self.deadline,
1999
+ )
2000
+ )
2001
+ self._require_current_database_instance()
2002
+ except (InterruptedError, TrustedReadRequestCancelledError):
2003
+ return
2004
+ except Exception as error:
2005
+ if self._cancelled.is_set():
2006
+ return
2007
+ log_exception("Selected-run detail worker failed", error, __name__)
2008
+ self._emit_finished(None, error)
2009
+ return
2010
+ self._emit_finished(detail, None)
2011
+
2012
+
2013
+ def _run_snapshot_detail(self):
2014
+ return get_snapshot_selected_run_detail(
2015
+ self._read_database_path,
2016
+ self.run_id,
2017
+ self.guid,
2018
+ self.run_metadata,
2019
+ connection_callback=self._set_sql_connection,
2020
+ **self._read_control_kwargs(),
2021
+ )
2022
+
2023
+
2024
+ def _emit_progress(self, detail):
2025
+ self._require_current_database_instance()
2026
+ with self._publication_lock:
2027
+ if self._cancelled.is_set():
2028
+ return
2029
+ self.signals.progress.emit(
2030
+ self.generation,
2031
+ self._signal_database_path,
2032
+ self.guid,
2033
+ detail,
2034
+ )
2035
+
2036
+
2037
+ def _emit_finished(self, detail, error):
2038
+ if not isinstance(error, DatabaseInstanceChangedError):
2039
+ try:
2040
+ self._require_current_database_instance()
2041
+ except DatabaseInstanceChangedError as changed_error:
2042
+ detail = None
2043
+ error = changed_error
2044
+ if error is not None:
2045
+ detail = None
2046
+ error = _bounded_worker_error(error)
2047
+ with self._publication_lock:
2048
+ if self._cancelled.is_set():
2049
+ return
2050
+ self.signals.finished.emit(
2051
+ self.generation,
2052
+ self._signal_database_path,
2053
+ self.guid,
2054
+ detail,
2055
+ error,
2056
+ )
2057
+
2058
+
2059
+ class DatabaseInfoSignals(QtCore.QObject):
2060
+ finished = QtCore.pyqtSignal(int, str, object, object)
2061
+
2062
+
2063
+ class DatabaseInfoWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
2064
+ """Read diagnostic metadata with cancellable source-bound background work."""
2065
+
2066
+ def __init__(
2067
+ self,
2068
+ generation,
2069
+ database_path,
2070
+ *,
2071
+ expected_database_instance=None,
2072
+ deadline=None,
2073
+ trusted_service=None,
2074
+ ):
2075
+ super().__init__()
2076
+ self.signals = DatabaseInfoSignals()
2077
+ self.generation = generation
2078
+ self._init_database_binding(
2079
+ database_path, expected_database_instance, deadline,
2080
+ )
2081
+ self._cancelled = threading.Event()
2082
+ self._init_sql_interrupt()
2083
+ self.trusted_service = trusted_service
2084
+
2085
+ def cancel(self):
2086
+ self._cancel_read()
2087
+
2088
+ def run(self):
2089
+ rows = None
2090
+ error = None
2091
+ try:
2092
+ if self._cancelled.is_set():
2093
+ return
2094
+ self._require_current_database_instance()
2095
+ if self.trusted_service is not None:
2096
+ trusted_info = self._wait_trusted_request(
2097
+ self.trusted_service.submit_database_info(deadline=self.deadline)
2098
+ )
2099
+ summary = _database_info_summary(
2100
+ self._read_database_path, trusted_info=trusted_info,
2101
+ )
2102
+ else:
2103
+ summary = _database_info_summary(
2104
+ self._read_database_path,
2105
+ connection_callback=self._set_sql_connection,
2106
+ **self._read_control_kwargs(),
2107
+ )
2108
+ rows = [row for group in _database_info_groups(summary) for row in group]
2109
+ self._require_current_database_instance()
2110
+ except (InterruptedError, TrustedReadRequestCancelledError) as err:
2111
+ if self._cancelled.is_set():
2112
+ return
2113
+ error = _bounded_worker_error(err)
2114
+ except Exception as err:
2115
+ rows = None
2116
+ error = _bounded_worker_error(err)
2117
+ with self._publication_lock:
2118
+ if not self._cancelled.is_set():
2119
+ self.signals.finished.emit(
2120
+ self.generation, self._signal_database_path, rows, error,
2121
+ )
2122
+
2123
+
2124
+ def database_info_report(database_path):
2125
+ """
2126
+ Build a diagnostic text report for a QCoDeS database file.
2127
+
2128
+ """
2129
+ return _format_database_info(_database_info_summary(database_path))
2130
+
2131
+
2132
+ def database_info_rows(database_path):
2133
+ """
2134
+ Build display rows for the database information dialog.
2135
+
2136
+ """
2137
+ groups = _database_info_groups(_database_info_summary(database_path))
2138
+ return [row for group in groups for row in group]
2139
+
2140
+
2141
+ def _database_info_summary(
2142
+ database_path,
2143
+ *,
2144
+ trusted_info=None,
2145
+ connection_callback=None,
2146
+ **read_controls,
2147
+ ):
2148
+ if not database_path:
2149
+ raise ValueError("No database is loaded.")
2150
+
2151
+ if not os.path.isfile(database_path):
2152
+ raise FileNotFoundError(database_path)
2153
+
2154
+ path = os.path.abspath(database_path)
2155
+ file_size = os.path.getsize(path)
2156
+
2157
+ if trusted_info is not None:
2158
+ return {
2159
+ "path": path,
2160
+ "folder": os.path.dirname(path),
2161
+ "filename": os.path.basename(path),
2162
+ "file_size": file_size,
2163
+ "file_modified": os.path.getmtime(path),
2164
+ "user_version": trusted_info.user_version,
2165
+ "application_id": trusted_info.application_id,
2166
+ "page_count": trusted_info.page_count,
2167
+ "page_size": trusted_info.page_size,
2168
+ "table_count": trusted_info.table_count,
2169
+ "experiment_count": trusted_info.experiment_count,
2170
+ "run_count": trusted_info.run_count,
2171
+ "latest_run": (
2172
+ trusted_info.latest_run.as_dict()
2173
+ if trusted_info.latest_run is not None else None
2174
+ ),
2175
+ }
2176
+
2177
+ conn = sqlite_read_only_connection(path, timeout=10, **read_controls)
2178
+ try:
2179
+ if connection_callback is not None:
2180
+ connection_callback(conn)
2181
+ cursor = conn.cursor()
2182
+ user_version = _pragma_value(cursor, "user_version")
2183
+ summary = {
2184
+ "path": path,
2185
+ "folder": os.path.dirname(path),
2186
+ "filename": os.path.basename(path),
2187
+ "file_size": file_size,
2188
+ "file_modified": os.path.getmtime(path),
2189
+ "user_version": user_version,
2190
+ "application_id": _pragma_value(cursor, "application_id"),
2191
+ "page_count": _pragma_value(cursor, "page_count"),
2192
+ "page_size": _pragma_value(cursor, "page_size"),
2193
+ "table_count": _table_count(cursor),
2194
+ "experiment_count": _row_count(cursor, "experiments"),
2195
+ "run_count": _row_count(cursor, "runs"),
2196
+ "latest_run": _latest_run(cursor),
2197
+ }
2198
+ finally:
2199
+ conn.close()
2200
+ if connection_callback is not None:
2201
+ connection_callback(None)
2202
+
2203
+ return summary
2204
+
2205
+
2206
+ def _pragma_value(cursor, name):
2207
+ cursor.execute(f"PRAGMA {name}")
2208
+ value = cursor.fetchone()
2209
+ return value[0] if value else None
2210
+
2211
+
2212
+ def _table_count(cursor):
2213
+ cursor.execute("""
2214
+ SELECT COUNT(*)
2215
+ FROM sqlite_master
2216
+ WHERE type='table' AND name NOT LIKE 'sqlite_%'
2217
+ """)
2218
+ return cursor.fetchone()[0]
2219
+
2220
+
2221
+ def _row_count(cursor, table_name):
2222
+ if not _table_exists(cursor, table_name):
2223
+ return None
2224
+
2225
+ cursor.execute(f"SELECT COUNT(*) FROM {_sqlite_identifier(table_name)}")
2226
+ return cursor.fetchone()[0]
2227
+
2228
+
2229
+ def _table_exists(cursor, table_name):
2230
+ cursor.execute(
2231
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
2232
+ (table_name, )
2233
+ )
2234
+ return cursor.fetchone() is not None
2235
+
2236
+
2237
+ def _latest_run(cursor):
2238
+ if not _table_exists(cursor, "runs"):
2239
+ return None
2240
+
2241
+ cursor.execute("""
2242
+ SELECT run_id, name, run_timestamp, completed_timestamp, is_completed, guid
2243
+ FROM runs
2244
+ ORDER BY run_id DESC
2245
+ LIMIT 1
2246
+ """)
2247
+ value = cursor.fetchone()
2248
+ if value is None:
2249
+ return None
2250
+
2251
+ return {
2252
+ "run_id": value[0],
2253
+ "name": value[1],
2254
+ "run_timestamp": value[2],
2255
+ "completed_timestamp": value[3],
2256
+ "is_completed": value[4],
2257
+ "guid": value[5],
2258
+ }
2259
+
2260
+
2261
+ def _sqlite_identifier(name):
2262
+ return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
2263
+
2264
+
2265
+ def _database_info_groups(summary):
2266
+ latest_run = summary["latest_run"]
2267
+ latest_run_rows = [("Latest run", "None")]
2268
+ if latest_run:
2269
+ status = "completed" if latest_run.get("is_completed") else "running or incomplete"
2270
+ latest_run_rows = [
2271
+ ("Latest run ID", _display_value(latest_run.get("run_id"))),
2272
+ ("Latest run name", _display_value(latest_run.get("name"))),
2273
+ ("Latest run status", status),
2274
+ ("Latest run started", _timestamp_value(latest_run.get("run_timestamp"))),
2275
+ ("Latest run completed", _timestamp_value(latest_run.get("completed_timestamp"))),
2276
+ ("Latest run GUID", _display_value(latest_run.get("guid"))),
2277
+ ]
2278
+
2279
+ page_bytes = None
2280
+ if summary["page_count"] is not None and summary["page_size"] is not None:
2281
+ page_bytes = int(summary["page_count"]) * int(summary["page_size"])
2282
+
2283
+ return [
2284
+ [
2285
+ ("Database", summary["filename"]),
2286
+ ("Path", summary["path"]),
2287
+ ("Folder", summary["folder"]),
2288
+ ("File size", _format_bytes(summary["file_size"])),
2289
+ ("Last modified", _timestamp_value(summary["file_modified"])),
2290
+ ("SQLite allocated size", _format_bytes(page_bytes)),
2291
+ ],
2292
+ [
2293
+ ("Database schema version", _display_value(summary["user_version"])),
2294
+ ("SQLite application_id", _display_value(summary["application_id"])),
2295
+ ],
2296
+ [
2297
+ ("Tables", _display_value(summary["table_count"])),
2298
+ ("Experiments", _display_value(summary["experiment_count"])),
2299
+ ("Runs", _display_value(summary["run_count"])),
2300
+ ],
2301
+ latest_run_rows,
2302
+ ]
2303
+
2304
+
2305
+ def _format_database_info(summary):
2306
+ groups = _database_info_groups(summary)
2307
+ sections = [
2308
+ "\n".join(f"{label}: {_display_value(value)}" for label, value in group)
2309
+ for group in groups
2310
+ ]
2311
+ return "\n\n".join(sections)
2312
+
2313
+
2314
+ def _format_bytes(value):
2315
+ if value is None:
2316
+ return "Unknown"
2317
+
2318
+ value = int(value)
2319
+ units = ("B", "KB", "MB", "GB", "TB")
2320
+ size = float(value)
2321
+ for unit in units:
2322
+ if size < 1024 or unit == units[-1]:
2323
+ return f"{size:.1f} {unit}" if unit != "B" else f"{value} B"
2324
+ size /= 1024
2325
+
2326
+
2327
+ def _timestamp_value(value):
2328
+ if value in (None, ""):
2329
+ return "Not recorded"
2330
+
2331
+ try:
2332
+ return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S")
2333
+ except (TypeError, ValueError, OSError):
2334
+ return str(value)
2335
+
2336
+
2337
+ def _display_value(value):
2338
+ if value in (None, ""):
2339
+ return "Unknown"
2340
+ return str(value)