qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2561 @@
1
+ import os
2
+ import sqlite3
3
+ import struct
4
+ import sys
5
+ import tempfile
6
+ import threading
7
+ import time
8
+ import weakref
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from qplot.datahandling.file_identity import (
14
+ QPLOT_GENERATED_DATABASE_APPLICATION_ID,
15
+ QPLOT_GENERATION_LINEAGE_FORMAT_VERSION,
16
+ QPLOT_GENERATION_LINEAGE_NONCE_BYTES,
17
+ QPLOT_GENERATION_LINEAGE_RING_TABLE,
18
+ QPLOT_GENERATION_LINEAGE_STATE_TABLE,
19
+ QPLOT_GENERATION_LINEAGE_WINDOW,
20
+ QPLOT_GENERATION_PROVENANCE_TABLE,
21
+ QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES,
22
+ DatabaseFileIdentity,
23
+ database_file_identity,
24
+ database_has_qplot_generation_marker,
25
+ database_publication_guard_path,
26
+ generation_provenance_trigger,
27
+ open_file_identity,
28
+ path_bound_file_identity,
29
+ )
30
+
31
+
32
+ def connect(
33
+ name,
34
+ debug=False,
35
+ version=-1,
36
+ read_only=False,
37
+ *,
38
+ cancelled_callback=None,
39
+ deadline=None,
40
+ ):
41
+ """Open QCoDeS connections while keeping viewer reads under qPlot control."""
42
+ from qcodes.dataset.sqlite.database import connect as qcodes_connect
43
+
44
+ if not read_only:
45
+ return qcodes_connect(
46
+ name,
47
+ debug=debug,
48
+ version=version,
49
+ read_only=False,
50
+ )
51
+ return _qcodes_read_only_atomic_connection(
52
+ name,
53
+ debug=debug,
54
+ cancelled_callback=cancelled_callback,
55
+ deadline=deadline,
56
+ )
57
+
58
+
59
+ def get_DB_debug():
60
+ """Return QCoDeS' database debug setting on demand."""
61
+ from qcodes.dataset.sqlite.database import get_DB_debug as qcodes_get_DB_debug
62
+
63
+ return qcodes_get_DB_debug()
64
+
65
+
66
+ def get_DB_location():
67
+ """Return QCoDeS' configured database path on demand."""
68
+ from qcodes.dataset.sqlite.database import (
69
+ get_DB_location as qcodes_get_DB_location,
70
+ )
71
+
72
+ return qcodes_get_DB_location()
73
+
74
+
75
+ def load_by_guid(*args, **kwargs):
76
+ """Forward to QCoDeS' GUID loader on demand."""
77
+ from qcodes.dataset import load_by_guid as qcodes_load_by_guid
78
+
79
+ return qcodes_load_by_guid(*args, **kwargs)
80
+
81
+
82
+ def load_by_id(*args, **kwargs):
83
+ """Forward to QCoDeS' run-ID loader on demand."""
84
+ from qcodes.dataset import load_by_id as qcodes_load_by_id
85
+
86
+ return qcodes_load_by_id(*args, **kwargs)
87
+
88
+
89
+ def result_owns_supplied_connection(result):
90
+ """Check QCoDeS connection ownership without importing it for probes."""
91
+ from qplot.datahandling.qcodes_compat import (
92
+ result_owns_supplied_connection as qcodes_result_owns_connection,
93
+ )
94
+
95
+ return qcodes_result_owns_connection(result)
96
+
97
+ SQLITE_READ_ONLY_CACHE_KIB = 16 * 1024
98
+ SNAPSHOT_COPY_CHUNK_BYTES = 1024 * 1024
99
+ QCODE_SQLITE_BUSY_QUANTUM_SECONDS = 0.05
100
+ QCODE_SQLITE_OPEN_TIMEOUT_SECONDS = 5.0
101
+ WAL_SNAPSHOT_ATTEMPTS = 5
102
+ _ROLLBACK_JOURNAL_MAGIC = b"\xd9\xd5\x05\xf9 \xa1c\xd7"
103
+ _ROLLBACK_JOURNAL_PREFIX_BYTES = 4096
104
+ _WAL_HEADER_BYTES = 32
105
+ _WAL_FRAME_HEADER_BYTES = 24
106
+ _WAL_FORMAT_VERSION = 3_007_000
107
+ _WAL_MAGIC_LITTLE_ENDIAN_CHECKSUMS = 0x377F0682
108
+ _WAL_MAGIC_BIG_ENDIAN_CHECKSUMS = 0x377F0683
109
+ _SQLITE_PENDING_BYTE = 0x40000000
110
+ _SQLITE_SHARED_FIRST = _SQLITE_PENDING_BYTE + 2
111
+ _SQLITE_SHARED_SIZE = 510
112
+ _DATABASE_INSTANCE_REGISTRY: dict[
113
+ Path,
114
+ tuple[DatabaseFileIdentity | None, bool],
115
+ ] = {}
116
+ _DATABASE_INSTANCE_REGISTRY_LOCK = threading.Lock()
117
+
118
+
119
+ class ReadOnlyDatabaseAccessError(RuntimeError):
120
+ """Raised when qPlot cannot take a non-mutating view of a database."""
121
+
122
+
123
+ class ReadOnlyDatabaseCancelledError(InterruptedError):
124
+ """Raised when a caller cancels read-only database preparation."""
125
+
126
+
127
+ class DatabaseInstanceChangedError(ReadOnlyDatabaseAccessError):
128
+ """Raised when a database was atomically replaced during a requested read."""
129
+
130
+
131
+ class UnverifiableDatabaseWalError(ReadOnlyDatabaseAccessError):
132
+ """Raised when a WAL cannot be proven to descend from its selected main."""
133
+
134
+
135
+ def _raise_if_read_interrupted(cancelled_callback=None, deadline=None):
136
+ """Stop cooperative work before it can publish a partial read target."""
137
+ if cancelled_callback is not None and cancelled_callback():
138
+ raise ReadOnlyDatabaseCancelledError("Database read cancelled.")
139
+ if deadline is not None and time.monotonic() >= deadline:
140
+ raise TimeoutError("Timed out while preparing a read-only database view.")
141
+
142
+
143
+ def _read_control_progress_handler(cancelled_callback=None, deadline=None):
144
+ """Return a SQLite progress handler for the same cooperative controls."""
145
+ if cancelled_callback is None and deadline is None:
146
+ return None
147
+
148
+ def interrupted():
149
+ if cancelled_callback is not None and cancelled_callback():
150
+ return 1
151
+ return int(deadline is not None and time.monotonic() >= deadline)
152
+
153
+ return interrupted
154
+
155
+
156
+ def _install_read_control_progress_handler(
157
+ connection,
158
+ cancelled_callback=None,
159
+ deadline=None,
160
+ ):
161
+ progress_handler = _read_control_progress_handler(
162
+ cancelled_callback,
163
+ deadline,
164
+ )
165
+ if progress_handler is not None:
166
+ set_progress_handler = getattr(connection, "set_progress_handler", None)
167
+ if callable(set_progress_handler):
168
+ set_progress_handler(progress_handler, 1000)
169
+
170
+
171
+ def _sqlite_timeout_before_deadline(timeout, deadline):
172
+ """Limit SQLite's busy wait to the remaining cooperative deadline."""
173
+ if deadline is None:
174
+ return timeout
175
+ remaining = deadline - time.monotonic()
176
+ if remaining <= 0:
177
+ raise TimeoutError("Timed out while preparing a read-only database view.")
178
+ return min(float(timeout), remaining)
179
+
180
+
181
+ def _register_qcodes_sqlite_types(qcodes_database):
182
+ """Register the adapters and converters used by QCoDeS connections."""
183
+ sqlite3.register_adapter(
184
+ qcodes_database.np.ndarray,
185
+ qcodes_database._adapt_array,
186
+ )
187
+ sqlite3.register_converter("array", qcodes_database._convert_array)
188
+ for numpy_int in qcodes_database.numpy_ints:
189
+ sqlite3.register_adapter(numpy_int, int)
190
+ sqlite3.register_converter("numeric", qcodes_database._convert_numeric)
191
+ for numpy_float in (float, *qcodes_database.numpy_floats):
192
+ sqlite3.register_adapter(numpy_float, qcodes_database._adapt_float)
193
+ for complex_type in qcodes_database.complex_types:
194
+ sqlite3.register_adapter(
195
+ complex_type,
196
+ qcodes_database._adapt_complex,
197
+ )
198
+ sqlite3.register_converter("complex", qcodes_database._convert_complex)
199
+
200
+
201
+ def _sqlite_error_is_busy(error):
202
+ """Return whether SQLite reported a retryable lock or busy condition."""
203
+ error_code = getattr(error, "sqlite_errorcode", None)
204
+ if isinstance(error_code, int):
205
+ primary_code = error_code & 0xFF
206
+ return primary_code in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED)
207
+ message = str(error).lower()
208
+ return "locked" in message or "busy" in message
209
+
210
+
211
+ def _qcodes_read_only_atomic_connection(
212
+ name,
213
+ *,
214
+ debug=False,
215
+ cancelled_callback=None,
216
+ deadline=None,
217
+ ):
218
+ """Build a latest-schema QCoDeS connection without viewer-side upgrades."""
219
+ from qcodes.dataset.sqlite import database as qcodes_database
220
+
221
+ _raise_if_read_interrupted(cancelled_callback, deadline)
222
+ _register_qcodes_sqlite_types(qcodes_database)
223
+ connection = None
224
+ busy_deadline = time.monotonic() + QCODE_SQLITE_OPEN_TIMEOUT_SECONDS
225
+ try:
226
+ connection = sqlite3.connect(
227
+ f"file:{name!s}?mode=ro",
228
+ detect_types=sqlite3.PARSE_DECLTYPES,
229
+ check_same_thread=True,
230
+ timeout=_sqlite_timeout_before_deadline(
231
+ QCODE_SQLITE_BUSY_QUANTUM_SECONDS,
232
+ deadline,
233
+ ),
234
+ uri=True,
235
+ factory=qcodes_database.AtomicConnection,
236
+ )
237
+ _install_read_control_progress_handler(
238
+ connection,
239
+ cancelled_callback,
240
+ deadline,
241
+ )
242
+
243
+ while True:
244
+ _raise_if_read_interrupted(cancelled_callback, deadline)
245
+ try:
246
+ version_row = connection.execute("PRAGMA user_version").fetchone()
247
+ connection.execute(
248
+ "SELECT 1 FROM sqlite_schema LIMIT 1"
249
+ ).fetchone()
250
+ except sqlite3.OperationalError as error:
251
+ _raise_if_read_interrupted(cancelled_callback, deadline)
252
+ if (
253
+ not _sqlite_error_is_busy(error)
254
+ or time.monotonic() >= busy_deadline
255
+ ):
256
+ raise
257
+ continue
258
+ _raise_if_read_interrupted(cancelled_callback, deadline)
259
+ break
260
+
261
+ if (
262
+ version_row is None
263
+ or len(version_row) != 1
264
+ or not isinstance(version_row[0], int)
265
+ ):
266
+ raise RuntimeError(
267
+ f"Database {name} has an invalid SQLite user_version."
268
+ )
269
+ database_version = version_row[0]
270
+ latest_version = qcodes_database._latest_available_version()
271
+ if database_version != latest_version:
272
+ raise RuntimeError(
273
+ f"Database {name} has schema version {database_version}, but "
274
+ f"qPlot requires the latest supported QCoDeS schema version "
275
+ f"{latest_version}. qPlot did not upgrade the input database."
276
+ )
277
+ if debug:
278
+ connection.set_trace_callback(print)
279
+ return connection
280
+ except Exception:
281
+ if connection is not None:
282
+ try:
283
+ connection.close()
284
+ except sqlite3.Error:
285
+ pass
286
+ raise
287
+
288
+
289
+ @contextmanager
290
+ def _posix_sqlite_shared_lock_bytes(
291
+ database_path,
292
+ *,
293
+ cancelled_callback=None,
294
+ deadline=None,
295
+ ):
296
+ """Hold SQLite's rollback-mode shared-lock bytes without opening SQLite."""
297
+ import errno
298
+ import fcntl
299
+
300
+ _raise_if_read_interrupted(cancelled_callback, deadline)
301
+ try:
302
+ database_file = open(database_path, "rb")
303
+ except OSError as error:
304
+ raise ReadOnlyDatabaseAccessError(
305
+ f"Could not open {database_path} for a read-only lock check: {error}"
306
+ ) from error
307
+
308
+ pending_locked = False
309
+ shared_locked = False
310
+ try:
311
+ try:
312
+ fcntl.lockf(
313
+ database_file.fileno(),
314
+ fcntl.LOCK_SH | fcntl.LOCK_NB,
315
+ 1,
316
+ _SQLITE_PENDING_BYTE,
317
+ os.SEEK_SET,
318
+ )
319
+ pending_locked = True
320
+ _raise_if_read_interrupted(cancelled_callback, deadline)
321
+ fcntl.lockf(
322
+ database_file.fileno(),
323
+ fcntl.LOCK_SH | fcntl.LOCK_NB,
324
+ _SQLITE_SHARED_SIZE,
325
+ _SQLITE_SHARED_FIRST,
326
+ os.SEEK_SET,
327
+ )
328
+ shared_locked = True
329
+ _raise_if_read_interrupted(cancelled_callback, deadline)
330
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
331
+ raise
332
+ except OSError as error:
333
+ if error.errno in (errno.EACCES, errno.EAGAIN):
334
+ raise sqlite3.OperationalError("database is locked") from error
335
+ raise ReadOnlyDatabaseAccessError(
336
+ "Could not prove that SQLite shared-lock bytes are available "
337
+ f"for {database_path}: {error}"
338
+ ) from error
339
+
340
+ yield database_file
341
+ finally:
342
+ if shared_locked:
343
+ try:
344
+ fcntl.lockf(
345
+ database_file.fileno(),
346
+ fcntl.LOCK_UN,
347
+ _SQLITE_SHARED_SIZE,
348
+ _SQLITE_SHARED_FIRST,
349
+ os.SEEK_SET,
350
+ )
351
+ except OSError:
352
+ pass
353
+ if pending_locked:
354
+ try:
355
+ fcntl.lockf(
356
+ database_file.fileno(),
357
+ fcntl.LOCK_UN,
358
+ 1,
359
+ _SQLITE_PENDING_BYTE,
360
+ os.SEEK_SET,
361
+ )
362
+ except OSError:
363
+ pass
364
+ database_file.close()
365
+
366
+
367
+ @contextmanager
368
+ def _windows_sqlite_shared_lock_bytes(
369
+ database_path,
370
+ *,
371
+ cancelled_callback=None,
372
+ deadline=None,
373
+ ):
374
+ """Hold SQLite's Windows shared-lock ranges using nonblocking LockFileEx."""
375
+ import ctypes
376
+ import msvcrt
377
+ from ctypes import wintypes
378
+
379
+ class _Overlapped(ctypes.Structure):
380
+ _fields_ = (
381
+ ("Internal", ctypes.c_size_t),
382
+ ("InternalHigh", ctypes.c_size_t),
383
+ ("Offset", wintypes.DWORD),
384
+ ("OffsetHigh", wintypes.DWORD),
385
+ ("hEvent", wintypes.HANDLE),
386
+ )
387
+
388
+ _raise_if_read_interrupted(cancelled_callback, deadline)
389
+ try:
390
+ database_file = open(database_path, "rb")
391
+ except OSError as error:
392
+ raise ReadOnlyDatabaseAccessError(
393
+ f"Could not open {database_path} for a read-only lock check: {error}"
394
+ ) from error
395
+
396
+ kernel32 = ctypes.WinDLL( # type: ignore[attr-defined]
397
+ "kernel32",
398
+ use_last_error=True,
399
+ )
400
+ lock_file_ex = kernel32.LockFileEx
401
+ lock_file_ex.argtypes = (
402
+ wintypes.HANDLE,
403
+ wintypes.DWORD,
404
+ wintypes.DWORD,
405
+ wintypes.DWORD,
406
+ wintypes.DWORD,
407
+ ctypes.POINTER(_Overlapped),
408
+ )
409
+ lock_file_ex.restype = wintypes.BOOL
410
+ unlock_file_ex = kernel32.UnlockFileEx
411
+ unlock_file_ex.argtypes = (
412
+ wintypes.HANDLE,
413
+ wintypes.DWORD,
414
+ wintypes.DWORD,
415
+ wintypes.DWORD,
416
+ ctypes.POINTER(_Overlapped),
417
+ )
418
+ unlock_file_ex.restype = wintypes.BOOL
419
+ windows_handle = wintypes.HANDLE(
420
+ msvcrt.get_osfhandle( # type: ignore[attr-defined]
421
+ database_file.fileno()
422
+ )
423
+ )
424
+ locked_ranges = []
425
+
426
+ def lock_shared_range(offset, length):
427
+ overlapped = _Overlapped()
428
+ overlapped.Offset = offset & 0xFFFFFFFF
429
+ overlapped.OffsetHigh = (offset >> 32) & 0xFFFFFFFF
430
+ if not lock_file_ex(
431
+ windows_handle,
432
+ 0x00000001,
433
+ 0,
434
+ length & 0xFFFFFFFF,
435
+ (length >> 32) & 0xFFFFFFFF,
436
+ ctypes.byref(overlapped),
437
+ ):
438
+ error_code = ctypes.get_last_error() # type: ignore[attr-defined]
439
+ if error_code in (32, 33, 997):
440
+ raise sqlite3.OperationalError("database is locked")
441
+ raise ReadOnlyDatabaseAccessError(
442
+ "Could not prove that SQLite shared-lock bytes are available "
443
+ f"for {database_path}: Windows error {error_code}."
444
+ )
445
+ locked_ranges.append((overlapped, length))
446
+
447
+ try:
448
+ lock_shared_range(_SQLITE_PENDING_BYTE, 1)
449
+ _raise_if_read_interrupted(cancelled_callback, deadline)
450
+ lock_shared_range(_SQLITE_SHARED_FIRST, _SQLITE_SHARED_SIZE)
451
+ _raise_if_read_interrupted(cancelled_callback, deadline)
452
+ yield database_file
453
+ finally:
454
+ for overlapped, length in reversed(locked_ranges):
455
+ unlock_file_ex(
456
+ windows_handle,
457
+ 0,
458
+ length & 0xFFFFFFFF,
459
+ (length >> 32) & 0xFFFFFFFF,
460
+ ctypes.byref(overlapped),
461
+ )
462
+ database_file.close()
463
+
464
+
465
+ @contextmanager
466
+ def _sqlite_shared_lock_bytes(
467
+ database_path,
468
+ *,
469
+ cancelled_callback=None,
470
+ deadline=None,
471
+ ):
472
+ """Hold the platform's SQLite shared-lock ranges or fail closed."""
473
+ if os.name == "posix":
474
+ lock_context = _posix_sqlite_shared_lock_bytes(
475
+ database_path,
476
+ cancelled_callback=cancelled_callback,
477
+ deadline=deadline,
478
+ )
479
+ elif os.name == "nt":
480
+ lock_context = _windows_sqlite_shared_lock_bytes(
481
+ database_path,
482
+ cancelled_callback=cancelled_callback,
483
+ deadline=deadline,
484
+ )
485
+ else:
486
+ raise ReadOnlyDatabaseAccessError(
487
+ "qPlot cannot perform a non-mutating SQLite lock check on this "
488
+ "platform. Database access was rejected without opening a mutable "
489
+ "SQLite view."
490
+ )
491
+ with lock_context as database_file:
492
+ yield database_file
493
+
494
+
495
+ def _source_matches_under_sqlite_lock(
496
+ database_path,
497
+ expected_source,
498
+ database_file,
499
+ *,
500
+ cancelled_callback=None,
501
+ deadline=None,
502
+ ):
503
+ """Validate a source while never reopening its locked main file."""
504
+ _raise_if_read_interrupted(cancelled_callback, deadline)
505
+ try:
506
+ descriptor_signature_before = _stat_signature(
507
+ os.fstat(database_file.fileno())
508
+ )
509
+ descriptor_identity_before = open_file_identity(database_file.fileno())
510
+ path_signature_before = _file_signature(database_path)
511
+ path_identity_before = path_bound_file_identity(database_path)
512
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
513
+ raise
514
+ except OSError as error:
515
+ raise ReadOnlyDatabaseAccessError(
516
+ "Could not validate the database main file under its SQLite "
517
+ f"shared lock: {error}"
518
+ ) from error
519
+
520
+ try:
521
+ wal_signature, wal_identity, wal_stable = _wal_observation(
522
+ database_path,
523
+ cancelled_callback=cancelled_callback,
524
+ deadline=deadline,
525
+ )
526
+ journal = _rollback_journal_observation(
527
+ database_path,
528
+ cancelled_callback=cancelled_callback,
529
+ deadline=deadline,
530
+ )
531
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
532
+ raise
533
+ except OSError as error:
534
+ raise ReadOnlyDatabaseAccessError(
535
+ "Could not validate SQLite sidecars while holding the database "
536
+ f"shared lock: {error}"
537
+ ) from error
538
+
539
+ _raise_if_read_interrupted(cancelled_callback, deadline)
540
+ try:
541
+ descriptor_signature_after = _stat_signature(
542
+ os.fstat(database_file.fileno())
543
+ )
544
+ descriptor_identity_after = open_file_identity(database_file.fileno())
545
+ path_signature_after = _file_signature(database_path)
546
+ path_identity_after = path_bound_file_identity(database_path)
547
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
548
+ raise
549
+ except OSError as error:
550
+ raise ReadOnlyDatabaseAccessError(
551
+ "Could not finish validating the database main file under its "
552
+ f"SQLite shared lock: {error}"
553
+ ) from error
554
+
555
+ main_file_stable = _path_and_descriptor_observations_match(
556
+ path_signature_before,
557
+ path_identity_before,
558
+ descriptor_signature_before,
559
+ descriptor_identity_before,
560
+ descriptor_signature_after,
561
+ descriptor_identity_after,
562
+ path_signature_after,
563
+ path_identity_after,
564
+ )
565
+ return (
566
+ expected_source.database is not None
567
+ and main_file_stable
568
+ and path_signature_before
569
+ == path_signature_after
570
+ == expected_source.database
571
+ and path_identity_before
572
+ == path_identity_after
573
+ == expected_source.database_identity
574
+ and wal_stable
575
+ and wal_signature == expected_source.wal
576
+ and wal_identity == expected_source.wal_identity
577
+ and journal == expected_source.journal
578
+ and (journal is None or journal.stable)
579
+ )
580
+
581
+
582
+ def _copy_file_cooperatively(
583
+ source,
584
+ destination,
585
+ *,
586
+ cancelled_callback=None,
587
+ deadline=None,
588
+ ):
589
+ """Copy one snapshot artifact in bounded, cooperatively checked chunks."""
590
+ _raise_if_read_interrupted(cancelled_callback, deadline)
591
+ if _clone_file_if_supported(source, destination):
592
+ _raise_if_read_interrupted(cancelled_callback, deadline)
593
+ return destination
594
+ with open(source, "rb") as source_file, open(destination, "wb") as destination_file:
595
+ while True:
596
+ _raise_if_read_interrupted(cancelled_callback, deadline)
597
+ chunk = source_file.read(SNAPSHOT_COPY_CHUNK_BYTES)
598
+ _raise_if_read_interrupted(cancelled_callback, deadline)
599
+ if not chunk:
600
+ break
601
+ written = destination_file.write(chunk)
602
+ if written != len(chunk):
603
+ raise OSError(
604
+ f"Short write while copying {source} to a private snapshot."
605
+ )
606
+ _raise_if_read_interrupted(cancelled_callback, deadline)
607
+ _raise_if_read_interrupted(cancelled_callback, deadline)
608
+ return destination
609
+
610
+
611
+ def _clone_file_if_supported(source, destination):
612
+ """Create a copy-on-write clone without weakening snapshot isolation."""
613
+ destination_path = Path(destination)
614
+ destination_existed = destination_path.exists()
615
+ if sys.platform == "darwin":
616
+ import ctypes
617
+
618
+ clonefile = ctypes.CDLL(None, use_errno=True).clonefile
619
+ clonefile.argtypes = (
620
+ ctypes.c_char_p,
621
+ ctypes.c_char_p,
622
+ ctypes.c_int,
623
+ )
624
+ clonefile.restype = ctypes.c_int
625
+ result = clonefile(
626
+ os.fsencode(source),
627
+ os.fsencode(destination),
628
+ 0,
629
+ )
630
+ if result == 0:
631
+ return True
632
+ elif sys.platform.startswith("linux"):
633
+ import fcntl
634
+
635
+ try:
636
+ with (
637
+ open(source, "rb") as source_file,
638
+ open(destination, "xb") as destination_file,
639
+ ):
640
+ fcntl.ioctl(
641
+ destination_file.fileno(),
642
+ 0x40049409, # Linux FICLONE
643
+ source_file.fileno(),
644
+ )
645
+ return True
646
+ except OSError:
647
+ pass
648
+ else:
649
+ return False
650
+
651
+ if not destination_existed:
652
+ try:
653
+ destination_path.unlink()
654
+ except FileNotFoundError:
655
+ pass
656
+ return False
657
+
658
+
659
+ @dataclass(frozen=True, slots=True)
660
+ class _RollbackJournalObservation:
661
+ """One path-bound rollback-journal identity and state observation."""
662
+
663
+ file_signature: tuple[int, int, int, int, int]
664
+ file_identity: DatabaseFileIdentity | None
665
+ prefix: bytes
666
+ trailer: bytes
667
+ stable: bool
668
+
669
+ @property
670
+ def potentially_hot(self):
671
+ return (
672
+ self.file_signature[2] > 512
673
+ and self.prefix.startswith(_ROLLBACK_JOURNAL_MAGIC)
674
+ )
675
+
676
+
677
+ @dataclass(frozen=True, slots=True)
678
+ class _SourceSignature:
679
+ """Ephemeral state binding one private snapshot to its source files."""
680
+
681
+ database: tuple[int, int, int, int, int] | None
682
+ database_identity: DatabaseFileIdentity | None
683
+ database_header: bytes
684
+ wal: tuple[int, int, int, int, int] | None
685
+ wal_identity: DatabaseFileIdentity | None
686
+ journal: _RollbackJournalObservation | None
687
+ stable: bool
688
+
689
+
690
+ @dataclass(frozen=True, slots=True)
691
+ class _SourceReadPolicy:
692
+ """Sidecar policy derived from one stable source observation."""
693
+
694
+ journal: _RollbackJournalObservation | None
695
+ wal_present: bool
696
+ quarantined: bool
697
+ ignore_wal: bool
698
+ include_wal: bool
699
+ database_is_wal_format: bool
700
+
701
+ @property
702
+ def requires_private_snapshot(self):
703
+ """Classify states unsuitable for the probe's direct lock-byte check.
704
+
705
+ Full viewer openers snapshot every source state. This narrower flag is
706
+ retained only so the isolated access probe never treats a journal- or
707
+ WAL-bearing source as a rollback-mode direct-lock candidate.
708
+ """
709
+ return (
710
+ self.journal is not None
711
+ or self.wal_present
712
+ or self.database_is_wal_format
713
+ )
714
+
715
+
716
+ class _ManagedSQLiteConnection(sqlite3.Connection):
717
+ """SQLite connection that owns its temporary database snapshot."""
718
+
719
+ _qplot_snapshot: tempfile.TemporaryDirectory | None = None
720
+
721
+ def attach_snapshot(self, snapshot):
722
+ self._qplot_snapshot = snapshot
723
+
724
+ def close(self):
725
+ try:
726
+ super().close()
727
+ finally:
728
+ if self._qplot_snapshot is not None:
729
+ self._qplot_snapshot.cleanup()
730
+ self._qplot_snapshot = None
731
+
732
+
733
+ def set_qcodes_database_location(database_path):
734
+ """Point QCoDeS at a database without initialising or upgrading it."""
735
+ import qcodes
736
+
737
+ qcodes.config.core.db_location = str(database_path)
738
+
739
+
740
+ def quarantine_wal_for_replaced_database(database_path):
741
+ """Keep an unpaired WAL out of a newly replaced database view.
742
+
743
+ Atomic replacement changes the main database file but can leave an old
744
+ writer's ``-wal`` sidecar behind. SQLite can otherwise combine those two
745
+ unrelated files and expose the old database. Once a replacement is
746
+ detected, qPlot captures a private main-only view while the retained
747
+ sidecar is unpaired. A sidecar can vanish and later be recreated by the old
748
+ writer, so a transient absence is not enough to prove a later WAL belongs
749
+ to the replacement. A generated database's matching token and nonce-linked
750
+ history can establish that a later WAL strictly descends from the
751
+ replacement and release this quarantine. qPlot never changes the source
752
+ database or any sidecar to make that happen.
753
+
754
+ A changed WAL identity is not enough to prove it belongs to the replacement
755
+ main file: an old writer can rotate or recreate its WAL after the main file
756
+ has been atomically replaced. Therefore the whole sidecar lifetime is
757
+ treated conservatively as ambiguous unless embedded lineage proves it.
758
+ """
759
+
760
+ source_path = _resolved_database_path(database_path)
761
+ database_identity = database_file_identity(source_path)
762
+ with _DATABASE_INSTANCE_REGISTRY_LOCK:
763
+ _DATABASE_INSTANCE_REGISTRY[source_path] = (database_identity, True)
764
+ return database_identity is not None
765
+
766
+
767
+ def _release_wal_quarantine(database_path, expected_database_identity):
768
+ """Trust a WAL whose embedded lineage proves it belongs to this main."""
769
+ source_path = _resolved_database_path(database_path)
770
+ with _DATABASE_INSTANCE_REGISTRY_LOCK:
771
+ database_identity = database_file_identity(source_path)
772
+ if database_identity != expected_database_identity:
773
+ return False
774
+ _DATABASE_INSTANCE_REGISTRY[source_path] = (database_identity, False)
775
+ return True
776
+
777
+
778
+ def _observe_database_instance(database_path):
779
+ """Record replacement history for every source opened by qPlot.
780
+
781
+ The quarantine intentionally survives a momentary missing WAL and later
782
+ main-file replacements. An old writer can recreate either sidecar after a
783
+ transient gap, so absence alone cannot prove that a future WAL belongs to
784
+ the current main file.
785
+ """
786
+
787
+ source_path = _resolved_database_path(database_path)
788
+ database_identity = database_file_identity(source_path)
789
+ with _DATABASE_INSTANCE_REGISTRY_LOCK:
790
+ prior = _DATABASE_INSTANCE_REGISTRY.get(source_path)
791
+ if prior is None:
792
+ quarantined = False
793
+ else:
794
+ prior_identity, quarantined = prior
795
+ if prior_identity is not None and prior_identity != database_identity:
796
+ quarantined = True
797
+ _DATABASE_INSTANCE_REGISTRY[source_path] = (
798
+ database_identity,
799
+ quarantined,
800
+ )
801
+ return database_identity, quarantined
802
+
803
+
804
+ def _require_expected_database_instance(database_path, expected_database_identity):
805
+ """Reject a read whose source identity no longer matches its dataset key."""
806
+
807
+ if expected_database_identity is None:
808
+ return
809
+ database_identity, _quarantined = _observe_database_instance(database_path)
810
+ if database_identity == expected_database_identity:
811
+ return
812
+ quarantine_wal_for_replaced_database(database_path)
813
+ raise DatabaseInstanceChangedError(
814
+ "The database was replaced while qPlot was preparing a read."
815
+ )
816
+
817
+
818
+ def _require_prepared_database_instance(database_path, prepared_database_identity):
819
+ """Bind publication-marker and sidecar decisions to one main-file identity."""
820
+ if database_file_identity(database_path) == prepared_database_identity:
821
+ return
822
+ quarantine_wal_for_replaced_database(database_path)
823
+ raise DatabaseInstanceChangedError(
824
+ "The database was replaced while qPlot was selecting a read-only view. "
825
+ "Refresh to retry; qPlot did not combine the replacement with a prior "
826
+ "SQLite sidecar."
827
+ )
828
+
829
+
830
+ def configure_read_only_sqlite_connection(conn):
831
+ """Keep read-only SQLite scans from growing qPlot's memory footprint."""
832
+ pragmas = (
833
+ "PRAGMA query_only = ON",
834
+ f"PRAGMA cache_size = -{SQLITE_READ_ONLY_CACHE_KIB}",
835
+ "PRAGMA temp_store = FILE",
836
+ )
837
+ for pragma in pragmas:
838
+ try:
839
+ conn.execute(pragma)
840
+ except Exception:
841
+ pass
842
+ return conn
843
+
844
+
845
+ def _cleanup_failed_connection_open(conn, snapshot):
846
+ """Release both provisional owners after any open/attachment failure."""
847
+ if conn is not None:
848
+ try:
849
+ conn.close()
850
+ except Exception:
851
+ pass
852
+ if snapshot is not None:
853
+ try:
854
+ snapshot.cleanup()
855
+ except Exception:
856
+ pass
857
+
858
+
859
+ def qcodes_read_only_connection(
860
+ database_path,
861
+ *,
862
+ ignore_unpaired_wal=False,
863
+ expected_database_identity=None,
864
+ cancelled_callback=None,
865
+ deadline=None,
866
+ ):
867
+ """Open an AtomicConnection-compatible private database snapshot.
868
+
869
+ Every source state is copied under the system temporary directory before
870
+ SQLite is invoked. Immutable access, WAL handling, and rollback recovery
871
+ therefore occur only on qPlot's private files.
872
+ """
873
+ _raise_if_read_interrupted(cancelled_callback, deadline)
874
+ source_path = _resolved_database_path(database_path)
875
+ for _attempt in range(2):
876
+ _raise_if_read_interrupted(cancelled_callback, deadline)
877
+ (
878
+ target_path,
879
+ immutable,
880
+ snapshot,
881
+ ignore_wal,
882
+ prepared_source,
883
+ ) = _prepare_read_target(
884
+ source_path,
885
+ ignore_unpaired_wal=ignore_unpaired_wal,
886
+ expected_database_identity=expected_database_identity,
887
+ cancelled_callback=cancelled_callback,
888
+ deadline=deadline,
889
+ )
890
+ conn = None
891
+ try:
892
+ _raise_if_read_interrupted(cancelled_callback, deadline)
893
+ if not _prepared_source_is_current(
894
+ source_path,
895
+ prepared_source,
896
+ direct=snapshot is None,
897
+ ignore_wal=ignore_wal,
898
+ cancelled_callback=cancelled_callback,
899
+ deadline=deadline,
900
+ ):
901
+ if snapshot is not None:
902
+ _cleanup_failed_connection_open(None, snapshot)
903
+ continue
904
+ _require_publication_complete(source_path)
905
+ conn = connect(
906
+ _qcodes_uri_path(target_path, immutable=immutable),
907
+ get_DB_debug(),
908
+ read_only=True,
909
+ cancelled_callback=cancelled_callback,
910
+ deadline=deadline,
911
+ )
912
+ _install_read_control_progress_handler(
913
+ conn,
914
+ cancelled_callback,
915
+ deadline,
916
+ )
917
+ _raise_if_read_interrupted(cancelled_callback, deadline)
918
+ _require_publication_complete(source_path)
919
+ _require_expected_database_instance(
920
+ source_path,
921
+ expected_database_identity,
922
+ )
923
+ if not _prepared_source_is_current(
924
+ source_path,
925
+ prepared_source,
926
+ direct=snapshot is None,
927
+ ignore_wal=ignore_wal,
928
+ cancelled_callback=cancelled_callback,
929
+ deadline=deadline,
930
+ ):
931
+ _cleanup_failed_connection_open(conn, snapshot)
932
+ continue
933
+ _require_publication_complete(source_path)
934
+ conn.path_to_dbfile = str(source_path)
935
+ conn._qplot_source_activity_timestamp = _prepared_activity_timestamp(
936
+ prepared_source, ignore_wal=ignore_wal
937
+ )
938
+ if snapshot is not None:
939
+ _attach_snapshot_cleanup(conn, snapshot)
940
+ snapshot = None
941
+ configured = configure_read_only_sqlite_connection(conn)
942
+ _raise_if_read_interrupted(cancelled_callback, deadline)
943
+ return configured
944
+ except Exception:
945
+ _cleanup_failed_connection_open(conn, snapshot)
946
+ raise
947
+
948
+ raise ReadOnlyDatabaseAccessError(
949
+ "The database became busy while qPlot was opening a transaction-consistent "
950
+ "view. It is temporarily unavailable; refresh to retry. qPlot did not "
951
+ "modify the source database or its SQLite sidecars."
952
+ )
953
+
954
+
955
+ def load_by_guid_read_only(
956
+ guid,
957
+ database_path=None,
958
+ *,
959
+ expected_database_identity=None,
960
+ cancelled_callback=None,
961
+ deadline=None,
962
+ ):
963
+ """Load a QCoDeS dataset by GUID through a read-only connection."""
964
+ if database_path is None:
965
+ database_path = get_DB_location()
966
+ conn = qcodes_read_only_connection(
967
+ database_path,
968
+ expected_database_identity=expected_database_identity,
969
+ cancelled_callback=cancelled_callback,
970
+ deadline=deadline,
971
+ )
972
+ connection_transferred = False
973
+ try:
974
+ _raise_if_read_interrupted(cancelled_callback, deadline)
975
+ result = load_by_guid(guid, conn=conn)
976
+ _raise_if_read_interrupted(cancelled_callback, deadline)
977
+ connection_transferred = result_owns_supplied_connection(result)
978
+ return result
979
+ except Exception:
980
+ _raise_if_read_interrupted(cancelled_callback, deadline)
981
+ raise
982
+ finally:
983
+ if not connection_transferred:
984
+ conn.close()
985
+
986
+
987
+ def load_by_id_read_only(
988
+ run_id,
989
+ database_path=None,
990
+ *,
991
+ expected_database_identity=None,
992
+ cancelled_callback=None,
993
+ deadline=None,
994
+ ):
995
+ """Load a QCoDeS dataset by run ID through a read-only connection."""
996
+ if database_path is None:
997
+ database_path = get_DB_location()
998
+ conn = qcodes_read_only_connection(
999
+ database_path,
1000
+ expected_database_identity=expected_database_identity,
1001
+ cancelled_callback=cancelled_callback,
1002
+ deadline=deadline,
1003
+ )
1004
+ connection_transferred = False
1005
+ try:
1006
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1007
+ result = load_by_id(run_id, conn=conn)
1008
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1009
+ connection_transferred = result_owns_supplied_connection(result)
1010
+ return result
1011
+ except Exception:
1012
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1013
+ raise
1014
+ finally:
1015
+ if not connection_transferred:
1016
+ conn.close()
1017
+
1018
+
1019
+ def _resolved_database_path(database_path):
1020
+ return Path(database_path).resolve()
1021
+
1022
+
1023
+ def _require_publication_complete(database_path):
1024
+ """Reject a view while qPlot is replacing the database main file."""
1025
+ guard_path = database_publication_guard_path(database_path)
1026
+ try:
1027
+ guard_path.lstat()
1028
+ except FileNotFoundError:
1029
+ return
1030
+ except OSError as error:
1031
+ raise ReadOnlyDatabaseAccessError(
1032
+ f"Could not prove that database publication is complete: {error}"
1033
+ ) from error
1034
+ raise ReadOnlyDatabaseAccessError(
1035
+ "Test-database publication is still in progress or needs recovery. "
1036
+ "If generation has stopped, follow its recovery instructions before "
1037
+ "removing the publication guard. qPlot did not open the database or "
1038
+ "its SQLite sidecars."
1039
+ )
1040
+
1041
+
1042
+ def _sqlite_uri_path(database_path):
1043
+ """Return an absolute, URI-escaped SQLite path without the ``file:`` prefix."""
1044
+ return _resolved_database_path(database_path).as_uri().removeprefix("file:")
1045
+
1046
+
1047
+ def sqlite_read_only_uri(database_path, *, immutable=False):
1048
+ """Build an exact-path SQLite URI with enforced read-only access."""
1049
+ uri = f"file:{_sqlite_uri_path(database_path)}?mode=ro"
1050
+ if immutable:
1051
+ uri += "&immutable=1"
1052
+ return uri
1053
+
1054
+
1055
+ def sqlite_read_only_connection(
1056
+ database_path,
1057
+ timeout=10,
1058
+ *,
1059
+ ignore_unpaired_wal=False,
1060
+ expected_database_identity=None,
1061
+ cancelled_callback=None,
1062
+ deadline=None,
1063
+ **kwargs,
1064
+ ):
1065
+ """Open a source-preserving SQLite connection to a private snapshot.
1066
+
1067
+ Every source state uses a consistency-checked private copy. SQLite can
1068
+ therefore use immutable access, follow a WAL, or recover a hot journal
1069
+ without opening or changing the source database and its sidecars.
1070
+ """
1071
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1072
+ source_path = _resolved_database_path(database_path)
1073
+ kwargs.setdefault("factory", _ManagedSQLiteConnection)
1074
+
1075
+ for _attempt in range(2):
1076
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1077
+ (
1078
+ target_path,
1079
+ immutable,
1080
+ snapshot,
1081
+ ignore_wal,
1082
+ prepared_source,
1083
+ ) = _prepare_read_target(
1084
+ source_path,
1085
+ ignore_unpaired_wal=ignore_unpaired_wal,
1086
+ expected_database_identity=expected_database_identity,
1087
+ cancelled_callback=cancelled_callback,
1088
+ deadline=deadline,
1089
+ )
1090
+ conn = None
1091
+ try:
1092
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1093
+ if not _prepared_source_is_current(
1094
+ source_path,
1095
+ prepared_source,
1096
+ direct=snapshot is None,
1097
+ ignore_wal=ignore_wal,
1098
+ cancelled_callback=cancelled_callback,
1099
+ deadline=deadline,
1100
+ ):
1101
+ if snapshot is not None:
1102
+ _cleanup_failed_connection_open(None, snapshot)
1103
+ continue
1104
+ _require_publication_complete(source_path)
1105
+ conn = sqlite3.connect(
1106
+ sqlite_read_only_uri(target_path, immutable=immutable),
1107
+ timeout=_sqlite_timeout_before_deadline(timeout, deadline),
1108
+ uri=True,
1109
+ **kwargs,
1110
+ )
1111
+ _install_read_control_progress_handler(
1112
+ conn,
1113
+ cancelled_callback,
1114
+ deadline,
1115
+ )
1116
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1117
+ _require_publication_complete(source_path)
1118
+ _require_expected_database_instance(
1119
+ source_path,
1120
+ expected_database_identity,
1121
+ )
1122
+ if not _prepared_source_is_current(
1123
+ source_path,
1124
+ prepared_source,
1125
+ direct=snapshot is None,
1126
+ ignore_wal=ignore_wal,
1127
+ cancelled_callback=cancelled_callback,
1128
+ deadline=deadline,
1129
+ ):
1130
+ _cleanup_failed_connection_open(conn, snapshot)
1131
+ continue
1132
+ _require_publication_complete(source_path)
1133
+ if snapshot is not None:
1134
+ attach_snapshot = getattr(conn, "attach_snapshot", None)
1135
+ if not callable(attach_snapshot):
1136
+ raise TypeError(
1137
+ "A custom SQLite connection factory used for a live "
1138
+ "database snapshot must provide attach_snapshot()."
1139
+ )
1140
+ attach_snapshot(snapshot)
1141
+ snapshot = None
1142
+ conn._qplot_source_activity_timestamp = _prepared_activity_timestamp(
1143
+ prepared_source, ignore_wal=ignore_wal
1144
+ )
1145
+ configured = configure_read_only_sqlite_connection(conn)
1146
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1147
+ return configured
1148
+ except Exception:
1149
+ _cleanup_failed_connection_open(conn, snapshot)
1150
+ raise
1151
+
1152
+ raise ReadOnlyDatabaseAccessError(
1153
+ "The database became busy while qPlot was opening a transaction-consistent "
1154
+ "view. It is temporarily unavailable; refresh to retry. qPlot did not "
1155
+ "modify the source database or its SQLite sidecars."
1156
+ )
1157
+
1158
+
1159
+ def _probe_sqlite_user_version(
1160
+ database_path,
1161
+ *,
1162
+ cancelled_callback=None,
1163
+ deadline=None,
1164
+ ):
1165
+ """Perform one bounded immutable read without ever following sidecars.
1166
+
1167
+ Lock availability is checked separately through the platform lock-byte
1168
+ protocol when the isolated access-probe child requests it. Keeping this
1169
+ SQLite connection immutable prevents a DELETE-to-WAL race from creating a
1170
+ source ``-shm`` file between signature observation and connection opening.
1171
+ """
1172
+ connection = None
1173
+ try:
1174
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1175
+ connection = sqlite3.connect(
1176
+ sqlite_read_only_uri(database_path, immutable=True),
1177
+ timeout=_sqlite_timeout_before_deadline(1, deadline),
1178
+ uri=True,
1179
+ )
1180
+ _install_read_control_progress_handler(
1181
+ connection,
1182
+ cancelled_callback,
1183
+ deadline,
1184
+ )
1185
+ connection.execute("SELECT 1 FROM sqlite_schema LIMIT 1").fetchone()
1186
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1187
+ except sqlite3.Error:
1188
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1189
+ raise
1190
+ finally:
1191
+ if connection is not None:
1192
+ connection.close()
1193
+
1194
+
1195
+ def probe_read_only_database(
1196
+ database_path,
1197
+ *,
1198
+ ignore_unpaired_wal=False,
1199
+ expected_database_identity=None,
1200
+ cancelled_callback=None,
1201
+ deadline=None,
1202
+ _check_sqlite_lock_bytes=False,
1203
+ ):
1204
+ """Promptly check access and identity without building a full snapshot.
1205
+
1206
+ The real opener remains responsible for transaction-consistent snapshot
1207
+ creation and generated-WAL provenance. This probe deliberately touches
1208
+ only bounded regions of source artifacts and opens SQLite immutable, so a
1209
+ concurrent transition to WAL cannot create source sidecars. The isolated
1210
+ access-check child can additionally request a platform lock-byte check for
1211
+ a sidecar-free rollback-mode source.
1212
+ """
1213
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1214
+ source_path = _resolved_database_path(database_path)
1215
+ _require_publication_complete(source_path)
1216
+ if not source_path.is_file():
1217
+ raise FileNotFoundError(source_path)
1218
+
1219
+ _require_expected_database_instance(
1220
+ source_path,
1221
+ expected_database_identity,
1222
+ )
1223
+ prepared_database_identity = database_file_identity(source_path)
1224
+ if prepared_database_identity is None:
1225
+ raise FileNotFoundError(source_path)
1226
+
1227
+ try:
1228
+ generation_marker = database_has_qplot_generation_marker(source_path)
1229
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
1230
+ raise
1231
+ except OSError as error:
1232
+ raise ReadOnlyDatabaseAccessError(
1233
+ f"Could not inspect the database publication marker: {error}"
1234
+ ) from error
1235
+
1236
+ for _attempt in range(WAL_SNAPSHOT_ATTEMPTS):
1237
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1238
+ _require_expected_database_instance(
1239
+ source_path,
1240
+ expected_database_identity,
1241
+ )
1242
+ try:
1243
+ before = _source_signature_with_controls(
1244
+ source_path,
1245
+ cancelled_callback,
1246
+ deadline,
1247
+ )
1248
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
1249
+ raise
1250
+ except OSError as error:
1251
+ raise ReadOnlyDatabaseAccessError(
1252
+ f"Could not inspect a read-only view of {source_path}: {error}"
1253
+ ) from error
1254
+ if before.database is None:
1255
+ raise FileNotFoundError(source_path)
1256
+ if not before.stable:
1257
+ continue
1258
+
1259
+ policy = _source_read_policy(
1260
+ source_path,
1261
+ before,
1262
+ generation_marker=generation_marker,
1263
+ ignore_unpaired_wal=ignore_unpaired_wal,
1264
+ )
1265
+ if policy.include_wal and not generation_marker:
1266
+ _require_expected_database_instance(
1267
+ source_path,
1268
+ expected_database_identity,
1269
+ )
1270
+ _require_prepared_database_instance(
1271
+ source_path,
1272
+ prepared_database_identity,
1273
+ )
1274
+ after = _source_signature_for_validation(
1275
+ source_path,
1276
+ cancelled_callback=cancelled_callback,
1277
+ deadline=deadline,
1278
+ )
1279
+ if after != before or not after.stable:
1280
+ continue
1281
+ raise _unverifiable_unmarked_wal_error(source_path)
1282
+
1283
+ if not _bounded_artifact_is_current(
1284
+ source_path,
1285
+ before.database,
1286
+ before.database_identity,
1287
+ cancelled_callback=cancelled_callback,
1288
+ deadline=deadline,
1289
+ ):
1290
+ continue
1291
+ if (
1292
+ before.wal is not None
1293
+ and not _bounded_artifact_is_current(
1294
+ _wal_path(source_path),
1295
+ before.wal,
1296
+ before.wal_identity,
1297
+ cancelled_callback=cancelled_callback,
1298
+ deadline=deadline,
1299
+ )
1300
+ ):
1301
+ continue
1302
+
1303
+ def source_is_still_current(expected_source=before):
1304
+ _require_publication_complete(source_path)
1305
+ _require_expected_database_instance(
1306
+ source_path,
1307
+ expected_database_identity,
1308
+ )
1309
+ _require_prepared_database_instance(
1310
+ source_path,
1311
+ prepared_database_identity,
1312
+ )
1313
+ after = _source_signature_for_validation(
1314
+ source_path,
1315
+ cancelled_callback=cancelled_callback,
1316
+ deadline=deadline,
1317
+ )
1318
+ return after == expected_source and after.stable
1319
+
1320
+ sqlite_error = None
1321
+ try:
1322
+ _probe_sqlite_user_version(
1323
+ source_path,
1324
+ cancelled_callback=cancelled_callback,
1325
+ deadline=deadline,
1326
+ )
1327
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
1328
+ raise
1329
+ except sqlite3.Error as error:
1330
+ sqlite_error = error
1331
+
1332
+ # This is deliberately the last full validation before a platform
1333
+ # lock-byte check. On POSIX, closing *any* descriptor for an inode drops
1334
+ # all process-owned fcntl locks for that inode, so no helper that opens
1335
+ # the main file may run after the lock context is entered.
1336
+ if not source_is_still_current():
1337
+ continue
1338
+ if sqlite_error is not None:
1339
+ raise sqlite_error
1340
+
1341
+ if (
1342
+ not _check_sqlite_lock_bytes
1343
+ or policy.requires_private_snapshot
1344
+ ):
1345
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1346
+ return
1347
+
1348
+ try:
1349
+ with _sqlite_shared_lock_bytes(
1350
+ source_path,
1351
+ cancelled_callback=cancelled_callback,
1352
+ deadline=deadline,
1353
+ ) as database_file:
1354
+ _require_publication_complete(source_path)
1355
+ _require_expected_database_instance(
1356
+ source_path,
1357
+ expected_database_identity,
1358
+ )
1359
+ _require_prepared_database_instance(
1360
+ source_path,
1361
+ prepared_database_identity,
1362
+ )
1363
+ if not _source_matches_under_sqlite_lock(
1364
+ source_path,
1365
+ before,
1366
+ database_file,
1367
+ cancelled_callback=cancelled_callback,
1368
+ deadline=deadline,
1369
+ ):
1370
+ continue
1371
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1372
+ return
1373
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
1374
+ raise
1375
+ except sqlite3.Error:
1376
+ # A concurrent journal-mode or file transition can look exactly
1377
+ # like lock contention. Revalidate after releasing any partial OS
1378
+ # locks; reject the lock only when the source is still identical.
1379
+ if not source_is_still_current():
1380
+ continue
1381
+ raise
1382
+
1383
+ raise ReadOnlyDatabaseAccessError(
1384
+ "The database main file or SQLite sidecars changed continuously while "
1385
+ "qPlot checked read-only access. The database is busy or temporarily "
1386
+ "unavailable; refresh to retry. qPlot did not modify the source database "
1387
+ "or its SQLite sidecars."
1388
+ )
1389
+
1390
+
1391
+ def _qcodes_uri_path(database_path, *, immutable):
1392
+ """Build the URI path expected by QCoDeS' URI-constructing helper.
1393
+
1394
+ QCoDeS adds ``file:`` and a final ``?mode=ro`` itself. For immutable
1395
+ access, the dummy final parameter absorbs that second question mark while
1396
+ retaining the earlier, valid ``mode=ro`` and ``immutable=1`` options.
1397
+ """
1398
+ path = _sqlite_uri_path(database_path)
1399
+ if immutable:
1400
+ return f"{path}?mode=ro&immutable=1&qplot="
1401
+ return path
1402
+
1403
+
1404
+ def _wal_path(database_path):
1405
+ return Path(f"{database_path}-wal")
1406
+
1407
+
1408
+ def _journal_path(database_path):
1409
+ return Path(f"{database_path}-journal")
1410
+
1411
+
1412
+ def replacement_wal_is_quarantined(database_path):
1413
+ """Return whether an unpaired WAL must be omitted from this read.
1414
+
1415
+ Replacement history is path-local and intentionally carries forward to a
1416
+ later main-file identity. A changed sidecar or a transient missing sidecar
1417
+ cannot prove it belongs to the current main file.
1418
+ """
1419
+
1420
+ _database_identity, quarantined = _observe_database_instance(database_path)
1421
+ return bool(quarantined)
1422
+
1423
+
1424
+ def _prepared_activity_timestamp(source, *, ignore_wal):
1425
+ """Keep source activity bound to the validated copy, before SQLite replay.
1426
+
1427
+ Snapshot creation/recovery timestamps are never acquisition timestamps.
1428
+ Omitted WAL files do not contribute evidence for the copied database.
1429
+ """
1430
+ signatures = (source.database, None if ignore_wal else source.wal)
1431
+ return max(
1432
+ (signature[3] / 1_000_000_000 for signature in signatures
1433
+ if signature is not None and signature[2]),
1434
+ default=None,
1435
+ )
1436
+
1437
+
1438
+ def _stat_signature(stat_result):
1439
+ return (
1440
+ stat_result.st_dev,
1441
+ stat_result.st_ino,
1442
+ stat_result.st_size,
1443
+ stat_result.st_mtime_ns,
1444
+ stat_result.st_ctime_ns,
1445
+ )
1446
+
1447
+
1448
+ def _file_signature(path):
1449
+ try:
1450
+ stat_result = path.stat()
1451
+ except FileNotFoundError:
1452
+ return None
1453
+ return _stat_signature(stat_result)
1454
+
1455
+
1456
+ def _file_prefix_observation(
1457
+ path,
1458
+ byte_count,
1459
+ *,
1460
+ include_trailer=False,
1461
+ cancelled_callback=None,
1462
+ deadline=None,
1463
+ ):
1464
+ """Read identifying bytes while proving which path instance supplied them."""
1465
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1466
+ path_identity_before = path_bound_file_identity(path)
1467
+ path_signature_before = _file_signature(path)
1468
+ if path_signature_before is None:
1469
+ return None, None, b"", b"", True
1470
+ try:
1471
+ with path.open("rb") as handle:
1472
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1473
+ descriptor_identity_before = open_file_identity(handle.fileno())
1474
+ descriptor_before = _stat_signature(os.fstat(handle.fileno()))
1475
+ prefix = handle.read(byte_count)
1476
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1477
+ trailer = b""
1478
+ if include_trailer and descriptor_before[2] >= 16:
1479
+ handle.seek(-16, os.SEEK_END)
1480
+ trailer = handle.read(16)
1481
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1482
+ descriptor_after = _stat_signature(os.fstat(handle.fileno()))
1483
+ descriptor_identity_after = open_file_identity(handle.fileno())
1484
+ except FileNotFoundError:
1485
+ return path_signature_before, path_identity_before, b"", b"", False
1486
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1487
+ path_signature_after = _file_signature(path)
1488
+ path_identity_after = path_bound_file_identity(path)
1489
+ stable = _path_and_descriptor_observations_match(
1490
+ path_signature_before,
1491
+ path_identity_before,
1492
+ descriptor_before,
1493
+ descriptor_identity_before,
1494
+ descriptor_after,
1495
+ descriptor_identity_after,
1496
+ path_signature_after,
1497
+ path_identity_after,
1498
+ )
1499
+ observed_signature = (
1500
+ path_signature_after
1501
+ if path_signature_after is not None
1502
+ else path_signature_before
1503
+ )
1504
+ return observed_signature, path_identity_after, prefix, trailer, stable
1505
+
1506
+
1507
+ def _path_and_descriptor_observations_match(
1508
+ path_signature_before,
1509
+ path_identity_before,
1510
+ descriptor_signature_before,
1511
+ descriptor_identity_before,
1512
+ descriptor_signature_after,
1513
+ descriptor_identity_after,
1514
+ path_signature_after,
1515
+ path_identity_after,
1516
+ ):
1517
+ """Compare path and descriptor observations without mixing stat domains."""
1518
+ signatures = (
1519
+ path_signature_before,
1520
+ descriptor_signature_before,
1521
+ descriptor_signature_after,
1522
+ path_signature_after,
1523
+ )
1524
+ if any(signature is None for signature in signatures):
1525
+ return False
1526
+
1527
+ stable_metadata = (
1528
+ path_signature_before == path_signature_after
1529
+ and descriptor_signature_before == descriptor_signature_after
1530
+ and path_signature_before[2:4] == descriptor_signature_before[2:4]
1531
+ and path_signature_after[2:4] == descriptor_signature_after[2:4]
1532
+ )
1533
+ identities = (
1534
+ path_identity_before,
1535
+ descriptor_identity_before,
1536
+ descriptor_identity_after,
1537
+ path_identity_after,
1538
+ )
1539
+ if all(identity is not None for identity in identities):
1540
+ return stable_metadata and len(set(identities)) == 1
1541
+ if any(identity is not None for identity in identities):
1542
+ return False
1543
+ return len(set(signatures)) == 1
1544
+
1545
+
1546
+ def _bounded_artifact_is_current(
1547
+ artifact_path,
1548
+ expected_signature,
1549
+ expected_identity,
1550
+ *,
1551
+ cancelled_callback=None,
1552
+ deadline=None,
1553
+ ):
1554
+ """Touch an artifact's beginning and end while retaining its identity."""
1555
+ signature, identity, _prefix, _trailer, stable = _file_prefix_observation(
1556
+ artifact_path,
1557
+ _WAL_HEADER_BYTES,
1558
+ include_trailer=True,
1559
+ cancelled_callback=cancelled_callback,
1560
+ deadline=deadline,
1561
+ )
1562
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1563
+ return (
1564
+ stable
1565
+ and signature == expected_signature
1566
+ and identity == expected_identity
1567
+ )
1568
+
1569
+
1570
+ def _rollback_journal_observation(
1571
+ database_path,
1572
+ *,
1573
+ cancelled_callback=None,
1574
+ deadline=None,
1575
+ ):
1576
+ journal_path = _journal_path(database_path)
1577
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1578
+ signature, identity, prefix, trailer, stable = _file_prefix_observation(
1579
+ journal_path,
1580
+ _ROLLBACK_JOURNAL_PREFIX_BYTES,
1581
+ include_trailer=True,
1582
+ cancelled_callback=cancelled_callback,
1583
+ deadline=deadline,
1584
+ )
1585
+ if signature is None:
1586
+ return None
1587
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1588
+ return _RollbackJournalObservation(
1589
+ file_signature=signature,
1590
+ file_identity=identity,
1591
+ prefix=prefix,
1592
+ trailer=trailer,
1593
+ stable=stable,
1594
+ )
1595
+
1596
+
1597
+ def _wal_observation(
1598
+ database_path,
1599
+ *,
1600
+ cancelled_callback=None,
1601
+ deadline=None,
1602
+ ):
1603
+ """Observe one path-bound WAL instance without opening it through SQLite."""
1604
+ wal_path = _wal_path(database_path)
1605
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1606
+ identity_before = path_bound_file_identity(wal_path)
1607
+ signature_before = _file_signature(wal_path)
1608
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1609
+ signature_after = _file_signature(wal_path)
1610
+ identity_after = path_bound_file_identity(wal_path)
1611
+ return (
1612
+ signature_after,
1613
+ identity_after,
1614
+ identity_before == identity_after and signature_before == signature_after,
1615
+ )
1616
+
1617
+
1618
+ def _source_signature(
1619
+ database_path,
1620
+ *,
1621
+ cancelled_callback=None,
1622
+ deadline=None,
1623
+ ):
1624
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1625
+ (
1626
+ database_signature,
1627
+ database_identity,
1628
+ database_header,
1629
+ _trailer,
1630
+ database_stable,
1631
+ ) = (
1632
+ _file_prefix_observation(
1633
+ database_path,
1634
+ 100,
1635
+ cancelled_callback=cancelled_callback,
1636
+ deadline=deadline,
1637
+ )
1638
+ )
1639
+ wal_signature, wal_identity, wal_stable = _wal_observation(
1640
+ database_path,
1641
+ cancelled_callback=cancelled_callback,
1642
+ deadline=deadline,
1643
+ )
1644
+ journal = _rollback_journal_observation(
1645
+ database_path,
1646
+ cancelled_callback=cancelled_callback,
1647
+ deadline=deadline,
1648
+ )
1649
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1650
+ return _SourceSignature(
1651
+ database=database_signature,
1652
+ database_identity=database_identity,
1653
+ database_header=database_header,
1654
+ wal=wal_signature,
1655
+ wal_identity=wal_identity,
1656
+ journal=journal,
1657
+ stable=(
1658
+ database_stable
1659
+ and wal_stable
1660
+ and (journal is None or journal.stable)
1661
+ ),
1662
+ )
1663
+
1664
+
1665
+ def _source_signature_with_controls(
1666
+ database_path,
1667
+ cancelled_callback=None,
1668
+ deadline=None,
1669
+ ):
1670
+ """Keep no-control instrumentation compatible with the legacy call shape."""
1671
+ if cancelled_callback is None and deadline is None:
1672
+ return _source_signature(database_path)
1673
+ return _source_signature(
1674
+ database_path,
1675
+ cancelled_callback=cancelled_callback,
1676
+ deadline=deadline,
1677
+ )
1678
+
1679
+
1680
+ def _signature_requires_private_snapshot(signature, *, ignore_wal):
1681
+ return (
1682
+ not signature.stable
1683
+ or signature.journal is not None
1684
+ or (signature.wal is not None and not ignore_wal)
1685
+ )
1686
+
1687
+
1688
+ def _source_read_policy(
1689
+ database_path,
1690
+ signature,
1691
+ *,
1692
+ generation_marker,
1693
+ ignore_unpaired_wal,
1694
+ ):
1695
+ """Apply the shared fail-closed sidecar policy to one observation."""
1696
+ journal = signature.journal
1697
+ wal_present = signature.wal is not None
1698
+ empty_wal = wal_present and signature.wal[2] == 0
1699
+ quarantined = (
1700
+ replacement_wal_is_quarantined(database_path)
1701
+ if wal_present or journal is not None
1702
+ else False
1703
+ )
1704
+ ignore_wal = wal_present and (
1705
+ empty_wal
1706
+ or (
1707
+ not generation_marker
1708
+ and (ignore_unpaired_wal or quarantined)
1709
+ )
1710
+ )
1711
+ include_wal = wal_present and not ignore_wal
1712
+
1713
+ if journal is not None and journal.potentially_hot and include_wal:
1714
+ raise ReadOnlyDatabaseAccessError(
1715
+ "The database has both an active-looking rollback journal and "
1716
+ "a WAL, so qPlot cannot prove which transaction state is valid. "
1717
+ "The database is busy or temporarily unavailable; finish the "
1718
+ "writer transaction and refresh. qPlot did not modify the source "
1719
+ "database or its SQLite sidecars."
1720
+ )
1721
+ if journal is not None and journal.potentially_hot and quarantined:
1722
+ raise ReadOnlyDatabaseAccessError(
1723
+ "The rollback journal cannot be paired with the database instance "
1724
+ "that replaced the previously loaded file. The database is busy "
1725
+ "or temporarily unavailable; close the old writer and refresh. "
1726
+ "qPlot did not modify the source database or its SQLite sidecars."
1727
+ )
1728
+ if journal is not None and _journal_names_super_journal(journal):
1729
+ raise ReadOnlyDatabaseAccessError(
1730
+ "The rollback journal belongs to a multi-database transaction, "
1731
+ "which qPlot cannot recover without following files outside its "
1732
+ "private snapshot. The database is temporarily unavailable; "
1733
+ "finish that transaction and refresh. qPlot did not modify the "
1734
+ "source database or its SQLite sidecars."
1735
+ )
1736
+
1737
+ return _SourceReadPolicy(
1738
+ journal=journal,
1739
+ wal_present=wal_present,
1740
+ quarantined=quarantined,
1741
+ ignore_wal=ignore_wal,
1742
+ include_wal=include_wal,
1743
+ database_is_wal_format=(
1744
+ signature.database_header[18:20] == b"\x02\x02"
1745
+ ),
1746
+ )
1747
+
1748
+
1749
+ def _prepared_source_is_current(
1750
+ database_path,
1751
+ prepared_source,
1752
+ *,
1753
+ direct,
1754
+ ignore_wal,
1755
+ cancelled_callback=None,
1756
+ deadline=None,
1757
+ ):
1758
+ """Validate the exact main/journal instance through connection opening."""
1759
+ try:
1760
+ current_source = _source_signature_with_controls(
1761
+ database_path,
1762
+ cancelled_callback,
1763
+ deadline,
1764
+ )
1765
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
1766
+ raise
1767
+ except OSError as error:
1768
+ raise ReadOnlyDatabaseAccessError(
1769
+ f"Could not validate the database instance and sidecars: {error}"
1770
+ ) from error
1771
+ if current_source.database_identity != prepared_source.database_identity:
1772
+ quarantine_wal_for_replaced_database(database_path)
1773
+ raise DatabaseInstanceChangedError(
1774
+ "The database was replaced while qPlot was opening its read-only view."
1775
+ )
1776
+ return (
1777
+ current_source == prepared_source
1778
+ and (
1779
+ not direct
1780
+ or not _signature_requires_private_snapshot(
1781
+ current_source,
1782
+ ignore_wal=ignore_wal,
1783
+ )
1784
+ )
1785
+ )
1786
+
1787
+
1788
+ def _source_signature_for_validation(
1789
+ database_path,
1790
+ *,
1791
+ cancelled_callback=None,
1792
+ deadline=None,
1793
+ ):
1794
+ """Read a validation signature while keeping filesystem errors explicit."""
1795
+ try:
1796
+ return _source_signature_with_controls(
1797
+ database_path,
1798
+ cancelled_callback,
1799
+ deadline,
1800
+ )
1801
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
1802
+ raise
1803
+ except OSError as error:
1804
+ raise ReadOnlyDatabaseAccessError(
1805
+ f"Could not validate a read-only copy of {database_path}: {error}"
1806
+ ) from error
1807
+
1808
+
1809
+ def _generation_provenance(connection):
1810
+ application_id = connection.execute("PRAGMA application_id").fetchone()
1811
+ row = connection.execute(
1812
+ f"SELECT generation_token, write_epoch "
1813
+ f"FROM {QPLOT_GENERATION_PROVENANCE_TABLE} WHERE singleton = 1"
1814
+ ).fetchone()
1815
+ extra_row = connection.execute(
1816
+ f"SELECT 1 FROM {QPLOT_GENERATION_PROVENANCE_TABLE} "
1817
+ "WHERE singleton != 1 LIMIT 1"
1818
+ ).fetchone()
1819
+ try:
1820
+ token_bytes = (
1821
+ bytes.fromhex(row[0])
1822
+ if row is not None and isinstance(row[0], str)
1823
+ else b""
1824
+ )
1825
+ except ValueError:
1826
+ token_bytes = b""
1827
+ if (
1828
+ application_id != (QPLOT_GENERATED_DATABASE_APPLICATION_ID,)
1829
+ or row is None
1830
+ or extra_row is not None
1831
+ or len(token_bytes) != QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES
1832
+ or not isinstance(row[1], int)
1833
+ or row[1] < 0
1834
+ ):
1835
+ raise ValueError("invalid qPlot generation provenance")
1836
+ return row
1837
+
1838
+
1839
+ def _generation_lineage_state(connection):
1840
+ """Read and structurally validate the branch-bound provenance head."""
1841
+ state = connection.execute(
1842
+ f"SELECT singleton, format_version, generation_token, head_sequence, "
1843
+ f"head_nonce, window_size FROM {QPLOT_GENERATION_LINEAGE_STATE_TABLE}"
1844
+ ).fetchall()
1845
+ if len(state) != 1:
1846
+ raise ValueError("invalid qPlot generation lineage state")
1847
+ singleton, version, token, sequence, nonce, window = state[0]
1848
+ tip = connection.execute(
1849
+ f"SELECT slot, parent_nonce, nonce FROM "
1850
+ f"{QPLOT_GENERATION_LINEAGE_RING_TABLE} WHERE sequence = ?",
1851
+ (sequence,),
1852
+ ).fetchone()
1853
+ if (
1854
+ singleton != 1
1855
+ or version != QPLOT_GENERATION_LINEAGE_FORMAT_VERSION
1856
+ or not isinstance(token, str)
1857
+ or not isinstance(sequence, int)
1858
+ or sequence < 0
1859
+ or not isinstance(nonce, bytes)
1860
+ or len(nonce) != QPLOT_GENERATION_LINEAGE_NONCE_BYTES
1861
+ or window != QPLOT_GENERATION_LINEAGE_WINDOW
1862
+ or tip is None
1863
+ or tip[0] != sequence % QPLOT_GENERATION_LINEAGE_WINDOW
1864
+ or tip[2] != nonce
1865
+ ):
1866
+ raise ValueError("invalid qPlot generation lineage state")
1867
+ return token, sequence, nonce, window
1868
+
1869
+
1870
+ def _wal_checksum(data, byte_order, state=(0, 0)):
1871
+ """Return SQLite's cumulative two-word WAL checksum."""
1872
+ if len(data) % 8:
1873
+ raise ValueError("invalid WAL checksum input length")
1874
+ checksum_0, checksum_1 = state
1875
+ for word_0, word_1 in struct.iter_unpack(f"{byte_order}II", data):
1876
+ checksum_0 = (checksum_0 + word_0 + checksum_1) & 0xFFFFFFFF
1877
+ checksum_1 = (checksum_1 + word_1 + checksum_0) & 0xFFFFFFFF
1878
+ return checksum_0, checksum_1
1879
+
1880
+
1881
+ def _committed_wal_transaction_pages(
1882
+ wal_path,
1883
+ *,
1884
+ cancelled_callback=None,
1885
+ deadline=None,
1886
+ ):
1887
+ """Return page sets for SQLite's valid committed private-WAL prefix.
1888
+
1889
+ WAL files can retain invalid frames from an earlier checkpoint cycle. The
1890
+ checksum scan mirrors SQLite recovery: it stops at the first invalid frame
1891
+ and ignores valid but uncommitted tail frames after the last commit marker.
1892
+ """
1893
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1894
+ with open(wal_path, "rb") as wal_file:
1895
+ header = wal_file.read(_WAL_HEADER_BYTES)
1896
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1897
+ if len(header) != _WAL_HEADER_BYTES:
1898
+ raise ValueError("missing or truncated WAL header")
1899
+ magic = int.from_bytes(header[:4], "big")
1900
+ if magic == _WAL_MAGIC_LITTLE_ENDIAN_CHECKSUMS:
1901
+ checksum_byte_order = "<"
1902
+ elif magic == _WAL_MAGIC_BIG_ENDIAN_CHECKSUMS:
1903
+ checksum_byte_order = ">"
1904
+ else:
1905
+ raise ValueError("invalid WAL magic")
1906
+ if int.from_bytes(header[4:8], "big") != _WAL_FORMAT_VERSION:
1907
+ raise ValueError("unsupported WAL format")
1908
+ page_size = int.from_bytes(header[8:12], "big")
1909
+ if (
1910
+ page_size < 512
1911
+ or page_size > 65_536
1912
+ or page_size & (page_size - 1)
1913
+ ):
1914
+ raise ValueError("invalid WAL page size")
1915
+ checksum = _wal_checksum(header[:24], checksum_byte_order)
1916
+ stored_header_checksum = (
1917
+ int.from_bytes(header[24:28], "big"),
1918
+ int.from_bytes(header[28:32], "big"),
1919
+ )
1920
+ if checksum != stored_header_checksum:
1921
+ raise ValueError("invalid WAL header checksum")
1922
+
1923
+ salts = header[16:24]
1924
+ current_transaction_pages = set()
1925
+ committed_transactions = []
1926
+ while True:
1927
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1928
+ frame_header = wal_file.read(_WAL_FRAME_HEADER_BYTES)
1929
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1930
+ if not frame_header:
1931
+ break
1932
+ if len(frame_header) != _WAL_FRAME_HEADER_BYTES:
1933
+ break
1934
+ page = wal_file.read(page_size)
1935
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1936
+ if len(page) != page_size or frame_header[8:16] != salts:
1937
+ break
1938
+ next_checksum = _wal_checksum(
1939
+ frame_header[:8],
1940
+ checksum_byte_order,
1941
+ checksum,
1942
+ )
1943
+ next_checksum = _wal_checksum(
1944
+ page,
1945
+ checksum_byte_order,
1946
+ next_checksum,
1947
+ )
1948
+ stored_frame_checksum = (
1949
+ int.from_bytes(frame_header[16:20], "big"),
1950
+ int.from_bytes(frame_header[20:24], "big"),
1951
+ )
1952
+ if next_checksum != stored_frame_checksum:
1953
+ break
1954
+ page_number = int.from_bytes(frame_header[:4], "big")
1955
+ if page_number == 0:
1956
+ break
1957
+ checksum = next_checksum
1958
+ current_transaction_pages.add(page_number)
1959
+ if int.from_bytes(frame_header[4:8], "big"):
1960
+ committed_transactions.append(
1961
+ frozenset(current_transaction_pages)
1962
+ )
1963
+ current_transaction_pages.clear()
1964
+
1965
+ _raise_if_read_interrupted(cancelled_callback, deadline)
1966
+ if not committed_transactions:
1967
+ raise ValueError("the WAL has no valid committed transaction")
1968
+ return tuple(committed_transactions)
1969
+
1970
+
1971
+ def _lineage_state_root_page(connection):
1972
+ rows = connection.execute(
1973
+ "SELECT rootpage FROM sqlite_schema WHERE type = 'table' AND name = ?",
1974
+ (QPLOT_GENERATION_LINEAGE_STATE_TABLE,),
1975
+ ).fetchall()
1976
+ if len(rows) != 1 or not isinstance(rows[0][0], int) or rows[0][0] <= 0:
1977
+ raise ValueError("invalid qPlot generation lineage root page")
1978
+ return rows[0][0]
1979
+
1980
+
1981
+ def _require_generation_lineage_trigger_inventory(connection):
1982
+ """Require every current user table to carry the exact v2 trigger trio."""
1983
+ excluded_tables = (
1984
+ QPLOT_GENERATION_PROVENANCE_TABLE,
1985
+ QPLOT_GENERATION_LINEAGE_STATE_TABLE,
1986
+ QPLOT_GENERATION_LINEAGE_RING_TABLE,
1987
+ )
1988
+ table_names = [
1989
+ row[0]
1990
+ for row in connection.execute(
1991
+ "SELECT name FROM sqlite_schema "
1992
+ "WHERE type = 'table' AND name NOT LIKE 'sqlite_%' "
1993
+ "AND name NOT IN (?, ?, ?) ORDER BY name",
1994
+ excluded_tables,
1995
+ ).fetchall()
1996
+ ]
1997
+ triggers = {
1998
+ row[0]: (row[1], row[2])
1999
+ for row in connection.execute(
2000
+ "SELECT name, tbl_name, sql FROM sqlite_schema WHERE type = 'trigger'"
2001
+ ).fetchall()
2002
+ }
2003
+ for table_name in table_names:
2004
+ for operation in ("INSERT", "UPDATE", "DELETE"):
2005
+ trigger_name, statement = generation_provenance_trigger(
2006
+ table_name,
2007
+ operation,
2008
+ )
2009
+ if triggers.get(trigger_name) != (table_name, statement):
2010
+ raise ValueError(
2011
+ f"missing or invalid lineage trigger for {table_name!r}"
2012
+ )
2013
+
2014
+
2015
+ def _require_generation_lineage_descendant(
2016
+ connection,
2017
+ main_sequence,
2018
+ main_nonce,
2019
+ snapshot_sequence,
2020
+ snapshot_nonce,
2021
+ window,
2022
+ ):
2023
+ """Prove the WAL head is linked directly to the selected main's head."""
2024
+ transition_count = snapshot_sequence - main_sequence
2025
+ if transition_count <= 0:
2026
+ raise ValueError("non-advancing lineage")
2027
+ if transition_count > window:
2028
+ raise OverflowError("lineage proof window exceeded")
2029
+ rows = connection.execute(
2030
+ f"SELECT slot, sequence, parent_nonce, nonce FROM "
2031
+ f"{QPLOT_GENERATION_LINEAGE_RING_TABLE} "
2032
+ "WHERE sequence > ? AND sequence <= ? ORDER BY sequence",
2033
+ (main_sequence, snapshot_sequence),
2034
+ ).fetchall()
2035
+ if len(rows) != transition_count:
2036
+ raise ValueError("lineage branch has missing transitions")
2037
+
2038
+ expected_sequence = main_sequence + 1
2039
+ expected_parent = main_nonce
2040
+ for slot, sequence, parent_nonce, nonce in rows:
2041
+ if (
2042
+ sequence != expected_sequence
2043
+ or slot != sequence % window
2044
+ or parent_nonce != expected_parent
2045
+ or not isinstance(nonce, bytes)
2046
+ or len(nonce) != QPLOT_GENERATION_LINEAGE_NONCE_BYTES
2047
+ ):
2048
+ raise ValueError("lineage branch does not descend from the main")
2049
+ expected_sequence += 1
2050
+ expected_parent = nonce
2051
+ if expected_parent != snapshot_nonce:
2052
+ raise ValueError("lineage branch does not reach the WAL head")
2053
+
2054
+
2055
+ def _unverifiable_generated_wal_error(database_path, reason):
2056
+ writer_guidance = ""
2057
+ if (
2058
+ "lineage event" in reason
2059
+ or "older qPlot" in reason
2060
+ or "proof window" in reason
2061
+ ):
2062
+ writer_guidance = (
2063
+ " If the owner will create later QCoDeS runs, call "
2064
+ "qplot.testdata.enable_generation_provenance_for_writer on the "
2065
+ "quiescent writable QCoDeS connection before creating the "
2066
+ "Measurement. The writer API refuses to bless a nonempty WAL; "
2067
+ "checkpoint it with TRUNCATE first. SQLite cannot prove an "
2068
+ "already-written uninstrumented WAL after the fact."
2069
+ )
2070
+ return UnverifiableDatabaseWalError(
2071
+ f"qPlot cannot prove that {database_path}-wal belongs to the current "
2072
+ "generated database main file, so it refused to show a possibly stale "
2073
+ f"view ({reason}).{writer_guidance} Close the QCoDeS/SQLite writer "
2074
+ "cleanly so it can "
2075
+ "checkpoint and remove the WAL, then refresh. If the database is being "
2076
+ "moved or copied, stop the writer and keep the main, -wal, and -shm "
2077
+ "files together. qPlot did not modify the database or its sidecars."
2078
+ )
2079
+
2080
+
2081
+ def _unverifiable_unmarked_wal_error(database_path):
2082
+ return UnverifiableDatabaseWalError(
2083
+ f"qPlot cannot prove that {database_path}-wal belongs to the selected "
2084
+ "database main file. Standard SQLite WAL files contain no identifier "
2085
+ "that binds them to a particular main file, and this database has no "
2086
+ "qPlot generation provenance. qPlot therefore refused to show possibly "
2087
+ "unrelated or stale data. Close every QCoDeS/SQLite connection that "
2088
+ "owns the database cleanly so the owning writer checkpoints the WAL, "
2089
+ "then refresh or retry loading. Do not delete, rename, or move the WAL "
2090
+ "by hand. qPlot did not modify the database or any SQLite sidecar."
2091
+ )
2092
+
2093
+
2094
+ def _require_matching_generated_wal_provenance(
2095
+ database_path,
2096
+ main_snapshot_path,
2097
+ snapshot_path,
2098
+ committed_transactions=None,
2099
+ *,
2100
+ cancelled_callback=None,
2101
+ deadline=None,
2102
+ ):
2103
+ """Require a private WAL view to carry this generated main's lineage."""
2104
+ main_connection = None
2105
+ snapshot_connection = None
2106
+ try:
2107
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2108
+ if committed_transactions is None:
2109
+ committed_transactions = _committed_wal_transaction_pages(
2110
+ _wal_path(snapshot_path),
2111
+ cancelled_callback=cancelled_callback,
2112
+ deadline=deadline,
2113
+ )
2114
+ main_connection = sqlite3.connect(
2115
+ sqlite_read_only_uri(main_snapshot_path, immutable=True),
2116
+ uri=True,
2117
+ )
2118
+ _install_read_control_progress_handler(
2119
+ main_connection,
2120
+ cancelled_callback,
2121
+ deadline,
2122
+ )
2123
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2124
+ main_provenance = _generation_provenance(main_connection)
2125
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2126
+ main_lineage = _generation_lineage_state(main_connection)
2127
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2128
+ main_lineage_root = _lineage_state_root_page(main_connection)
2129
+ _require_generation_lineage_trigger_inventory(main_connection)
2130
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2131
+ snapshot_connection = sqlite3.connect(snapshot_path)
2132
+ _install_read_control_progress_handler(
2133
+ snapshot_connection,
2134
+ cancelled_callback,
2135
+ deadline,
2136
+ )
2137
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2138
+ snapshot_provenance = _generation_provenance(snapshot_connection)
2139
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2140
+ snapshot_lineage = _generation_lineage_state(snapshot_connection)
2141
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2142
+ snapshot_lineage_root = _lineage_state_root_page(snapshot_connection)
2143
+ _require_generation_lineage_trigger_inventory(snapshot_connection)
2144
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2145
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2146
+ raise
2147
+ except (sqlite3.Error, ValueError) as error:
2148
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2149
+ raise _unverifiable_generated_wal_error(
2150
+ database_path,
2151
+ "the lineage history or trigger coverage is missing, invalid, or "
2152
+ "from an older qPlot",
2153
+ ) from error
2154
+ else:
2155
+ if (
2156
+ snapshot_lineage_root != main_lineage_root
2157
+ or any(
2158
+ main_lineage_root not in transaction_pages
2159
+ for transaction_pages in committed_transactions
2160
+ )
2161
+ ):
2162
+ raise _unverifiable_generated_wal_error(
2163
+ database_path,
2164
+ "one or more WAL transactions carry no verified lineage event",
2165
+ )
2166
+ main_token, main_epoch = main_provenance
2167
+ snapshot_token, snapshot_epoch = snapshot_provenance
2168
+ main_lineage_token, main_sequence, main_nonce, main_window = main_lineage
2169
+ (
2170
+ snapshot_lineage_token,
2171
+ snapshot_sequence,
2172
+ snapshot_nonce,
2173
+ snapshot_window,
2174
+ ) = snapshot_lineage
2175
+ if (
2176
+ snapshot_token != main_token
2177
+ or snapshot_lineage_token != main_lineage_token
2178
+ or main_lineage_token != main_token
2179
+ ):
2180
+ raise _unverifiable_generated_wal_error(
2181
+ database_path,
2182
+ "the WAL carries a different generation token",
2183
+ )
2184
+ if main_epoch != main_sequence or snapshot_epoch != snapshot_sequence:
2185
+ raise _unverifiable_generated_wal_error(
2186
+ database_path,
2187
+ "the lineage counters disagree with their branch heads",
2188
+ )
2189
+ if main_window != snapshot_window:
2190
+ raise _unverifiable_generated_wal_error(
2191
+ database_path,
2192
+ "the WAL carries a different lineage proof window",
2193
+ )
2194
+ if snapshot_sequence <= main_sequence:
2195
+ raise _unverifiable_generated_wal_error(
2196
+ database_path,
2197
+ "the WAL does not carry a later verified lineage event",
2198
+ )
2199
+ try:
2200
+ _require_generation_lineage_descendant(
2201
+ snapshot_connection,
2202
+ main_sequence,
2203
+ main_nonce,
2204
+ snapshot_sequence,
2205
+ snapshot_nonce,
2206
+ main_window,
2207
+ )
2208
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2209
+ except OverflowError as error:
2210
+ raise _unverifiable_generated_wal_error(
2211
+ database_path,
2212
+ "the WAL exceeded the retained lineage proof window",
2213
+ ) from error
2214
+ except ValueError as error:
2215
+ raise _unverifiable_generated_wal_error(
2216
+ database_path,
2217
+ "the WAL lineage branch does not descend from the selected main",
2218
+ ) from error
2219
+ except sqlite3.Error as error:
2220
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2221
+ raise _unverifiable_generated_wal_error(
2222
+ database_path,
2223
+ "the WAL lineage branch could not be validated",
2224
+ ) from error
2225
+ finally:
2226
+ if snapshot_connection is not None:
2227
+ try:
2228
+ snapshot_connection.close()
2229
+ except sqlite3.Error:
2230
+ pass
2231
+ if main_connection is not None:
2232
+ try:
2233
+ main_connection.close()
2234
+ except sqlite3.Error:
2235
+ pass
2236
+
2237
+
2238
+ def _journal_names_super_journal(journal):
2239
+ """Reject recovery that could follow a path outside the private snapshot."""
2240
+ if not journal.potentially_hot or len(journal.trailer) != 16:
2241
+ return False
2242
+ if journal.trailer[-8:] != _ROLLBACK_JOURNAL_MAGIC:
2243
+ return False
2244
+ name_length = int.from_bytes(journal.trailer[:4], "big")
2245
+ journal_size = journal.file_signature[2]
2246
+ return 0 < name_length <= journal_size - 20
2247
+
2248
+
2249
+ def _recover_private_rollback_journal(
2250
+ database_path,
2251
+ snapshot_path,
2252
+ *,
2253
+ cancelled_callback=None,
2254
+ deadline=None,
2255
+ ):
2256
+ """Let SQLite resolve a copied journal without ever opening the source."""
2257
+ connection = None
2258
+ try:
2259
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2260
+ connection = sqlite3.connect(
2261
+ f"file:{_sqlite_uri_path(snapshot_path)}?mode=rw",
2262
+ uri=True,
2263
+ timeout=0,
2264
+ )
2265
+ _install_read_control_progress_handler(
2266
+ connection,
2267
+ cancelled_callback,
2268
+ deadline,
2269
+ )
2270
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2271
+ connection.execute("PRAGMA query_only = ON")
2272
+ connection.execute("SELECT 1 FROM sqlite_schema LIMIT 1").fetchone()
2273
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2274
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2275
+ raise
2276
+ except sqlite3.Error as error:
2277
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2278
+ raise ReadOnlyDatabaseAccessError(
2279
+ "qPlot could not recover a transaction-consistent private copy of "
2280
+ f"{database_path}. The database is busy or temporarily unavailable; "
2281
+ "finish the current transaction or refresh to retry. qPlot did not "
2282
+ "modify the source database or its SQLite sidecars."
2283
+ ) from error
2284
+ finally:
2285
+ if connection is not None:
2286
+ try:
2287
+ connection.close()
2288
+ except sqlite3.Error:
2289
+ pass
2290
+
2291
+
2292
+ def _prepare_read_target(
2293
+ database_path,
2294
+ *,
2295
+ ignore_unpaired_wal=False,
2296
+ expected_database_identity=None,
2297
+ cancelled_callback=None,
2298
+ deadline=None,
2299
+ ):
2300
+ """Capture a stable private SQLite snapshot for every viewer read."""
2301
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2302
+ _require_publication_complete(database_path)
2303
+ if not database_path.is_file():
2304
+ raise FileNotFoundError(database_path)
2305
+
2306
+ _require_expected_database_instance(
2307
+ database_path,
2308
+ expected_database_identity,
2309
+ )
2310
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2311
+ prepared_database_identity = database_file_identity(database_path)
2312
+ if prepared_database_identity is None:
2313
+ raise FileNotFoundError(database_path)
2314
+
2315
+ try:
2316
+ generation_marker = database_has_qplot_generation_marker(database_path)
2317
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2318
+ raise
2319
+ except OSError as error:
2320
+ raise ReadOnlyDatabaseAccessError(
2321
+ f"Could not inspect the database publication marker: {error}"
2322
+ ) from error
2323
+
2324
+ wal_path = _wal_path(database_path)
2325
+ journal_path = _journal_path(database_path)
2326
+
2327
+ for _attempt in range(WAL_SNAPSHOT_ATTEMPTS):
2328
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2329
+ _require_expected_database_instance(
2330
+ database_path,
2331
+ expected_database_identity,
2332
+ )
2333
+ try:
2334
+ before = _source_signature_with_controls(
2335
+ database_path,
2336
+ cancelled_callback,
2337
+ deadline,
2338
+ )
2339
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2340
+ raise
2341
+ except OSError as error:
2342
+ raise ReadOnlyDatabaseAccessError(
2343
+ f"Could not inspect a read-only view of {database_path}: {error}"
2344
+ ) from error
2345
+ if before.database is None:
2346
+ raise FileNotFoundError(database_path)
2347
+ if not before.stable:
2348
+ continue
2349
+
2350
+ policy = _source_read_policy(
2351
+ database_path,
2352
+ before,
2353
+ generation_marker=generation_marker,
2354
+ ignore_unpaired_wal=ignore_unpaired_wal,
2355
+ )
2356
+ journal = policy.journal
2357
+ quarantined = policy.quarantined
2358
+ ignore_wal = policy.ignore_wal
2359
+ include_wal = policy.include_wal
2360
+
2361
+ if include_wal and not generation_marker:
2362
+ # SQLite WAL salts and checksums validate only the WAL's own frame
2363
+ # sequence. They do not commit to the base main file, and SQLite
2364
+ # will replay a structurally compatible unrelated WAL. Revalidate
2365
+ # the observed pair before rejecting it, but do not allocate or
2366
+ # copy a private snapshot whose contents can never be trusted.
2367
+ _require_expected_database_instance(
2368
+ database_path,
2369
+ expected_database_identity,
2370
+ )
2371
+ _require_prepared_database_instance(
2372
+ database_path,
2373
+ prepared_database_identity,
2374
+ )
2375
+ after = _source_signature_for_validation(
2376
+ database_path,
2377
+ cancelled_callback=cancelled_callback,
2378
+ deadline=deadline,
2379
+ )
2380
+ if after != before or not after.stable:
2381
+ continue
2382
+ raise _unverifiable_unmarked_wal_error(database_path)
2383
+
2384
+ snapshot = tempfile.TemporaryDirectory(prefix="qplot-readonly-")
2385
+ snapshot_path = Path(snapshot.name) / "database.db"
2386
+ main_snapshot_path = Path(snapshot.name) / "database-main.db"
2387
+ try:
2388
+ _copy_file_cooperatively(
2389
+ database_path,
2390
+ snapshot_path,
2391
+ cancelled_callback=cancelled_callback,
2392
+ deadline=deadline,
2393
+ )
2394
+ if include_wal and generation_marker:
2395
+ _copy_file_cooperatively(
2396
+ snapshot_path,
2397
+ main_snapshot_path,
2398
+ cancelled_callback=cancelled_callback,
2399
+ deadline=deadline,
2400
+ )
2401
+ if include_wal:
2402
+ _copy_file_cooperatively(
2403
+ wal_path,
2404
+ _wal_path(snapshot_path),
2405
+ cancelled_callback=cancelled_callback,
2406
+ deadline=deadline,
2407
+ )
2408
+ if journal is not None:
2409
+ _copy_file_cooperatively(
2410
+ journal_path,
2411
+ _journal_path(snapshot_path),
2412
+ cancelled_callback=cancelled_callback,
2413
+ deadline=deadline,
2414
+ )
2415
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2416
+ _cleanup_failed_connection_open(None, snapshot)
2417
+ raise
2418
+ except FileNotFoundError:
2419
+ _cleanup_failed_connection_open(None, snapshot)
2420
+ continue
2421
+ except OSError as err:
2422
+ _cleanup_failed_connection_open(None, snapshot)
2423
+ raise ReadOnlyDatabaseAccessError(
2424
+ f"Could not copy a read-only view of {database_path}: {err}"
2425
+ ) from err
2426
+
2427
+ try:
2428
+ after_copy = _source_signature_with_controls(
2429
+ database_path,
2430
+ cancelled_callback,
2431
+ deadline,
2432
+ )
2433
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2434
+ _cleanup_failed_connection_open(None, snapshot)
2435
+ raise
2436
+ except OSError as error:
2437
+ _cleanup_failed_connection_open(None, snapshot)
2438
+ raise ReadOnlyDatabaseAccessError(
2439
+ f"Could not validate a read-only copy of {database_path}: {error}"
2440
+ ) from error
2441
+ if after_copy != before or not after_copy.stable:
2442
+ _cleanup_failed_connection_open(None, snapshot)
2443
+ continue
2444
+
2445
+ try:
2446
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2447
+ _require_expected_database_instance(
2448
+ database_path,
2449
+ expected_database_identity,
2450
+ )
2451
+ _require_prepared_database_instance(
2452
+ database_path,
2453
+ prepared_database_identity,
2454
+ )
2455
+ committed_transactions = None
2456
+ if include_wal and generation_marker:
2457
+ try:
2458
+ committed_transactions = _committed_wal_transaction_pages(
2459
+ _wal_path(snapshot_path),
2460
+ cancelled_callback=cancelled_callback,
2461
+ deadline=deadline,
2462
+ )
2463
+ except (ReadOnlyDatabaseCancelledError, TimeoutError):
2464
+ raise
2465
+ except (OSError, ValueError) as error:
2466
+ if _source_signature_for_validation(
2467
+ database_path,
2468
+ cancelled_callback=cancelled_callback,
2469
+ deadline=deadline,
2470
+ ) != before:
2471
+ _cleanup_failed_connection_open(None, snapshot)
2472
+ continue
2473
+ raise _unverifiable_generated_wal_error(
2474
+ database_path,
2475
+ "the WAL has no verifiable committed lineage events",
2476
+ ) from error
2477
+ if journal is not None:
2478
+ try:
2479
+ _recover_private_rollback_journal(
2480
+ database_path,
2481
+ snapshot_path,
2482
+ cancelled_callback=cancelled_callback,
2483
+ deadline=deadline,
2484
+ )
2485
+ except ReadOnlyDatabaseAccessError:
2486
+ if _source_signature_for_validation(
2487
+ database_path,
2488
+ cancelled_callback=cancelled_callback,
2489
+ deadline=deadline,
2490
+ ) != before:
2491
+ _cleanup_failed_connection_open(None, snapshot)
2492
+ continue
2493
+ raise
2494
+ if include_wal and generation_marker:
2495
+ try:
2496
+ _require_matching_generated_wal_provenance(
2497
+ database_path,
2498
+ main_snapshot_path,
2499
+ snapshot_path,
2500
+ committed_transactions,
2501
+ cancelled_callback=cancelled_callback,
2502
+ deadline=deadline,
2503
+ )
2504
+ except UnverifiableDatabaseWalError:
2505
+ if _source_signature_for_validation(
2506
+ database_path,
2507
+ cancelled_callback=cancelled_callback,
2508
+ deadline=deadline,
2509
+ ) != before:
2510
+ _cleanup_failed_connection_open(None, snapshot)
2511
+ continue
2512
+ if not (ignore_unpaired_wal or quarantined):
2513
+ raise
2514
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2515
+ return main_snapshot_path, True, snapshot, True, before
2516
+
2517
+ if _source_signature_for_validation(
2518
+ database_path,
2519
+ cancelled_callback=cancelled_callback,
2520
+ deadline=deadline,
2521
+ ) != before:
2522
+ _cleanup_failed_connection_open(None, snapshot)
2523
+ continue
2524
+ if include_wal and generation_marker:
2525
+ if not _release_wal_quarantine(
2526
+ database_path,
2527
+ prepared_database_identity,
2528
+ ):
2529
+ raise DatabaseInstanceChangedError(
2530
+ "The database was replaced while qPlot was validating "
2531
+ "its WAL provenance."
2532
+ )
2533
+ _raise_if_read_interrupted(cancelled_callback, deadline)
2534
+ return snapshot_path, not include_wal, snapshot, ignore_wal, before
2535
+ except Exception:
2536
+ _cleanup_failed_connection_open(None, snapshot)
2537
+ raise
2538
+
2539
+ raise ReadOnlyDatabaseAccessError(
2540
+ "The database main file or SQLite sidecars changed continuously while "
2541
+ "qPlot tried to capture a transaction-consistent snapshot. The database "
2542
+ "is busy or temporarily unavailable; refresh to retry. qPlot did not "
2543
+ "modify the source database or its SQLite sidecars."
2544
+ )
2545
+
2546
+
2547
+ def _attach_snapshot_cleanup(conn, snapshot):
2548
+ """Make an AtomicConnection clean up its private snapshot on close or GC."""
2549
+ connection_ref = weakref.ref(conn)
2550
+ finalizer = weakref.finalize(conn, snapshot.cleanup)
2551
+
2552
+ def close_snapshot():
2553
+ connection = connection_ref()
2554
+ try:
2555
+ if connection is not None:
2556
+ sqlite3.Connection.close(connection)
2557
+ finally:
2558
+ finalizer()
2559
+
2560
+ conn.close = close_snapshot
2561
+ conn._qplot_snapshot_finalizer = finalizer