qplotter 1.6.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2330 @@
|
|
|
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
|
+
|
|
839
|
+
|
|
840
|
+
def acknowledge_new_runs_published(self):
|
|
841
|
+
"""Release the off-GUI refresh only after its basic page is visible."""
|
|
842
|
+
with self._new_runs_publication_lock:
|
|
843
|
+
self._new_runs_publication_error = None
|
|
844
|
+
self._new_runs_publication_ack.set()
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def reject_new_runs_publication(self, error):
|
|
848
|
+
"""Fail closed when a trusted basic page could not reach the GUI."""
|
|
849
|
+
with self._new_runs_publication_lock:
|
|
850
|
+
self._new_runs_publication_error = error
|
|
851
|
+
# The adapter cursor already accepted this bounded page. Retire the
|
|
852
|
+
# session instead of letting a later refresh omit rows the GUI failed
|
|
853
|
+
# to publish. close_async is deliberately prompt and GUI-safe.
|
|
854
|
+
if self.trusted_service is not None:
|
|
855
|
+
self.trusted_service.close_async()
|
|
856
|
+
self._new_runs_publication_ack.set()
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def run(self):
|
|
860
|
+
new_runs = {}
|
|
861
|
+
statuses = {}
|
|
862
|
+
try:
|
|
863
|
+
if self._cancelled.is_set():
|
|
864
|
+
return
|
|
865
|
+
|
|
866
|
+
self._require_current_database_instance()
|
|
867
|
+
if self.trusted_service is not None:
|
|
868
|
+
new_runs, statuses = self._run_trusted_refresh()
|
|
869
|
+
self._require_current_database_instance()
|
|
870
|
+
self._emit_finished({}, statuses, None)
|
|
871
|
+
return
|
|
872
|
+
new_runs = find_new_runs(
|
|
873
|
+
self.last_run_id,
|
|
874
|
+
database_path=self._read_database_path,
|
|
875
|
+
connection_callback=self._set_sql_connection,
|
|
876
|
+
**self._read_control_kwargs(),
|
|
877
|
+
) or {}
|
|
878
|
+
for guid in self.watched_runs:
|
|
879
|
+
if self._cancelled.is_set():
|
|
880
|
+
return
|
|
881
|
+
self._require_current_database_instance()
|
|
882
|
+
status = get_run_status(
|
|
883
|
+
guid,
|
|
884
|
+
database_path=self._read_database_path,
|
|
885
|
+
include_storage_bytes=False,
|
|
886
|
+
connection_callback=self._set_sql_connection,
|
|
887
|
+
**self._read_control_kwargs(),
|
|
888
|
+
)
|
|
889
|
+
if status:
|
|
890
|
+
statuses[guid] = status
|
|
891
|
+
self._require_current_database_instance()
|
|
892
|
+
except InterruptedError:
|
|
893
|
+
return
|
|
894
|
+
except DatabaseInstanceChangedError as err:
|
|
895
|
+
if self._cancelled.is_set():
|
|
896
|
+
return
|
|
897
|
+
self._emit_finished({}, {}, err)
|
|
898
|
+
return
|
|
899
|
+
except Exception as err:
|
|
900
|
+
if self._cancelled.is_set():
|
|
901
|
+
return
|
|
902
|
+
log_exception("Database refresh worker failed", err, __name__)
|
|
903
|
+
self._emit_finished(new_runs, statuses, err)
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
self._emit_finished(new_runs, statuses, None)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _run_trusted_refresh(self):
|
|
910
|
+
header = self._wait_trusted_request(
|
|
911
|
+
self.trusted_service.submit_refresh(
|
|
912
|
+
self.last_run_id,
|
|
913
|
+
deadline=self.deadline,
|
|
914
|
+
)
|
|
915
|
+
)
|
|
916
|
+
new_runs = {}
|
|
917
|
+
if header.data_version_changed:
|
|
918
|
+
cursor = header.prior_run_id_watermark
|
|
919
|
+
while True:
|
|
920
|
+
if self._cancelled.is_set():
|
|
921
|
+
raise InterruptedError("Database refresh cancelled.")
|
|
922
|
+
page = self._wait_trusted_request(
|
|
923
|
+
self.trusted_service.submit_basic_page(
|
|
924
|
+
cursor,
|
|
925
|
+
header.run_id_watermark,
|
|
926
|
+
priority=TrustedReadPriority.REFRESH,
|
|
927
|
+
deadline=self.deadline,
|
|
928
|
+
)
|
|
929
|
+
)
|
|
930
|
+
page_runs = run_records_as_dict(page.runs)
|
|
931
|
+
new_runs.update(page_runs)
|
|
932
|
+
if page_runs:
|
|
933
|
+
self._emit_new_runs_ready(page_runs)
|
|
934
|
+
if page.complete:
|
|
935
|
+
break
|
|
936
|
+
cursor = page.next_run_id
|
|
937
|
+
|
|
938
|
+
statuses = {}
|
|
939
|
+
if not header.data_version_changed:
|
|
940
|
+
return new_runs, statuses
|
|
941
|
+
for watched in self.watched_runs:
|
|
942
|
+
if self._cancelled.is_set():
|
|
943
|
+
raise InterruptedError("Database refresh cancelled.")
|
|
944
|
+
if (
|
|
945
|
+
isinstance(watched, (tuple, list))
|
|
946
|
+
and len(watched) in {2, 3}
|
|
947
|
+
):
|
|
948
|
+
if len(watched) == 2:
|
|
949
|
+
run_id, guid = watched
|
|
950
|
+
category = "remaining"
|
|
951
|
+
else:
|
|
952
|
+
run_id, guid, category = watched
|
|
953
|
+
else:
|
|
954
|
+
continue
|
|
955
|
+
try:
|
|
956
|
+
run_id = int(run_id)
|
|
957
|
+
except (TypeError, ValueError):
|
|
958
|
+
continue
|
|
959
|
+
if category == "selected":
|
|
960
|
+
cheap_priority = TrustedReadPriority.SELECTED_CHEAP
|
|
961
|
+
elif category == "visible":
|
|
962
|
+
cheap_priority = TrustedReadPriority.VISIBLE_CHEAP
|
|
963
|
+
else:
|
|
964
|
+
cheap_priority = TrustedReadPriority.REMAINING_CHEAP
|
|
965
|
+
cheap = self._wait_trusted_request(
|
|
966
|
+
self.trusted_service.submit_cheap_run(
|
|
967
|
+
run_id,
|
|
968
|
+
priority=cheap_priority,
|
|
969
|
+
deadline=self.deadline,
|
|
970
|
+
)
|
|
971
|
+
)
|
|
972
|
+
status = cheap.as_dict()
|
|
973
|
+
if not self._derived_coordinator_owned:
|
|
974
|
+
if category == "selected":
|
|
975
|
+
expensive_priority = TrustedReadPriority.SELECTED_EXPENSIVE
|
|
976
|
+
elif category == "visible":
|
|
977
|
+
expensive_priority = TrustedReadPriority.VISIBLE_EXPENSIVE
|
|
978
|
+
else:
|
|
979
|
+
expensive_priority = TrustedReadPriority.REMAINING_EXPENSIVE
|
|
980
|
+
expensive = self._wait_trusted_request(
|
|
981
|
+
self.trusted_service.submit_expensive_run(
|
|
982
|
+
run_id,
|
|
983
|
+
priority=expensive_priority,
|
|
984
|
+
deadline=self.deadline,
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
status.update(expensive.as_dict())
|
|
988
|
+
statuses[str(guid)] = status
|
|
989
|
+
return new_runs, statuses
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _emit_new_runs_ready(self, new_runs):
|
|
993
|
+
self._require_current_database_instance()
|
|
994
|
+
new_runs = _bounded_runs_publication(new_runs)
|
|
995
|
+
if self._require_publication_ack:
|
|
996
|
+
with self._new_runs_publication_lock:
|
|
997
|
+
self._new_runs_publication_error = None
|
|
998
|
+
self._new_runs_publication_ack.clear()
|
|
999
|
+
with self._publication_lock:
|
|
1000
|
+
if self._cancelled.is_set():
|
|
1001
|
+
return
|
|
1002
|
+
self.signals.new_runs_ready.emit(
|
|
1003
|
+
self.generation,
|
|
1004
|
+
self._signal_database_path,
|
|
1005
|
+
new_runs,
|
|
1006
|
+
)
|
|
1007
|
+
if not self._require_publication_ack:
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
acknowledgement_deadline = monotonic() + 10.0
|
|
1011
|
+
if self.deadline is not None:
|
|
1012
|
+
acknowledgement_deadline = min(
|
|
1013
|
+
acknowledgement_deadline,
|
|
1014
|
+
self.deadline,
|
|
1015
|
+
)
|
|
1016
|
+
while not self._new_runs_publication_ack.wait(0.05):
|
|
1017
|
+
if self._cancelled.is_set():
|
|
1018
|
+
raise InterruptedError("Database refresh cancelled.")
|
|
1019
|
+
if monotonic() >= acknowledgement_deadline:
|
|
1020
|
+
raise TimeoutError(
|
|
1021
|
+
"The GUI did not publish a trusted basic-run page in time."
|
|
1022
|
+
)
|
|
1023
|
+
with self._new_runs_publication_lock:
|
|
1024
|
+
publication_error = self._new_runs_publication_error
|
|
1025
|
+
if publication_error is not None:
|
|
1026
|
+
raise RuntimeError(
|
|
1027
|
+
"The GUI could not publish a trusted basic-run page; the "
|
|
1028
|
+
"accepted service retirement was requested to prevent an "
|
|
1029
|
+
"omitted run."
|
|
1030
|
+
) from publication_error
|
|
1031
|
+
if self._cancelled.is_set():
|
|
1032
|
+
raise InterruptedError("Database refresh cancelled.")
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _emit_finished(self, new_runs, statuses, error):
|
|
1036
|
+
if not isinstance(error, DatabaseInstanceChangedError):
|
|
1037
|
+
try:
|
|
1038
|
+
self._require_current_database_instance()
|
|
1039
|
+
except DatabaseInstanceChangedError as err:
|
|
1040
|
+
new_runs = {}
|
|
1041
|
+
statuses = {}
|
|
1042
|
+
error = err
|
|
1043
|
+
new_runs = _bounded_runs_publication(new_runs)
|
|
1044
|
+
statuses = _bounded_runs_publication(statuses)
|
|
1045
|
+
error = _bounded_worker_error(error)
|
|
1046
|
+
with self._publication_lock:
|
|
1047
|
+
if self._cancelled.is_set():
|
|
1048
|
+
return
|
|
1049
|
+
try:
|
|
1050
|
+
self.signals.finished.emit(
|
|
1051
|
+
self.generation,
|
|
1052
|
+
self._signal_database_path,
|
|
1053
|
+
new_runs,
|
|
1054
|
+
statuses,
|
|
1055
|
+
error,
|
|
1056
|
+
)
|
|
1057
|
+
except RuntimeError as err:
|
|
1058
|
+
message = str(err)
|
|
1059
|
+
if not (
|
|
1060
|
+
"wrapped C/C++ object" in message
|
|
1061
|
+
and "has been deleted" in message
|
|
1062
|
+
):
|
|
1063
|
+
raise
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
class DatabaseLoadWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
|
|
1067
|
+
"""
|
|
1068
|
+
Loads database metadata away from the GUI thread.
|
|
1069
|
+
|
|
1070
|
+
The worker is responsible for the blocking parts of opening a database:
|
|
1071
|
+
access probing, QCoDeS initialisation, and collecting the run table
|
|
1072
|
+
metadata. Widget updates stay in MainWindow on the GUI thread.
|
|
1073
|
+
|
|
1074
|
+
"""
|
|
1075
|
+
|
|
1076
|
+
def __init__(
|
|
1077
|
+
self,
|
|
1078
|
+
generation,
|
|
1079
|
+
database_path,
|
|
1080
|
+
cloud_sync_timeout=DATABASE_CLOUD_SYNC_TIMEOUT_SECONDS,
|
|
1081
|
+
*,
|
|
1082
|
+
expected_database_instance=None,
|
|
1083
|
+
deadline=None,
|
|
1084
|
+
trusted_service=None,
|
|
1085
|
+
):
|
|
1086
|
+
super().__init__()
|
|
1087
|
+
self.signals = DatabaseLoadSignals()
|
|
1088
|
+
self.generation = generation
|
|
1089
|
+
self._init_database_binding(
|
|
1090
|
+
database_path,
|
|
1091
|
+
expected_database_instance,
|
|
1092
|
+
deadline,
|
|
1093
|
+
)
|
|
1094
|
+
self.cloud_sync_timeout = cloud_sync_timeout
|
|
1095
|
+
self._cancelled = threading.Event()
|
|
1096
|
+
self._init_sql_interrupt()
|
|
1097
|
+
self._owns_trusted_service = trusted_service is None
|
|
1098
|
+
self.trusted_service = trusted_service or TrustedLiveReadService(
|
|
1099
|
+
self._read_database_path,
|
|
1100
|
+
expected_database_instance=self.database_instance,
|
|
1101
|
+
session_generation=max(1, int(generation)),
|
|
1102
|
+
)
|
|
1103
|
+
self.access_mode = None
|
|
1104
|
+
self.fallback_reason = None
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def cancel(self):
|
|
1108
|
+
"""
|
|
1109
|
+
Marks this load as cancelled so later phases do not run.
|
|
1110
|
+
|
|
1111
|
+
"""
|
|
1112
|
+
self._cancel_read()
|
|
1113
|
+
if self._owns_trusted_service:
|
|
1114
|
+
self.trusted_service.close_async()
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def run(self):
|
|
1118
|
+
try:
|
|
1119
|
+
if self._is_cancelled():
|
|
1120
|
+
return
|
|
1121
|
+
|
|
1122
|
+
self._require_current_database_instance()
|
|
1123
|
+
if database_is_likely_cloud_placeholder(self._read_database_path):
|
|
1124
|
+
self._prefetch_cloud_file()
|
|
1125
|
+
if self._is_cancelled():
|
|
1126
|
+
return
|
|
1127
|
+
self._require_current_database_instance()
|
|
1128
|
+
try:
|
|
1129
|
+
runs = self._load_trusted_runs()
|
|
1130
|
+
self.access_mode = TRUSTED_LIVE_MODE
|
|
1131
|
+
except Exception as trusted_error:
|
|
1132
|
+
if self._is_cancelled():
|
|
1133
|
+
return
|
|
1134
|
+
if (
|
|
1135
|
+
self.trusted_service.accepted
|
|
1136
|
+
or not trusted_open_failure_allows_snapshot_fallback(
|
|
1137
|
+
trusted_error
|
|
1138
|
+
)
|
|
1139
|
+
):
|
|
1140
|
+
raise
|
|
1141
|
+
self.fallback_reason = type(trusted_error).__name__
|
|
1142
|
+
self._close_trusted_before_fallback()
|
|
1143
|
+
runs = self._load_snapshot_fallback()
|
|
1144
|
+
self.access_mode = SNAPSHOT_FALLBACK_MODE
|
|
1145
|
+
if self._is_cancelled():
|
|
1146
|
+
return
|
|
1147
|
+
self._require_current_database_instance()
|
|
1148
|
+
except (InterruptedError, TrustedReadRequestCancelledError):
|
|
1149
|
+
return
|
|
1150
|
+
except DatabaseInstanceChangedError as err:
|
|
1151
|
+
if self._is_cancelled():
|
|
1152
|
+
return
|
|
1153
|
+
self._emit_finished({}, err)
|
|
1154
|
+
return
|
|
1155
|
+
except Exception as err:
|
|
1156
|
+
if self._is_cancelled():
|
|
1157
|
+
return
|
|
1158
|
+
log_exception("Database load worker failed", err, __name__)
|
|
1159
|
+
provider = database_cloud_storage_label(self._read_database_path)
|
|
1160
|
+
if (
|
|
1161
|
+
provider
|
|
1162
|
+
and (
|
|
1163
|
+
isinstance(err, (TrustedLiveSourceIOError, TimeoutError))
|
|
1164
|
+
or (
|
|
1165
|
+
isinstance(err, OSError)
|
|
1166
|
+
and err.errno in {errno.EIO, errno.ETIMEDOUT}
|
|
1167
|
+
)
|
|
1168
|
+
)
|
|
1169
|
+
):
|
|
1170
|
+
sync_app = provider if provider != "cloud storage" else "your cloud sync app"
|
|
1171
|
+
err = RuntimeError(
|
|
1172
|
+
f"The database is stored in {provider}, and qPlot could not "
|
|
1173
|
+
"read it or its auxiliary files in time. The cloud sync "
|
|
1174
|
+
"app may not be running, or some files may not be "
|
|
1175
|
+
"available on this device.\n\n"
|
|
1176
|
+
f"Start or restart {sync_app}, check that it is signed in, "
|
|
1177
|
+
"and wait for the database folder to finish downloading. "
|
|
1178
|
+
"Mark the whole folder as always available on this device, "
|
|
1179
|
+
"then open the database again in qPlot.\n\n"
|
|
1180
|
+
f"Technical details: {err}"
|
|
1181
|
+
)
|
|
1182
|
+
self._emit_finished({}, err)
|
|
1183
|
+
return
|
|
1184
|
+
|
|
1185
|
+
self._emit_finished(runs, None)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _load_trusted_runs(self):
|
|
1189
|
+
self._emit_status("Opening trusted live database...")
|
|
1190
|
+
header = self._wait_trusted_request(
|
|
1191
|
+
self.trusted_service.submit_bootstrap(deadline=self.deadline)
|
|
1192
|
+
)
|
|
1193
|
+
self._emit_status("Loading basic run list...")
|
|
1194
|
+
runs = {}
|
|
1195
|
+
cursor = 0
|
|
1196
|
+
while True:
|
|
1197
|
+
if self._is_cancelled():
|
|
1198
|
+
raise InterruptedError("Database load cancelled.")
|
|
1199
|
+
page = self._wait_trusted_request(
|
|
1200
|
+
self.trusted_service.submit_basic_page(
|
|
1201
|
+
cursor,
|
|
1202
|
+
header.run_id_watermark,
|
|
1203
|
+
priority=TrustedReadPriority.BOOTSTRAP,
|
|
1204
|
+
deadline=self.deadline,
|
|
1205
|
+
)
|
|
1206
|
+
)
|
|
1207
|
+
runs.update(run_records_as_dict(page.runs))
|
|
1208
|
+
if page.complete:
|
|
1209
|
+
break
|
|
1210
|
+
if page.next_run_id <= cursor:
|
|
1211
|
+
raise RuntimeError(
|
|
1212
|
+
"Trusted run-list pagination did not advance its cursor."
|
|
1213
|
+
)
|
|
1214
|
+
cursor = page.next_run_id
|
|
1215
|
+
return runs
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _close_trusted_before_fallback(self):
|
|
1219
|
+
self.trusted_service.close_async()
|
|
1220
|
+
if not self.trusted_service.wait_closed(10):
|
|
1221
|
+
raise TimeoutError(
|
|
1222
|
+
"The unavailable trusted reader did not retire before fallback."
|
|
1223
|
+
)
|
|
1224
|
+
if self.trusted_service.close_error is not None:
|
|
1225
|
+
raise self.trusted_service.close_error
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _load_snapshot_fallback(self):
|
|
1229
|
+
self._emit_status("Checking database access for snapshot fallback...")
|
|
1230
|
+
access_error = database_access_error(
|
|
1231
|
+
self._read_database_path,
|
|
1232
|
+
**self._read_control_kwargs(),
|
|
1233
|
+
)
|
|
1234
|
+
if self._is_cancelled():
|
|
1235
|
+
raise InterruptedError("Database load cancelled.")
|
|
1236
|
+
|
|
1237
|
+
if (
|
|
1238
|
+
access_error
|
|
1239
|
+
and getattr(access_error, "error_type", None) not in {
|
|
1240
|
+
DatabaseInstanceChangedError.__name__,
|
|
1241
|
+
UnverifiableDatabaseWalError.__name__,
|
|
1242
|
+
}
|
|
1243
|
+
and database_cloud_storage_label(self._read_database_path)
|
|
1244
|
+
and os.path.isfile(self._read_database_path)
|
|
1245
|
+
):
|
|
1246
|
+
self._prefetch_cloud_file()
|
|
1247
|
+
if self._is_cancelled():
|
|
1248
|
+
raise InterruptedError("Database load cancelled.")
|
|
1249
|
+
self._emit_status("Checking database access for snapshot fallback...")
|
|
1250
|
+
access_error = database_access_error(
|
|
1251
|
+
self._read_database_path,
|
|
1252
|
+
**self._read_control_kwargs(),
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
if access_error:
|
|
1256
|
+
error_type = getattr(access_error, "error_type", None)
|
|
1257
|
+
if error_type == DatabaseInstanceChangedError.__name__:
|
|
1258
|
+
raise DatabaseInstanceChangedError(access_error)
|
|
1259
|
+
if error_type == UnverifiableDatabaseWalError.__name__:
|
|
1260
|
+
raise UnverifiableDatabaseWalError(access_error)
|
|
1261
|
+
raise RuntimeError(access_error)
|
|
1262
|
+
|
|
1263
|
+
self._emit_status("Opening snapshot fallback read-only...")
|
|
1264
|
+
self._emit_status("Loading basic run list...")
|
|
1265
|
+
return get_runs_basic_via_sql(
|
|
1266
|
+
self._read_database_path,
|
|
1267
|
+
connection_callback=self._set_sql_connection,
|
|
1268
|
+
**self._read_control_kwargs(),
|
|
1269
|
+
) or {}
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _is_cancelled(self):
|
|
1273
|
+
return self._cancelled.is_set()
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _emit_status(self, message):
|
|
1277
|
+
with self._publication_lock:
|
|
1278
|
+
if self._cancelled.is_set():
|
|
1279
|
+
return
|
|
1280
|
+
try:
|
|
1281
|
+
self.signals.status.emit(self.generation, message)
|
|
1282
|
+
except RuntimeError as err:
|
|
1283
|
+
if not self._qt_signal_was_deleted(err):
|
|
1284
|
+
raise
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _emit_finished(self, runs, error):
|
|
1288
|
+
if not isinstance(error, DatabaseInstanceChangedError):
|
|
1289
|
+
try:
|
|
1290
|
+
self._require_current_database_instance()
|
|
1291
|
+
except DatabaseInstanceChangedError as err:
|
|
1292
|
+
runs = {}
|
|
1293
|
+
error = err
|
|
1294
|
+
if error is None:
|
|
1295
|
+
runs = _bounded_runs_publication(runs)
|
|
1296
|
+
else:
|
|
1297
|
+
runs = {}
|
|
1298
|
+
error = _bounded_worker_error(error)
|
|
1299
|
+
with self._publication_lock:
|
|
1300
|
+
if self._cancelled.is_set():
|
|
1301
|
+
return
|
|
1302
|
+
try:
|
|
1303
|
+
self.signals.finished.emit(
|
|
1304
|
+
self.generation,
|
|
1305
|
+
self._signal_database_path,
|
|
1306
|
+
runs,
|
|
1307
|
+
error,
|
|
1308
|
+
)
|
|
1309
|
+
except RuntimeError as err:
|
|
1310
|
+
if not self._qt_signal_was_deleted(err):
|
|
1311
|
+
raise
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _qt_signal_was_deleted(self, err):
|
|
1315
|
+
message = str(err)
|
|
1316
|
+
return "wrapped C/C++ object" in message and "has been deleted" in message
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _prefetch_cloud_file(self):
|
|
1320
|
+
timeout = self.cloud_sync_timeout
|
|
1321
|
+
if self.deadline is not None:
|
|
1322
|
+
remaining = self.deadline - monotonic()
|
|
1323
|
+
if remaining <= 0:
|
|
1324
|
+
raise TimeoutError("Database load deadline exceeded.")
|
|
1325
|
+
timeout = min(timeout, remaining)
|
|
1326
|
+
prefetch_database_file_with_timeout(
|
|
1327
|
+
self._read_database_path,
|
|
1328
|
+
timeout=timeout,
|
|
1329
|
+
status_callback=self._emit_status,
|
|
1330
|
+
cancelled_callback=self._is_cancelled,
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
class _PrioritizedRunWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
|
|
1335
|
+
"""
|
|
1336
|
+
Base for background workers that need selected/visible run prioritisation.
|
|
1337
|
+
|
|
1338
|
+
"""
|
|
1339
|
+
|
|
1340
|
+
_trusted_selected_priority = TrustedReadPriority.SELECTED_CHEAP
|
|
1341
|
+
_trusted_visible_priority = TrustedReadPriority.VISIBLE_CHEAP
|
|
1342
|
+
_trusted_remaining_priority = TrustedReadPriority.REMAINING_CHEAP
|
|
1343
|
+
|
|
1344
|
+
def __init__(
|
|
1345
|
+
self,
|
|
1346
|
+
generation,
|
|
1347
|
+
database_path,
|
|
1348
|
+
run_ids,
|
|
1349
|
+
*,
|
|
1350
|
+
expected_database_instance=None,
|
|
1351
|
+
deadline=None,
|
|
1352
|
+
trusted_service=None,
|
|
1353
|
+
):
|
|
1354
|
+
super().__init__()
|
|
1355
|
+
self.signals = DatabaseDetailSignals()
|
|
1356
|
+
self.generation = generation
|
|
1357
|
+
self._init_database_binding(
|
|
1358
|
+
database_path,
|
|
1359
|
+
expected_database_instance,
|
|
1360
|
+
deadline,
|
|
1361
|
+
)
|
|
1362
|
+
self.run_ids = list(run_ids or [])
|
|
1363
|
+
self._default_run_order = {
|
|
1364
|
+
run_id: index
|
|
1365
|
+
for index, run_id in enumerate(self.run_ids)
|
|
1366
|
+
}
|
|
1367
|
+
self._cancelled = threading.Event()
|
|
1368
|
+
self._init_sql_interrupt()
|
|
1369
|
+
self._priority_lock = threading.Lock()
|
|
1370
|
+
self._priority_scores = {}
|
|
1371
|
+
self._promoted_run_ids = []
|
|
1372
|
+
self._trusted_active_run_id = None
|
|
1373
|
+
self._accepting_run_ids = True
|
|
1374
|
+
self.trusted_service = trusted_service
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def cancel(self):
|
|
1378
|
+
with self._priority_lock:
|
|
1379
|
+
self._accepting_run_ids = False
|
|
1380
|
+
self._cancel_read()
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def add_run_ids(self, run_ids):
|
|
1384
|
+
"""Append trusted incremental work without replaying completed runs."""
|
|
1385
|
+
candidates = []
|
|
1386
|
+
for run_id in run_ids or ():
|
|
1387
|
+
try:
|
|
1388
|
+
candidate = int(run_id)
|
|
1389
|
+
except (TypeError, ValueError):
|
|
1390
|
+
continue
|
|
1391
|
+
if candidate > 0:
|
|
1392
|
+
candidates.append(candidate)
|
|
1393
|
+
|
|
1394
|
+
with self._priority_lock:
|
|
1395
|
+
if not self._accepting_run_ids or self._cancelled.is_set():
|
|
1396
|
+
return False
|
|
1397
|
+
known = {
|
|
1398
|
+
int(run_id)
|
|
1399
|
+
for run_id in self.run_ids
|
|
1400
|
+
if str(run_id).lstrip("-").isdigit()
|
|
1401
|
+
}
|
|
1402
|
+
additions = [run_id for run_id in candidates if run_id not in known]
|
|
1403
|
+
for run_id in additions:
|
|
1404
|
+
self._default_run_order[run_id] = len(self.run_ids)
|
|
1405
|
+
self.run_ids.append(run_id)
|
|
1406
|
+
known.add(run_id)
|
|
1407
|
+
return True
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
def prioritize_run_ids(self, run_ids):
|
|
1411
|
+
normalised = []
|
|
1412
|
+
seen = set()
|
|
1413
|
+
for run_id in run_ids or []:
|
|
1414
|
+
run_id = self._normalise_run_id(run_id)
|
|
1415
|
+
if run_id is None or run_id in seen:
|
|
1416
|
+
continue
|
|
1417
|
+
normalised.append(run_id)
|
|
1418
|
+
seen.add(run_id)
|
|
1419
|
+
|
|
1420
|
+
with self._priority_lock:
|
|
1421
|
+
# This is a replacement snapshot of the selected/visible rows, not
|
|
1422
|
+
# an accumulating promotion log. Omitted rows immediately return
|
|
1423
|
+
# to their stable table order, and an empty snapshot clears every
|
|
1424
|
+
# prior viewport/selection promotion.
|
|
1425
|
+
base_score = -len(normalised)
|
|
1426
|
+
self._priority_scores = {
|
|
1427
|
+
run_id: base_score + offset
|
|
1428
|
+
for offset, run_id in enumerate(normalised)
|
|
1429
|
+
}
|
|
1430
|
+
self._promoted_run_ids = normalised
|
|
1431
|
+
active_run_id = self._trusted_active_run_id
|
|
1432
|
+
with self._trusted_request_lock:
|
|
1433
|
+
request = self._trusted_request
|
|
1434
|
+
if active_run_id is not None and request is not None:
|
|
1435
|
+
requested_priority = self._trusted_priority_for_run_locked(
|
|
1436
|
+
active_run_id,
|
|
1437
|
+
promoted_order=normalised,
|
|
1438
|
+
)
|
|
1439
|
+
reprioritize = getattr(request, "reprioritize", None)
|
|
1440
|
+
if callable(reprioritize):
|
|
1441
|
+
reprioritize(requested_priority)
|
|
1442
|
+
elif active_run_id in normalised:
|
|
1443
|
+
# Compatibility with narrow request doubles that predate
|
|
1444
|
+
# bidirectional trusted request priorities.
|
|
1445
|
+
request.promote(requested_priority)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _is_cancelled(self):
|
|
1449
|
+
return self._cancelled.is_set()
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def _normalise_run_id(self, run_id):
|
|
1453
|
+
if run_id in self._default_run_order:
|
|
1454
|
+
return run_id
|
|
1455
|
+
|
|
1456
|
+
try:
|
|
1457
|
+
int_run_id = int(run_id)
|
|
1458
|
+
except (TypeError, ValueError):
|
|
1459
|
+
return None
|
|
1460
|
+
|
|
1461
|
+
if int_run_id in self._default_run_order:
|
|
1462
|
+
return int_run_id
|
|
1463
|
+
text_run_id = str(int_run_id)
|
|
1464
|
+
if text_run_id in self._default_run_order:
|
|
1465
|
+
return text_run_id
|
|
1466
|
+
return None
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def _next_priority_batch(self, done, batch_size):
|
|
1470
|
+
with self._priority_lock:
|
|
1471
|
+
priority_scores = dict(self._priority_scores)
|
|
1472
|
+
run_ids = tuple(self.run_ids)
|
|
1473
|
+
default_run_order = dict(self._default_run_order)
|
|
1474
|
+
|
|
1475
|
+
candidates = [run_id for run_id in run_ids if run_id not in done]
|
|
1476
|
+
if not candidates:
|
|
1477
|
+
return []
|
|
1478
|
+
|
|
1479
|
+
def sort_key(run_id):
|
|
1480
|
+
return (
|
|
1481
|
+
priority_scores.get(run_id, 0),
|
|
1482
|
+
default_run_order.get(run_id, 0),
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
candidates.sort(key=sort_key)
|
|
1486
|
+
return candidates[:max(1, int(batch_size or 1))]
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _next_trusted_run(self, done):
|
|
1490
|
+
"""Choose one run or atomically stop accepting dynamic additions."""
|
|
1491
|
+
with self._priority_lock:
|
|
1492
|
+
candidates = [run_id for run_id in self.run_ids if run_id not in done]
|
|
1493
|
+
if not candidates:
|
|
1494
|
+
self._accepting_run_ids = False
|
|
1495
|
+
return None
|
|
1496
|
+
priority_scores = dict(self._priority_scores)
|
|
1497
|
+
default_run_order = dict(self._default_run_order)
|
|
1498
|
+
candidates.sort(
|
|
1499
|
+
key=lambda run_id: (
|
|
1500
|
+
priority_scores.get(run_id, 0),
|
|
1501
|
+
default_run_order.get(run_id, 0),
|
|
1502
|
+
)
|
|
1503
|
+
)
|
|
1504
|
+
return candidates[0]
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def _trusted_priority_for_run(self, run_id, promoted_order=None):
|
|
1508
|
+
with self._priority_lock:
|
|
1509
|
+
return self._trusted_priority_for_run_locked(
|
|
1510
|
+
run_id,
|
|
1511
|
+
promoted_order=promoted_order,
|
|
1512
|
+
)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _trusted_priority_for_run_locked(self, run_id, promoted_order=None):
|
|
1516
|
+
"""Return one trusted priority while ``_priority_lock`` is held."""
|
|
1517
|
+
|
|
1518
|
+
order = list(
|
|
1519
|
+
self._promoted_run_ids
|
|
1520
|
+
if promoted_order is None
|
|
1521
|
+
else promoted_order
|
|
1522
|
+
)
|
|
1523
|
+
if run_id in order:
|
|
1524
|
+
return (
|
|
1525
|
+
self._trusted_selected_priority
|
|
1526
|
+
if order.index(run_id) == 0
|
|
1527
|
+
else self._trusted_visible_priority
|
|
1528
|
+
)
|
|
1529
|
+
return self._trusted_remaining_priority
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _submit_next_trusted_run(self, done, submit):
|
|
1533
|
+
"""Choose, submit, and install one request as one priority transition."""
|
|
1534
|
+
|
|
1535
|
+
with self._priority_lock:
|
|
1536
|
+
candidates = [run_id for run_id in self.run_ids if run_id not in done]
|
|
1537
|
+
if not candidates:
|
|
1538
|
+
self._accepting_run_ids = False
|
|
1539
|
+
return None
|
|
1540
|
+
candidates.sort(
|
|
1541
|
+
key=lambda run_id: (
|
|
1542
|
+
self._priority_scores.get(run_id, 0),
|
|
1543
|
+
self._default_run_order.get(run_id, 0),
|
|
1544
|
+
)
|
|
1545
|
+
)
|
|
1546
|
+
run_id = candidates[0]
|
|
1547
|
+
priority = self._trusted_priority_for_run_locked(run_id)
|
|
1548
|
+
self._trusted_active_run_id = run_id
|
|
1549
|
+
try:
|
|
1550
|
+
request = submit(run_id, priority)
|
|
1551
|
+
except Exception:
|
|
1552
|
+
self._trusted_active_run_id = None
|
|
1553
|
+
raise
|
|
1554
|
+
# ``prioritize_run_ids`` takes these locks in the same order. It
|
|
1555
|
+
# therefore observes either no active run or an installed request;
|
|
1556
|
+
# promotion cannot fall into the former submit/install gap.
|
|
1557
|
+
with self._trusted_request_lock:
|
|
1558
|
+
self._trusted_request = request
|
|
1559
|
+
return run_id, request
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _run_trusted_details(self, status_label, submit):
|
|
1563
|
+
with self._priority_lock:
|
|
1564
|
+
total = len(self.run_ids)
|
|
1565
|
+
if total == 0:
|
|
1566
|
+
self._emit_finished(None)
|
|
1567
|
+
return
|
|
1568
|
+
done = set()
|
|
1569
|
+
first_error = None
|
|
1570
|
+
self._emit_status(f"{status_label} 0/{total}")
|
|
1571
|
+
while True:
|
|
1572
|
+
if self._is_cancelled():
|
|
1573
|
+
return
|
|
1574
|
+
submitted = self._submit_next_trusted_run(done, submit)
|
|
1575
|
+
if submitted is None:
|
|
1576
|
+
break
|
|
1577
|
+
run_id, request = submitted
|
|
1578
|
+
try:
|
|
1579
|
+
record = self._wait_trusted_request(request)
|
|
1580
|
+
except Exception as error:
|
|
1581
|
+
if self._is_cancelled():
|
|
1582
|
+
return
|
|
1583
|
+
service = self.trusted_service
|
|
1584
|
+
if (
|
|
1585
|
+
service is None
|
|
1586
|
+
or isinstance(error, DatabaseInstanceChangedError)
|
|
1587
|
+
or TrustedLiveReadService._terminal_session_error(error)
|
|
1588
|
+
or bool(getattr(service, "closing", False))
|
|
1589
|
+
or bool(getattr(service, "closed", False))
|
|
1590
|
+
):
|
|
1591
|
+
raise
|
|
1592
|
+
if first_error is None:
|
|
1593
|
+
first_error = error
|
|
1594
|
+
done.add(run_id)
|
|
1595
|
+
finally:
|
|
1596
|
+
with self._priority_lock:
|
|
1597
|
+
if self._trusted_active_run_id == run_id:
|
|
1598
|
+
self._trusted_active_run_id = None
|
|
1599
|
+
if self._is_cancelled():
|
|
1600
|
+
return
|
|
1601
|
+
if run_id not in done:
|
|
1602
|
+
self._emit_batch_ready({record.run_id: record.as_dict()})
|
|
1603
|
+
done.add(run_id)
|
|
1604
|
+
with self._priority_lock:
|
|
1605
|
+
total = len(self.run_ids)
|
|
1606
|
+
self._emit_status(f"{status_label} {len(done)}/{total}")
|
|
1607
|
+
self._emit_finished(first_error)
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def _emit_status(self, message):
|
|
1611
|
+
with self._publication_lock:
|
|
1612
|
+
if self._cancelled.is_set():
|
|
1613
|
+
return
|
|
1614
|
+
try:
|
|
1615
|
+
self.signals.status.emit(self.generation, message)
|
|
1616
|
+
except RuntimeError as err:
|
|
1617
|
+
if not self._qt_signal_was_deleted(err):
|
|
1618
|
+
raise
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _emit_batch_ready(self, details):
|
|
1622
|
+
self._require_current_database_instance()
|
|
1623
|
+
details = _bounded_runs_publication(details)
|
|
1624
|
+
with self._publication_lock:
|
|
1625
|
+
if self._cancelled.is_set():
|
|
1626
|
+
return
|
|
1627
|
+
try:
|
|
1628
|
+
self.signals.batch_ready.emit(
|
|
1629
|
+
self.generation,
|
|
1630
|
+
self._signal_database_path,
|
|
1631
|
+
details,
|
|
1632
|
+
)
|
|
1633
|
+
except RuntimeError as err:
|
|
1634
|
+
if not self._qt_signal_was_deleted(err):
|
|
1635
|
+
raise
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _emit_finished(self, error):
|
|
1639
|
+
with self._priority_lock:
|
|
1640
|
+
self._accepting_run_ids = False
|
|
1641
|
+
if not isinstance(error, DatabaseInstanceChangedError):
|
|
1642
|
+
try:
|
|
1643
|
+
self._require_current_database_instance()
|
|
1644
|
+
except DatabaseInstanceChangedError as err:
|
|
1645
|
+
error = err
|
|
1646
|
+
error = _bounded_worker_error(error)
|
|
1647
|
+
with self._publication_lock:
|
|
1648
|
+
if self._cancelled.is_set():
|
|
1649
|
+
return
|
|
1650
|
+
try:
|
|
1651
|
+
self.signals.finished.emit(
|
|
1652
|
+
self.generation,
|
|
1653
|
+
self._signal_database_path,
|
|
1654
|
+
error,
|
|
1655
|
+
)
|
|
1656
|
+
except RuntimeError as err:
|
|
1657
|
+
if not self._qt_signal_was_deleted(err):
|
|
1658
|
+
raise
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def _qt_signal_was_deleted(self, err):
|
|
1662
|
+
message = str(err)
|
|
1663
|
+
return "wrapped C/C++ object" in message and "has been deleted" in message
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
class DatabaseDetailWorker(_PrioritizedRunWorker):
|
|
1667
|
+
"""
|
|
1668
|
+
Loads cheap per-run metadata after the basic run table is visible.
|
|
1669
|
+
|
|
1670
|
+
"""
|
|
1671
|
+
|
|
1672
|
+
def __init__(
|
|
1673
|
+
self,
|
|
1674
|
+
generation,
|
|
1675
|
+
database_path,
|
|
1676
|
+
run_ids,
|
|
1677
|
+
batch_size=1,
|
|
1678
|
+
*,
|
|
1679
|
+
expected_database_instance=None,
|
|
1680
|
+
deadline=None,
|
|
1681
|
+
trusted_service=None,
|
|
1682
|
+
):
|
|
1683
|
+
super().__init__(
|
|
1684
|
+
generation,
|
|
1685
|
+
database_path,
|
|
1686
|
+
run_ids,
|
|
1687
|
+
expected_database_instance=expected_database_instance,
|
|
1688
|
+
deadline=deadline,
|
|
1689
|
+
trusted_service=trusted_service,
|
|
1690
|
+
)
|
|
1691
|
+
self.batch_size = max(1, int(batch_size or 1))
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def run(self):
|
|
1695
|
+
total = len(self.run_ids)
|
|
1696
|
+
completed = 0
|
|
1697
|
+
try:
|
|
1698
|
+
if self._is_cancelled():
|
|
1699
|
+
return
|
|
1700
|
+
if self.trusted_service is not None:
|
|
1701
|
+
self._run_trusted_details(
|
|
1702
|
+
"Loading cheap run metadata...",
|
|
1703
|
+
lambda run_id, priority:
|
|
1704
|
+
self.trusted_service.submit_cheap_run(
|
|
1705
|
+
int(run_id),
|
|
1706
|
+
priority=priority,
|
|
1707
|
+
deadline=self.deadline,
|
|
1708
|
+
),
|
|
1709
|
+
)
|
|
1710
|
+
return
|
|
1711
|
+
if total == 0:
|
|
1712
|
+
self._emit_finished(None)
|
|
1713
|
+
return
|
|
1714
|
+
|
|
1715
|
+
self._emit_status(f"Loading run details... 0/{total}")
|
|
1716
|
+
done = set()
|
|
1717
|
+
while len(done) < total:
|
|
1718
|
+
if self._is_cancelled():
|
|
1719
|
+
return
|
|
1720
|
+
|
|
1721
|
+
batch = self._next_priority_batch(done, self.batch_size)
|
|
1722
|
+
if not batch:
|
|
1723
|
+
break
|
|
1724
|
+
self._require_current_database_instance()
|
|
1725
|
+
batches = iter_run_detail_batches_via_sql(
|
|
1726
|
+
self._read_database_path,
|
|
1727
|
+
batch,
|
|
1728
|
+
batch_size=self.batch_size,
|
|
1729
|
+
infer_missing_shapes=False,
|
|
1730
|
+
include_storage_bytes=False,
|
|
1731
|
+
include_storage_estimate=True,
|
|
1732
|
+
include_read_setpoint_count=True,
|
|
1733
|
+
connection_callback=self._set_sql_connection,
|
|
1734
|
+
**self._read_control_kwargs(),
|
|
1735
|
+
)
|
|
1736
|
+
try:
|
|
1737
|
+
for details in batches:
|
|
1738
|
+
if self._is_cancelled():
|
|
1739
|
+
return
|
|
1740
|
+
if details:
|
|
1741
|
+
self._emit_batch_ready(details)
|
|
1742
|
+
finally:
|
|
1743
|
+
self._close_batch_iterator(batches)
|
|
1744
|
+
|
|
1745
|
+
done.update(batch)
|
|
1746
|
+
completed = len(done)
|
|
1747
|
+
self._emit_status(
|
|
1748
|
+
f"Loading run details... {min(completed, total)}/{total}"
|
|
1749
|
+
)
|
|
1750
|
+
except InterruptedError:
|
|
1751
|
+
return
|
|
1752
|
+
except DatabaseInstanceChangedError as err:
|
|
1753
|
+
if self._is_cancelled():
|
|
1754
|
+
return
|
|
1755
|
+
self._emit_finished(err)
|
|
1756
|
+
return
|
|
1757
|
+
except Exception as err:
|
|
1758
|
+
if self._is_cancelled():
|
|
1759
|
+
return
|
|
1760
|
+
log_exception("Database detail worker failed", err, __name__)
|
|
1761
|
+
self._emit_finished(err)
|
|
1762
|
+
return
|
|
1763
|
+
|
|
1764
|
+
self._emit_finished(None)
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
class DatabaseExpensiveDetailWorker(_PrioritizedRunWorker):
|
|
1768
|
+
"""
|
|
1769
|
+
Loads expensive shape and storage metadata in priority order.
|
|
1770
|
+
|
|
1771
|
+
"""
|
|
1772
|
+
|
|
1773
|
+
_trusted_selected_priority = TrustedReadPriority.SELECTED_EXPENSIVE
|
|
1774
|
+
_trusted_visible_priority = TrustedReadPriority.VISIBLE_EXPENSIVE
|
|
1775
|
+
_trusted_remaining_priority = TrustedReadPriority.REMAINING_EXPENSIVE
|
|
1776
|
+
|
|
1777
|
+
def __init__(
|
|
1778
|
+
self,
|
|
1779
|
+
generation,
|
|
1780
|
+
database_path,
|
|
1781
|
+
run_ids,
|
|
1782
|
+
batch_size=10,
|
|
1783
|
+
*,
|
|
1784
|
+
expected_database_instance=None,
|
|
1785
|
+
deadline=None,
|
|
1786
|
+
trusted_service=None,
|
|
1787
|
+
):
|
|
1788
|
+
super().__init__(
|
|
1789
|
+
generation,
|
|
1790
|
+
database_path,
|
|
1791
|
+
run_ids,
|
|
1792
|
+
expected_database_instance=expected_database_instance,
|
|
1793
|
+
deadline=deadline,
|
|
1794
|
+
trusted_service=trusted_service,
|
|
1795
|
+
)
|
|
1796
|
+
self.batch_size = max(1, int(batch_size or 1))
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def run(self):
|
|
1800
|
+
total = len(self.run_ids)
|
|
1801
|
+
try:
|
|
1802
|
+
if self._is_cancelled():
|
|
1803
|
+
return
|
|
1804
|
+
if self.trusted_service is not None:
|
|
1805
|
+
self._run_trusted_details(
|
|
1806
|
+
"Loading counts, shapes, and sizes...",
|
|
1807
|
+
lambda run_id, priority:
|
|
1808
|
+
self.trusted_service.submit_expensive_run(
|
|
1809
|
+
int(run_id),
|
|
1810
|
+
priority=priority,
|
|
1811
|
+
deadline=self.deadline,
|
|
1812
|
+
),
|
|
1813
|
+
)
|
|
1814
|
+
return
|
|
1815
|
+
if total == 0:
|
|
1816
|
+
self._emit_finished(None)
|
|
1817
|
+
return
|
|
1818
|
+
|
|
1819
|
+
shape_done = set()
|
|
1820
|
+
self._emit_status(f"Loading setpoint shapes... 0/{total}")
|
|
1821
|
+
while len(shape_done) < total:
|
|
1822
|
+
if self._is_cancelled():
|
|
1823
|
+
return
|
|
1824
|
+
|
|
1825
|
+
batch = self._next_priority_batch(
|
|
1826
|
+
shape_done,
|
|
1827
|
+
batch_size=self.batch_size,
|
|
1828
|
+
)
|
|
1829
|
+
if not batch:
|
|
1830
|
+
break
|
|
1831
|
+
|
|
1832
|
+
self._require_current_database_instance()
|
|
1833
|
+
batches = iter_run_shape_batches_via_sql(
|
|
1834
|
+
self._read_database_path,
|
|
1835
|
+
batch,
|
|
1836
|
+
batch_size=self.batch_size,
|
|
1837
|
+
connection_callback=self._set_sql_connection,
|
|
1838
|
+
**self._read_control_kwargs(),
|
|
1839
|
+
)
|
|
1840
|
+
try:
|
|
1841
|
+
for shapes in batches:
|
|
1842
|
+
if self._is_cancelled():
|
|
1843
|
+
return
|
|
1844
|
+
|
|
1845
|
+
if shapes:
|
|
1846
|
+
self._emit_batch_ready(shapes)
|
|
1847
|
+
finally:
|
|
1848
|
+
self._close_batch_iterator(batches)
|
|
1849
|
+
|
|
1850
|
+
shape_done.update(batch)
|
|
1851
|
+
self._emit_status(
|
|
1852
|
+
f"Loading setpoint shapes... {len(shape_done)}/{total}"
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
storage_done = set()
|
|
1856
|
+
storage_batch_size = max(25, self.batch_size)
|
|
1857
|
+
self._emit_status(f"Loading exact run sizes... 0/{total}")
|
|
1858
|
+
while len(storage_done) < total:
|
|
1859
|
+
if self._is_cancelled():
|
|
1860
|
+
return
|
|
1861
|
+
|
|
1862
|
+
batch = self._next_priority_batch(
|
|
1863
|
+
storage_done,
|
|
1864
|
+
batch_size=storage_batch_size,
|
|
1865
|
+
)
|
|
1866
|
+
if not batch:
|
|
1867
|
+
break
|
|
1868
|
+
|
|
1869
|
+
self._require_current_database_instance()
|
|
1870
|
+
batches = iter_run_storage_batches_via_sql(
|
|
1871
|
+
self._read_database_path,
|
|
1872
|
+
batch,
|
|
1873
|
+
batch_size=storage_batch_size,
|
|
1874
|
+
connection_callback=self._set_sql_connection,
|
|
1875
|
+
**self._read_control_kwargs(),
|
|
1876
|
+
)
|
|
1877
|
+
try:
|
|
1878
|
+
for storage in batches:
|
|
1879
|
+
if self._is_cancelled():
|
|
1880
|
+
return
|
|
1881
|
+
|
|
1882
|
+
if storage:
|
|
1883
|
+
self._emit_batch_ready(storage)
|
|
1884
|
+
finally:
|
|
1885
|
+
self._close_batch_iterator(batches)
|
|
1886
|
+
|
|
1887
|
+
storage_done.update(batch)
|
|
1888
|
+
self._emit_status(
|
|
1889
|
+
f"Loading exact run sizes... {len(storage_done)}/{total}"
|
|
1890
|
+
)
|
|
1891
|
+
except InterruptedError:
|
|
1892
|
+
return
|
|
1893
|
+
except DatabaseInstanceChangedError as err:
|
|
1894
|
+
if self._is_cancelled():
|
|
1895
|
+
return
|
|
1896
|
+
self._emit_finished(err)
|
|
1897
|
+
return
|
|
1898
|
+
except Exception as err:
|
|
1899
|
+
if self._is_cancelled():
|
|
1900
|
+
return
|
|
1901
|
+
log_exception("Expensive database detail worker failed", err, __name__)
|
|
1902
|
+
self._emit_finished(err)
|
|
1903
|
+
return
|
|
1904
|
+
|
|
1905
|
+
self._emit_finished(None)
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
class DatabaseSelectedRunWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
|
|
1909
|
+
"""Load a plain selected-run view off-GUI for either accepted read mode."""
|
|
1910
|
+
|
|
1911
|
+
def __init__(
|
|
1912
|
+
self,
|
|
1913
|
+
generation,
|
|
1914
|
+
database_path,
|
|
1915
|
+
run_id,
|
|
1916
|
+
guid,
|
|
1917
|
+
trusted_service=None,
|
|
1918
|
+
*,
|
|
1919
|
+
run_metadata=None,
|
|
1920
|
+
expected_database_instance=None,
|
|
1921
|
+
deadline=None,
|
|
1922
|
+
):
|
|
1923
|
+
super().__init__()
|
|
1924
|
+
if trusted_service is not None and not isinstance(
|
|
1925
|
+
trusted_service,
|
|
1926
|
+
TrustedLiveReadService,
|
|
1927
|
+
):
|
|
1928
|
+
raise TypeError(
|
|
1929
|
+
"trusted_service must be a TrustedLiveReadService or None."
|
|
1930
|
+
)
|
|
1931
|
+
self.signals = DatabaseSelectedRunSignals()
|
|
1932
|
+
self.generation = generation
|
|
1933
|
+
self.run_id = int(run_id)
|
|
1934
|
+
self.guid = str(guid)
|
|
1935
|
+
self.trusted_service = trusted_service
|
|
1936
|
+
self.run_metadata = dict(run_metadata or {})
|
|
1937
|
+
self._init_database_binding(
|
|
1938
|
+
database_path,
|
|
1939
|
+
expected_database_instance,
|
|
1940
|
+
deadline,
|
|
1941
|
+
)
|
|
1942
|
+
self._cancelled = threading.Event()
|
|
1943
|
+
self._init_sql_interrupt()
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def cancel(self):
|
|
1947
|
+
self._cancel_read()
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def run(self):
|
|
1951
|
+
try:
|
|
1952
|
+
if self._cancelled.is_set():
|
|
1953
|
+
return
|
|
1954
|
+
self._require_current_database_instance()
|
|
1955
|
+
if self.trusted_service is None:
|
|
1956
|
+
detail = self._run_snapshot_detail()
|
|
1957
|
+
self._require_current_database_instance()
|
|
1958
|
+
self._emit_finished(detail, None)
|
|
1959
|
+
return
|
|
1960
|
+
self._wait_trusted_request(
|
|
1961
|
+
self.trusted_service.submit_cheap_run(
|
|
1962
|
+
self.run_id,
|
|
1963
|
+
priority=TrustedReadPriority.SELECTED_CHEAP,
|
|
1964
|
+
deadline=self.deadline,
|
|
1965
|
+
)
|
|
1966
|
+
)
|
|
1967
|
+
initial_detail = self._wait_trusted_request(
|
|
1968
|
+
self.trusted_service.submit_selected_run(
|
|
1969
|
+
self.run_id,
|
|
1970
|
+
priority=TrustedReadPriority.SELECTED_CHEAP,
|
|
1971
|
+
deadline=self.deadline,
|
|
1972
|
+
)
|
|
1973
|
+
)
|
|
1974
|
+
self._emit_progress(initial_detail)
|
|
1975
|
+
if self._cancelled.is_set():
|
|
1976
|
+
return
|
|
1977
|
+
self._wait_trusted_request(
|
|
1978
|
+
self.trusted_service.submit_expensive_run(
|
|
1979
|
+
self.run_id,
|
|
1980
|
+
priority=TrustedReadPriority.SELECTED_EXPENSIVE,
|
|
1981
|
+
deadline=self.deadline,
|
|
1982
|
+
)
|
|
1983
|
+
)
|
|
1984
|
+
detail = self._wait_trusted_request(
|
|
1985
|
+
self.trusted_service.submit_selected_run(
|
|
1986
|
+
self.run_id,
|
|
1987
|
+
priority=TrustedReadPriority.SELECTED_EXPENSIVE,
|
|
1988
|
+
deadline=self.deadline,
|
|
1989
|
+
)
|
|
1990
|
+
)
|
|
1991
|
+
self._require_current_database_instance()
|
|
1992
|
+
except (InterruptedError, TrustedReadRequestCancelledError):
|
|
1993
|
+
return
|
|
1994
|
+
except Exception as error:
|
|
1995
|
+
if self._cancelled.is_set():
|
|
1996
|
+
return
|
|
1997
|
+
log_exception("Selected-run detail worker failed", error, __name__)
|
|
1998
|
+
self._emit_finished(None, error)
|
|
1999
|
+
return
|
|
2000
|
+
self._emit_finished(detail, None)
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _run_snapshot_detail(self):
|
|
2004
|
+
return get_snapshot_selected_run_detail(
|
|
2005
|
+
self._read_database_path,
|
|
2006
|
+
self.run_id,
|
|
2007
|
+
self.guid,
|
|
2008
|
+
self.run_metadata,
|
|
2009
|
+
connection_callback=self._set_sql_connection,
|
|
2010
|
+
**self._read_control_kwargs(),
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
def _emit_progress(self, detail):
|
|
2015
|
+
self._require_current_database_instance()
|
|
2016
|
+
with self._publication_lock:
|
|
2017
|
+
if self._cancelled.is_set():
|
|
2018
|
+
return
|
|
2019
|
+
self.signals.progress.emit(
|
|
2020
|
+
self.generation,
|
|
2021
|
+
self._signal_database_path,
|
|
2022
|
+
self.guid,
|
|
2023
|
+
detail,
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def _emit_finished(self, detail, error):
|
|
2028
|
+
if not isinstance(error, DatabaseInstanceChangedError):
|
|
2029
|
+
try:
|
|
2030
|
+
self._require_current_database_instance()
|
|
2031
|
+
except DatabaseInstanceChangedError as changed_error:
|
|
2032
|
+
detail = None
|
|
2033
|
+
error = changed_error
|
|
2034
|
+
if error is not None:
|
|
2035
|
+
detail = None
|
|
2036
|
+
error = _bounded_worker_error(error)
|
|
2037
|
+
with self._publication_lock:
|
|
2038
|
+
if self._cancelled.is_set():
|
|
2039
|
+
return
|
|
2040
|
+
self.signals.finished.emit(
|
|
2041
|
+
self.generation,
|
|
2042
|
+
self._signal_database_path,
|
|
2043
|
+
self.guid,
|
|
2044
|
+
detail,
|
|
2045
|
+
error,
|
|
2046
|
+
)
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
class DatabaseInfoSignals(QtCore.QObject):
|
|
2050
|
+
finished = QtCore.pyqtSignal(int, str, object, object)
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
class DatabaseInfoWorker(_InterruptibleSqlWorker, QtCore.QRunnable):
|
|
2054
|
+
"""Read diagnostic metadata with cancellable source-bound background work."""
|
|
2055
|
+
|
|
2056
|
+
def __init__(
|
|
2057
|
+
self,
|
|
2058
|
+
generation,
|
|
2059
|
+
database_path,
|
|
2060
|
+
*,
|
|
2061
|
+
expected_database_instance=None,
|
|
2062
|
+
deadline=None,
|
|
2063
|
+
trusted_service=None,
|
|
2064
|
+
):
|
|
2065
|
+
super().__init__()
|
|
2066
|
+
self.signals = DatabaseInfoSignals()
|
|
2067
|
+
self.generation = generation
|
|
2068
|
+
self._init_database_binding(
|
|
2069
|
+
database_path, expected_database_instance, deadline,
|
|
2070
|
+
)
|
|
2071
|
+
self._cancelled = threading.Event()
|
|
2072
|
+
self._init_sql_interrupt()
|
|
2073
|
+
self.trusted_service = trusted_service
|
|
2074
|
+
|
|
2075
|
+
def cancel(self):
|
|
2076
|
+
self._cancel_read()
|
|
2077
|
+
|
|
2078
|
+
def run(self):
|
|
2079
|
+
rows = None
|
|
2080
|
+
error = None
|
|
2081
|
+
try:
|
|
2082
|
+
if self._cancelled.is_set():
|
|
2083
|
+
return
|
|
2084
|
+
self._require_current_database_instance()
|
|
2085
|
+
if self.trusted_service is not None:
|
|
2086
|
+
trusted_info = self._wait_trusted_request(
|
|
2087
|
+
self.trusted_service.submit_database_info(deadline=self.deadline)
|
|
2088
|
+
)
|
|
2089
|
+
summary = _database_info_summary(
|
|
2090
|
+
self._read_database_path, trusted_info=trusted_info,
|
|
2091
|
+
)
|
|
2092
|
+
else:
|
|
2093
|
+
summary = _database_info_summary(
|
|
2094
|
+
self._read_database_path,
|
|
2095
|
+
connection_callback=self._set_sql_connection,
|
|
2096
|
+
**self._read_control_kwargs(),
|
|
2097
|
+
)
|
|
2098
|
+
rows = [row for group in _database_info_groups(summary) for row in group]
|
|
2099
|
+
self._require_current_database_instance()
|
|
2100
|
+
except (InterruptedError, TrustedReadRequestCancelledError) as err:
|
|
2101
|
+
if self._cancelled.is_set():
|
|
2102
|
+
return
|
|
2103
|
+
error = _bounded_worker_error(err)
|
|
2104
|
+
except Exception as err:
|
|
2105
|
+
rows = None
|
|
2106
|
+
error = _bounded_worker_error(err)
|
|
2107
|
+
with self._publication_lock:
|
|
2108
|
+
if not self._cancelled.is_set():
|
|
2109
|
+
self.signals.finished.emit(
|
|
2110
|
+
self.generation, self._signal_database_path, rows, error,
|
|
2111
|
+
)
|
|
2112
|
+
|
|
2113
|
+
|
|
2114
|
+
def database_info_report(database_path):
|
|
2115
|
+
"""
|
|
2116
|
+
Build a diagnostic text report for a QCoDeS database file.
|
|
2117
|
+
|
|
2118
|
+
"""
|
|
2119
|
+
return _format_database_info(_database_info_summary(database_path))
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
def database_info_rows(database_path):
|
|
2123
|
+
"""
|
|
2124
|
+
Build display rows for the database information dialog.
|
|
2125
|
+
|
|
2126
|
+
"""
|
|
2127
|
+
groups = _database_info_groups(_database_info_summary(database_path))
|
|
2128
|
+
return [row for group in groups for row in group]
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def _database_info_summary(
|
|
2132
|
+
database_path,
|
|
2133
|
+
*,
|
|
2134
|
+
trusted_info=None,
|
|
2135
|
+
connection_callback=None,
|
|
2136
|
+
**read_controls,
|
|
2137
|
+
):
|
|
2138
|
+
if not database_path:
|
|
2139
|
+
raise ValueError("No database is loaded.")
|
|
2140
|
+
|
|
2141
|
+
if not os.path.isfile(database_path):
|
|
2142
|
+
raise FileNotFoundError(database_path)
|
|
2143
|
+
|
|
2144
|
+
path = os.path.abspath(database_path)
|
|
2145
|
+
file_size = os.path.getsize(path)
|
|
2146
|
+
|
|
2147
|
+
if trusted_info is not None:
|
|
2148
|
+
return {
|
|
2149
|
+
"path": path,
|
|
2150
|
+
"folder": os.path.dirname(path),
|
|
2151
|
+
"filename": os.path.basename(path),
|
|
2152
|
+
"file_size": file_size,
|
|
2153
|
+
"file_modified": os.path.getmtime(path),
|
|
2154
|
+
"user_version": trusted_info.user_version,
|
|
2155
|
+
"application_id": trusted_info.application_id,
|
|
2156
|
+
"page_count": trusted_info.page_count,
|
|
2157
|
+
"page_size": trusted_info.page_size,
|
|
2158
|
+
"table_count": trusted_info.table_count,
|
|
2159
|
+
"experiment_count": trusted_info.experiment_count,
|
|
2160
|
+
"run_count": trusted_info.run_count,
|
|
2161
|
+
"latest_run": (
|
|
2162
|
+
trusted_info.latest_run.as_dict()
|
|
2163
|
+
if trusted_info.latest_run is not None else None
|
|
2164
|
+
),
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
conn = sqlite_read_only_connection(path, timeout=10, **read_controls)
|
|
2168
|
+
try:
|
|
2169
|
+
if connection_callback is not None:
|
|
2170
|
+
connection_callback(conn)
|
|
2171
|
+
cursor = conn.cursor()
|
|
2172
|
+
user_version = _pragma_value(cursor, "user_version")
|
|
2173
|
+
summary = {
|
|
2174
|
+
"path": path,
|
|
2175
|
+
"folder": os.path.dirname(path),
|
|
2176
|
+
"filename": os.path.basename(path),
|
|
2177
|
+
"file_size": file_size,
|
|
2178
|
+
"file_modified": os.path.getmtime(path),
|
|
2179
|
+
"user_version": user_version,
|
|
2180
|
+
"application_id": _pragma_value(cursor, "application_id"),
|
|
2181
|
+
"page_count": _pragma_value(cursor, "page_count"),
|
|
2182
|
+
"page_size": _pragma_value(cursor, "page_size"),
|
|
2183
|
+
"table_count": _table_count(cursor),
|
|
2184
|
+
"experiment_count": _row_count(cursor, "experiments"),
|
|
2185
|
+
"run_count": _row_count(cursor, "runs"),
|
|
2186
|
+
"latest_run": _latest_run(cursor),
|
|
2187
|
+
}
|
|
2188
|
+
finally:
|
|
2189
|
+
conn.close()
|
|
2190
|
+
if connection_callback is not None:
|
|
2191
|
+
connection_callback(None)
|
|
2192
|
+
|
|
2193
|
+
return summary
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def _pragma_value(cursor, name):
|
|
2197
|
+
cursor.execute(f"PRAGMA {name}")
|
|
2198
|
+
value = cursor.fetchone()
|
|
2199
|
+
return value[0] if value else None
|
|
2200
|
+
|
|
2201
|
+
|
|
2202
|
+
def _table_count(cursor):
|
|
2203
|
+
cursor.execute("""
|
|
2204
|
+
SELECT COUNT(*)
|
|
2205
|
+
FROM sqlite_master
|
|
2206
|
+
WHERE type='table' AND name NOT LIKE 'sqlite_%'
|
|
2207
|
+
""")
|
|
2208
|
+
return cursor.fetchone()[0]
|
|
2209
|
+
|
|
2210
|
+
|
|
2211
|
+
def _row_count(cursor, table_name):
|
|
2212
|
+
if not _table_exists(cursor, table_name):
|
|
2213
|
+
return None
|
|
2214
|
+
|
|
2215
|
+
cursor.execute(f"SELECT COUNT(*) FROM {_sqlite_identifier(table_name)}")
|
|
2216
|
+
return cursor.fetchone()[0]
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def _table_exists(cursor, table_name):
|
|
2220
|
+
cursor.execute(
|
|
2221
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
|
|
2222
|
+
(table_name, )
|
|
2223
|
+
)
|
|
2224
|
+
return cursor.fetchone() is not None
|
|
2225
|
+
|
|
2226
|
+
|
|
2227
|
+
def _latest_run(cursor):
|
|
2228
|
+
if not _table_exists(cursor, "runs"):
|
|
2229
|
+
return None
|
|
2230
|
+
|
|
2231
|
+
cursor.execute("""
|
|
2232
|
+
SELECT run_id, name, run_timestamp, completed_timestamp, is_completed, guid
|
|
2233
|
+
FROM runs
|
|
2234
|
+
ORDER BY run_id DESC
|
|
2235
|
+
LIMIT 1
|
|
2236
|
+
""")
|
|
2237
|
+
value = cursor.fetchone()
|
|
2238
|
+
if value is None:
|
|
2239
|
+
return None
|
|
2240
|
+
|
|
2241
|
+
return {
|
|
2242
|
+
"run_id": value[0],
|
|
2243
|
+
"name": value[1],
|
|
2244
|
+
"run_timestamp": value[2],
|
|
2245
|
+
"completed_timestamp": value[3],
|
|
2246
|
+
"is_completed": value[4],
|
|
2247
|
+
"guid": value[5],
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
def _sqlite_identifier(name):
|
|
2252
|
+
return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
|
|
2253
|
+
|
|
2254
|
+
|
|
2255
|
+
def _database_info_groups(summary):
|
|
2256
|
+
latest_run = summary["latest_run"]
|
|
2257
|
+
latest_run_rows = [("Latest run", "None")]
|
|
2258
|
+
if latest_run:
|
|
2259
|
+
status = "completed" if latest_run.get("is_completed") else "running or incomplete"
|
|
2260
|
+
latest_run_rows = [
|
|
2261
|
+
("Latest run ID", _display_value(latest_run.get("run_id"))),
|
|
2262
|
+
("Latest run name", _display_value(latest_run.get("name"))),
|
|
2263
|
+
("Latest run status", status),
|
|
2264
|
+
("Latest run started", _timestamp_value(latest_run.get("run_timestamp"))),
|
|
2265
|
+
("Latest run completed", _timestamp_value(latest_run.get("completed_timestamp"))),
|
|
2266
|
+
("Latest run GUID", _display_value(latest_run.get("guid"))),
|
|
2267
|
+
]
|
|
2268
|
+
|
|
2269
|
+
page_bytes = None
|
|
2270
|
+
if summary["page_count"] is not None and summary["page_size"] is not None:
|
|
2271
|
+
page_bytes = int(summary["page_count"]) * int(summary["page_size"])
|
|
2272
|
+
|
|
2273
|
+
return [
|
|
2274
|
+
[
|
|
2275
|
+
("Database", summary["filename"]),
|
|
2276
|
+
("Path", summary["path"]),
|
|
2277
|
+
("Folder", summary["folder"]),
|
|
2278
|
+
("File size", _format_bytes(summary["file_size"])),
|
|
2279
|
+
("Last modified", _timestamp_value(summary["file_modified"])),
|
|
2280
|
+
("SQLite allocated size", _format_bytes(page_bytes)),
|
|
2281
|
+
],
|
|
2282
|
+
[
|
|
2283
|
+
("Database schema version", _display_value(summary["user_version"])),
|
|
2284
|
+
("SQLite application_id", _display_value(summary["application_id"])),
|
|
2285
|
+
],
|
|
2286
|
+
[
|
|
2287
|
+
("Tables", _display_value(summary["table_count"])),
|
|
2288
|
+
("Experiments", _display_value(summary["experiment_count"])),
|
|
2289
|
+
("Runs", _display_value(summary["run_count"])),
|
|
2290
|
+
],
|
|
2291
|
+
latest_run_rows,
|
|
2292
|
+
]
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def _format_database_info(summary):
|
|
2296
|
+
groups = _database_info_groups(summary)
|
|
2297
|
+
sections = [
|
|
2298
|
+
"\n".join(f"{label}: {_display_value(value)}" for label, value in group)
|
|
2299
|
+
for group in groups
|
|
2300
|
+
]
|
|
2301
|
+
return "\n\n".join(sections)
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _format_bytes(value):
|
|
2305
|
+
if value is None:
|
|
2306
|
+
return "Unknown"
|
|
2307
|
+
|
|
2308
|
+
value = int(value)
|
|
2309
|
+
units = ("B", "KB", "MB", "GB", "TB")
|
|
2310
|
+
size = float(value)
|
|
2311
|
+
for unit in units:
|
|
2312
|
+
if size < 1024 or unit == units[-1]:
|
|
2313
|
+
return f"{size:.1f} {unit}" if unit != "B" else f"{value} B"
|
|
2314
|
+
size /= 1024
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def _timestamp_value(value):
|
|
2318
|
+
if value in (None, ""):
|
|
2319
|
+
return "Not recorded"
|
|
2320
|
+
|
|
2321
|
+
try:
|
|
2322
|
+
return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S")
|
|
2323
|
+
except (TypeError, ValueError, OSError):
|
|
2324
|
+
return str(value)
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
def _display_value(value):
|
|
2328
|
+
if value in (None, ""):
|
|
2329
|
+
return "Unknown"
|
|
2330
|
+
return str(value)
|