cctally 1.79.2 → 1.80.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 +20 -0
- package/bin/_cctally_cache.py +148 -61
- package/bin/_cctally_config.py +23 -6
- package/bin/_cctally_core.py +342 -88
- package/bin/_cctally_dashboard.py +13 -7
- package/bin/_cctally_db.py +361 -36
- package/bin/_cctally_doctor.py +156 -0
- package/bin/_cctally_five_hour.py +19 -7
- package/bin/_cctally_forecast.py +43 -18
- package/bin/_cctally_journal.py +2148 -0
- package/bin/_cctally_milestones.py +116 -26
- package/bin/_cctally_parser.py +21 -3
- package/bin/_cctally_quota.py +195 -76
- package/bin/_cctally_record.py +1596 -1133
- package/bin/_cctally_statusline.py +13 -1
- package/bin/_cctally_store.py +505 -0
- package/bin/_cctally_sync_week.py +139 -2
- package/bin/_lib_doctor.py +209 -12
- package/bin/_lib_journal.py +188 -0
- package/bin/cctally +11 -1
- package/dashboard/static/assets/index-Cc2ykqF_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CrIlpAlQ.css +0 -1
- /package/dashboard/static/assets/{index-BDkr0bdD.js → index-B3y14l1o.js} +0 -0
|
@@ -1112,7 +1112,19 @@ def _statusline_reduce_and_publish() -> "_candidates.ReductionDecision | None":
|
|
|
1112
1112
|
five_hour_resets_at=None if plan.five_hour is None else plan.five_hour.raw_resets_at,
|
|
1113
1113
|
source="statusline",
|
|
1114
1114
|
)
|
|
1115
|
-
|
|
1115
|
+
# 6f mode wiring (P2 exit-code fix): the statusline persist fork's publish
|
|
1116
|
+
# step ingests OPPORTUNISTICALLY. A render tick must NEVER crash on a
|
|
1117
|
+
# derivation error — the opportunistic path swallows a cycle exception
|
|
1118
|
+
# (loud log only) instead of propagating, whereas the authoritative default
|
|
1119
|
+
# would re-raise out of this unwrapped persist fork. In the uncontended
|
|
1120
|
+
# common case opportunistic still consumes the just-appended obs
|
|
1121
|
+
# synchronously (the ingest lock is free), so `after` reflects the write
|
|
1122
|
+
# exactly as before; only under a concurrent-ingester storm does it skip and
|
|
1123
|
+
# let the winner consume the line (the next tick then observes it). The
|
|
1124
|
+
# statusline's AUTHORITATIVE publication step (`_authoritative_record_usage`)
|
|
1125
|
+
# keeps the default authoritative mode — it is already try/except-wrapped to
|
|
1126
|
+
# "record_failed".
|
|
1127
|
+
if _cctally().cmd_record_usage(args, ingest_mode="opportunistic") != 0:
|
|
1116
1128
|
return decision
|
|
1117
1129
|
after = _read_db_projection_stable()
|
|
1118
1130
|
if _projection_changed(projection, after):
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Unified SQLite store opener — one connect+PRAGMA policy, one version gate.
|
|
2
|
+
|
|
3
|
+
Design spec docs/superpowers/specs/2026-07-22-db-journal-redesign-design.md
|
|
4
|
+
§6 (access layer). This module is the single chokepoint every SQLite
|
|
5
|
+
connection for the three disposable indexes (stats.db / cache.db /
|
|
6
|
+
conversations.db) routes through for its **connection policy** and its
|
|
7
|
+
**schema-apply version gate**. ``open_db`` / ``open_cache_db`` /
|
|
8
|
+
``open_conversations_db`` stay thin wrappers whose public signatures are
|
|
9
|
+
unchanged; they call in here for PRAGMAs and ask ``schema_current`` whether the
|
|
10
|
+
full DDL/migration apply may be skipped.
|
|
11
|
+
|
|
12
|
+
Two responsibilities live here (spec §6.1 / §6.2):
|
|
13
|
+
|
|
14
|
+
- ``open_index(store)`` — connect to the store's DB path (URI mode per policy),
|
|
15
|
+
install the row factory, and (test-only) install a trace hook. Corruption
|
|
16
|
+
handling stays in each opener (Task 8 moves it to the classifier-gated
|
|
17
|
+
``HEAL_HOOK`` seam below), so ``open_index`` never probes or recreates.
|
|
18
|
+
- ``apply_policy(conn, store)`` — apply the §6.1 PRAGMA policy
|
|
19
|
+
(journal_mode / synchronous / busy_timeout / journal_size_limit /
|
|
20
|
+
auto_vacuum). ``auto_vacuum`` is emitted **before** ``journal_mode`` because
|
|
21
|
+
it only takes on a DB whose first page has not been written yet.
|
|
22
|
+
- ``schema_current(conn, store)`` — the §6.2 version gate: one
|
|
23
|
+
``PRAGMA user_version`` read compared to the store's registry head. When it
|
|
24
|
+
returns ``True`` the opener skips the full schema executescript +
|
|
25
|
+
``add_column_if_missing`` probes + FTS branch, so the steady-state open is
|
|
26
|
+
connect → PRAGMAs → one ``user_version`` read → done.
|
|
27
|
+
|
|
28
|
+
**Contract change (Task 2, spec §6.2):** cache/conversations schema changes must
|
|
29
|
+
ride a migration-registry bump; a bare ``add_column_if_missing`` in
|
|
30
|
+
``_apply_cache_schema`` (or ``_apply_conversations_schema``) no longer reaches
|
|
31
|
+
version-current DBs, because the whole schema apply is version-gated and skipped
|
|
32
|
+
once ``user_version == registry head``. Add the column via a registered
|
|
33
|
+
migration (which bumps the head and re-triggers the apply) instead.
|
|
34
|
+
|
|
35
|
+
**Lock-order law** (spec §5.2 / §6.4; asserted here as documentation, exercised
|
|
36
|
+
by the storm test): maintenance flocks → ``journal.ingest.lock`` → provider
|
|
37
|
+
flocks (cache Claude → cache Codex → conversations Claude → conversations
|
|
38
|
+
Codex) → SQLite transactions → ``journal.lock`` (leaf). Never acquire the
|
|
39
|
+
ingest lock while holding a provider flock; no SQLite write transaction ever
|
|
40
|
+
spans a flock acquisition.
|
|
41
|
+
|
|
42
|
+
**Raw-connect escape hatches stay OUT of this module by design** (spec §6.1):
|
|
43
|
+
``db checkpoint``'s ``mode=rw`` connect, ``db vacuum``'s exclusive connect, and
|
|
44
|
+
doctor's read-only gather deliberately bypass the opener so they carry no
|
|
45
|
+
schema-apply / migration side effects on maintenance/diagnostic paths.
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import fcntl
|
|
50
|
+
import os
|
|
51
|
+
import sqlite3
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
from dataclasses import dataclass
|
|
55
|
+
|
|
56
|
+
import _cctally_core
|
|
57
|
+
import _cctally_db
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------
|
|
61
|
+
# §6.1 policy table
|
|
62
|
+
# --------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# WAL size caps (spec §6.1). Mirror the long-standing per-DB constants
|
|
65
|
+
# (_cctally_core.STATS_WAL_SIZE_LIMIT_BYTES, _cctally_cache.CACHE_WAL_SIZE_LIMIT_BYTES);
|
|
66
|
+
# duplicated here so the store module does not import _cctally_cache (which
|
|
67
|
+
# loads this module — that would be a cycle).
|
|
68
|
+
_STATS_WAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16 MiB
|
|
69
|
+
_CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 128 MiB
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class StorePolicy:
|
|
74
|
+
"""The §6.1 connection policy for one store."""
|
|
75
|
+
|
|
76
|
+
journal_mode: str # "WAL"
|
|
77
|
+
synchronous: str # "NORMAL"
|
|
78
|
+
busy_timeout: int # milliseconds
|
|
79
|
+
journal_size_limit: int # bytes
|
|
80
|
+
auto_vacuum: str | None # "INCREMENTAL", or None to leave unset
|
|
81
|
+
row_factory: str # "row" (sqlite3.Row) | "tuple"
|
|
82
|
+
uri: bool # connect with uri=True (RO ATTACH support)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
STORE_POLICY: dict[str, StorePolicy] = {
|
|
86
|
+
# stats.db: auto_vacuum stays unset on normal opens. §6.1: INCREMENTAL only
|
|
87
|
+
# from the first epoch rebuild — a populated DB needs a full VACUUM to
|
|
88
|
+
# change modes, so it is deliberately NOT applied at in-place cutover.
|
|
89
|
+
"stats": StorePolicy(
|
|
90
|
+
journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
|
|
91
|
+
journal_size_limit=_STATS_WAL_SIZE_LIMIT_BYTES, auto_vacuum=None,
|
|
92
|
+
row_factory="row", uri=False,
|
|
93
|
+
),
|
|
94
|
+
"cache": StorePolicy(
|
|
95
|
+
journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
|
|
96
|
+
journal_size_limit=_CACHE_WAL_SIZE_LIMIT_BYTES, auto_vacuum="INCREMENTAL",
|
|
97
|
+
row_factory="tuple", uri=False,
|
|
98
|
+
),
|
|
99
|
+
"conversations": StorePolicy(
|
|
100
|
+
journal_mode="WAL", synchronous="NORMAL", busy_timeout=15000,
|
|
101
|
+
journal_size_limit=_CACHE_WAL_SIZE_LIMIT_BYTES, auto_vacuum="INCREMENTAL",
|
|
102
|
+
row_factory="tuple", uri=True,
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Test-only tracing seam (spec §10 / §6.5 hot-path guards). When non-None it is
|
|
108
|
+
# installed via ``conn.set_trace_callback`` on every connection ``open_index``
|
|
109
|
+
# hands out, so a test can count the DDL statements a steady-state open runs
|
|
110
|
+
# (must be zero once the version gate holds). Production leaves it None.
|
|
111
|
+
_TRACE_HOOK = None
|
|
112
|
+
|
|
113
|
+
# Task 8 seam (spec §6.3): classifier-gated corruption auto-heal. Wired to
|
|
114
|
+
# ``_stats_heal_hook`` at the bottom of the module. ``open_db`` calls
|
|
115
|
+
# ``HEAL_HOOK("stats", exc)`` from its corruption boundary; a True return means
|
|
116
|
+
# "healed — retry the open once".
|
|
117
|
+
HEAL_HOOK = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _store_path(store: str):
|
|
121
|
+
if store == "stats":
|
|
122
|
+
return _cctally_core.DB_PATH
|
|
123
|
+
if store == "cache":
|
|
124
|
+
return _cctally_core.CACHE_DB_PATH
|
|
125
|
+
if store == "conversations":
|
|
126
|
+
return _cctally_core.CONVERSATIONS_DB_PATH
|
|
127
|
+
raise ValueError(f"unknown store {store!r}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _apply_row_factory(conn: sqlite3.Connection, policy: StorePolicy) -> None:
|
|
131
|
+
conn.row_factory = sqlite3.Row if policy.row_factory == "row" else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def open_index(store: str) -> sqlite3.Connection:
|
|
135
|
+
"""Connect to ``store``'s DB (URI mode per policy) + install the row factory.
|
|
136
|
+
|
|
137
|
+
Does NOT apply PRAGMAs, probe, or recreate — corruption handling is the
|
|
138
|
+
opener's job (Task 8 moves it to ``HEAL_HOOK``). The test trace hook, when
|
|
139
|
+
armed, is installed here so it captures every statement the opener then runs
|
|
140
|
+
(the gated schema apply included). Callers apply the PRAGMA policy with
|
|
141
|
+
``apply_policy`` once the connection is confirmed usable.
|
|
142
|
+
"""
|
|
143
|
+
policy = STORE_POLICY[store]
|
|
144
|
+
conn = sqlite3.connect(_store_path(store), uri=policy.uri)
|
|
145
|
+
if _TRACE_HOOK is not None:
|
|
146
|
+
conn.set_trace_callback(_TRACE_HOOK)
|
|
147
|
+
_apply_row_factory(conn, policy)
|
|
148
|
+
return conn
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def apply_policy(conn: sqlite3.Connection, store: str) -> None:
|
|
152
|
+
"""Apply the §6.1 PRAGMA policy for ``store`` to an open connection.
|
|
153
|
+
|
|
154
|
+
``auto_vacuum`` (when set) is emitted first: it only takes effect before the
|
|
155
|
+
first page is written, so it must precede ``journal_mode=WAL`` / any DDL.
|
|
156
|
+
"""
|
|
157
|
+
policy = STORE_POLICY[store]
|
|
158
|
+
if policy.auto_vacuum is not None:
|
|
159
|
+
conn.execute(f"PRAGMA auto_vacuum={policy.auto_vacuum}")
|
|
160
|
+
conn.execute(f"PRAGMA journal_mode={policy.journal_mode}")
|
|
161
|
+
conn.execute(f"PRAGMA synchronous={policy.synchronous}")
|
|
162
|
+
conn.execute(f"PRAGMA busy_timeout={policy.busy_timeout}")
|
|
163
|
+
conn.execute(f"PRAGMA journal_size_limit={policy.journal_size_limit}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
# §6.2 version gate
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _expected_head(store: str) -> int:
|
|
171
|
+
"""The store's schema head to compare ``user_version`` against.
|
|
172
|
+
|
|
173
|
+
cache/conversations gate on their migration-registry length (read live off
|
|
174
|
+
``_cctally_db`` so a test that mutates the registry is honored). stats
|
|
175
|
+
returns -1 — a value ``user_version`` can never equal — so ``schema_current``
|
|
176
|
+
is always False for it; ``open_db`` keeps its own schema-apply until Task 9
|
|
177
|
+
flips it to the ``STATS_INDEX_EPOCH`` gate.
|
|
178
|
+
|
|
179
|
+
conversations joined the framework in Task 10 (spec §7.2): it gates on
|
|
180
|
+
``len(_CONVERSATIONS_MIGRATIONS)`` (head 1), so an up-to-date conversations.db
|
|
181
|
+
(``user_version == 1``) skips the schema apply on the steady-state open.
|
|
182
|
+
"""
|
|
183
|
+
if store == "cache":
|
|
184
|
+
return len(_cctally_db._CACHE_MIGRATIONS)
|
|
185
|
+
if store == "conversations":
|
|
186
|
+
return len(_cctally_db._CONVERSATIONS_MIGRATIONS)
|
|
187
|
+
return -1 # stats: never gated in Task 2
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def schema_current(conn: sqlite3.Connection, store: str) -> bool:
|
|
191
|
+
"""True when ``store``'s stamped ``user_version`` equals its registry head.
|
|
192
|
+
|
|
193
|
+
When True the opener may skip the full DDL executescript +
|
|
194
|
+
``add_column_if_missing`` probes + FTS branch entirely (spec §6.2). A head
|
|
195
|
+
of ``<= 0`` (a store with no registry yet, e.g. conversations pre-Task-10)
|
|
196
|
+
always returns False so the schema keeps being applied every open.
|
|
197
|
+
"""
|
|
198
|
+
head = _expected_head(store)
|
|
199
|
+
if head <= 0:
|
|
200
|
+
return False
|
|
201
|
+
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
202
|
+
return user_version == head
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------
|
|
206
|
+
# §7.1 stats.db epoch gate (Task 9)
|
|
207
|
+
# --------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def stats_epoch_enabled() -> bool:
|
|
210
|
+
"""True when the stats epoch/cutover machinery should engage (spec §7.1/§8).
|
|
211
|
+
|
|
212
|
+
It engages ONLY against the FROZEN production stats registry
|
|
213
|
+
(``len(_STATS_MIGRATIONS) == LEGACY_STATS_HEAD``). Under
|
|
214
|
+
``CCTALLY_MIGRATION_TEST_MODE`` the injected 14th stats migration lifts the
|
|
215
|
+
count, so this returns False and ``open_db`` keeps the legacy dispatcher
|
|
216
|
+
behavior (no epoch gate, no cutover) — which is exactly what the
|
|
217
|
+
migration-framework golden harness exercises."""
|
|
218
|
+
return len(_cctally_db._STATS_MIGRATIONS) == _cctally_core.LEGACY_STATS_HEAD
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def would_block_prod_stats_cutover(path) -> bool:
|
|
222
|
+
"""A dev/worktree binary must refuse to CUT OVER a stats.db physically in the
|
|
223
|
+
real prod dir (spec §8, mirrors #146): the epoch stamp would brick the
|
|
224
|
+
installed release via ``DowngradeDetected``. Reuses the path-based prod-guard
|
|
225
|
+
predicate; ``CCTALLY_ALLOW_PROD_MIGRATION=1`` is the escape hatch."""
|
|
226
|
+
return _cctally_db._would_block_prod_stats(path)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --------------------------------------------------------------------------
|
|
230
|
+
# §6.2 one-shot gate for open_db's three open-time backfills (Task 8)
|
|
231
|
+
# --------------------------------------------------------------------------
|
|
232
|
+
#
|
|
233
|
+
# stats.db is NOT epoch-gated until Task 9 (which flips the whole schema+
|
|
234
|
+
# migration+backfill region onto ``user_version == STATS_INDEX_EPOCH``). Until
|
|
235
|
+
# then, three self-extinguishing open-time writers still run — the
|
|
236
|
+
# five_hour_window_key backfill probe, the durable quota-projection schema apply,
|
|
237
|
+
# and the historical five_hour_blocks rollup backfill (+ its migration-003
|
|
238
|
+
# re-invocation). Each is cheap-but-nonzero per open (probe SELECTs / an
|
|
239
|
+
# executescript), and under a multi-agent hook storm that cost recurs many times
|
|
240
|
+
# a second (spec §1.3). §6.2 requires the steady-state open to do ZERO
|
|
241
|
+
# probe/DDL/backfill work.
|
|
242
|
+
#
|
|
243
|
+
# We gate all three on a single DB-RESIDENT marker (``stats_open_fixups``) — a
|
|
244
|
+
# marker must travel WITH the DB, not a file in APP_DIR, because a fresh index
|
|
245
|
+
# built by ``rebuild_stats_index`` (which quarantines the old file) needs the
|
|
246
|
+
# quota-projection SCHEMA applied; a stale file marker would leave the fresh DB
|
|
247
|
+
# missing those tables. A single-row table is the same framework-untracked
|
|
248
|
+
# additive posture as ``journal_cursor`` / ``weekly_credit_floors``. Steady state:
|
|
249
|
+
# one guarded SELECT, no DDL. Task 9's epoch gate SUBSUMES this (the whole region
|
|
250
|
+
# is skipped once the DB is epoch-current); the marker + these helpers then become
|
|
251
|
+
# a harmless inner short-circuit that Task 9 may retire.
|
|
252
|
+
#
|
|
253
|
+
# Bump ``_STATS_OPEN_FIXUPS_VERSION`` when a NEW open-time backfill is added, so
|
|
254
|
+
# existing installs re-run the fixups once to pick it up.
|
|
255
|
+
_STATS_OPEN_FIXUPS_VERSION = 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def stats_open_fixups_current(conn: sqlite3.Connection) -> bool:
|
|
259
|
+
"""True when open_db's three open-time backfills have already run for this
|
|
260
|
+
stats.db and need not run (or probe) again (spec §6.2, Task 8).
|
|
261
|
+
|
|
262
|
+
A missing ``stats_open_fixups`` table (fresh / pre-gate DB) or a stamped
|
|
263
|
+
version below the binary's expectation returns False, so the fixups run once
|
|
264
|
+
and re-stamp. Guarded so the read is safe before any schema exists — the only
|
|
265
|
+
steady-state cost is this one SELECT."""
|
|
266
|
+
try:
|
|
267
|
+
row = conn.execute(
|
|
268
|
+
"SELECT version FROM stats_open_fixups WHERE id = 1"
|
|
269
|
+
).fetchone()
|
|
270
|
+
except sqlite3.OperationalError:
|
|
271
|
+
return False
|
|
272
|
+
return row is not None and int(row[0]) >= _STATS_OPEN_FIXUPS_VERSION
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def mark_stats_open_fixups_done(conn: sqlite3.Connection) -> None:
|
|
276
|
+
"""Stamp the ``stats_open_fixups`` marker after the three open-time backfills
|
|
277
|
+
ran (spec §6.2). Creates the single-row marker table on demand — this DDL
|
|
278
|
+
runs ONLY when the fixups run (first open / upgrade / rebuild), never on the
|
|
279
|
+
steady-state open — then upserts the current version."""
|
|
280
|
+
conn.execute(
|
|
281
|
+
"CREATE TABLE IF NOT EXISTS stats_open_fixups ("
|
|
282
|
+
"id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)"
|
|
283
|
+
)
|
|
284
|
+
conn.execute(
|
|
285
|
+
"INSERT INTO stats_open_fixups (id, version) VALUES (1, ?) "
|
|
286
|
+
"ON CONFLICT(id) DO UPDATE SET version = excluded.version",
|
|
287
|
+
(_STATS_OPEN_FIXUPS_VERSION,),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------
|
|
292
|
+
# §6.3 classifier-gated corruption auto-heal (Task 8 Item 3)
|
|
293
|
+
# --------------------------------------------------------------------------
|
|
294
|
+
#
|
|
295
|
+
# The sequence on a POSITIVELY classified stats.db corruption (spec §6.3):
|
|
296
|
+
# classify (SQLITE_CORRUPT / NOTADB / "malformed" — NEVER busy/locked/perm)
|
|
297
|
+
# → dev-checkout-on-prod guard → acquire the MAINTENANCE lock (top of the
|
|
298
|
+
# lock-order law) → locked RE-CHECK (a sibling process may have healed already)
|
|
299
|
+
# → FORENSICS-FIRST (bundle written before evidence is disturbed) → acquire the
|
|
300
|
+
# ingest lock (bounded — the serialized stats writer) → QUARANTINE the damaged
|
|
301
|
+
# family into a timestamped incident dir (never delete evidence) → REBUILD a
|
|
302
|
+
# fresh index from the journal → return True so ``open_db`` retries the open
|
|
303
|
+
# once. A second failure surfaces loudly.
|
|
304
|
+
#
|
|
305
|
+
# The ingest-lock acquire is BOUNDED (not indefinite): a corruption surfacing
|
|
306
|
+
# from *inside* a ``run_stats_ingest`` cycle already holds the ingest lock, so an
|
|
307
|
+
# indefinite wait would self-deadlock. The bounded wait proceeds without the lock
|
|
308
|
+
# on timeout — correctness does not depend on it, because the rebuild writes a
|
|
309
|
+
# scratch index and ATOMICALLY swaps it in while the damaged family is already
|
|
310
|
+
# quarantined out of the way (a concurrent writer's writes land on the
|
|
311
|
+
# quarantined inode and are discarded).
|
|
312
|
+
#
|
|
313
|
+
# ``_HEAL_ACTIVE`` is a re-entrancy guard: the rebuild's own ``open_db`` calls use
|
|
314
|
+
# ``_target_path`` (auto-heal disarmed) and the post-heal retry opens the FRESH
|
|
315
|
+
# index, so this is belt-and-suspenders against any nested corruption looping.
|
|
316
|
+
|
|
317
|
+
_HEAL_ACTIVE = False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _heal_flock_blocking(path) -> int:
|
|
321
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
323
|
+
try:
|
|
324
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
325
|
+
except BaseException:
|
|
326
|
+
os.close(fd)
|
|
327
|
+
raise
|
|
328
|
+
return fd
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _heal_flock_bounded(path, timeout_s: float) -> int:
|
|
332
|
+
"""Bounded EX flock: return the held fd, or (on timeout) the OPEN fd WITHOUT
|
|
333
|
+
the lock held — the caller proceeds best-effort (see the module note)."""
|
|
334
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
336
|
+
deadline = time.monotonic() + timeout_s
|
|
337
|
+
try:
|
|
338
|
+
while True:
|
|
339
|
+
try:
|
|
340
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
341
|
+
return fd
|
|
342
|
+
except (BlockingIOError, OSError):
|
|
343
|
+
if time.monotonic() >= deadline:
|
|
344
|
+
return fd
|
|
345
|
+
time.sleep(0.02)
|
|
346
|
+
except BaseException:
|
|
347
|
+
os.close(fd)
|
|
348
|
+
raise
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _heal_release_flock(fd: int) -> None:
|
|
352
|
+
try:
|
|
353
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
354
|
+
finally:
|
|
355
|
+
os.close(fd)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _probe_stats_ok(path) -> bool:
|
|
359
|
+
"""Raw read-only probe (NEVER ``open_db`` — no schema/heal side effects) of
|
|
360
|
+
whether stats.db opens cleanly. Used for the locked re-check."""
|
|
361
|
+
if not path.exists():
|
|
362
|
+
return False
|
|
363
|
+
try:
|
|
364
|
+
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
365
|
+
try:
|
|
366
|
+
c.execute("SELECT 1").fetchone()
|
|
367
|
+
finally:
|
|
368
|
+
c.close()
|
|
369
|
+
return True
|
|
370
|
+
except sqlite3.DatabaseError:
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _stats_heal_hook(store: str, exc: Exception) -> bool:
|
|
375
|
+
"""Classifier-gated corruption auto-heal for stats.db (spec §6.3). Returns
|
|
376
|
+
True when it healed (quarantined + rebuilt) OR a sibling already healed under
|
|
377
|
+
the maintenance lock; False when it DECLINES — a non-corruption
|
|
378
|
+
``DatabaseError`` (BUSY / disk-full / permission), the dev-checkout-on-prod
|
|
379
|
+
guard, or re-entrancy. A False return leaves ``open_db`` to raise its guided
|
|
380
|
+
``StatsDbCorruptError``."""
|
|
381
|
+
global _HEAL_ACTIVE
|
|
382
|
+
if store != "stats":
|
|
383
|
+
return False
|
|
384
|
+
if not _cctally_db._is_sqlite_corruption_error(exc):
|
|
385
|
+
return False # BUSY / disk-full / permission / SQL error — never heal
|
|
386
|
+
if _HEAL_ACTIVE:
|
|
387
|
+
return False
|
|
388
|
+
path = _cctally_core.DB_PATH
|
|
389
|
+
if _cctally_db._would_block_prod_stats(path):
|
|
390
|
+
print(
|
|
391
|
+
"[heal] refusing to auto-heal the prod stats.db from a dev checkout; "
|
|
392
|
+
"run the installed binary or `cctally db repair --db stats --yes`.",
|
|
393
|
+
file=sys.stderr,
|
|
394
|
+
)
|
|
395
|
+
return False
|
|
396
|
+
# No journal content ⇒ nothing to rebuild FROM. A rebuild would produce an
|
|
397
|
+
# EMPTY index — data loss for a pre-cutover corrupt DB whose legacy history
|
|
398
|
+
# was never journaled (spec §9: `db repair` is the transitional path there).
|
|
399
|
+
# Decline so open_db raises its guided StatsDbCorruptError → `db repair`.
|
|
400
|
+
import _cctally_journal
|
|
401
|
+
hw = _cctally_journal.journal_high_water()
|
|
402
|
+
if hw is None or hw[1] == 0:
|
|
403
|
+
return False
|
|
404
|
+
_HEAL_ACTIVE = True
|
|
405
|
+
try:
|
|
406
|
+
maint_fd = _heal_flock_blocking(_cctally_core.STATS_LOCK_MAINTENANCE_PATH)
|
|
407
|
+
try:
|
|
408
|
+
if _probe_stats_ok(path):
|
|
409
|
+
return True # a sibling process already healed it — retry the open
|
|
410
|
+
_cctally_db.write_corruption_forensics(path, db_label="stats")
|
|
411
|
+
ingest_fd = _heal_flock_bounded(
|
|
412
|
+
_cctally_core.JOURNAL_INGEST_LOCK_PATH, 5.0)
|
|
413
|
+
try:
|
|
414
|
+
_cctally_db.quarantine_db_family(path)
|
|
415
|
+
import _cctally_journal
|
|
416
|
+
_cctally_journal.rebuild_stats_index()
|
|
417
|
+
finally:
|
|
418
|
+
_heal_release_flock(ingest_fd)
|
|
419
|
+
print(
|
|
420
|
+
f"[heal] stats.db was corrupt ({exc}); quarantined its file family "
|
|
421
|
+
"under quarantine/ (forensics in logs/) and rebuilt a fresh index "
|
|
422
|
+
"from the journal.",
|
|
423
|
+
file=sys.stderr,
|
|
424
|
+
)
|
|
425
|
+
return True
|
|
426
|
+
finally:
|
|
427
|
+
_heal_release_flock(maint_fd)
|
|
428
|
+
except Exception as heal_exc:
|
|
429
|
+
print(f"[heal] stats.db auto-heal failed: {heal_exc}", file=sys.stderr)
|
|
430
|
+
return False
|
|
431
|
+
finally:
|
|
432
|
+
_HEAL_ACTIVE = False
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
HEAL_HOOK = _stats_heal_hook
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# --------------------------------------------------------------------------
|
|
439
|
+
# §7.1 stats.db epoch-mismatch resolution (Task 9)
|
|
440
|
+
# --------------------------------------------------------------------------
|
|
441
|
+
#
|
|
442
|
+
# A stats.db whose ``user_version`` is neither legacy (<= LEGACY_STATS_HEAD) nor
|
|
443
|
+
# the current ``STATS_INDEX_EPOCH`` — a future epoch written by a newer binary,
|
|
444
|
+
# or any stray value > 13 — resolves by journal REBUILD (spec §7.1). This is
|
|
445
|
+
# DISJOINT from the corruption heal path: the DB is readable, only its version is
|
|
446
|
+
# wrong. The version-ahead DB is quarantined (nothing deleted), then a fresh
|
|
447
|
+
# index is rebuilt from the journal and swapped in. A mismatch with NO journal is
|
|
448
|
+
# a HARD ERROR (``StatsEpochMismatchError``) — never a silent rebuild-to-empty.
|
|
449
|
+
|
|
450
|
+
_EPOCH_MISMATCH_ACTIVE = False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _raw_user_version(path) -> int:
|
|
454
|
+
"""Read ``PRAGMA user_version`` via a raw RO connect (no open_db side
|
|
455
|
+
effects). -1 when the file cannot be read."""
|
|
456
|
+
try:
|
|
457
|
+
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
458
|
+
try:
|
|
459
|
+
return c.execute("PRAGMA user_version").fetchone()[0]
|
|
460
|
+
finally:
|
|
461
|
+
c.close()
|
|
462
|
+
except sqlite3.DatabaseError:
|
|
463
|
+
return -1
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def resolve_stats_epoch_mismatch():
|
|
467
|
+
"""Resolve a version-mismatched stats.db by journal rebuild (spec §7.1).
|
|
468
|
+
Called by ``open_db`` after ``conn.close()`` — returns a fresh steady-state
|
|
469
|
+
``open_db()`` connection over the rebuilt index."""
|
|
470
|
+
global _EPOCH_MISMATCH_ACTIVE
|
|
471
|
+
import _cctally_journal
|
|
472
|
+
|
|
473
|
+
path = _cctally_core.DB_PATH
|
|
474
|
+
if _cctally_db._would_block_prod_stats(path):
|
|
475
|
+
# A dev/worktree binary must not rebuild the real prod stats.db (#146).
|
|
476
|
+
raise _cctally_db.ProdMigrationRefused("stats.db", "epoch-rebuild")
|
|
477
|
+
if _EPOCH_MISMATCH_ACTIVE:
|
|
478
|
+
raise _cctally_db.StatsEpochMismatchError(
|
|
479
|
+
"stats.db epoch rebuild did not converge to epoch "
|
|
480
|
+
f"{_cctally_core.STATS_INDEX_EPOCH}; refusing to loop. Inspect the "
|
|
481
|
+
"journal and run `cctally db rebuild --db stats`.")
|
|
482
|
+
_EPOCH_MISMATCH_ACTIVE = True
|
|
483
|
+
try:
|
|
484
|
+
maint_fd = _heal_flock_blocking(_cctally_core.STATS_LOCK_MAINTENANCE_PATH)
|
|
485
|
+
try:
|
|
486
|
+
# Locked re-check: a sibling process may have already rebuilt it.
|
|
487
|
+
if _raw_user_version(path) != _cctally_core.STATS_INDEX_EPOCH:
|
|
488
|
+
hw = _cctally_journal.journal_high_water()
|
|
489
|
+
if hw is None or hw[1] == 0:
|
|
490
|
+
raise _cctally_db.StatsEpochMismatchError(
|
|
491
|
+
f"stats.db is at index epoch {_raw_user_version(path)}, "
|
|
492
|
+
f"but this cctally builds epoch "
|
|
493
|
+
f"{_cctally_core.STATS_INDEX_EPOCH} and no journal is "
|
|
494
|
+
"present to rebuild from. The journal/ directory is the "
|
|
495
|
+
"durable source — restore it from backup, then run "
|
|
496
|
+
"`cctally db rebuild --db stats`.")
|
|
497
|
+
# Preserve the version-ahead DB (nothing deleted), then rebuild a
|
|
498
|
+
# fresh epoch index into the now-absent destination.
|
|
499
|
+
_cctally_db.quarantine_db_family(path)
|
|
500
|
+
_cctally_journal.rebuild_stats_index()
|
|
501
|
+
finally:
|
|
502
|
+
_heal_release_flock(maint_fd)
|
|
503
|
+
return _cctally_core.open_db()
|
|
504
|
+
finally:
|
|
505
|
+
_EPOCH_MISMATCH_ACTIVE = False
|