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.
Files changed (58) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -5
  3. package/bin/_cctally_cache.py +1683 -485
  4. package/bin/_cctally_core.py +223 -1
  5. package/bin/_cctally_dashboard.py +96 -18
  6. package/bin/_cctally_dashboard_perf.py +128 -9
  7. package/bin/_cctally_dashboard_sources.py +267 -204
  8. package/bin/_cctally_db.py +861 -18
  9. package/bin/_cctally_diagnosis_sources.py +22 -4
  10. package/bin/_cctally_doctor.py +136 -18
  11. package/bin/_cctally_five_hour.py +2 -2
  12. package/bin/_cctally_forecast.py +27 -6
  13. package/bin/_cctally_journal.py +67 -5
  14. package/bin/_cctally_milestone_history.py +82 -9
  15. package/bin/_cctally_percent_breakdown.py +3 -2
  16. package/bin/_cctally_quota.py +201 -33
  17. package/bin/_cctally_record.py +14 -0
  18. package/bin/_cctally_rederive.py +23 -9
  19. package/bin/_cctally_source_analytics.py +60 -15
  20. package/bin/_cctally_statusline.py +17 -0
  21. package/bin/_cctally_store.py +80 -5
  22. package/bin/_cctally_tui.py +4 -3
  23. package/bin/_cctally_weekrefs.py +128 -21
  24. package/bin/_lib_cache_report.py +35 -6
  25. package/bin/_lib_codex_conversation.py +32 -0
  26. package/bin/_lib_codex_conversation_query.py +164 -30
  27. package/bin/_lib_conversation_query.py +95 -56
  28. package/bin/_lib_conversation_retention.py +1605 -640
  29. package/bin/_lib_cost_provenance.py +53 -2
  30. package/bin/_lib_doctor.py +263 -69
  31. package/bin/_lib_fmt.py +11 -0
  32. package/bin/_lib_merge_gate.py +469 -51
  33. package/bin/_lib_perf.py +27 -1
  34. package/bin/_lib_pricing.py +209 -8
  35. package/bin/_lib_pricing_check.py +20 -4
  36. package/bin/_lib_reclaim_planner.py +792 -0
  37. package/bin/_lib_rederive.py +225 -77
  38. package/bin/_lib_snapshot_cache.py +21 -0
  39. package/bin/_lib_sqlite_close.py +81 -0
  40. package/bin/_lib_subscription_weeks.py +100 -17
  41. package/bin/_lib_tick_stats.py +217 -17
  42. package/bin/_lib_wal_checkpoint.py +1077 -0
  43. package/bin/_lib_write_budget.py +483 -0
  44. package/bin/_lib_write_io.py +703 -0
  45. package/bin/cctally +8 -0
  46. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  47. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  48. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  49. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  50. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  51. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  52. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  53. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  54. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  55. package/dashboard/static/dashboard.html +2 -2
  56. package/package.json +6 -1
  57. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  58. 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