cctally 1.94.2 → 1.95.1
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 +29 -0
- package/bin/_cctally_core.py +28 -24
- package/bin/_cctally_dashboard_share.py +6 -0
- package/bin/_cctally_db.py +198 -70
- package/bin/_cctally_journal.py +164 -288
- package/bin/_cctally_record.py +9 -0
- package/bin/_cctally_store.py +460 -292
- package/bin/_lib_doctor.py +2 -2
- package/bin/_lib_pricing.py +26 -7
- package/dashboard/static/assets/index-BvCbpJJA.css +1 -0
- package/dashboard/static/assets/{index-CaUQziq_.js → index-CRZMxeYI.js} +55 -55
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DUQjFSX7.css +0 -1
package/bin/_cctally_store.py
CHANGED
|
@@ -40,9 +40,10 @@ provider flocks (Claude → Codex) → SQLite transactions → ``journal.lock``
|
|
|
40
40
|
flock; no SQLite write transaction ever spans a flock acquisition.
|
|
41
41
|
|
|
42
42
|
**Raw-connect escape hatches stay OUT of this module by design** (spec §6.1):
|
|
43
|
-
|
|
44
|
-
deliberately bypass ``open_index``/``open_db`` so they carry no
|
|
45
|
-
migration side effects on maintenance paths.
|
|
43
|
+
the cache checkpoint's ``mode=rw`` connect and ``db vacuum``'s exclusive
|
|
44
|
+
connect deliberately bypass ``open_index``/``open_db`` so they carry no
|
|
45
|
+
schema-apply / migration side effects on maintenance paths. The retired stats
|
|
46
|
+
checkpoint returns before any raw connect.
|
|
46
47
|
|
|
47
48
|
**#386 narrowed that carve-out for stats.** Skipping the *schema apply* is not
|
|
48
49
|
the same as skipping the *opener protocol*: spec §3.1's third clause requires
|
|
@@ -79,11 +80,12 @@ import _cctally_db
|
|
|
79
80
|
# §6.1 policy table
|
|
80
81
|
# --------------------------------------------------------------------------
|
|
81
82
|
|
|
82
|
-
#
|
|
83
|
-
# (
|
|
84
|
-
#
|
|
83
|
+
# Journal size limits (spec §6.1). The stats setting applies to rollback
|
|
84
|
+
# journaling (DELETE removes the transient journal after a clean transaction);
|
|
85
|
+
# the cache value remains a persistent WAL cap.
|
|
86
|
+
# Duplicated here so the store module does not import _cctally_cache (which
|
|
85
87
|
# loads this module — that would be a cycle).
|
|
86
|
-
|
|
88
|
+
_STATS_JOURNAL_SIZE_LIMIT_BYTES = 16 * 1024 * 1024 # 16 MiB
|
|
87
89
|
_CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 128 MiB
|
|
88
90
|
|
|
89
91
|
|
|
@@ -91,8 +93,8 @@ _CACHE_WAL_SIZE_LIMIT_BYTES = 128 * 1024 * 1024 # 128 MiB
|
|
|
91
93
|
class StorePolicy:
|
|
92
94
|
"""The §6.1 connection policy for one store."""
|
|
93
95
|
|
|
94
|
-
journal_mode: str # "WAL"
|
|
95
|
-
synchronous: str # "NORMAL"
|
|
96
|
+
journal_mode: str # "DELETE" | "WAL"
|
|
97
|
+
synchronous: str # "FULL" | "NORMAL"
|
|
96
98
|
busy_timeout: int # milliseconds
|
|
97
99
|
journal_size_limit: int # bytes
|
|
98
100
|
auto_vacuum: str | None # "INCREMENTAL", or None to leave unset
|
|
@@ -105,8 +107,8 @@ STORE_POLICY: dict[str, StorePolicy] = {
|
|
|
105
107
|
# from the first epoch rebuild — a populated DB needs a full VACUUM to
|
|
106
108
|
# change modes, so it is deliberately NOT applied at in-place cutover.
|
|
107
109
|
"stats": StorePolicy(
|
|
108
|
-
journal_mode="
|
|
109
|
-
journal_size_limit=
|
|
110
|
+
journal_mode="DELETE", synchronous="FULL", busy_timeout=15000,
|
|
111
|
+
journal_size_limit=_STATS_JOURNAL_SIZE_LIMIT_BYTES, auto_vacuum=None,
|
|
110
112
|
row_factory="row", uri=False,
|
|
111
113
|
),
|
|
112
114
|
"cache": StorePolicy(
|
|
@@ -170,7 +172,7 @@ def apply_policy(conn: sqlite3.Connection, store: str) -> None:
|
|
|
170
172
|
"""Apply the §6.1 PRAGMA policy for ``store`` to an open connection.
|
|
171
173
|
|
|
172
174
|
``auto_vacuum`` (when set) is emitted first: it only takes effect before the
|
|
173
|
-
first page is written, so it must precede ``journal_mode
|
|
175
|
+
first page is written, so it must precede ``journal_mode`` / any DDL.
|
|
174
176
|
"""
|
|
175
177
|
policy = STORE_POLICY[store]
|
|
176
178
|
if policy.auto_vacuum is not None:
|
|
@@ -199,7 +201,7 @@ STATS_FAMILY_MODE = 0o600
|
|
|
199
201
|
|
|
200
202
|
|
|
201
203
|
def _harden_stats_family(path) -> None:
|
|
202
|
-
"""Best-effort `0600` on a stats database and
|
|
204
|
+
"""Best-effort `0600` on a stats database and SQLite sidecars.
|
|
203
205
|
|
|
204
206
|
**This closes a defense-in-depth inconsistency, not a live exposure**
|
|
205
207
|
(§9.1). `ensure_dirs()` chmods the data directory to `0700`, so no other
|
|
@@ -214,8 +216,8 @@ def _harden_stats_family(path) -> None:
|
|
|
214
216
|
installs a new inode behind a memoized path. The comparison is what avoids
|
|
215
217
|
the repeated `chmod`: an `lstat` is cheap, and a `chmod` on an
|
|
216
218
|
already-correct file is the cost being avoided. The statusline opens the
|
|
217
|
-
index at three sites, so the steady state is
|
|
218
|
-
render and zero `chmod` calls.
|
|
219
|
+
index at three sites, so the steady state is twelve family-member `lstat`
|
|
220
|
+
calls per render and zero `chmod` calls.
|
|
219
221
|
|
|
220
222
|
The `lstat` does not follow symlinks, and a member that IS a symlink is
|
|
221
223
|
left alone rather than chmod'd through. This function never unlinks and
|
|
@@ -226,7 +228,7 @@ def _harden_stats_family(path) -> None:
|
|
|
226
228
|
`ENOENT`.
|
|
227
229
|
"""
|
|
228
230
|
base = str(path)
|
|
229
|
-
for member in (base, base + "-wal", base + "-shm"):
|
|
231
|
+
for member in (base, base + "-journal", base + "-wal", base + "-shm"):
|
|
230
232
|
try:
|
|
231
233
|
info = os.lstat(member)
|
|
232
234
|
except OSError:
|
|
@@ -1142,7 +1144,7 @@ def _resume_pending_quarantine(db_path: pathlib.Path) -> None:
|
|
|
1142
1144
|
|
|
1143
1145
|
_STATS_REBUILD_ARTIFACT_RE = re.compile(
|
|
1144
1146
|
r"^(?P<base>stats\.db\.rebuilding-\d{8}T\d{6}_\d{6})"
|
|
1145
|
-
r"(?P<sidecar>-wal|-shm)?$"
|
|
1147
|
+
r"(?P<sidecar>-journal|-wal|-shm)?$"
|
|
1146
1148
|
)
|
|
1147
1149
|
_STATS_QUARANTINE_INCIDENT_RE = re.compile(
|
|
1148
1150
|
r"^stats\.db-(?:\d{8}T\d{6}Z|\d{8}T\d{6}_\d{6})$"
|
|
@@ -1271,7 +1273,7 @@ def _remove_stale_stats_rebuild_artifacts(
|
|
|
1271
1273
|
) -> None:
|
|
1272
1274
|
"""Remove only exact scratch families, failing loudly on incomplete cleanup."""
|
|
1273
1275
|
for artifact in artifacts:
|
|
1274
|
-
for suffix in ("", "-wal", "-shm"):
|
|
1276
|
+
for suffix in ("", "-journal", "-wal", "-shm"):
|
|
1275
1277
|
candidate = pathlib.Path(f"{artifact}{suffix}")
|
|
1276
1278
|
try:
|
|
1277
1279
|
candidate.unlink()
|
|
@@ -1282,7 +1284,7 @@ def _remove_stale_stats_rebuild_artifacts(
|
|
|
1282
1284
|
leftovers = [
|
|
1283
1285
|
str(pathlib.Path(f"{artifact}{suffix}"))
|
|
1284
1286
|
for artifact in artifacts
|
|
1285
|
-
for suffix in ("", "-wal", "-shm")
|
|
1287
|
+
for suffix in ("", "-journal", "-wal", "-shm")
|
|
1286
1288
|
if pathlib.Path(f"{artifact}{suffix}").exists()
|
|
1287
1289
|
]
|
|
1288
1290
|
if leftovers:
|
|
@@ -1539,8 +1541,8 @@ def stats_open_guarded(
|
|
|
1539
1541
|
raise _cctally_db.StatsDbMaintenanceError()
|
|
1540
1542
|
# #386 enforcement: EVERY stats connection this module hands out
|
|
1541
1543
|
# carries the authorizer. Arming HERE and nowhere else is what
|
|
1542
|
-
# keeps raw `sqlite3.connect` escape hatches (the storm
|
|
1543
|
-
#
|
|
1544
|
+
# keeps raw `sqlite3.connect` escape hatches (such as the storm
|
|
1545
|
+
# suite's legacy `_grow_wal`) unaffected — a
|
|
1544
1546
|
# broader arming point would make their writes unsanctioned and
|
|
1545
1547
|
# the correct fix would then be to narrow the arming, never to
|
|
1546
1548
|
# weaken the guard.
|
|
@@ -1572,9 +1574,8 @@ def _acquire_stats_maintenance_reentrant(path) -> "int | None":
|
|
|
1572
1574
|
indefinitely. ``run_stats_ingest`` holds maintenance SHARED across its entire
|
|
1573
1575
|
cycle, and this helper's caller — the epoch resolver — is reachable from a
|
|
1574
1576
|
nested ``open_db()`` inside that cycle. Without this check that nested open
|
|
1575
|
-
is an unconditional self-deadlock. The corruption
|
|
1576
|
-
|
|
1577
|
-
additionally BOUNDS the acquire.
|
|
1577
|
+
is an unconditional self-deadlock. The corruption detector no longer
|
|
1578
|
+
acquires maintenance; its detached worker starts in a fresh process.
|
|
1578
1579
|
|
|
1579
1580
|
Proceeding on a shared hold is a deliberate, narrow weakening: the caller
|
|
1580
1581
|
still runs ``_stats_family_drained`` before any physical replacement, which
|
|
@@ -1624,54 +1625,6 @@ def _heal_release_maintenance_flock(fd: int) -> None:
|
|
|
1624
1625
|
_heal_release_flock(fd)
|
|
1625
1626
|
|
|
1626
1627
|
|
|
1627
|
-
_HEAL_MAINTENANCE_WAIT_S = 5.0
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
def _acquire_stats_maintenance_for_heal(
|
|
1631
|
-
timeout_s: float = _HEAL_MAINTENANCE_WAIT_S,
|
|
1632
|
-
) -> "tuple[int | None, bool]":
|
|
1633
|
-
"""Ownership-first, mode-aware maintenance for the corruption heal (§6).
|
|
1634
|
-
|
|
1635
|
-
Returns ``(fd, True)`` when the heal may proceed — ``fd`` is ``None`` when
|
|
1636
|
-
an existing hold was REUSED and nothing was acquired — and ``(None,
|
|
1637
|
-
False)`` when the bounded acquire expired.
|
|
1638
|
-
|
|
1639
|
-
**Ownership-first, not mode-first.** ``flock`` conflicts are per open-file-
|
|
1640
|
-
description and apply WITHIN a process, so requesting the lock a second
|
|
1641
|
-
time on a second descriptor blocks this process against itself whenever the
|
|
1642
|
-
hold it already owns is EXCLUSIVE (`_cctally_core` documents that at the
|
|
1643
|
-
maintenance tracker, and ``run_stats_ingest`` can hold exclusive when it
|
|
1644
|
-
calls ``open_db()``). The tracker is a depth counter that records THAT a
|
|
1645
|
-
hold exists and never which mode, and it does not need to: the rule reuses
|
|
1646
|
-
any hold whatever its mode, so the two cases never have to be told apart.
|
|
1647
|
-
Upgrading a shared hold to exclusive is the one operation that would need
|
|
1648
|
-
the mode, and it is exactly the second acquire that deadlocks.
|
|
1649
|
-
|
|
1650
|
-
**Bounded, never blocking, when nothing is held.** The heal runs inside an
|
|
1651
|
-
ordinary open, and the detached worker owns maintenance EXCLUSIVE for the
|
|
1652
|
-
whole of its rebuild. An unbounded acquire here would make every statusline
|
|
1653
|
-
and dashboard open that meets corruption wait out that rebuild — the
|
|
1654
|
-
blocking this architecture exists to remove. A timeout means some OTHER
|
|
1655
|
-
holder owns it, and failing soft is correct: decline, and let a later open
|
|
1656
|
-
retry.
|
|
1657
|
-
"""
|
|
1658
|
-
if _cctally_core.holds_stats_maintenance():
|
|
1659
|
-
return (None, True)
|
|
1660
|
-
fd = _heal_flock_bounded(
|
|
1661
|
-
_cctally_core.STATS_LOCK_MAINTENANCE_PATH, timeout_s
|
|
1662
|
-
)
|
|
1663
|
-
if fd is None:
|
|
1664
|
-
return (None, False)
|
|
1665
|
-
_cctally_core.note_stats_maintenance_acquired()
|
|
1666
|
-
return (fd, True)
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
def _release_stats_maintenance_for_heal(fd: "int | None") -> None:
|
|
1670
|
-
"""Release what ``_acquire_stats_maintenance_for_heal`` took, if anything."""
|
|
1671
|
-
if fd is not None:
|
|
1672
|
-
_heal_release_maintenance_flock(fd)
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
1628
|
def _heal_flock_bounded(path, timeout_s: float) -> "int | None":
|
|
1676
1629
|
"""Bounded EX flock. Returns the HELD fd, or ``None`` on timeout.
|
|
1677
1630
|
|
|
@@ -1752,6 +1705,51 @@ def _stats_family_drained(path) -> "str | None":
|
|
|
1752
1705
|
return None
|
|
1753
1706
|
|
|
1754
1707
|
|
|
1708
|
+
def _stats_header_declares_wal(path) -> bool:
|
|
1709
|
+
"""Whether the on-disk SQLite header declares WAL read/write versions.
|
|
1710
|
+
|
|
1711
|
+
This deliberately reads raw bytes instead of opening SQLite. Opening a
|
|
1712
|
+
previous-epoch WAL family is already enough to materialize SHM, and the
|
|
1713
|
+
#538 transition must prove every old handle is gone before it asks SQLite
|
|
1714
|
+
to convert that family to rollback journaling.
|
|
1715
|
+
"""
|
|
1716
|
+
try:
|
|
1717
|
+
with pathlib.Path(path).open("rb") as handle:
|
|
1718
|
+
header = handle.read(20)
|
|
1719
|
+
except OSError:
|
|
1720
|
+
return False
|
|
1721
|
+
return (
|
|
1722
|
+
len(header) == 20
|
|
1723
|
+
and header[:16] == b"SQLite format 3\x00"
|
|
1724
|
+
and (header[18] == 2 or header[19] == 2)
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _stats_legacy_wal_family_present(path) -> bool:
|
|
1729
|
+
"""Whether `path` still carries any part of the retired stats WAL mode."""
|
|
1730
|
+
path = pathlib.Path(path)
|
|
1731
|
+
return (
|
|
1732
|
+
_stats_header_declares_wal(path)
|
|
1733
|
+
or pathlib.Path(f"{path}-wal").exists()
|
|
1734
|
+
or pathlib.Path(f"{path}-shm").exists()
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def _require_stats_legacy_wal_cold(path) -> None:
|
|
1739
|
+
"""Fail before opening/converting a previous-epoch stats WAL family."""
|
|
1740
|
+
if not _stats_legacy_wal_family_present(path):
|
|
1741
|
+
return
|
|
1742
|
+
blocked = _stats_family_drained(path)
|
|
1743
|
+
if blocked is None:
|
|
1744
|
+
return
|
|
1745
|
+
raise _cctally_db.StatsEpochMismatchError(
|
|
1746
|
+
"stats.db still uses the retired WAL format and cannot cross the "
|
|
1747
|
+
f"rollback-journal epoch while its old family is live ({blocked}). "
|
|
1748
|
+
"Stop and restart the dashboard and every older cctally process, then "
|
|
1749
|
+
"retry; the existing stats.db/WAL/SHM bytes were left untouched."
|
|
1750
|
+
)
|
|
1751
|
+
|
|
1752
|
+
|
|
1755
1753
|
def _heal_release_flock(fd: int) -> None:
|
|
1756
1754
|
try:
|
|
1757
1755
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
@@ -1806,12 +1804,14 @@ def _stats_heal_hook(
|
|
|
1806
1804
|
*,
|
|
1807
1805
|
post_query: bool = False,
|
|
1808
1806
|
) -> bool:
|
|
1809
|
-
"""
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
``DatabaseError`` (BUSY / disk-full / permission), the dev-
|
|
1813
|
-
|
|
1814
|
-
``
|
|
1807
|
+
"""Admit classifier-gated stats corruption recovery without maintenance.
|
|
1808
|
+
|
|
1809
|
+
False means the trigger is ineligible before admission: a non-corruption
|
|
1810
|
+
``DatabaseError`` (BUSY / disk-full / permission), the dev-on-prod guard,
|
|
1811
|
+
re-entrancy, or no journal. An eligible trigger durably elects or coalesces
|
|
1812
|
+
onto one detached worker and raises ``StatsHealDeferred`` immediately. The
|
|
1813
|
+
worker owns the maintenance wait, authoritative probe, forensics and
|
|
1814
|
+
classifier confirmation before any rebuild.
|
|
1815
1815
|
|
|
1816
1816
|
It can also RAISE ``_cctally_db.StatsPublicationFailedError`` (#496 S1 F1),
|
|
1817
1817
|
and both callers depend on that: ``_cctally_tui._tui_heal_post_query_stats``
|
|
@@ -1845,136 +1845,61 @@ def _stats_heal_hook(
|
|
|
1845
1845
|
return False
|
|
1846
1846
|
_HEAL_ACTIVE = True
|
|
1847
1847
|
try:
|
|
1848
|
-
#
|
|
1849
|
-
#
|
|
1850
|
-
#
|
|
1851
|
-
#
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1848
|
+
# #530: admission is the FIRST contended operation. The detector never
|
|
1849
|
+
# asks for maintenance — even non-blockingly — because a rotating set of
|
|
1850
|
+
# shared hook owners can otherwise make every caller lose before a
|
|
1851
|
+
# durable request exists. The elected fresh worker owns the authoritative
|
|
1852
|
+
# maintenance wait, classifier probe, forensics and rebuild.
|
|
1853
|
+
request = _build_stats_heal_request(
|
|
1854
|
+
exc, None, post_query=post_query, high_water=hw,
|
|
1855
|
+
)
|
|
1856
|
+
admission_event = {
|
|
1857
|
+
**build_stats_heal_event(request, "pending-classification"),
|
|
1858
|
+
"outcome": "admitted",
|
|
1859
|
+
"coalescedDetections": 0,
|
|
1860
|
+
}
|
|
1861
|
+
reservation, elected_request = _reserve_stats_corruption_heal(
|
|
1862
|
+
request, admission_event=admission_event,
|
|
1863
|
+
)
|
|
1864
|
+
if reservation == "reserved":
|
|
1865
|
+
active_request = elected_request or request
|
|
1866
|
+
elif reservation == "pending":
|
|
1867
|
+
# The elected request and coalesced count were read and updated
|
|
1868
|
+
# while admission was still held. Never reconstruct identity from
|
|
1869
|
+
# a marker that the worker may not have written yet or may already
|
|
1870
|
+
# have removed.
|
|
1871
|
+
active_request = elected_request or request
|
|
1872
|
+
else:
|
|
1873
|
+
active_request = request
|
|
1874
|
+
|
|
1875
|
+
# Spawn under no lock. A worker is launched only for the elected
|
|
1876
|
+
# request; pending callers coalesce and failed admission stays retryable.
|
|
1877
|
+
outcome = complete_stats_corruption_heal(reservation)
|
|
1878
|
+
# F15 (#496 S3 §7). Detachment supplies the timing for free: report at
|
|
1879
|
+
# admission, naming the elected heal id. Forensics now belongs to the
|
|
1880
|
+
# detached owner so it can capture evidence only after maintenance has
|
|
1881
|
+
# drained and before any publication mutation.
|
|
1882
|
+
bundle = active_request.get("forensicsPath")
|
|
1883
|
+
if outcome == "failed":
|
|
1859
1884
|
print(
|
|
1860
|
-
"[heal] stats.db
|
|
1861
|
-
"
|
|
1862
|
-
"retry.",
|
|
1885
|
+
f"[heal] stats.db is corrupt ({exc}); nothing was replaced by "
|
|
1886
|
+
"this command. Recovery could not be durably admitted; a "
|
|
1887
|
+
"later open will retry.",
|
|
1863
1888
|
file=sys.stderr,
|
|
1864
1889
|
)
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
# decision is terminal. A decline writes no marker and needs no bridge,
|
|
1871
|
-
# because nothing will act on the bundle later.
|
|
1872
|
-
retention = contextlib.ExitStack()
|
|
1873
|
-
try:
|
|
1874
|
-
probe = _probe_stats_integrity_ok if post_query else _probe_stats_ok
|
|
1875
|
-
if probe(path):
|
|
1876
|
-
return True # a sibling process already healed it — retry the open
|
|
1877
|
-
retention.enter_context(
|
|
1878
|
-
_cctally_retention.retention_shared(label="stats auto-heal")
|
|
1879
|
-
)
|
|
1880
|
-
# Forensics FIRST — before anything disturbs the evidence. The
|
|
1881
|
-
# trigger pair is what arms the #496 S1 forensics-time WAL capture
|
|
1882
|
-
# and what lets the quarantine incident name the bundle that
|
|
1883
|
-
# preceded it.
|
|
1884
|
-
#
|
|
1885
|
-
forensics = _cctally_db.write_corruption_forensics(
|
|
1886
|
-
path,
|
|
1887
|
-
db_label="stats",
|
|
1888
|
-
trigger_origin="corruption-heal",
|
|
1889
|
-
trigger_exception=exc,
|
|
1890
|
-
return_result=True,
|
|
1891
|
-
)
|
|
1892
|
-
request = _build_stats_heal_request(
|
|
1893
|
-
exc, forensics, post_query=post_query, high_water=hw,
|
|
1890
|
+
else:
|
|
1891
|
+
evidence = (
|
|
1892
|
+
f"Forensics: {bundle}."
|
|
1893
|
+
if bundle
|
|
1894
|
+
else "Forensics will be captured by that owner before recovery."
|
|
1894
1895
|
)
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
forensics is not None
|
|
1902
|
-
and forensics.disposition
|
|
1903
|
-
is _cctally_db.CorruptionProbeDisposition.CONFIRMED
|
|
1904
|
-
and forensics.path is not None
|
|
1896
|
+
print(
|
|
1897
|
+
f"[heal] stats.db is corrupt ({exc}); nothing was replaced by "
|
|
1898
|
+
f"this command. Recovery was admitted to run in the background "
|
|
1899
|
+
f"as heal {active_request.get('healId') or request['healId']}. "
|
|
1900
|
+
f"{evidence}",
|
|
1901
|
+
file=sys.stderr,
|
|
1905
1902
|
)
|
|
1906
|
-
if not confirmed:
|
|
1907
|
-
bundle = (
|
|
1908
|
-
str(forensics.path)
|
|
1909
|
-
if forensics is not None and forensics.path is not None
|
|
1910
|
-
else "unavailable"
|
|
1911
|
-
)
|
|
1912
|
-
reason = (
|
|
1913
|
-
forensics.reason if forensics is not None else "unavailable"
|
|
1914
|
-
)
|
|
1915
|
-
append_stats_heal_event({
|
|
1916
|
-
**build_stats_heal_event(request, "unconfirmed"),
|
|
1917
|
-
"outcome": "declined-unconfirmed",
|
|
1918
|
-
"declineReason": reason,
|
|
1919
|
-
})
|
|
1920
|
-
print(
|
|
1921
|
-
"[heal] stats.db auto-heal declined for classified "
|
|
1922
|
-
f"trigger: corruption was not confirmed ({reason}; "
|
|
1923
|
-
f"forensics: {bundle}); leaving the stats.db file family "
|
|
1924
|
-
"untouched.",
|
|
1925
|
-
file=sys.stderr,
|
|
1926
|
-
)
|
|
1927
|
-
return False
|
|
1928
|
-
append_stats_heal_event(build_stats_heal_event(request, "confirmed"))
|
|
1929
|
-
# Phase 1: reserve and persist, UNDER maintenance and the shared
|
|
1930
|
-
# retention hold. The marker is durable when this returns
|
|
1931
|
-
# `reserved`, which is what closes the bundle-to-request gap.
|
|
1932
|
-
reservation = reserve_stats_corruption_heal(request)
|
|
1933
|
-
if reservation != "reserved":
|
|
1934
|
-
# #496 S6 §5.3, the SECOND terminal decision. The shared hold
|
|
1935
|
-
# is about to be released over a forensics bundle the durable
|
|
1936
|
-
# request marker does not name — it names the earlier
|
|
1937
|
-
# detection's bundle. That is legitimate for the same reason a
|
|
1938
|
-
# decline is: nothing will act on THIS bundle later. Rewriting
|
|
1939
|
-
# the marker to name it instead would be worse, because a
|
|
1940
|
-
# worker that has already read the marker would attribute its
|
|
1941
|
-
# incident to a bundle it never captured, and the record would
|
|
1942
|
-
# mix two detections. §3.3 then governs the stranded bundle as
|
|
1943
|
-
# an unreferenced one, which self-classifies from its own
|
|
1944
|
-
# `trigger.origin` and is reclaimable under the ordinary
|
|
1945
|
-
# bounds. Terminalizing it here, inside the hold, is what makes
|
|
1946
|
-
# the decision durable before the hold drops, and stops the
|
|
1947
|
-
# ring entry sitting at `detected` forever.
|
|
1948
|
-
update_stats_heal_event(
|
|
1949
|
-
request["healId"],
|
|
1950
|
-
outcome=(
|
|
1951
|
-
"coalesced" if reservation == "pending"
|
|
1952
|
-
else "admission-failed"
|
|
1953
|
-
),
|
|
1954
|
-
)
|
|
1955
|
-
finally:
|
|
1956
|
-
# Retention is released first: it sits BELOW maintenance in the
|
|
1957
|
-
# lock order, so it must not outlive the hold it was taken under.
|
|
1958
|
-
retention.close()
|
|
1959
|
-
# Maintenance released BEFORE spawning: the worker takes it
|
|
1960
|
-
# EXCLUSIVE as a fresh process holding nothing, and a caller still
|
|
1961
|
-
# holding it here would make that acquire wait for a request it is
|
|
1962
|
-
# itself in the middle of filing.
|
|
1963
|
-
_release_stats_maintenance_for_heal(maint_fd)
|
|
1964
|
-
# Phase 3: spawn, under no lock at all.
|
|
1965
|
-
outcome = complete_stats_corruption_heal(reservation)
|
|
1966
|
-
# F15 (#496 S3 §7). Detachment supplies the timing for free: report at
|
|
1967
|
-
# DETECTION, naming the absolute forensics path and the heal id. It
|
|
1968
|
-
# cannot name an incident path, because the quarantine directory is
|
|
1969
|
-
# allocated only during preservation, after the worker has chosen
|
|
1970
|
-
# physical fallback and begun it; the worker adds that to the ring.
|
|
1971
|
-
bundle = request.get("forensicsPath") or "unavailable"
|
|
1972
|
-
print(
|
|
1973
|
-
f"[heal] stats.db is corrupt ({exc}); nothing was replaced by this "
|
|
1974
|
-
f"command. A rebuild from the journal was scheduled to run in the "
|
|
1975
|
-
f"background as heal {request['healId']}. Forensics: {bundle}.",
|
|
1976
|
-
file=sys.stderr,
|
|
1977
|
-
)
|
|
1978
1903
|
# Escalation is REPORT-ONLY: no halt, and no throttle beyond the
|
|
1979
1904
|
# admission marker's existing retry interval. Halting auto-heal after
|
|
1980
1905
|
# N occurrences was considered and rejected (§3 Q4).
|
|
@@ -1995,8 +1920,8 @@ def _stats_heal_hook(
|
|
|
1995
1920
|
# partial report.
|
|
1996
1921
|
raise _cctally_db.StatsHealDeferred(
|
|
1997
1922
|
outcome,
|
|
1998
|
-
heal_id=request["healId"],
|
|
1999
|
-
forensics_path=
|
|
1923
|
+
heal_id=str(active_request.get("healId") or request["healId"]),
|
|
1924
|
+
forensics_path=active_request.get("forensicsPath"),
|
|
2000
1925
|
)
|
|
2001
1926
|
except Exception as heal_exc:
|
|
2002
1927
|
print(f"[heal] stats.db auto-heal failed: {heal_exc}", file=sys.stderr)
|
|
@@ -2021,8 +1946,9 @@ HEAL_HOOK = _stats_heal_hook
|
|
|
2021
1946
|
# #496 S3 §6 — the detached corruption heal
|
|
2022
1947
|
# --------------------------------------------------------------------------
|
|
2023
1948
|
#
|
|
2024
|
-
# The hook
|
|
2025
|
-
# rebuild. Admission copies the three layers of
|
|
1949
|
+
# The hook files a preliminary REQUEST; a detached worker captures forensics
|
|
1950
|
+
# and does the rebuild. Admission copies the three layers of
|
|
1951
|
+
# `defer_stats_epoch_rebuild` — a
|
|
2026
1952
|
# non-blocking admission flock whose loser returns immediately, a pending
|
|
2027
1953
|
# marker with a retry window, and a worker-active probe that refreshes the
|
|
2028
1954
|
# marker instead of spawning a duplicate — over its OWN files, so the two
|
|
@@ -2031,8 +1957,9 @@ HEAL_HOOK = _stats_heal_hook
|
|
|
2031
1957
|
# The epoch path's marker is an empty touched file. This one is a durable JSON
|
|
2032
1958
|
# document, because the worker runs later and in another process and needs
|
|
2033
1959
|
# facts the hook established at detection: the heal id that correlates the
|
|
2034
|
-
# durable event record, the trigger evidence, the
|
|
2035
|
-
#
|
|
1960
|
+
# durable event record, the trigger evidence, the journal information to
|
|
1961
|
+
# revalidate, and — load-bearing — WHICH PROBE to run. The elected worker
|
|
1962
|
+
# enriches that same marker with its forensics bundle before publication.
|
|
2036
1963
|
# A `post_query` detection was established by a failed `quick_check` against a
|
|
2037
1964
|
# file SQLite opens happily, so a worker that always used the cheap readability
|
|
2038
1965
|
# probe would exit on exactly the readable-but-corrupt population this
|
|
@@ -2041,6 +1968,8 @@ HEAL_HOOK = _stats_heal_hook
|
|
|
2041
1968
|
STATS_CORRUPTION_HEAL_COMMAND = "_stats-corruption-heal"
|
|
2042
1969
|
_STATS_HEAL_RETRY_SECONDS = 60.0
|
|
2043
1970
|
_STATS_HEAL_WORKER_MAINTENANCE_WAIT_S = 120.0
|
|
1971
|
+
_STATS_HEAL_ADMISSION_WAIT_S = 0.75
|
|
1972
|
+
_STATS_HEAL_ADMISSION_RING_WAIT_S = 0.25
|
|
2044
1973
|
_STATS_HEAL_PROBE_INTEGRITY = "integrity"
|
|
2045
1974
|
_STATS_HEAL_PROBE_READABILITY = "readability"
|
|
2046
1975
|
|
|
@@ -2096,6 +2025,8 @@ def _build_stats_heal_request(
|
|
|
2096
2025
|
exc, _cctally_db._FORENSICS_EXCEPTION_MESSAGE_MAX
|
|
2097
2026
|
),
|
|
2098
2027
|
"triggerType": type(exc).__name__,
|
|
2028
|
+
"triggerSqliteErrorCode": getattr(exc, "sqlite_errorcode", None),
|
|
2029
|
+
"triggerSqliteErrorName": getattr(exc, "sqlite_errorname", None),
|
|
2099
2030
|
"forensicsPath": (
|
|
2100
2031
|
str(forensics.path)
|
|
2101
2032
|
if forensics is not None and forensics.path is not None
|
|
@@ -2112,6 +2043,62 @@ def _build_stats_heal_request(
|
|
|
2112
2043
|
}
|
|
2113
2044
|
|
|
2114
2045
|
|
|
2046
|
+
def _stats_heal_trigger_exception(request: dict) -> BaseException:
|
|
2047
|
+
"""Reconstitute the classified SQLite trigger for worker-side forensics."""
|
|
2048
|
+
exc_type = getattr(
|
|
2049
|
+
sqlite3, str(request.get("triggerType") or "DatabaseError"),
|
|
2050
|
+
sqlite3.DatabaseError,
|
|
2051
|
+
)
|
|
2052
|
+
if not isinstance(exc_type, type) or not issubclass(
|
|
2053
|
+
exc_type, sqlite3.DatabaseError
|
|
2054
|
+
):
|
|
2055
|
+
exc_type = sqlite3.DatabaseError
|
|
2056
|
+
exc = exc_type(str(request.get("triggerError") or "database is malformed"))
|
|
2057
|
+
for attr, key in (
|
|
2058
|
+
("sqlite_errorcode", "triggerSqliteErrorCode"),
|
|
2059
|
+
("sqlite_errorname", "triggerSqliteErrorName"),
|
|
2060
|
+
):
|
|
2061
|
+
value = request.get(key)
|
|
2062
|
+
if value is not None:
|
|
2063
|
+
try:
|
|
2064
|
+
setattr(exc, attr, value)
|
|
2065
|
+
except (AttributeError, TypeError):
|
|
2066
|
+
pass
|
|
2067
|
+
return exc
|
|
2068
|
+
|
|
2069
|
+
|
|
2070
|
+
def _persist_stats_heal_request(request: dict) -> bool:
|
|
2071
|
+
"""Durably enrich the elected request without changing its identity.
|
|
2072
|
+
|
|
2073
|
+
The worker calls this while holding maintenance, ingest and retention
|
|
2074
|
+
SHARED, after forensics and before any publication mutation. A crash after
|
|
2075
|
+
the write therefore leaves the durable marker naming the evidence bundle;
|
|
2076
|
+
a failed write declines recovery rather than stranding an unreferenced
|
|
2077
|
+
bundle and proceeding destructively.
|
|
2078
|
+
"""
|
|
2079
|
+
fd = _heal_flock_bounded(_stats_heal_admission_path(), 10.0)
|
|
2080
|
+
if fd is None:
|
|
2081
|
+
return False
|
|
2082
|
+
try:
|
|
2083
|
+
current = _read_stats_heal_request()
|
|
2084
|
+
if not current or current.get("healId") != request.get("healId"):
|
|
2085
|
+
return False
|
|
2086
|
+
# Coalescers update the preliminary marker under admission. Preserve
|
|
2087
|
+
# their newest count when the worker enriches its older in-memory copy.
|
|
2088
|
+
request["coalescedDetections"] = int(
|
|
2089
|
+
current.get("coalescedDetections") or 0
|
|
2090
|
+
)
|
|
2091
|
+
try:
|
|
2092
|
+
_cctally_db._atomic_write_private_json(
|
|
2093
|
+
_stats_heal_marker_path(), request
|
|
2094
|
+
)
|
|
2095
|
+
except OSError:
|
|
2096
|
+
return False
|
|
2097
|
+
return True
|
|
2098
|
+
finally:
|
|
2099
|
+
_heal_release_flock(fd)
|
|
2100
|
+
|
|
2101
|
+
|
|
2115
2102
|
def _read_stats_heal_request() -> "dict | None":
|
|
2116
2103
|
try:
|
|
2117
2104
|
payload = json.loads(_stats_heal_marker_path().read_text())
|
|
@@ -2189,68 +2176,126 @@ def _log_stats_heal(
|
|
|
2189
2176
|
pass
|
|
2190
2177
|
|
|
2191
2178
|
|
|
2192
|
-
def
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
lock order is satisfiable at all. Reservation — the admission flock, the
|
|
2197
|
-
marker-age check, the worker-active probe and the fsynced marker write —
|
|
2198
|
-
must happen while the caller still holds stats maintenance and the SHARED
|
|
2199
|
-
retention lock, because the marker is what bridges the parent-to-worker
|
|
2200
|
-
process boundary and tells reclamation that the forensics bundle it names
|
|
2201
|
-
is still live. The spawn must happen AFTER maintenance is released,
|
|
2202
|
-
because the worker takes maintenance exclusive as a fresh process.
|
|
2179
|
+
def _reserve_stats_corruption_heal(
|
|
2180
|
+
request: dict, *, admission_event: "dict | None" = None,
|
|
2181
|
+
) -> "tuple[str, dict | None]":
|
|
2182
|
+
"""Elect one recovery owner before any maintenance acquisition.
|
|
2203
2183
|
|
|
2204
|
-
The
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2184
|
+
The preliminary marker bridges detector to worker and contains no
|
|
2185
|
+
forensics path yet. When supplied, ``admission_event`` is appended before
|
|
2186
|
+
the admission flock is released, so a coalescer cannot observe the marker
|
|
2187
|
+
before the one occurrence event it increments exists. The worker later
|
|
2188
|
+
captures forensics under maintenance/ingest/retention and durably enriches
|
|
2189
|
+
the same marker before any publication mutation.
|
|
2209
2190
|
|
|
2210
|
-
Returns
|
|
2211
|
-
|
|
2191
|
+
Returns ``(status, elected_request)``. The elected identity is read and any
|
|
2192
|
+
coalesced count is persisted while this function still owns admission; a
|
|
2193
|
+
caller never has to race a later marker read. Admission is bounded well
|
|
2194
|
+
below the old five-second maintenance wait. It succeeds only when both the
|
|
2195
|
+
marker and its required occurrence event are durable.
|
|
2212
2196
|
"""
|
|
2213
2197
|
try:
|
|
2214
2198
|
pathlib.Path(_cctally_core.APP_DIR).mkdir(parents=True, exist_ok=True)
|
|
2215
|
-
admission_fd =
|
|
2216
|
-
_stats_heal_admission_path(),
|
|
2199
|
+
admission_fd = _heal_flock_bounded(
|
|
2200
|
+
_stats_heal_admission_path(), _STATS_HEAL_ADMISSION_WAIT_S
|
|
2217
2201
|
)
|
|
2218
2202
|
except OSError:
|
|
2219
|
-
return "failed"
|
|
2203
|
+
return ("failed", None)
|
|
2204
|
+
if admission_fd is None:
|
|
2205
|
+
return ("failed", None)
|
|
2220
2206
|
try:
|
|
2221
|
-
try:
|
|
2222
|
-
fcntl.flock(admission_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
2223
|
-
except OSError:
|
|
2224
|
-
return "pending"
|
|
2225
2207
|
marker = _stats_heal_marker_path()
|
|
2226
2208
|
try:
|
|
2227
2209
|
age = time.time() - marker.stat().st_mtime
|
|
2228
2210
|
except FileNotFoundError:
|
|
2229
2211
|
age = None
|
|
2230
2212
|
except OSError:
|
|
2231
|
-
return "failed"
|
|
2213
|
+
return ("failed", None)
|
|
2232
2214
|
if age is not None and age < _STATS_HEAL_RETRY_SECONDS:
|
|
2233
|
-
|
|
2215
|
+
elected = _read_stats_heal_request()
|
|
2216
|
+
if not elected:
|
|
2217
|
+
return ("failed", None)
|
|
2218
|
+
elected = dict(elected)
|
|
2219
|
+
elected["coalescedDetections"] = int(
|
|
2220
|
+
elected.get("coalescedDetections") or 0
|
|
2221
|
+
) + 1
|
|
2222
|
+
try:
|
|
2223
|
+
_cctally_db._atomic_write_private_json(marker, elected)
|
|
2224
|
+
except OSError:
|
|
2225
|
+
return ("failed", None)
|
|
2226
|
+
# The marker is the eventual source of truth for this count. Keep
|
|
2227
|
+
# the in-flight event current when its short prompt-safe ring bound
|
|
2228
|
+
# is available; the worker reconciles it before marker removal.
|
|
2229
|
+
update_stats_heal_event(
|
|
2230
|
+
str(elected.get("healId") or ""),
|
|
2231
|
+
wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
|
|
2232
|
+
coalescedDetections=elected["coalescedDetections"],
|
|
2233
|
+
)
|
|
2234
|
+
return ("pending", elected)
|
|
2234
2235
|
if _stats_heal_worker_active():
|
|
2235
2236
|
# A real rebuild outlives the marker retry interval. Refresh the
|
|
2236
2237
|
# admission stamp instead of launching a process that can only lose
|
|
2237
2238
|
# the worker flock and exit.
|
|
2239
|
+
elected = _read_stats_heal_request()
|
|
2240
|
+
if not elected:
|
|
2241
|
+
return ("failed", None)
|
|
2242
|
+
elected = dict(elected)
|
|
2243
|
+
elected["coalescedDetections"] = int(
|
|
2244
|
+
elected.get("coalescedDetections") or 0
|
|
2245
|
+
) + 1
|
|
2238
2246
|
try:
|
|
2239
|
-
|
|
2247
|
+
_cctally_db._atomic_write_private_json(marker, elected)
|
|
2240
2248
|
except OSError:
|
|
2241
|
-
|
|
2242
|
-
|
|
2249
|
+
return ("failed", None)
|
|
2250
|
+
update_stats_heal_event(
|
|
2251
|
+
str(elected.get("healId") or ""),
|
|
2252
|
+
wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
|
|
2253
|
+
coalescedDetections=elected["coalescedDetections"],
|
|
2254
|
+
)
|
|
2255
|
+
return ("pending", elected)
|
|
2256
|
+
stale_request = _read_stats_heal_request() if age is not None else None
|
|
2257
|
+
if stale_request:
|
|
2258
|
+
if not update_stats_heal_event(
|
|
2259
|
+
str(stale_request.get("healId") or ""),
|
|
2260
|
+
wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
|
|
2261
|
+
outcome="stale-retried",
|
|
2262
|
+
coalescedDetections=int(
|
|
2263
|
+
stale_request.get("coalescedDetections") or 0
|
|
2264
|
+
),
|
|
2265
|
+
):
|
|
2266
|
+
return ("failed", None)
|
|
2267
|
+
admitted = dict(request)
|
|
2268
|
+
admitted["coalescedDetections"] = 0
|
|
2243
2269
|
try:
|
|
2244
|
-
_cctally_db._atomic_write_private_json(marker,
|
|
2270
|
+
_cctally_db._atomic_write_private_json(marker, admitted)
|
|
2245
2271
|
except OSError:
|
|
2246
|
-
return "failed"
|
|
2247
|
-
|
|
2272
|
+
return ("failed", None)
|
|
2273
|
+
# Keep the marker and its one occurrence event inside the same
|
|
2274
|
+
# admission critical section. A coalescing detector cannot observe the
|
|
2275
|
+
# marker before the event it must increment exists.
|
|
2276
|
+
event = admission_event or {
|
|
2277
|
+
**build_stats_heal_event(admitted, "pending-classification"),
|
|
2278
|
+
"outcome": "admitted",
|
|
2279
|
+
"coalescedDetections": 0,
|
|
2280
|
+
}
|
|
2281
|
+
if not append_stats_heal_event(
|
|
2282
|
+
event, wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S
|
|
2283
|
+
):
|
|
2284
|
+
_unlink_stats_heal_marker()
|
|
2285
|
+
return ("failed", None)
|
|
2286
|
+
return ("reserved", admitted)
|
|
2248
2287
|
finally:
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2288
|
+
_heal_release_flock(admission_fd)
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
def reserve_stats_corruption_heal(
|
|
2292
|
+
request: dict, *, admission_event: "dict | None" = None,
|
|
2293
|
+
) -> str:
|
|
2294
|
+
"""Compatibility wrapper returning only the admission status."""
|
|
2295
|
+
status, _elected = _reserve_stats_corruption_heal(
|
|
2296
|
+
request, admission_event=admission_event,
|
|
2297
|
+
)
|
|
2298
|
+
return status
|
|
2254
2299
|
|
|
2255
2300
|
|
|
2256
2301
|
def complete_stats_corruption_heal(reservation: str) -> str:
|
|
@@ -2275,33 +2320,68 @@ def complete_stats_corruption_heal(reservation: str) -> str:
|
|
|
2275
2320
|
return "failed"
|
|
2276
2321
|
|
|
2277
2322
|
|
|
2278
|
-
def _unlink_stats_heal_marker_under_admission() ->
|
|
2323
|
+
def _unlink_stats_heal_marker_under_admission() -> bool:
|
|
2324
|
+
"""Settle a spawn failure with the final count before allowing retry."""
|
|
2325
|
+
admission_fd = _heal_flock_bounded(
|
|
2326
|
+
_stats_heal_admission_path(), _STATS_HEAL_ADMISSION_WAIT_S
|
|
2327
|
+
)
|
|
2328
|
+
if admission_fd is None:
|
|
2329
|
+
return False
|
|
2279
2330
|
try:
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2331
|
+
current = _read_stats_heal_request()
|
|
2332
|
+
if current:
|
|
2333
|
+
heal_id = str(current.get("healId") or "")
|
|
2334
|
+
if not update_stats_heal_event(
|
|
2335
|
+
heal_id,
|
|
2336
|
+
wait_s=_STATS_HEAL_ADMISSION_RING_WAIT_S,
|
|
2337
|
+
outcome="spawn-failed",
|
|
2338
|
+
coalescedDetections=int(
|
|
2339
|
+
current.get("coalescedDetections") or 0
|
|
2340
|
+
),
|
|
2341
|
+
):
|
|
2342
|
+
return False
|
|
2343
|
+
_unlink_stats_heal_marker()
|
|
2344
|
+
return True
|
|
2345
|
+
finally:
|
|
2346
|
+
_heal_release_flock(admission_fd)
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def _finalize_stats_heal_request(heal_id: str) -> bool:
|
|
2350
|
+
"""Reconcile the elected count and remove its marker atomically.
|
|
2351
|
+
|
|
2352
|
+
A coalescer may update the marker after the worker's in-memory request was
|
|
2353
|
+
read. The worker therefore takes admission once more, copies the final
|
|
2354
|
+
count into the already-durable occurrence event, and only then unlinks the
|
|
2355
|
+
marker. If the event cannot be updated, the marker stays retryable.
|
|
2356
|
+
"""
|
|
2357
|
+
fd = _heal_flock_bounded(_stats_heal_admission_path(), 10.0)
|
|
2358
|
+
if fd is None:
|
|
2359
|
+
return False
|
|
2285
2360
|
try:
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2361
|
+
current = _read_stats_heal_request()
|
|
2362
|
+
if not current:
|
|
2363
|
+
return True
|
|
2364
|
+
if str(current.get("healId") or "") != heal_id:
|
|
2365
|
+
return False
|
|
2366
|
+
if not update_stats_heal_event(
|
|
2367
|
+
heal_id,
|
|
2368
|
+
coalescedDetections=int(
|
|
2369
|
+
current.get("coalescedDetections") or 0
|
|
2370
|
+
),
|
|
2371
|
+
):
|
|
2372
|
+
return False
|
|
2290
2373
|
_unlink_stats_heal_marker()
|
|
2374
|
+
return True
|
|
2291
2375
|
finally:
|
|
2292
|
-
|
|
2293
|
-
fcntl.flock(admission_fd, fcntl.LOCK_UN)
|
|
2294
|
-
except OSError:
|
|
2295
|
-
pass
|
|
2296
|
-
os.close(admission_fd)
|
|
2376
|
+
_heal_release_flock(fd)
|
|
2297
2377
|
|
|
2298
2378
|
|
|
2299
2379
|
def defer_stats_corruption_heal(request: dict) -> str:
|
|
2300
2380
|
"""Schedule one retryable detached corruption heal without blocking.
|
|
2301
2381
|
|
|
2302
|
-
The composed form of the two phases above, kept for callers
|
|
2303
|
-
|
|
2304
|
-
|
|
2382
|
+
The composed form of the two phases above, kept for direct callers. The
|
|
2383
|
+
auto-heal hook calls the phases separately so it can attach the admission
|
|
2384
|
+
event and report the elected occurrence.
|
|
2305
2385
|
"""
|
|
2306
2386
|
return complete_stats_corruption_heal(
|
|
2307
2387
|
reserve_stats_corruption_heal(request)
|
|
@@ -2358,22 +2438,73 @@ def _run_stats_corruption_heal(request: dict) -> str:
|
|
|
2358
2438
|
)
|
|
2359
2439
|
if ingest_fd is None:
|
|
2360
2440
|
return "ingest-busy"
|
|
2361
|
-
# #
|
|
2362
|
-
#
|
|
2363
|
-
#
|
|
2441
|
+
# #530 moves the complete evidence/classification phase into the elected
|
|
2442
|
+
# worker. It owns maintenance -> ingest -> retention before capturing
|
|
2443
|
+
# forensics, then durably enriches the request marker before any
|
|
2444
|
+
# publication mutation. The marker is cleared last by the caller.
|
|
2364
2445
|
import _cctally_retention
|
|
2365
2446
|
|
|
2366
2447
|
try:
|
|
2367
|
-
with _cctally_retention.retention_shared(label="stats heal worker")
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2448
|
+
with _cctally_retention.retention_shared(label="stats heal worker"):
|
|
2449
|
+
trigger_exc = _stats_heal_trigger_exception(request)
|
|
2450
|
+
forensics = _cctally_db.write_corruption_forensics(
|
|
2451
|
+
path,
|
|
2452
|
+
db_label="stats",
|
|
2453
|
+
trigger_origin="corruption-heal",
|
|
2454
|
+
trigger_exception=trigger_exc,
|
|
2455
|
+
return_result=True,
|
|
2456
|
+
)
|
|
2457
|
+
enriched = dict(request)
|
|
2458
|
+
enriched["forensicsPath"] = (
|
|
2459
|
+
str(forensics.path)
|
|
2460
|
+
if forensics is not None and forensics.path is not None
|
|
2461
|
+
else None
|
|
2462
|
+
)
|
|
2463
|
+
enriched["forensicsDisposition"] = (
|
|
2464
|
+
forensics.disposition.value
|
|
2465
|
+
if forensics is not None else None
|
|
2466
|
+
)
|
|
2467
|
+
confirmed = (
|
|
2468
|
+
forensics is not None
|
|
2469
|
+
and forensics.disposition
|
|
2470
|
+
is _cctally_db.CorruptionProbeDisposition.CONFIRMED
|
|
2471
|
+
and forensics.path is not None
|
|
2472
|
+
)
|
|
2473
|
+
disposition = "confirmed" if confirmed else "unconfirmed"
|
|
2474
|
+
reason = (
|
|
2475
|
+
forensics.reason if forensics is not None else "unavailable"
|
|
2476
|
+
)
|
|
2477
|
+
if not _persist_stats_heal_request(enriched):
|
|
2478
|
+
update_stats_heal_event(
|
|
2479
|
+
heal_id,
|
|
2480
|
+
disposition=disposition,
|
|
2481
|
+
forensicsPath=enriched.get("forensicsPath"),
|
|
2482
|
+
outcome="request-persist-failed",
|
|
2483
|
+
declineReason="request marker could not be enriched",
|
|
2484
|
+
)
|
|
2485
|
+
return "request-persist-failed"
|
|
2486
|
+
request = enriched
|
|
2487
|
+
update_stats_heal_event(
|
|
2488
|
+
heal_id,
|
|
2489
|
+
disposition=disposition,
|
|
2490
|
+
forensicsPath=request.get("forensicsPath"),
|
|
2491
|
+
coalescedDetections=int(
|
|
2492
|
+
request.get("coalescedDetections") or 0
|
|
2374
2493
|
),
|
|
2375
|
-
|
|
2494
|
+
outcome=("confirmed" if confirmed else "declined-unconfirmed"),
|
|
2495
|
+
declineReason=(None if confirmed else reason),
|
|
2376
2496
|
)
|
|
2497
|
+
if not confirmed:
|
|
2498
|
+
return "declined-unconfirmed"
|
|
2499
|
+
with stats_write_scope("maintenance-heal"):
|
|
2500
|
+
result = _cctally_journal.rebuild_stats_index(
|
|
2501
|
+
context=_cctally_journal.RebuildContext(
|
|
2502
|
+
trigger="corruption-heal",
|
|
2503
|
+
trigger_error=str(request.get("triggerError") or ""),
|
|
2504
|
+
forensics_path=request.get("forensicsPath"),
|
|
2505
|
+
),
|
|
2506
|
+
high_water=high_water,
|
|
2507
|
+
)
|
|
2377
2508
|
finally:
|
|
2378
2509
|
if ingest_fd is not None:
|
|
2379
2510
|
_heal_release_flock(ingest_fd)
|
|
@@ -2418,7 +2549,9 @@ def cmd_stats_corruption_heal_internal(args) -> int:
|
|
|
2418
2549
|
return 0
|
|
2419
2550
|
if outcome != "success":
|
|
2420
2551
|
_record_stats_heal_outcome(heal_id, outcome)
|
|
2421
|
-
|
|
2552
|
+
if not _finalize_stats_heal_request(heal_id):
|
|
2553
|
+
_log_stats_heal("finalization-pending", heal_id=heal_id)
|
|
2554
|
+
return 0
|
|
2422
2555
|
_log_stats_heal(outcome, heal_id=heal_id)
|
|
2423
2556
|
return 0
|
|
2424
2557
|
finally:
|
|
@@ -2545,14 +2678,16 @@ def _write_stats_heal_ring(events: list) -> None:
|
|
|
2545
2678
|
)
|
|
2546
2679
|
|
|
2547
2680
|
|
|
2548
|
-
def _mutate_stats_heal_ring(
|
|
2681
|
+
def _mutate_stats_heal_ring(
|
|
2682
|
+
mutate, *, wait_s: float = _STATS_HEAL_RING_WAIT_S,
|
|
2683
|
+
) -> bool:
|
|
2549
2684
|
"""Read-modify-write the ring under a BOUNDED wait for its lock."""
|
|
2550
2685
|
try:
|
|
2551
2686
|
pathlib.Path(_cctally_core.LOG_DIR).mkdir(parents=True, exist_ok=True)
|
|
2552
2687
|
except OSError:
|
|
2553
2688
|
return False
|
|
2554
2689
|
fd = _heal_flock_bounded(
|
|
2555
|
-
_stats_heal_ring_lock_path(),
|
|
2690
|
+
_stats_heal_ring_lock_path(), wait_s
|
|
2556
2691
|
)
|
|
2557
2692
|
if fd is None:
|
|
2558
2693
|
# Loud, never silent: the ring is the accountability guarantee, so a
|
|
@@ -2577,16 +2712,20 @@ def _mutate_stats_heal_ring(mutate) -> bool:
|
|
|
2577
2712
|
_heal_release_flock(fd)
|
|
2578
2713
|
|
|
2579
2714
|
|
|
2580
|
-
def append_stats_heal_event(
|
|
2715
|
+
def append_stats_heal_event(
|
|
2716
|
+
entry: dict, *, wait_s: float = _STATS_HEAL_RING_WAIT_S,
|
|
2717
|
+
) -> bool:
|
|
2581
2718
|
"""Append one detection entry. Bounded by count, oldest dropped first."""
|
|
2582
2719
|
def mutate(events):
|
|
2583
2720
|
events.append(entry)
|
|
2584
2721
|
return events
|
|
2585
2722
|
|
|
2586
|
-
return _mutate_stats_heal_ring(mutate)
|
|
2723
|
+
return _mutate_stats_heal_ring(mutate, wait_s=wait_s)
|
|
2587
2724
|
|
|
2588
2725
|
|
|
2589
|
-
def update_stats_heal_event(
|
|
2726
|
+
def update_stats_heal_event(
|
|
2727
|
+
heal_id: str, *, wait_s: float = _STATS_HEAL_RING_WAIT_S, **fields,
|
|
2728
|
+
) -> bool:
|
|
2590
2729
|
"""Update the entry MATCHING ``heal_id``, and no other.
|
|
2591
2730
|
|
|
2592
2731
|
Admission coalesces several detections into one run, so an update keyed by
|
|
@@ -2604,6 +2743,24 @@ def update_stats_heal_event(heal_id: str, **fields) -> bool:
|
|
|
2604
2743
|
return events
|
|
2605
2744
|
return None
|
|
2606
2745
|
|
|
2746
|
+
return _mutate_stats_heal_ring(mutate, wait_s=wait_s)
|
|
2747
|
+
|
|
2748
|
+
|
|
2749
|
+
def record_stats_heal_coalesced(heal_id: str) -> bool:
|
|
2750
|
+
"""Count one losing detector on the elected occurrence's single event."""
|
|
2751
|
+
if not heal_id:
|
|
2752
|
+
return False
|
|
2753
|
+
|
|
2754
|
+
def mutate(events):
|
|
2755
|
+
for event in events:
|
|
2756
|
+
if event.get("healId") == heal_id:
|
|
2757
|
+
event["coalescedDetections"] = (
|
|
2758
|
+
int(event.get("coalescedDetections") or 0) + 1
|
|
2759
|
+
)
|
|
2760
|
+
event["updatedAtUtc"] = _cctally_core.now_utc_iso()
|
|
2761
|
+
return events
|
|
2762
|
+
return None
|
|
2763
|
+
|
|
2607
2764
|
return _mutate_stats_heal_ring(mutate)
|
|
2608
2765
|
|
|
2609
2766
|
|
|
@@ -2854,24 +3011,29 @@ def cmd_stats_epoch_rebuild_internal(args) -> int:
|
|
|
2854
3011
|
|
|
2855
3012
|
|
|
2856
3013
|
def _raw_user_version(path) -> int:
|
|
2857
|
-
"""Read
|
|
2858
|
-
|
|
3014
|
+
"""Read the SQLite header's user_version without opening the family.
|
|
3015
|
+
|
|
3016
|
+
A raw read-only SQLite connection is not side-effect-free for a WAL
|
|
3017
|
+
database: it can create SHM/WAL paths and, more importantly, it bypasses
|
|
3018
|
+
the maintenance-shared opener fence. The deferred-epoch probe needs only
|
|
3019
|
+
the fixed big-endian header field, so read exactly that instead.
|
|
3020
|
+
"""
|
|
2859
3021
|
try:
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
except sqlite3.DatabaseError:
|
|
3022
|
+
with pathlib.Path(path).open("rb") as handle:
|
|
3023
|
+
header = handle.read(64)
|
|
3024
|
+
except OSError:
|
|
3025
|
+
return -1
|
|
3026
|
+
if len(header) < 64 or header[:16] != b"SQLite format 3\x00":
|
|
2866
3027
|
return -1
|
|
3028
|
+
return int.from_bytes(header[60:64], "big", signed=False)
|
|
2867
3029
|
|
|
2868
3030
|
|
|
2869
3031
|
def stats_epoch_rebuild_pending(path=None) -> bool:
|
|
2870
3032
|
"""Whether an ordinary live open would require whole-journal replay.
|
|
2871
3033
|
|
|
2872
|
-
Missing indexes are cheap fresh installs.
|
|
3034
|
+
Missing indexes are cheap fresh installs. Invalid headers belong to the
|
|
2873
3035
|
corruption classifier, and legacy indexes take the one-time cutover path.
|
|
2874
|
-
Only a
|
|
3036
|
+
Only a post-legacy main-file header at a non-current epoch is deferrable.
|
|
2875
3037
|
"""
|
|
2876
3038
|
candidate = pathlib.Path(
|
|
2877
3039
|
_cctally_core.DB_PATH if path is None else path
|
|
@@ -2908,6 +3070,12 @@ def resolve_stats_epoch_mismatch():
|
|
|
2908
3070
|
maint_fd = _acquire_stats_maintenance_reentrant(
|
|
2909
3071
|
_cctally_core.STATS_LOCK_MAINTENANCE_PATH)
|
|
2910
3072
|
try:
|
|
3073
|
+
# #538: this is the first operation after maintenance-exclusive
|
|
3074
|
+
# ownership and it performs no SQLite open. A previous-epoch
|
|
3075
|
+
# dashboard may still hold the WAL/SHM mapping; applying the new
|
|
3076
|
+
# DELETE policy before proving a cold family would attempt an
|
|
3077
|
+
# in-place journal-mode conversion under that old handle.
|
|
3078
|
+
_require_stats_legacy_wal_cold(path)
|
|
2911
3079
|
# Locked re-check: a sibling process may have already rebuilt it.
|
|
2912
3080
|
if _raw_user_version(path) != _cctally_core.STATS_INDEX_EPOCH:
|
|
2913
3081
|
hw, journal_has_bytes = (
|