cctally 1.112.0 → 1.114.0
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.
- package/CHANGELOG.md +27 -0
- package/README.md +4 -5
- package/bin/_cctally_cache.py +1683 -485
- package/bin/_cctally_core.py +223 -1
- package/bin/_cctally_dashboard.py +96 -18
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +267 -204
- package/bin/_cctally_db.py +861 -18
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +136 -18
- package/bin/_cctally_five_hour.py +2 -2
- package/bin/_cctally_forecast.py +27 -6
- package/bin/_cctally_journal.py +67 -5
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_quota.py +201 -33
- package/bin/_cctally_record.py +14 -0
- package/bin/_cctally_rederive.py +23 -9
- package/bin/_cctally_source_analytics.py +60 -15
- package/bin/_cctally_statusline.py +17 -0
- package/bin/_cctally_store.py +80 -5
- package/bin/_cctally_tui.py +4 -3
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +164 -30
- package/bin/_lib_conversation_query.py +95 -56
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_doctor.py +263 -69
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_merge_gate.py +469 -51
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +209 -8
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +225 -77
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +8 -0
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"""Writer-owned WAL checkpoints for the sync writers (#901 spec §5.3c W9).
|
|
2
|
+
|
|
3
|
+
SQLite's automatic checkpoint runs after every commit that leaves 1,000 or more
|
|
4
|
+
frames in the WAL. While readers keep the WAL from resetting it runs after
|
|
5
|
+
nearly every commit and copies its frames into the database file again: P2
|
|
6
|
+
measured 85% of conversations.db's WAL bytes copied a second time. A sync
|
|
7
|
+
writer instead sets ``PRAGMA wal_autocheckpoint = 0`` once its opener's
|
|
8
|
+
open-time work is done and owns the checkpoint itself (Q13), and revision 13
|
|
9
|
+
(Q14) makes that deferral span passes and ticks:
|
|
10
|
+
|
|
11
|
+
* It counts the WAL frames its own connection appends, from
|
|
12
|
+
``sqlite3_db_status(SQLITE_DBSTATUS_CACHE_WRITE)``, which in WAL mode counts
|
|
13
|
+
the pages a connection writes to the WAL. The count is read through the
|
|
14
|
+
stdlib extension's own linked SQLite, by the same ctypes layout
|
|
15
|
+
``_lib_sqlite_close`` uses, and reset on every read, so this module owns the
|
|
16
|
+
counter. It is never the ``-wal`` file's length: a checkpointed WAL restarts
|
|
17
|
+
from its first frame and reuses the file, whose length then stays constant
|
|
18
|
+
while frames are appended (901-SR-024). A test proves the counter on the
|
|
19
|
+
runtime SQLite; where it cannot be read, the connection keeps SQLite's
|
|
20
|
+
default ``wal_autocheckpoint``.
|
|
21
|
+
* A per-process, per-database policy keeps a pending flag that a commit sets
|
|
22
|
+
and only a complete checkpoint clears. It runs one
|
|
23
|
+
``PRAGMA main.wal_checkpoint(PASSIVE)`` when the flag is set and the frames
|
|
24
|
+
appended since its last attempt reach ``SIZE_TRIGGER_BYTES`` (size trigger,
|
|
25
|
+
evaluated after a commit), and whenever ``TIMER_SECONDS`` have passed since
|
|
26
|
+
the process's last attempt on that store, counted from when the process
|
|
27
|
+
armed it if it never attempted, whether or not the process committed
|
|
28
|
+
anything itself (timer). Another process's frames (a hook's,
|
|
29
|
+
``cache-sync``'s) are otherwise copied by no one; an attempt with nothing
|
|
30
|
+
to copy writes nothing.
|
|
31
|
+
* A checkpoint is complete only when the pragma succeeds with ``busy = 0`` and
|
|
32
|
+
valid, non-negative counts with ``log = checkpointed``. Anything else (an
|
|
33
|
+
error, ``SQLITE_BUSY`` from a competing checkpoint, a busy result, ``-1``
|
|
34
|
+
counts, an incomplete copy while a reader still needs frames) keeps the flag
|
|
35
|
+
set, so a later due attempt retries it, never sooner than ``TIMER_SECONDS``
|
|
36
|
+
after the attempt; nothing loops between attempts.
|
|
37
|
+
* The keeper (Q14). The first arm of a store in a process also opens one idle
|
|
38
|
+
keeper connection to it, kept until a maintenance yield (below), a store
|
|
39
|
+
replacement or the process's exit. It reads
|
|
40
|
+
the database once, so it holds the store's WAL open, and then holds no
|
|
41
|
+
transaction, so it pins no frame and blocks no checkpoint. While it is open
|
|
42
|
+
a sync connection's close is not the store's last close, so it neither
|
|
43
|
+
checkpoints nor resets the WAL: the dashboard opens and closes its sync
|
|
44
|
+
connections every pass and tick, and with SQLite's close-time checkpoint
|
|
45
|
+
every pass had ended in a checkpoint and a WAL reset (§1.8). Each later arm
|
|
46
|
+
compares the store's file identity with the keeper's and reopens it on a
|
|
47
|
+
replaced file, so no keeper stays on an obsolete inode, and a drain check in
|
|
48
|
+
this process releases it (``release``) so a keeper never blocks this
|
|
49
|
+
process's own store recovery.
|
|
50
|
+
* The timer thread (901-SR-033). A dashboard iteration whose ingest frontier
|
|
51
|
+
is caught up skips the sync functions, so boundaries alone cannot bound the
|
|
52
|
+
retained frames. One daemon thread per process, started with the first
|
|
53
|
+
keeper, makes the timed attempt through the keeper on each kept store every
|
|
54
|
+
``TIMER_INTERVAL_SECONDS``. The keeper is used only by that thread and by
|
|
55
|
+
the finalizer, under one lock.
|
|
56
|
+
* The finalizer (901-SR-034). ``finalize`` attempts one PASSIVE on each
|
|
57
|
+
store the process armed and closes its keepers; a keeper close that is the
|
|
58
|
+
store's last also runs SQLite's close-time checkpoint, so a short-lived
|
|
59
|
+
writer that is alone drains the WAL as before, and one that is not still
|
|
60
|
+
hands its frames to a checkpoint attempt. A store whose keeper was released
|
|
61
|
+
or yielded is attempted through a short-lived connection under the same
|
|
62
|
+
rules (#901 Amendment 19 PR-7). It runs at interpreter exit (``atexit``) and is called
|
|
63
|
+
explicitly before the ``os._exit`` of every detached worker that may sync.
|
|
64
|
+
Only abrupt termination drains nothing; the next opener recovers the WAL.
|
|
65
|
+
* Fork. A forked child starts with no keeper and no timer thread: the at-fork
|
|
66
|
+
handler drops the parent's keepers in the child without using or closing
|
|
67
|
+
them, and a child that syncs arms its own.
|
|
68
|
+
* The yield (Q15). An idle keeper still keeps its shared lock on the database
|
|
69
|
+
file, as every WAL-mode connection does between transactions, so while one
|
|
70
|
+
is open SQLite's exclusive access (``db vacuum``) and every handle-drain
|
|
71
|
+
check fail. A process that needs the family exclusive or drained publishes
|
|
72
|
+
an owner-qualified request, ``<store>.keeper-yield``; a live repair marker
|
|
73
|
+
and a pending-quarantine record fence keepers the same way. The fence check
|
|
74
|
+
(``live_fence``) runs at every ``arm``, at the start of every timer round
|
|
75
|
+
before the due check, and in the finalizer; it reads the owner records
|
|
76
|
+
through the repair marker's reader and liveness check, takes no maintenance
|
|
77
|
+
or provider lock, writes nothing and removes nothing. On a live fence the
|
|
78
|
+
keeper closes with no-checkpoint-on-close and no PASSIVE, the store's policy
|
|
79
|
+
is kept, the store is suspended and one diagnostic line (``YIELD_DIAGNOSTIC``)
|
|
80
|
+
goes to stderr. An admission checks before it opens the keeper and again
|
|
81
|
+
after, while the arming connection is still open, under the module's lock.
|
|
82
|
+
A suspended store resumes at the first ``arm`` (under the module's lock
|
|
83
|
+
alone, because its callers already hold the writer and provider locks) or
|
|
84
|
+
timer round (under the store's maintenance lock taken shared and
|
|
85
|
+
non-blocking for that admission only) that finds no fence. Contention on a
|
|
86
|
+
maintenance flock is never read as a request.
|
|
87
|
+
* The close control (Q15). The keeper's no-checkpoint-on-close setting is
|
|
88
|
+
applied and verified before its first database read; where the runtime
|
|
89
|
+
cannot set it the process admits no keeper (remembered per process) and the
|
|
90
|
+
store keeps revision 12's close-time checkpoints. Only the finalizer, for an
|
|
91
|
+
unchanged and unfenced store, restores normal close behaviour.
|
|
92
|
+
* The #297 end-of-sync ``TRUNCATE`` shrink stays; a failed shrink is not
|
|
93
|
+
retried for ``SHRINK_RETRY_SECONDS``.
|
|
94
|
+
|
|
95
|
+
Reclaim's and the deletion connection's settings are not touched here (§5.4).
|
|
96
|
+
``CCTALLY_TEST_W9_DISABLE=1`` is a test-only seam that leaves every connection
|
|
97
|
+
at SQLite's default and opens no keeper, timer or exit attempt (P3's "W9
|
|
98
|
+
disabled" run). Stdlib-only leaf.
|
|
99
|
+
"""
|
|
100
|
+
from __future__ import annotations
|
|
101
|
+
|
|
102
|
+
import atexit
|
|
103
|
+
import fcntl
|
|
104
|
+
import os
|
|
105
|
+
import pathlib
|
|
106
|
+
import sqlite3
|
|
107
|
+
import sys
|
|
108
|
+
import threading
|
|
109
|
+
import time
|
|
110
|
+
from typing import Callable
|
|
111
|
+
|
|
112
|
+
#: Frames appended since the last attempt that trigger a checkpoint after a
|
|
113
|
+
#: commit, in bytes of WAL frames (page plus the 24-byte frame header).
|
|
114
|
+
SIZE_TRIGGER_BYTES = 16 * 1024 * 1024
|
|
115
|
+
#: Seconds since the process's last attempt on a store (or since it armed the
|
|
116
|
+
#: store) after which a boundary or the timer thread attempts a checkpoint.
|
|
117
|
+
TIMER_SECONDS = 60.0
|
|
118
|
+
#: Real seconds between two rounds of the timer thread. Read before every
|
|
119
|
+
#: wait, so a test that injects it before the first arm gets a fast thread.
|
|
120
|
+
TIMER_INTERVAL_SECONDS = 10.0
|
|
121
|
+
#: Seconds a failed #297 end-of-sync shrink waits before the next one.
|
|
122
|
+
SHRINK_RETRY_SECONDS = 60.0
|
|
123
|
+
#: The test-only seam that turns W9 off for a whole process.
|
|
124
|
+
DISABLE_ENV = "CCTALLY_TEST_W9_DISABLE"
|
|
125
|
+
#: `SQLITE_DBSTATUS_CACHE_WRITE` from sqlite3.h.
|
|
126
|
+
SQLITE_DBSTATUS_CACHE_WRITE = 9
|
|
127
|
+
#: The WAL frame header that precedes every page image.
|
|
128
|
+
WAL_FRAME_HEADER_BYTES = 24
|
|
129
|
+
#: The timer thread's name, so a test or a stack dump can find it.
|
|
130
|
+
TIMER_THREAD_NAME = "cctally-w9-checkpoint-timer"
|
|
131
|
+
#: The stable marker of the one stderr line every keeper yield writes, which
|
|
132
|
+
#: P's verdicts look for in the dashboard log (spec §6.3, P after revision 14).
|
|
133
|
+
YIELD_DIAGNOSTIC = "[w9] keeper-yield"
|
|
134
|
+
#: The fences a keeper yields to (Q15), as the diagnostic line names them.
|
|
135
|
+
FENCE_REQUEST = "keeper-yield request"
|
|
136
|
+
FENCE_REPAIR = "repair marker"
|
|
137
|
+
FENCE_QUARANTINE = "pending quarantine"
|
|
138
|
+
FENCE_UNVERIFIABLE = "fence state unverifiable"
|
|
139
|
+
|
|
140
|
+
#: The clock every policy reads (monotonic seconds); a test injects its own.
|
|
141
|
+
CLOCK: "Callable[[], float]" = time.monotonic
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def disabled() -> bool:
|
|
145
|
+
"""Whether the test-only seam turned W9 off for this process."""
|
|
146
|
+
return os.environ.get(DISABLE_ENV) == "1"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── the frame source ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
_DB_STATUS = None
|
|
152
|
+
_DB_STATUS_RESOLVED = False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _db_status_function():
|
|
156
|
+
"""``sqlite3_db_status`` from the stdlib extension's own SQLite, or None."""
|
|
157
|
+
global _DB_STATUS, _DB_STATUS_RESOLVED
|
|
158
|
+
if _DB_STATUS_RESOLVED:
|
|
159
|
+
return _DB_STATUS
|
|
160
|
+
_DB_STATUS_RESOLVED = True
|
|
161
|
+
try:
|
|
162
|
+
if sys.implementation.name != "cpython":
|
|
163
|
+
return None
|
|
164
|
+
import sysconfig
|
|
165
|
+
if sysconfig.get_config_var("Py_GIL_DISABLED"):
|
|
166
|
+
# A free-threaded object header does not have the layout below.
|
|
167
|
+
return None
|
|
168
|
+
import _sqlite3
|
|
169
|
+
import ctypes
|
|
170
|
+
|
|
171
|
+
function = ctypes.CDLL(_sqlite3.__file__).sqlite3_db_status
|
|
172
|
+
function.argtypes = (
|
|
173
|
+
ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_int),
|
|
174
|
+
ctypes.POINTER(ctypes.c_int), ctypes.c_int)
|
|
175
|
+
function.restype = ctypes.c_int
|
|
176
|
+
_DB_STATUS = function
|
|
177
|
+
except (ImportError, OSError, AttributeError):
|
|
178
|
+
_DB_STATUS = None
|
|
179
|
+
return _DB_STATUS
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _connection_handle(conn: sqlite3.Connection) -> "int | None":
|
|
183
|
+
"""The ``sqlite3 *`` behind a CPython connection (first field after the
|
|
184
|
+
object header, as ``_lib_sqlite_close`` reads it), or None when closed."""
|
|
185
|
+
import ctypes
|
|
186
|
+
|
|
187
|
+
if type(conn) is not sqlite3.Connection and not isinstance(
|
|
188
|
+
conn, sqlite3.Connection):
|
|
189
|
+
return None
|
|
190
|
+
pointer_size = ctypes.sizeof(ctypes.c_void_p)
|
|
191
|
+
handle = ctypes.c_void_p.from_address(id(conn) + 2 * pointer_size).value
|
|
192
|
+
return int(handle) if handle else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def take_frames(conn: sqlite3.Connection, *,
|
|
196
|
+
reset: bool = True) -> "int | None":
|
|
197
|
+
"""WAL frames this connection appended since the previous call.
|
|
198
|
+
|
|
199
|
+
``SQLITE_DBSTATUS_CACHE_WRITE`` read with its reset flag, so each call
|
|
200
|
+
returns the frames written since the last one and this module owns the
|
|
201
|
+
counter. In WAL mode it counts the pages the connection writes to the WAL
|
|
202
|
+
(summed over its attached databases, of which a sync writer only writes
|
|
203
|
+
its main one). None when the count cannot be read on this runtime.
|
|
204
|
+
``reset=False`` reads without consuming.
|
|
205
|
+
"""
|
|
206
|
+
function = _db_status_function()
|
|
207
|
+
if function is None:
|
|
208
|
+
return None
|
|
209
|
+
import ctypes
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
handle = _connection_handle(conn)
|
|
213
|
+
except (ValueError, OverflowError):
|
|
214
|
+
return None
|
|
215
|
+
if handle is None:
|
|
216
|
+
return None
|
|
217
|
+
current, highwater = ctypes.c_int(), ctypes.c_int()
|
|
218
|
+
rc = function(ctypes.c_void_p(handle), SQLITE_DBSTATUS_CACHE_WRITE,
|
|
219
|
+
ctypes.byref(current), ctypes.byref(highwater),
|
|
220
|
+
1 if reset else 0)
|
|
221
|
+
if rc != 0 or current.value < 0:
|
|
222
|
+
return None
|
|
223
|
+
return int(current.value)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def frame_source_available(conn: sqlite3.Connection) -> bool:
|
|
227
|
+
"""Whether ``take_frames`` can read this connection's frame count.
|
|
228
|
+
|
|
229
|
+
The probe does not consume the count, so frames written before arming
|
|
230
|
+
(the opener's open-time work) still reach the policy at the next read."""
|
|
231
|
+
return take_frames(conn, reset=False) is not None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ── the per-process, per-database policy ─────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class WalCheckpointPolicy:
|
|
238
|
+
"""Checkpoint state for one database in this process (W9)."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, label: str, *,
|
|
241
|
+
clock: "Callable[[], float] | None" = None) -> None:
|
|
242
|
+
self.label = label
|
|
243
|
+
self._clock = clock
|
|
244
|
+
self._lock = threading.Lock()
|
|
245
|
+
self.pending = False
|
|
246
|
+
self.pending_since: "float | None" = None
|
|
247
|
+
self.frames_since_attempt = 0
|
|
248
|
+
self.last_attempt: "float | None" = None
|
|
249
|
+
#: Whether the last attempt was incomplete or failed: until a
|
|
250
|
+
#: complete checkpoint clears it, the size trigger waits
|
|
251
|
+
#: ``TIMER_SECONDS`` from that attempt (W9: nothing retries sooner
|
|
252
|
+
#: than 60 s; 901-RW-002).
|
|
253
|
+
self.last_attempt_unsuccessful = False
|
|
254
|
+
#: When this process first armed the store; the timer counts from it
|
|
255
|
+
#: until the first attempt.
|
|
256
|
+
self.armed_at: "float | None" = None
|
|
257
|
+
self.last_failed_shrink: "float | None" = None
|
|
258
|
+
self.page_size = 4096
|
|
259
|
+
self.attempts = 0
|
|
260
|
+
self.completions = 0
|
|
261
|
+
#: The last attempt's result row, for tests and telemetry.
|
|
262
|
+
self.last_result: "tuple | None" = None
|
|
263
|
+
|
|
264
|
+
def now(self) -> float:
|
|
265
|
+
return float((self._clock or CLOCK)())
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def frame_bytes(self) -> int:
|
|
269
|
+
return self.page_size + WAL_FRAME_HEADER_BYTES
|
|
270
|
+
|
|
271
|
+
# -- accounting --------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def note_armed(self) -> None:
|
|
274
|
+
"""Start the timer's count the first time the process arms the store."""
|
|
275
|
+
with self._lock:
|
|
276
|
+
if self.armed_at is None:
|
|
277
|
+
self.armed_at = self.now()
|
|
278
|
+
|
|
279
|
+
def record_frames(self, frames: int) -> None:
|
|
280
|
+
"""Add frames this process appended; a commit sets the pending flag."""
|
|
281
|
+
if frames <= 0:
|
|
282
|
+
return
|
|
283
|
+
with self._lock:
|
|
284
|
+
if not self.pending:
|
|
285
|
+
self.pending = True
|
|
286
|
+
self.pending_since = self.now()
|
|
287
|
+
self.frames_since_attempt += int(frames)
|
|
288
|
+
|
|
289
|
+
def size_due(self) -> bool:
|
|
290
|
+
"""16 MiB of this process's frames since its last attempt; after an
|
|
291
|
+
unsuccessful attempt, also ``TIMER_SECONDS`` since that attempt, so a
|
|
292
|
+
pinned reader or a competing checkpoint is not retried sooner."""
|
|
293
|
+
with self._lock:
|
|
294
|
+
if not (self.pending and self.frames_since_attempt
|
|
295
|
+
* self.frame_bytes >= SIZE_TRIGGER_BYTES):
|
|
296
|
+
return False
|
|
297
|
+
return (not self.last_attempt_unsuccessful
|
|
298
|
+
or self.last_attempt is None
|
|
299
|
+
or self.now() - self.last_attempt >= TIMER_SECONDS)
|
|
300
|
+
|
|
301
|
+
def timer_due(self) -> bool:
|
|
302
|
+
"""``TIMER_SECONDS`` since the last attempt, or since the arm when
|
|
303
|
+
there was none, whoever wrote the frames (Q14). A policy that was
|
|
304
|
+
never armed (a test standing for another process) counts from its
|
|
305
|
+
own first commit, as revision 12 did."""
|
|
306
|
+
with self._lock:
|
|
307
|
+
reference = self.last_attempt
|
|
308
|
+
if reference is None:
|
|
309
|
+
reference = (self.armed_at if self.armed_at is not None
|
|
310
|
+
else self.pending_since)
|
|
311
|
+
return reference is not None and (
|
|
312
|
+
self.now() - reference >= TIMER_SECONDS)
|
|
313
|
+
|
|
314
|
+
def checkpoint_completed(self) -> None:
|
|
315
|
+
"""Clear the flag: every committed frame reached the database file."""
|
|
316
|
+
with self._lock:
|
|
317
|
+
self.pending = False
|
|
318
|
+
self.pending_since = None
|
|
319
|
+
self.last_attempt_unsuccessful = False
|
|
320
|
+
self.completions += 1
|
|
321
|
+
|
|
322
|
+
# -- the attempt -------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def attempt(self, conn: sqlite3.Connection) -> bool:
|
|
325
|
+
"""One ``PRAGMA main.wal_checkpoint(PASSIVE)``; True when complete."""
|
|
326
|
+
if conn.in_transaction:
|
|
327
|
+
return False
|
|
328
|
+
with self._lock:
|
|
329
|
+
self.last_attempt = self.now()
|
|
330
|
+
self.frames_since_attempt = 0
|
|
331
|
+
self.attempts += 1
|
|
332
|
+
try:
|
|
333
|
+
row = run_passive_checkpoint(conn)
|
|
334
|
+
except sqlite3.DatabaseError:
|
|
335
|
+
row = None
|
|
336
|
+
self.last_result = row
|
|
337
|
+
if checkpoint_complete(row):
|
|
338
|
+
self.checkpoint_completed()
|
|
339
|
+
return True
|
|
340
|
+
with self._lock:
|
|
341
|
+
self.last_attempt_unsuccessful = True
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
# -- the shrink's rate limit ---------------------------------------------
|
|
345
|
+
|
|
346
|
+
def shrink_allowed(self) -> bool:
|
|
347
|
+
with self._lock:
|
|
348
|
+
return (self.last_failed_shrink is None
|
|
349
|
+
or self.now() - self.last_failed_shrink
|
|
350
|
+
>= SHRINK_RETRY_SECONDS)
|
|
351
|
+
|
|
352
|
+
def record_shrink(self, truncated: bool) -> None:
|
|
353
|
+
if truncated:
|
|
354
|
+
with self._lock:
|
|
355
|
+
self.last_failed_shrink = None
|
|
356
|
+
self.checkpoint_completed()
|
|
357
|
+
else:
|
|
358
|
+
with self._lock:
|
|
359
|
+
self.last_failed_shrink = self.now()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def run_passive_checkpoint(conn: sqlite3.Connection) -> "tuple | None":
|
|
363
|
+
"""The checkpoint itself, a seam for tests: ``(busy, log, checkpointed)``.
|
|
364
|
+
|
|
365
|
+
``main.`` matters: an unqualified pragma checkpoints every attached
|
|
366
|
+
database, and the conversations writer attaches cache.db read-only."""
|
|
367
|
+
row = conn.execute("PRAGMA main.wal_checkpoint(PASSIVE)").fetchone()
|
|
368
|
+
return tuple(row) if row is not None else None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def checkpoint_complete(row) -> bool:
|
|
372
|
+
"""``busy = 0`` and valid, non-negative counts with ``log = checkpointed``."""
|
|
373
|
+
if row is None or len(row) != 3:
|
|
374
|
+
return False
|
|
375
|
+
try:
|
|
376
|
+
busy, log, checkpointed = (int(value) for value in row)
|
|
377
|
+
except (TypeError, ValueError):
|
|
378
|
+
return False
|
|
379
|
+
return busy == 0 and log >= 0 and checkpointed >= 0 and log == checkpointed
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_POLICIES: "dict[str, WalCheckpointPolicy]" = {}
|
|
383
|
+
_POLICIES_LOCK = threading.Lock()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _key(db_path) -> str:
|
|
387
|
+
return os.path.realpath(os.fspath(db_path))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def policy_for_path(db_path) -> WalCheckpointPolicy:
|
|
391
|
+
"""This process's policy for the database at ``db_path``."""
|
|
392
|
+
key = _key(db_path)
|
|
393
|
+
with _POLICIES_LOCK:
|
|
394
|
+
policy = _POLICIES.get(key)
|
|
395
|
+
if policy is None:
|
|
396
|
+
policy = WalCheckpointPolicy(key)
|
|
397
|
+
_POLICIES[key] = policy
|
|
398
|
+
return policy
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _main_path(conn: sqlite3.Connection) -> "str | None":
|
|
402
|
+
for _seq, name, path in conn.execute("PRAGMA database_list"):
|
|
403
|
+
if name == "main":
|
|
404
|
+
return path or None
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def policy_for(conn: sqlite3.Connection) -> "WalCheckpointPolicy | None":
|
|
409
|
+
path = _main_path(conn)
|
|
410
|
+
return policy_for_path(path) if path else None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ── the keeper registry, the timer thread and the finalizer (Q14) ────────────
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class _Keeper:
|
|
417
|
+
"""One idle connection holding a store's WAL open for this process."""
|
|
418
|
+
|
|
419
|
+
__slots__ = ("path", "conn", "identity")
|
|
420
|
+
|
|
421
|
+
def __init__(self, path: str, conn: sqlite3.Connection,
|
|
422
|
+
identity: "tuple[int, int]") -> None:
|
|
423
|
+
self.path = path
|
|
424
|
+
self.conn = conn
|
|
425
|
+
self.identity = identity
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
_KEEPERS: "dict[str, _Keeper]" = {}
|
|
429
|
+
#: The one lock under which a keeper is opened, used, closed or dropped, and
|
|
430
|
+
#: under which a store is suspended or resumed.
|
|
431
|
+
_KEEPER_LOCK = threading.Lock()
|
|
432
|
+
#: Stores whose keeper yielded to a fence (Q15) and has not been reopened.
|
|
433
|
+
_SUSPENDED: "set[str]" = set()
|
|
434
|
+
#: Every spelling of each store's main path this process armed it under, so
|
|
435
|
+
#: the fence files next to it are found whichever directory names it.
|
|
436
|
+
_ALIASES: "dict[str, set[str]]" = {}
|
|
437
|
+
#: Every store this process armed, with the file identity it last armed
|
|
438
|
+
#: (None when the file could not be stat'ed then). The finalizer attempts each
|
|
439
|
+
#: one, kept or not (#901 Amendment 19 PR-7).
|
|
440
|
+
_ARMED: "dict[str, tuple[int, int] | None]" = {}
|
|
441
|
+
#: Whether this runtime can set a keeper's no-checkpoint-on-close control;
|
|
442
|
+
#: None until the first keeper probes it (Q15). Remembered per process.
|
|
443
|
+
_CLOSE_CONTROL: "bool | None" = None
|
|
444
|
+
_TIMER: "threading.Thread | None" = None
|
|
445
|
+
_TIMER_STOP = threading.Event()
|
|
446
|
+
_ATEXIT_REGISTERED = False
|
|
447
|
+
#: Keepers a forked child inherited: never used, never closed, kept alive so
|
|
448
|
+
#: their deallocation never runs SQLite's close in the child.
|
|
449
|
+
_INHERITED: list = []
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _identity(path: str) -> "tuple[int, int] | None":
|
|
453
|
+
try:
|
|
454
|
+
st = os.stat(path)
|
|
455
|
+
except OSError:
|
|
456
|
+
return None
|
|
457
|
+
return st.st_dev, st.st_ino
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# -- the fences (Q15) ---------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _fence_at(path: str) -> "str | None":
|
|
464
|
+
"""The first live fence next to the store at ``path``, or None.
|
|
465
|
+
|
|
466
|
+
Reads the owner records through the repair marker's reader and liveness
|
|
467
|
+
check (`_cctally_db`, imported lazily, as `_cctally_db` imports this
|
|
468
|
+
module lazily in the other direction): a dead or reused-pid owner is not a
|
|
469
|
+
fence. Takes no lock, writes nothing and removes nothing; only the owner
|
|
470
|
+
of a request or marker ever removes it."""
|
|
471
|
+
import _cctally_db
|
|
472
|
+
|
|
473
|
+
store = pathlib.Path(path)
|
|
474
|
+
request = _cctally_db._keeper_yield_request_path(store)
|
|
475
|
+
if os.path.lexists(request) and _cctally_db._repair_marker_is_live(
|
|
476
|
+
request)[0]:
|
|
477
|
+
return FENCE_REQUEST
|
|
478
|
+
marker = _cctally_db._repair_marker_path(store)
|
|
479
|
+
if os.path.lexists(marker) and _cctally_db._repair_marker_is_live(
|
|
480
|
+
marker)[0]:
|
|
481
|
+
return FENCE_REPAIR
|
|
482
|
+
if os.path.lexists(_cctally_db._quarantine_pending_path(store)):
|
|
483
|
+
return FENCE_QUARANTINE
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def live_fence(db_path) -> "str | None":
|
|
488
|
+
"""The kind of the first live fence on the store, or None (W9, Q15).
|
|
489
|
+
|
|
490
|
+
A fence that cannot be verified counts as live: a keeper that yields
|
|
491
|
+
wrongly costs one reopen, while one that stays open wrongly makes a
|
|
492
|
+
requester wait out its deadline and refuse."""
|
|
493
|
+
key = _key(db_path)
|
|
494
|
+
paths = {key, os.fspath(db_path)} | _ALIASES.get(key, set())
|
|
495
|
+
try:
|
|
496
|
+
for path in sorted(paths):
|
|
497
|
+
fence = _fence_at(path)
|
|
498
|
+
if fence is not None:
|
|
499
|
+
return fence
|
|
500
|
+
except Exception: # noqa: BLE001 — unknown is not unfenced
|
|
501
|
+
return FENCE_UNVERIFIABLE
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _diagnose(key: str, fence: str) -> None:
|
|
506
|
+
"""The one stderr line a keeper yield writes; nothing on the ordinary
|
|
507
|
+
path."""
|
|
508
|
+
try:
|
|
509
|
+
sys.stderr.write(
|
|
510
|
+
f"cctally: {YIELD_DIAGNOSTIC}: {os.path.basename(key)} "
|
|
511
|
+
f"released its checkpoint keeper ({fence})\n")
|
|
512
|
+
sys.stderr.flush()
|
|
513
|
+
except Exception: # noqa: BLE001 — a diagnostic must never fail a sync
|
|
514
|
+
pass
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _yield_locked(key: str, fence: str) -> None:
|
|
518
|
+
"""Close the store's keeper silently and suspend the store. Caller holds
|
|
519
|
+
the lock. The store's policy (pending flag, frame accounting, attempt
|
|
520
|
+
times) is kept and its sidecars stay in place: recovery must find the
|
|
521
|
+
family as it was, so no PASSIVE runs and the close does not checkpoint."""
|
|
522
|
+
keeper = _KEEPERS.pop(key, None)
|
|
523
|
+
_SUSPENDED.add(key)
|
|
524
|
+
if keeper is not None:
|
|
525
|
+
_close_quietly(keeper.conn)
|
|
526
|
+
_diagnose(key, fence)
|
|
527
|
+
if not disabled():
|
|
528
|
+
# A suspended store is resumed by the timer too, so a process whose
|
|
529
|
+
# every later iteration is caught up still reopens its keeper.
|
|
530
|
+
_register_atexit()
|
|
531
|
+
_start_timer_locked()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# -- the close control (Q15) --------------------------------------------------
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _apply_close_control(conn: sqlite3.Connection) -> None:
|
|
538
|
+
"""Turn SQLite's checkpoint at the last close off and verify it; raise
|
|
539
|
+
when the runtime cannot. Runs before the keeper's first read."""
|
|
540
|
+
import _lib_sqlite_close
|
|
541
|
+
|
|
542
|
+
_lib_sqlite_close.set_no_checkpoint_on_close(
|
|
543
|
+
conn, True, purpose="W9 keeper")
|
|
544
|
+
if _lib_sqlite_close.no_checkpoint_on_close_enabled(conn) is False:
|
|
545
|
+
raise sqlite3.NotSupportedError(
|
|
546
|
+
"W9 keeper could not verify SQLite close checkpointing")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _connect_keeper(path: str) -> sqlite3.Connection:
|
|
550
|
+
"""Connect without creating the file and without reading it."""
|
|
551
|
+
uri = pathlib.Path(path).as_uri() + "?mode=rw"
|
|
552
|
+
return sqlite3.connect(uri, uri=True, check_same_thread=False,
|
|
553
|
+
isolation_level=None)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _open_keeper(path: str, identity: "tuple[int, int]") -> "_Keeper | None":
|
|
557
|
+
"""Connect, apply the close control, read once, hold no transaction.
|
|
558
|
+
|
|
559
|
+
No keeper where the close control cannot be set: that is remembered for
|
|
560
|
+
the process, and the never-read connection closes without touching the
|
|
561
|
+
store (no WAL was opened, so there is nothing to checkpoint)."""
|
|
562
|
+
global _CLOSE_CONTROL
|
|
563
|
+
if _CLOSE_CONTROL is False:
|
|
564
|
+
return None
|
|
565
|
+
conn = None
|
|
566
|
+
try:
|
|
567
|
+
conn = _connect_keeper(path)
|
|
568
|
+
try:
|
|
569
|
+
_apply_close_control(conn)
|
|
570
|
+
except Exception: # noqa: BLE001 — any failure means no control
|
|
571
|
+
_CLOSE_CONTROL = False
|
|
572
|
+
try:
|
|
573
|
+
conn.close()
|
|
574
|
+
except Exception: # noqa: BLE001
|
|
575
|
+
pass
|
|
576
|
+
return None
|
|
577
|
+
_CLOSE_CONTROL = True
|
|
578
|
+
# Fully consumed, so the statement is reset and no read transaction
|
|
579
|
+
# stays open: the keeper pins no frame and blocks no checkpoint.
|
|
580
|
+
conn.execute("SELECT count(*) FROM sqlite_master").fetchall()
|
|
581
|
+
if conn.in_transaction or _identity(path) != identity:
|
|
582
|
+
# Replaced between the stat and the connect: no keeper this time.
|
|
583
|
+
_close_quietly(conn)
|
|
584
|
+
return None
|
|
585
|
+
return _Keeper(path, conn, identity)
|
|
586
|
+
except (sqlite3.Error, OSError, ValueError):
|
|
587
|
+
if conn is not None:
|
|
588
|
+
_close_quietly(conn)
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _close_quietly(conn: sqlite3.Connection) -> None:
|
|
593
|
+
"""Close with SQLite's close-time checkpoint turned off.
|
|
594
|
+
|
|
595
|
+
A keeper dropped for a fence, a replaced store, a drain check or a test
|
|
596
|
+
reset must neither copy frames into a file that is no longer the store
|
|
597
|
+
(quarantine evidence) or that recovery must find as it was, nor delete the
|
|
598
|
+
WAL by its name, which may now be the replacement's live WAL: without the
|
|
599
|
+
close-time checkpoint SQLite does neither. Every keeper carries the
|
|
600
|
+
setting from its admission (Q15); it is applied again here because the
|
|
601
|
+
finalizer may have turned it back on."""
|
|
602
|
+
try:
|
|
603
|
+
import _lib_sqlite_close
|
|
604
|
+
|
|
605
|
+
_lib_sqlite_close.set_no_checkpoint_on_close(
|
|
606
|
+
conn, True, purpose="W9 keeper")
|
|
607
|
+
except Exception: # noqa: BLE001 — never fail a close over this
|
|
608
|
+
pass
|
|
609
|
+
try:
|
|
610
|
+
conn.close()
|
|
611
|
+
except Exception: # noqa: BLE001
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _admit_locked(key: str) -> None:
|
|
616
|
+
"""Open the store's keeper, or reopen it on a new file, unless a fence is
|
|
617
|
+
live. Caller holds the lock and, for `arm`, the arming connection is still
|
|
618
|
+
open, so a request published between the check before the open and the
|
|
619
|
+
check after it is never missed. Takes no maintenance or provider lock."""
|
|
620
|
+
fence = live_fence(key)
|
|
621
|
+
if fence is not None:
|
|
622
|
+
_yield_locked(key, fence)
|
|
623
|
+
return
|
|
624
|
+
identity = _identity(key)
|
|
625
|
+
if identity is None:
|
|
626
|
+
return
|
|
627
|
+
current = _KEEPERS.get(key)
|
|
628
|
+
if current is not None and current.identity == identity:
|
|
629
|
+
_SUSPENDED.discard(key)
|
|
630
|
+
return
|
|
631
|
+
if current is not None:
|
|
632
|
+
del _KEEPERS[key]
|
|
633
|
+
_close_quietly(current.conn)
|
|
634
|
+
keeper = _open_keeper(key, identity)
|
|
635
|
+
if keeper is None:
|
|
636
|
+
return
|
|
637
|
+
fence = live_fence(key)
|
|
638
|
+
if fence is not None:
|
|
639
|
+
_close_quietly(keeper.conn)
|
|
640
|
+
_SUSPENDED.add(key)
|
|
641
|
+
_diagnose(key, fence)
|
|
642
|
+
return
|
|
643
|
+
_KEEPERS[key] = keeper
|
|
644
|
+
_SUSPENDED.discard(key)
|
|
645
|
+
_register_atexit()
|
|
646
|
+
_start_timer_locked()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _ensure_keeper(path: str) -> None:
|
|
650
|
+
"""Open this process's keeper for ``path``, reopen it on a new file, or
|
|
651
|
+
resume a suspended store, unless a fence is live (`arm`'s admission)."""
|
|
652
|
+
key = _key(path)
|
|
653
|
+
with _KEEPER_LOCK:
|
|
654
|
+
aliases = _ALIASES.setdefault(key, set())
|
|
655
|
+
aliases.add(os.fspath(path))
|
|
656
|
+
_ARMED[key] = _identity(key)
|
|
657
|
+
_admit_locked(key)
|
|
658
|
+
# The finalizer attempts every armed store, kept or not (PR-7).
|
|
659
|
+
_register_atexit()
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def maintenance_lock_path(db_path) -> str:
|
|
663
|
+
"""The store's maintenance flock: ``_cctally_core`` names it
|
|
664
|
+
``APP_DIR / "<store>.maintenance.lock"`` for both kept stores
|
|
665
|
+
(``CACHE_LOCK_MAINTENANCE_PATH``, ``CONVERSATIONS_LOCK_MAINTENANCE_PATH``),
|
|
666
|
+
so it is derived from the store's own path."""
|
|
667
|
+
return f"{_key(db_path)}.maintenance.lock"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _resume_from_timer_locked(key: str) -> None:
|
|
671
|
+
"""Reopen a suspended store's keeper from the timer (901-SR-036).
|
|
672
|
+
|
|
673
|
+
The store's maintenance lock is taken shared and non-blocking for the
|
|
674
|
+
admission only, never for the keeper's life: while a requester (or this
|
|
675
|
+
process's own maintenance on another descriptor) holds it exclusively,
|
|
676
|
+
the store stays suspended until the next round. Under it the fences and
|
|
677
|
+
the file identity are checked again before SQLite is opened, and the
|
|
678
|
+
fences once more after, as at any admission. Caller holds the lock."""
|
|
679
|
+
if live_fence(key) is not None or _identity(key) is None:
|
|
680
|
+
return
|
|
681
|
+
try:
|
|
682
|
+
lock_fh = open(maintenance_lock_path(key), "a+")
|
|
683
|
+
except OSError:
|
|
684
|
+
return
|
|
685
|
+
try:
|
|
686
|
+
try:
|
|
687
|
+
fcntl.flock(lock_fh, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
688
|
+
except OSError:
|
|
689
|
+
return
|
|
690
|
+
try:
|
|
691
|
+
if key in _KEEPERS:
|
|
692
|
+
_SUSPENDED.discard(key)
|
|
693
|
+
return
|
|
694
|
+
_admit_locked(key)
|
|
695
|
+
finally:
|
|
696
|
+
try:
|
|
697
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
698
|
+
except OSError:
|
|
699
|
+
pass
|
|
700
|
+
finally:
|
|
701
|
+
lock_fh.close()
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _register_atexit() -> None:
|
|
705
|
+
global _ATEXIT_REGISTERED
|
|
706
|
+
if not _ATEXIT_REGISTERED:
|
|
707
|
+
atexit.register(finalize)
|
|
708
|
+
_ATEXIT_REGISTERED = True
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _start_timer_locked() -> None:
|
|
712
|
+
"""Start the process's timer thread unless it runs. Caller holds the lock."""
|
|
713
|
+
global _TIMER, _TIMER_STOP
|
|
714
|
+
if (_TIMER is not None and _TIMER.is_alive()
|
|
715
|
+
and not _TIMER_STOP.is_set()):
|
|
716
|
+
return
|
|
717
|
+
_TIMER_STOP = threading.Event()
|
|
718
|
+
_TIMER = threading.Thread(target=_timer_main, args=(_TIMER_STOP,),
|
|
719
|
+
name=TIMER_THREAD_NAME, daemon=True)
|
|
720
|
+
_TIMER.start()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _timer_main(stop: threading.Event) -> None:
|
|
724
|
+
while not stop.wait(max(0.001, float(TIMER_INTERVAL_SECONDS))):
|
|
725
|
+
try:
|
|
726
|
+
_timer_round(stop)
|
|
727
|
+
except Exception: # noqa: BLE001 — the thread must outlive any error
|
|
728
|
+
pass
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _timer_round(stop: "threading.Event | None" = None) -> None:
|
|
732
|
+
"""One round: the fence check on every kept store before the due check,
|
|
733
|
+
resumption of suspended stores no longer fenced, then the timed attempt
|
|
734
|
+
through each kept store's keeper."""
|
|
735
|
+
with _KEEPER_LOCK:
|
|
736
|
+
if stop is not None and stop.is_set():
|
|
737
|
+
return
|
|
738
|
+
for key in list(_KEEPERS):
|
|
739
|
+
fence = live_fence(key)
|
|
740
|
+
if fence is not None:
|
|
741
|
+
_yield_locked(key, fence)
|
|
742
|
+
for key in sorted(_SUSPENDED):
|
|
743
|
+
_resume_from_timer_locked(key)
|
|
744
|
+
for key, keeper in list(_KEEPERS.items()):
|
|
745
|
+
policy = policy_for_path(key)
|
|
746
|
+
if not policy.timer_due():
|
|
747
|
+
continue
|
|
748
|
+
if _identity(key) != keeper.identity:
|
|
749
|
+
# The store was replaced underneath this process; the next arm
|
|
750
|
+
# opens a keeper on the new file.
|
|
751
|
+
del _KEEPERS[key]
|
|
752
|
+
_close_quietly(keeper.conn)
|
|
753
|
+
continue
|
|
754
|
+
try:
|
|
755
|
+
policy.attempt(keeper.conn)
|
|
756
|
+
except sqlite3.Error:
|
|
757
|
+
pass
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def finalize() -> None:
|
|
761
|
+
"""At orderly exit: one PASSIVE per armed store, then close the keepers.
|
|
762
|
+
|
|
763
|
+
Idempotent. A keeper whose store is unchanged and unfenced gets its normal
|
|
764
|
+
close behaviour back, so when it is the store's last connection SQLite's
|
|
765
|
+
close-time checkpoint drains the WAL; one whose store was replaced or is
|
|
766
|
+
fenced closes without touching either file, and a fenced store gets no
|
|
767
|
+
PASSIVE either.
|
|
768
|
+
|
|
769
|
+
#901 Amendment 19 PR-7: a store this process armed whose keeper is gone —
|
|
770
|
+
released for a drain check, or yielded to a fence that has since cleared —
|
|
771
|
+
is attempted too, through a short-lived connection with the same rules
|
|
772
|
+
(`_finalize_unkept_locked`). Only the kept stores were attempted before,
|
|
773
|
+
so such a store left its frames for whoever opened it next."""
|
|
774
|
+
_TIMER_STOP.set()
|
|
775
|
+
with _KEEPER_LOCK:
|
|
776
|
+
keepers = list(_KEEPERS.items())
|
|
777
|
+
_KEEPERS.clear()
|
|
778
|
+
_SUSPENDED.clear()
|
|
779
|
+
kept = {key for key, _keeper in keepers}
|
|
780
|
+
unkept = sorted(key for key in _ARMED if key not in kept)
|
|
781
|
+
armed_identity = dict(_ARMED)
|
|
782
|
+
_ARMED.clear()
|
|
783
|
+
for key in unkept:
|
|
784
|
+
_finalize_unkept_locked(key, armed_identity.get(key))
|
|
785
|
+
for key, keeper in keepers:
|
|
786
|
+
same = _identity(key) == keeper.identity
|
|
787
|
+
fence = live_fence(key) if same else None
|
|
788
|
+
if same and fence is None:
|
|
789
|
+
if not disabled():
|
|
790
|
+
try:
|
|
791
|
+
policy_for_path(key).attempt(keeper.conn)
|
|
792
|
+
except sqlite3.Error:
|
|
793
|
+
pass
|
|
794
|
+
try:
|
|
795
|
+
import _lib_sqlite_close
|
|
796
|
+
|
|
797
|
+
_lib_sqlite_close.set_no_checkpoint_on_close(
|
|
798
|
+
keeper.conn, False, purpose="W9 keeper")
|
|
799
|
+
except Exception: # noqa: BLE001 — keep it silent instead
|
|
800
|
+
_close_quietly(keeper.conn)
|
|
801
|
+
continue
|
|
802
|
+
try:
|
|
803
|
+
keeper.conn.close()
|
|
804
|
+
except Exception: # noqa: BLE001
|
|
805
|
+
pass
|
|
806
|
+
else:
|
|
807
|
+
_close_quietly(keeper.conn)
|
|
808
|
+
if fence is not None:
|
|
809
|
+
_diagnose(key, fence)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _finalize_unkept_locked(key: str,
|
|
813
|
+
armed_identity: "tuple[int, int] | None") -> None:
|
|
814
|
+
"""The finalizer's PASSIVE for an armed store with no keeper (PR-7).
|
|
815
|
+
|
|
816
|
+
The kept-store rules, on a connection opened for the purpose: no attempt
|
|
817
|
+
when the store is gone, was replaced since it was armed, or is fenced.
|
|
818
|
+
As at a keeper admission, the identity and the fences are checked before
|
|
819
|
+
the connection opens and again after, before any read (901-RW-003), and
|
|
820
|
+
the connection carries no-checkpoint-on-close until the store is found
|
|
821
|
+
unchanged and unfenced once more after the PASSIVE: a fence or a
|
|
822
|
+
replacement that lands meanwhile gets a silent close, which touches
|
|
823
|
+
neither file. Where that setting cannot be established and verified
|
|
824
|
+
(OV-4), it makes no attempt at all, exactly as keeper admission refuses.
|
|
825
|
+
Otherwise it closes normally, so when it is the store's last connection
|
|
826
|
+
SQLite's close-time checkpoint drains the WAL.
|
|
827
|
+
|
|
828
|
+
Like the kept-store exit path, and unlike a timer round's admission, it
|
|
829
|
+
takes no maintenance lock: the finalizer may run while the process still
|
|
830
|
+
holds its writer and provider locks (a detached worker's explicit call
|
|
831
|
+
before `os._exit`), and the lock-order law forbids taking the earlier
|
|
832
|
+
maintenance lock after them, even non-blocking (W9, Q15)."""
|
|
833
|
+
if disabled():
|
|
834
|
+
return
|
|
835
|
+
identity = _identity(key)
|
|
836
|
+
if identity is None or (armed_identity is not None
|
|
837
|
+
and identity != armed_identity):
|
|
838
|
+
return
|
|
839
|
+
if live_fence(key) is not None or _CLOSE_CONTROL is False:
|
|
840
|
+
return
|
|
841
|
+
try:
|
|
842
|
+
conn = _connect_keeper(key)
|
|
843
|
+
except (sqlite3.Error, OSError, ValueError):
|
|
844
|
+
return
|
|
845
|
+
try:
|
|
846
|
+
_apply_close_control(conn)
|
|
847
|
+
except Exception: # noqa: BLE001 — any failure means no control
|
|
848
|
+
# As at keeper admission: without the control a fence landing
|
|
849
|
+
# during the attempt could not be honoured at the close, so there is
|
|
850
|
+
# no attempt at all. Never read, the connection closes untouched.
|
|
851
|
+
_close_quietly(conn)
|
|
852
|
+
return
|
|
853
|
+
# Never read yet: a connection that opened no WAL closes untouched.
|
|
854
|
+
if _identity(key) != identity or live_fence(key) is not None:
|
|
855
|
+
_close_quietly(conn)
|
|
856
|
+
return
|
|
857
|
+
try:
|
|
858
|
+
policy_for_path(key).attempt(conn)
|
|
859
|
+
except sqlite3.Error:
|
|
860
|
+
pass
|
|
861
|
+
if _identity(key) != identity or live_fence(key) is not None:
|
|
862
|
+
_close_quietly(conn)
|
|
863
|
+
return
|
|
864
|
+
try:
|
|
865
|
+
import _lib_sqlite_close
|
|
866
|
+
|
|
867
|
+
_lib_sqlite_close.set_no_checkpoint_on_close(
|
|
868
|
+
conn, False, purpose="W9 keeper")
|
|
869
|
+
except Exception: # noqa: BLE001 — keep it silent instead
|
|
870
|
+
_close_quietly(conn)
|
|
871
|
+
return
|
|
872
|
+
try:
|
|
873
|
+
conn.close()
|
|
874
|
+
except Exception: # noqa: BLE001
|
|
875
|
+
pass
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def release(db_path) -> None:
|
|
879
|
+
"""Drop this process's keeper for ``db_path`` without a checkpoint.
|
|
880
|
+
|
|
881
|
+
Called by a requester before it waits for the family (W9, Q15) and before
|
|
882
|
+
every drain check (`_cctally_db._db_family_open_pids`): a keeper exists
|
|
883
|
+
only to defer checkpoints and must never be the open handle that refuses
|
|
884
|
+
this process's own maintenance or store recovery. The store is suspended,
|
|
885
|
+
so the next arm, or a timer round once no fence is live, reopens it."""
|
|
886
|
+
key = _key(db_path)
|
|
887
|
+
if key not in _KEEPERS:
|
|
888
|
+
return
|
|
889
|
+
with _KEEPER_LOCK:
|
|
890
|
+
keeper = _KEEPERS.pop(key, None)
|
|
891
|
+
if keeper is not None:
|
|
892
|
+
_close_quietly(keeper.conn)
|
|
893
|
+
_SUSPENDED.add(key)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def kept_stores() -> "dict[str, tuple[int, int]]":
|
|
897
|
+
"""The stores this process keeps, with the file identity of each keeper."""
|
|
898
|
+
with _KEEPER_LOCK:
|
|
899
|
+
return {key: keeper.identity for key, keeper in _KEEPERS.items()}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def suspended_stores() -> "frozenset[str]":
|
|
903
|
+
"""The stores whose keeper yielded and has not been reopened (Q15)."""
|
|
904
|
+
with _KEEPER_LOCK:
|
|
905
|
+
return frozenset(_SUSPENDED)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def timer_thread() -> "threading.Thread | None":
|
|
909
|
+
"""This process's timer thread, or None when it never started one."""
|
|
910
|
+
return _TIMER
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def reset_policies() -> None:
|
|
914
|
+
"""Forget this process's W9 state (tests: a fresh process).
|
|
915
|
+
|
|
916
|
+
The policies, and with them every keeper (closed without a checkpoint),
|
|
917
|
+
the suspended stores, the close-control probe and the timer thread."""
|
|
918
|
+
global _TIMER, _CLOSE_CONTROL
|
|
919
|
+
_TIMER_STOP.set()
|
|
920
|
+
thread = _TIMER
|
|
921
|
+
with _KEEPER_LOCK:
|
|
922
|
+
keepers = list(_KEEPERS.values())
|
|
923
|
+
_KEEPERS.clear()
|
|
924
|
+
_SUSPENDED.clear()
|
|
925
|
+
_ALIASES.clear()
|
|
926
|
+
_ARMED.clear()
|
|
927
|
+
_CLOSE_CONTROL = None
|
|
928
|
+
for keeper in keepers:
|
|
929
|
+
_close_quietly(keeper.conn)
|
|
930
|
+
_TIMER = None
|
|
931
|
+
if (thread is not None and thread is not threading.current_thread()
|
|
932
|
+
and thread.is_alive()):
|
|
933
|
+
thread.join(timeout=5.0)
|
|
934
|
+
with _POLICIES_LOCK:
|
|
935
|
+
_POLICIES.clear()
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _after_fork_in_child() -> None:
|
|
939
|
+
"""A forked child starts with no keeper, no timer thread and fresh locks.
|
|
940
|
+
|
|
941
|
+
The inherited keepers are neither used nor closed: they are parked in
|
|
942
|
+
``_INHERITED`` (with an extra reference, so the child's teardown never
|
|
943
|
+
deallocates them), because closing a copy of the parent's connection in
|
|
944
|
+
the child could run SQLite's close path against the parent's files."""
|
|
945
|
+
global _KEEPERS, _KEEPER_LOCK, _TIMER, _TIMER_STOP, _POLICIES
|
|
946
|
+
global _POLICIES_LOCK, _SUSPENDED, _ALIASES, _ARMED
|
|
947
|
+
inherited = [keeper.conn for keeper in _KEEPERS.values()]
|
|
948
|
+
_INHERITED.extend(inherited)
|
|
949
|
+
try:
|
|
950
|
+
import ctypes
|
|
951
|
+
|
|
952
|
+
for conn in inherited:
|
|
953
|
+
ctypes.pythonapi.Py_IncRef(ctypes.py_object(conn))
|
|
954
|
+
except Exception: # noqa: BLE001 — the list reference still holds them
|
|
955
|
+
pass
|
|
956
|
+
_KEEPERS = {}
|
|
957
|
+
_KEEPER_LOCK = threading.Lock()
|
|
958
|
+
_SUSPENDED = set()
|
|
959
|
+
_ALIASES = {}
|
|
960
|
+
_ARMED = {}
|
|
961
|
+
_TIMER = None
|
|
962
|
+
_TIMER_STOP = threading.Event()
|
|
963
|
+
_POLICIES = {}
|
|
964
|
+
_POLICIES_LOCK = threading.Lock()
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _before_fork() -> None:
|
|
968
|
+
"""PR-6: no keeper connection crosses a fork.
|
|
969
|
+
|
|
970
|
+
A child forked without `exec` (the transcript rebuild worker, a detached
|
|
971
|
+
hook or provider worker) that opens a store the parent keeps would share
|
|
972
|
+
SQLite's per-process inode lock bookkeeping with the parent's keeper while
|
|
973
|
+
the fcntl locks behind it are not inherited, so it could believe it holds
|
|
974
|
+
locks the kernel never granted it. Every keeper is released first, as a
|
|
975
|
+
requester releases it (no checkpoint, the store suspended and reopened by
|
|
976
|
+
the next arm or timer round), and the keeper lock is held across the fork
|
|
977
|
+
so nothing is reopened before it. `subprocess` without `preexec_fn` runs
|
|
978
|
+
no fork hook, and the steady-state dashboard does not fork."""
|
|
979
|
+
_KEEPER_LOCK.acquire()
|
|
980
|
+
for key, keeper in list(_KEEPERS.items()):
|
|
981
|
+
_close_quietly(keeper.conn)
|
|
982
|
+
_SUSPENDED.add(key)
|
|
983
|
+
_KEEPERS.clear()
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _after_fork_in_parent() -> None:
|
|
987
|
+
_KEEPER_LOCK.release()
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
if hasattr(os, "register_at_fork"):
|
|
991
|
+
os.register_at_fork(before=_before_fork,
|
|
992
|
+
after_in_parent=_after_fork_in_parent,
|
|
993
|
+
after_in_child=_after_fork_in_child)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
# ── the writer's three calls ─────────────────────────────────────────────────
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def armed(conn: sqlite3.Connection) -> bool:
|
|
1000
|
+
"""Whether ``conn`` is a writer this module owns checkpoints for."""
|
|
1001
|
+
if disabled():
|
|
1002
|
+
return False
|
|
1003
|
+
try:
|
|
1004
|
+
return int(conn.execute(
|
|
1005
|
+
"PRAGMA wal_autocheckpoint").fetchone()[0]) == 0
|
|
1006
|
+
except sqlite3.Error:
|
|
1007
|
+
return False
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def arm(conn: sqlite3.Connection) -> bool:
|
|
1011
|
+
"""Make ``conn`` a writer-owned-checkpoint sync writer (W9).
|
|
1012
|
+
|
|
1013
|
+
Called by the sync functions after the opener's open-time work; outside
|
|
1014
|
+
any transaction, holding the writer and provider flocks. Opens (or, on a
|
|
1015
|
+
replaced file, reopens, or after a yield resumes) the process's keeper for
|
|
1016
|
+
the store unless a fence is live, taking no maintenance or provider lock
|
|
1017
|
+
(Q15), and sets ``wal_autocheckpoint = 0`` only when the frame count can
|
|
1018
|
+
be read on this runtime and the database is in WAL mode; otherwise the
|
|
1019
|
+
connection keeps SQLite's default (the keeper still applies). Idempotent.
|
|
1020
|
+
Returns whether the connection is armed.
|
|
1021
|
+
"""
|
|
1022
|
+
try:
|
|
1023
|
+
if disabled() or conn.in_transaction:
|
|
1024
|
+
return False
|
|
1025
|
+
mode = str(conn.execute("PRAGMA journal_mode").fetchone()[0]).lower()
|
|
1026
|
+
if mode != "wal":
|
|
1027
|
+
return False
|
|
1028
|
+
path = _main_path(conn)
|
|
1029
|
+
if not path:
|
|
1030
|
+
return False
|
|
1031
|
+
policy = policy_for_path(path)
|
|
1032
|
+
policy.note_armed()
|
|
1033
|
+
_ensure_keeper(path)
|
|
1034
|
+
if not frame_source_available(conn):
|
|
1035
|
+
return False
|
|
1036
|
+
policy.page_size = int(conn.execute("PRAGMA page_size").fetchone()[0])
|
|
1037
|
+
conn.execute("PRAGMA wal_autocheckpoint = 0")
|
|
1038
|
+
return True
|
|
1039
|
+
except sqlite3.Error:
|
|
1040
|
+
return False
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def after_commit(conn: sqlite3.Connection, *,
|
|
1044
|
+
policy: "WalCheckpointPolicy | None" = None) -> None:
|
|
1045
|
+
"""Account for this connection's new frames; size-trigger a checkpoint.
|
|
1046
|
+
|
|
1047
|
+
``policy`` defaults to this process's policy for the database; a test
|
|
1048
|
+
passes its own to stand for another process."""
|
|
1049
|
+
try:
|
|
1050
|
+
if not armed(conn):
|
|
1051
|
+
return
|
|
1052
|
+
policy = policy or policy_for(conn)
|
|
1053
|
+
frames = take_frames(conn)
|
|
1054
|
+
if policy is None or frames is None:
|
|
1055
|
+
return
|
|
1056
|
+
policy.record_frames(frames)
|
|
1057
|
+
if policy.size_due():
|
|
1058
|
+
policy.attempt(conn)
|
|
1059
|
+
except sqlite3.Error:
|
|
1060
|
+
pass # checkpoint policy must never fail a sync
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def at_boundary(conn: sqlite3.Connection, *,
|
|
1064
|
+
policy: "WalCheckpointPolicy | None" = None) -> None:
|
|
1065
|
+
"""End of a sync: account for new frames; timer-trigger a checkpoint."""
|
|
1066
|
+
try:
|
|
1067
|
+
if not armed(conn) or conn.in_transaction:
|
|
1068
|
+
return
|
|
1069
|
+
policy = policy or policy_for(conn)
|
|
1070
|
+
frames = take_frames(conn)
|
|
1071
|
+
if policy is None or frames is None:
|
|
1072
|
+
return
|
|
1073
|
+
policy.record_frames(frames)
|
|
1074
|
+
if policy.size_due() or policy.timer_due():
|
|
1075
|
+
policy.attempt(conn)
|
|
1076
|
+
except sqlite3.Error:
|
|
1077
|
+
pass # checkpoint policy must never fail a sync
|