cctally 1.92.0 → 1.92.2
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 +21 -1
- package/README.md +4 -2
- package/bin/_cctally_cache.py +44 -4
- package/bin/_cctally_core.py +56 -7
- package/bin/_cctally_dashboard.py +71 -14
- package/bin/_cctally_dashboard_conversation.py +8 -4
- package/bin/_cctally_db.py +111 -11
- package/bin/_cctally_journal.py +1102 -102
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +2 -2
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +783 -67
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_codex_conversation_query.py +30 -5
- package/bin/_lib_codex_find_projection.py +147 -0
- package/bin/_lib_conversation_dispatch.py +15 -1
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_journal_router.py +234 -0
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +12 -3
- package/dashboard/static/assets/{index-BEzzJtUd.js → index-Dat-mza6.js} +51 -51
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Pure record-routing kernel for the stats rebuild's journal read pass (#496 S4).
|
|
2
|
+
|
|
3
|
+
The rebuild used to materialize the whole journal twice: `_read_range` built a
|
|
4
|
+
list of every raw line, and the decode loop built a second list of every parsed
|
|
5
|
+
record while the first was still referenced. On the maintainer's install that is
|
|
6
|
+
1,954,007 lines and 1.72 GB producing an 8.08 GiB peak, of which the stats fold
|
|
7
|
+
consumes 99,289 records (5.08%).
|
|
8
|
+
|
|
9
|
+
`bin/_cctally_doctor.py`'s conflict scan already established the shape this
|
|
10
|
+
kernel generalizes: retain only what the effective selector consumes and drop
|
|
11
|
+
everything else as it is decoded (#374 review, measured at 4.3 GB of peak RSS
|
|
12
|
+
for an identical result).
|
|
13
|
+
|
|
14
|
+
No I/O and no imports from `_cctally_journal`, so the rules here are unit
|
|
15
|
+
testable without a journal on disk.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: The record types the stats rebuild retains decoded. `resolve_effective_events`
|
|
23
|
+
#: acts only on evt / correction / correction_batch and the protocol-resolution
|
|
24
|
+
#: op; the op-fold stream takes only ops whose kind is in `FOLD_APPLIERS`.
|
|
25
|
+
#: Deliberately identical to `_cctally_doctor._CONFLICT_SCAN_RECORD_TYPES` — the
|
|
26
|
+
#: two must not drift, because both feed the same shared selector.
|
|
27
|
+
RETAINED_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch", "op"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LastSeenAccumulator:
|
|
31
|
+
"""Reproduce `_derive_account_last_seen`'s contribution set from a stream.
|
|
32
|
+
|
|
33
|
+
The rebuild normalizes every record and then takes `_account_of`, which
|
|
34
|
+
reads a top-level ``account`` or an ``account_observe`` op's
|
|
35
|
+
``payload.account_key``. `_normalize_legacy_account_stamp` writes a
|
|
36
|
+
top-level ``account`` ONLY for ``t == "obs"``; a legacy evt or op instead
|
|
37
|
+
gets ``payload.account_key``, which `_account_of` does not read.
|
|
38
|
+
|
|
39
|
+
So exactly three classes contribute, and a provider-wide maximum over every
|
|
40
|
+
legacy line would over-count — advancing `last_seen_utc` from legacy events
|
|
41
|
+
and vendor-tagged budget events that contribute nothing today. The Claude
|
|
42
|
+
legacy bucket is deferred because the cutover account is not known until the
|
|
43
|
+
stream reaches it, at 92.9% of a production journal.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("stamped", "legacy_claude_at", "legacy_codex_at")
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self.stamped: dict = {}
|
|
50
|
+
self.legacy_claude_at = None
|
|
51
|
+
self.legacy_codex_at = None
|
|
52
|
+
|
|
53
|
+
def observe(self, record, provider_of_legacy) -> None:
|
|
54
|
+
"""Fold one record. `provider_of_legacy` is `classify_legacy_provider`."""
|
|
55
|
+
at = record.get("at")
|
|
56
|
+
if not at:
|
|
57
|
+
return
|
|
58
|
+
account = record.get("account")
|
|
59
|
+
if isinstance(account, str) and account:
|
|
60
|
+
self._bump(account, at)
|
|
61
|
+
return
|
|
62
|
+
record_type = record.get("t")
|
|
63
|
+
if record_type == "op":
|
|
64
|
+
payload = record.get("payload") or {}
|
|
65
|
+
if payload.get("kind") == "account_observe":
|
|
66
|
+
key = payload.get("account_key")
|
|
67
|
+
if isinstance(key, str) and key:
|
|
68
|
+
self._bump(key, at)
|
|
69
|
+
return
|
|
70
|
+
if record_type != "obs":
|
|
71
|
+
# A legacy evt normalizes into `payload.account_key`, which
|
|
72
|
+
# `_account_of` ignores. Contributing here would move last-seen.
|
|
73
|
+
return
|
|
74
|
+
provider = provider_of_legacy(record)
|
|
75
|
+
if provider == "claude":
|
|
76
|
+
if self.legacy_claude_at is None or at > self.legacy_claude_at:
|
|
77
|
+
self.legacy_claude_at = at
|
|
78
|
+
elif provider == "codex":
|
|
79
|
+
if self.legacy_codex_at is None or at > self.legacy_codex_at:
|
|
80
|
+
self.legacy_codex_at = at
|
|
81
|
+
|
|
82
|
+
def _bump(self, key: str, at: str) -> None:
|
|
83
|
+
previous = self.stamped.get(key)
|
|
84
|
+
if previous is None or at > previous:
|
|
85
|
+
self.stamped[key] = at
|
|
86
|
+
|
|
87
|
+
def resolve(self, cutover_claude: str, unattributed: str) -> dict:
|
|
88
|
+
"""Apply the deferred legacy buckets and return the final MAX map."""
|
|
89
|
+
out = dict(self.stamped)
|
|
90
|
+
if self.legacy_claude_at is not None:
|
|
91
|
+
previous = out.get(cutover_claude)
|
|
92
|
+
if previous is None or self.legacy_claude_at > previous:
|
|
93
|
+
out[cutover_claude] = self.legacy_claude_at
|
|
94
|
+
if self.legacy_codex_at is not None:
|
|
95
|
+
previous = out.get(unattributed)
|
|
96
|
+
if previous is None or self.legacy_codex_at > previous:
|
|
97
|
+
out[unattributed] = self.legacy_codex_at
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PrefixEvidenceUnavailable(LookupError):
|
|
102
|
+
"""A protocol-evidence digest was asked for a prefix the stream dropped.
|
|
103
|
+
|
|
104
|
+
Never expected: an evidence point is always the end of the line immediately
|
|
105
|
+
preceding a `journal_protocol_resolution` op, so it is either inside the
|
|
106
|
+
segment being streamed or the boundary registered at the last segment
|
|
107
|
+
transition. Raised rather than silently degraded, because the alternative is
|
|
108
|
+
a rebuild that quietly disagrees with `journal_prefix_hash`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PrefixHashAccumulator:
|
|
113
|
+
"""`journal_prefix_hash` computed from the bytes the rebuild already read.
|
|
114
|
+
|
|
115
|
+
`journal_prefix_hash(prior_high_water)` does `path.read_bytes()[:size]` on
|
|
116
|
+
every segment through the prefix, so one `journal_protocol_resolution` op
|
|
117
|
+
re-reads the whole journal up to its own position and builds a full-segment
|
|
118
|
+
bytes transient. This accumulator reproduces the identical durable digest
|
|
119
|
+
from the bytes the single streaming pass is reading anyway (#496 S4 §5.2).
|
|
120
|
+
|
|
121
|
+
The framing is `journal_prefix_hash`'s, verbatim: per segment, the 4-byte
|
|
122
|
+
big-endian name length, the name, the 8-byte big-endian data length, then
|
|
123
|
+
the data. Completed segments are absorbed into a running `sha256`; only the
|
|
124
|
+
segment currently being streamed is buffered, so residency is bounded by one
|
|
125
|
+
segment. At a segment transition the caller passes the boundary the next
|
|
126
|
+
record's `prior_high_water` will name, which is the ONE offset in the
|
|
127
|
+
outgoing segment that can still be asked for; its digest is precomputed
|
|
128
|
+
before the buffer is released.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# A transition can only ever register one boundary, and consecutive empty
|
|
132
|
+
# segments re-register the same one. A handful of slots is therefore already
|
|
133
|
+
# generous; the cap exists so a pathological journal cannot grow this map.
|
|
134
|
+
# Eviction is safe because a registered boundary is only ever READ
|
|
135
|
+
# IMMEDIATELY AFTER the `begin_segment` that registered it: a resolution op
|
|
136
|
+
# can name a previous segment's end only when it is the first line of the
|
|
137
|
+
# new segment, and every later position falls in the `_current_name` branch
|
|
138
|
+
# of `digest_at`. So the cap bounds a map that never needs more than the
|
|
139
|
+
# most recent entry; widening it buys nothing and narrowing it below the
|
|
140
|
+
# runs of empty segments a journal can contain would start dropping the one
|
|
141
|
+
# entry that is still live.
|
|
142
|
+
_MAX_BOUNDARIES = 16
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self._running = hashlib.sha256()
|
|
146
|
+
self._current_name = None
|
|
147
|
+
self._current = bytearray()
|
|
148
|
+
self._boundaries: dict = {}
|
|
149
|
+
self._boundary_order: list = []
|
|
150
|
+
#: Bytes fed into an evidence digest, reported as the `protocol_evidence`
|
|
151
|
+
#: traversal pass. Zero on any journal with no resolution op.
|
|
152
|
+
self.bytes_hashed = 0
|
|
153
|
+
self.digests_computed = 0
|
|
154
|
+
|
|
155
|
+
# -- feeding ----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def begin_segment(self, name: str, boundary=None) -> None:
|
|
158
|
+
"""Start `name`, absorbing whatever segment was open before it.
|
|
159
|
+
|
|
160
|
+
`boundary` is the `(segment, offset)` the next record's evidence would
|
|
161
|
+
name — i.e. the streaming loop's current `prior_high_water`. It is
|
|
162
|
+
resolved and cached HERE because the outgoing segment's bytes are gone
|
|
163
|
+
immediately afterwards.
|
|
164
|
+
"""
|
|
165
|
+
if boundary is not None:
|
|
166
|
+
self._register_boundary(boundary)
|
|
167
|
+
if self._current_name is not None:
|
|
168
|
+
# `hashlib.update` accepts the buffer directly, so the outgoing
|
|
169
|
+
# segment is framed WITHOUT a copy of itself. The maintainer's
|
|
170
|
+
# largest segment is 410 MB, and copying it here briefly doubled
|
|
171
|
+
# that at every transition.
|
|
172
|
+
with memoryview(self._current) as data:
|
|
173
|
+
self._absorb(self._current_name, data)
|
|
174
|
+
self._current_name = name
|
|
175
|
+
self._current = bytearray()
|
|
176
|
+
|
|
177
|
+
def extend(self, data: bytes) -> None:
|
|
178
|
+
"""Absorb raw bytes read from the segment currently open."""
|
|
179
|
+
self._current.extend(data)
|
|
180
|
+
|
|
181
|
+
# -- reading ----------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def digest_at(self, high_water) -> "str | None":
|
|
184
|
+
"""`journal_prefix_hash(high_water)`, from the streamed bytes."""
|
|
185
|
+
if high_water is None:
|
|
186
|
+
return None
|
|
187
|
+
segment, offset = high_water
|
|
188
|
+
if segment == self._current_name:
|
|
189
|
+
digest = self._running.copy()
|
|
190
|
+
with memoryview(self._current)[:offset] as data:
|
|
191
|
+
self._frame(digest, segment, data)
|
|
192
|
+
self.bytes_hashed += len(data)
|
|
193
|
+
self.digests_computed += 1
|
|
194
|
+
return "sha256:" + digest.hexdigest()
|
|
195
|
+
cached = self._boundaries.get((segment, offset))
|
|
196
|
+
if cached is None:
|
|
197
|
+
raise PrefixEvidenceUnavailable(
|
|
198
|
+
f"no streamed prefix evidence for {segment}@{offset}"
|
|
199
|
+
)
|
|
200
|
+
self.bytes_hashed += cached[1]
|
|
201
|
+
self.digests_computed += 1
|
|
202
|
+
return cached[0]
|
|
203
|
+
|
|
204
|
+
# -- internals --------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _register_boundary(self, boundary) -> None:
|
|
207
|
+
segment, offset = boundary
|
|
208
|
+
if segment != self._current_name:
|
|
209
|
+
# Already registered at an earlier transition (a run of empty
|
|
210
|
+
# segments leaves `prior_high_water` unchanged), or never streamed.
|
|
211
|
+
return
|
|
212
|
+
key = (segment, offset)
|
|
213
|
+
if key in self._boundaries:
|
|
214
|
+
return
|
|
215
|
+
digest = self._running.copy()
|
|
216
|
+
with memoryview(self._current)[:offset] as data:
|
|
217
|
+
self._frame(digest, segment, data)
|
|
218
|
+
size = len(data)
|
|
219
|
+
self._boundaries[key] = ("sha256:" + digest.hexdigest(), size)
|
|
220
|
+
self._boundary_order.append(key)
|
|
221
|
+
while len(self._boundary_order) > self._MAX_BOUNDARIES:
|
|
222
|
+
self._boundaries.pop(self._boundary_order.pop(0), None)
|
|
223
|
+
|
|
224
|
+
def _absorb(self, name, data) -> None:
|
|
225
|
+
self._frame(self._running, name, data)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _frame(digest, name: str, data) -> None:
|
|
229
|
+
"""`data` is any bytes-like buffer; `hashlib` consumes it in place."""
|
|
230
|
+
encoded = name.encode("utf-8")
|
|
231
|
+
digest.update(len(encoded).to_bytes(4, "big"))
|
|
232
|
+
digest.update(encoded)
|
|
233
|
+
digest.update(len(data).to_bytes(8, "big"))
|
|
234
|
+
digest.update(data)
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Pure planning kernel for in-place stats index publication (#496 S3).
|
|
2
|
+
|
|
3
|
+
No I/O and no SQLite connection: every function takes plain data so the
|
|
4
|
+
publication protocol's decisions can be tested without a database.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
|
|
13
|
+
_INTERNAL = "sqlite_"
|
|
14
|
+
_VIRTUAL = re.compile(r"CREATE\s+VIRTUAL\s+TABLE", re.IGNORECASE)
|
|
15
|
+
# `INSERT INTO t SELECT * FROM src.t` is not valid for a generated column, so a
|
|
16
|
+
# table carrying one is refused rather than mis-copied. Both spellings SQLite
|
|
17
|
+
# accepts are matched, and the pattern is anchored on what actually precedes the
|
|
18
|
+
# `AS (` of a generated column: either the `GENERATED ALWAYS` keywords, or the
|
|
19
|
+
# end of a column's type/constraint text at a `,` or `(` boundary. A bare
|
|
20
|
+
# `\bAS\s*\(` would also match a `CHECK (x AS (…))`-shaped expression or any
|
|
21
|
+
# future DDL that merely contains those characters, and the refusal is a hard,
|
|
22
|
+
# non-fallback-eligible raise — so a false positive would abort publication on a
|
|
23
|
+
# table the copy handles perfectly well.
|
|
24
|
+
_GENERATED = re.compile(
|
|
25
|
+
r"GENERATED\s+ALWAYS\s+AS\s*\("
|
|
26
|
+
r"|[(,]\s*\"?\w+\"?(?:[^,()]|\([^()]*\))*?\bAS\s*\(",
|
|
27
|
+
re.IGNORECASE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Drop order: dropping a table already removes its own indexes and triggers, so
|
|
31
|
+
# the dependent classes go first and a precomputed flat list never touches an
|
|
32
|
+
# object that no longer exists.
|
|
33
|
+
_DROP_ORDER = (
|
|
34
|
+
("view", "DROP VIEW IF EXISTS"),
|
|
35
|
+
("trigger", "DROP TRIGGER IF EXISTS"),
|
|
36
|
+
("index", "DROP INDEX IF EXISTS"),
|
|
37
|
+
("table", "DROP TABLE IF EXISTS"),
|
|
38
|
+
)
|
|
39
|
+
# Create order for everything that is not a table: after the rows, so index
|
|
40
|
+
# builds are single-pass, and indexes before triggers before views.
|
|
41
|
+
_CREATE_ORDER = ("index", "trigger", "view")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclasses.dataclass(frozen=True)
|
|
45
|
+
class GenerationSwapPlan:
|
|
46
|
+
"""One transaction's worth of statements, in the order they must run."""
|
|
47
|
+
|
|
48
|
+
drop_statements: tuple
|
|
49
|
+
create_table_statements: tuple
|
|
50
|
+
copy_tables: tuple
|
|
51
|
+
# Indexes, then triggers, then views — every non-table object, created
|
|
52
|
+
# after the row copy. Named for the only class the stats schema uses today.
|
|
53
|
+
create_index_statements: tuple
|
|
54
|
+
rejected: tuple
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _usable(objects):
|
|
58
|
+
"""Objects a swap may act on: named by the user, and carrying their DDL.
|
|
59
|
+
|
|
60
|
+
`sqlite_sequence` cannot be dropped, and an automatic index created by a
|
|
61
|
+
UNIQUE constraint has `sql IS NULL` and no independent existence.
|
|
62
|
+
"""
|
|
63
|
+
for kind, name, sql in objects:
|
|
64
|
+
if str(name).startswith(_INTERNAL):
|
|
65
|
+
continue
|
|
66
|
+
if sql is None:
|
|
67
|
+
continue
|
|
68
|
+
yield str(kind), str(name), str(sql)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unsupported(kind: str, sql: str) -> bool:
|
|
72
|
+
if _VIRTUAL.search(sql):
|
|
73
|
+
return True
|
|
74
|
+
return kind == "table" and bool(_GENERATED.search(sql))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def plan_generation_swap(dest_objects, src_objects) -> GenerationSwapPlan:
|
|
78
|
+
"""Plan the swap of ``dest_objects`` for ``src_objects``.
|
|
79
|
+
|
|
80
|
+
Each argument is a sequence of ``(type, name, sql)`` triples exactly as
|
|
81
|
+
``SELECT type, name, sql FROM sqlite_schema`` returns them. Deriving the
|
|
82
|
+
drop list from the destination and the create list from the source retires
|
|
83
|
+
whatever the live generation holds without a maintained table of names, so
|
|
84
|
+
a table added in a later epoch cannot be forgotten.
|
|
85
|
+
"""
|
|
86
|
+
rejected = tuple(
|
|
87
|
+
name
|
|
88
|
+
for kind, name, sql in _usable(src_objects)
|
|
89
|
+
if _unsupported(kind, sql)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
drops = []
|
|
93
|
+
for wanted, statement in _DROP_ORDER:
|
|
94
|
+
for kind, name, _sql in _usable(dest_objects):
|
|
95
|
+
if kind == wanted:
|
|
96
|
+
drops.append(f'{statement} "{name}"')
|
|
97
|
+
|
|
98
|
+
creates, copies = [], []
|
|
99
|
+
others: dict = {kind: [] for kind in _CREATE_ORDER}
|
|
100
|
+
for kind, name, sql in _usable(src_objects):
|
|
101
|
+
if _unsupported(kind, sql):
|
|
102
|
+
continue
|
|
103
|
+
if kind == "table":
|
|
104
|
+
creates.append(sql)
|
|
105
|
+
copies.append(name)
|
|
106
|
+
elif kind in others:
|
|
107
|
+
others[kind].append(sql)
|
|
108
|
+
|
|
109
|
+
post = [sql for kind in _CREATE_ORDER for sql in others[kind]]
|
|
110
|
+
return GenerationSwapPlan(
|
|
111
|
+
drop_statements=tuple(drops),
|
|
112
|
+
create_table_statements=tuple(creates),
|
|
113
|
+
copy_tables=tuple(copies),
|
|
114
|
+
create_index_statements=tuple(post),
|
|
115
|
+
rejected=rejected,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --------------------------------------------------------------------------
|
|
120
|
+
# The publication phase machine (#496 S3 §4.1)
|
|
121
|
+
# --------------------------------------------------------------------------
|
|
122
|
+
#
|
|
123
|
+
# "The transaction raised" is not a safe discriminator, because a failure
|
|
124
|
+
# before the commit can roll back while a failure after it cannot, and a
|
|
125
|
+
# commit-time I/O error leaves the outcome genuinely unknown. The publisher
|
|
126
|
+
# therefore records which of these four phases it had reached when it failed.
|
|
127
|
+
|
|
128
|
+
# Rollback is proven, the old generation is intact, and physical fallback is
|
|
129
|
+
# legal.
|
|
130
|
+
PRE_COMMIT = "pre_commit"
|
|
131
|
+
# The commit's outcome is undetermined. Do not fall back; reopen and resolve
|
|
132
|
+
# through the publication stamp.
|
|
133
|
+
COMMIT_UNKNOWN = "commit_unknown"
|
|
134
|
+
# The new generation is live. Physical fallback is never legal from here.
|
|
135
|
+
COMMITTED = "committed"
|
|
136
|
+
# The record and the marker agree with the bytes.
|
|
137
|
+
VERDICT_SETTLED = "verdict_settled"
|
|
138
|
+
|
|
139
|
+
# Only a structural inability to operate on the destination authorizes
|
|
140
|
+
# discarding it. Busy, full, out-of-memory and I/O-resource failures leave a
|
|
141
|
+
# perfectly good generation live and must return a retryable failure instead.
|
|
142
|
+
# The structural tokens are `_cctally_db._SQLITE_CORRUPTION_MESSAGES` plus the
|
|
143
|
+
# encrypted-file spelling of SQLITE_NOTADB.
|
|
144
|
+
_STRUCTURAL = (
|
|
145
|
+
"database disk image is malformed",
|
|
146
|
+
"file is not a database",
|
|
147
|
+
"malformed database schema",
|
|
148
|
+
"encrypted or is not a database",
|
|
149
|
+
)
|
|
150
|
+
_RETRYABLE = (
|
|
151
|
+
"locked",
|
|
152
|
+
"busy",
|
|
153
|
+
"full",
|
|
154
|
+
"out of memory",
|
|
155
|
+
"i/o error",
|
|
156
|
+
"readonly",
|
|
157
|
+
"permission denied",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------
|
|
162
|
+
# Three-state stamp resolution (#496 S3 §5)
|
|
163
|
+
# --------------------------------------------------------------------------
|
|
164
|
+
#
|
|
165
|
+
# An in-place publish attaches its scratch read-only and detaches it, so the
|
|
166
|
+
# scratch survives commit and rollback identically and the publication marker's
|
|
167
|
+
# `scratchPath` crash discriminator inverts. The publication writes its own
|
|
168
|
+
# identity into `stats_publication_stamp` inside the publication transaction
|
|
169
|
+
# instead, and the opener compares that against the marker's `recordPath`.
|
|
170
|
+
#
|
|
171
|
+
# The answer is three-valued, not boolean. "The stamp does not name this
|
|
172
|
+
# record" proves a rollback only if the stamp was READ successfully; a missing
|
|
173
|
+
# table, a corrupt page, an unreadable schema, a malformed or duplicated row,
|
|
174
|
+
# or any query error proves nothing at all. Treating one of those as
|
|
175
|
+
# never-committed would discard a verdict owed on bytes that are live.
|
|
176
|
+
|
|
177
|
+
#: The stamp names this marker's rebuild record: the publication COMMITTED.
|
|
178
|
+
STAMP_MATCH = "MATCH"
|
|
179
|
+
#: The stamp was read and does not name this record: the publication never
|
|
180
|
+
#: became live, and the live bytes are its untouched predecessor.
|
|
181
|
+
STAMP_PROVEN_PREDECESSOR = "PROVEN_PREDECESSOR"
|
|
182
|
+
#: The stamp could not be read, or read as something it may not be. Preserve
|
|
183
|
+
#: the marker and fail closed; only PROVEN_PREDECESSOR may discard it.
|
|
184
|
+
STAMP_INDETERMINATE = "INDETERMINATE"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def resolve_stamp(stamp, marker_record_path) -> str:
|
|
188
|
+
"""Resolve a pending publication against the stamp read from its destination.
|
|
189
|
+
|
|
190
|
+
``stamp`` is whatever the read produced: the exception that prevented it,
|
|
191
|
+
``None`` or an empty sequence when the table read cleanly and held no row,
|
|
192
|
+
a single row mapping, or the sequence of row mappings the table held. Each
|
|
193
|
+
mapping carries at least ``record_path``.
|
|
194
|
+
|
|
195
|
+
``marker_record_path`` is the marker's ``recordPath`` as a string.
|
|
196
|
+
|
|
197
|
+
Returns one of `STAMP_MATCH`, `STAMP_PROVEN_PREDECESSOR` or
|
|
198
|
+
`STAMP_INDETERMINATE`. Anything the function cannot interpret resolves
|
|
199
|
+
INDETERMINATE, because that is the state that preserves evidence.
|
|
200
|
+
"""
|
|
201
|
+
if isinstance(stamp, BaseException):
|
|
202
|
+
return STAMP_INDETERMINATE
|
|
203
|
+
if not isinstance(marker_record_path, str) or not marker_record_path:
|
|
204
|
+
return STAMP_INDETERMINATE
|
|
205
|
+
if stamp is None:
|
|
206
|
+
return STAMP_PROVEN_PREDECESSOR
|
|
207
|
+
if isinstance(stamp, (list, tuple)):
|
|
208
|
+
if not stamp:
|
|
209
|
+
return STAMP_PROVEN_PREDECESSOR
|
|
210
|
+
if len(stamp) != 1:
|
|
211
|
+
# The publication transaction deletes before it inserts, so more
|
|
212
|
+
# than one row is a state the protocol cannot produce.
|
|
213
|
+
return STAMP_INDETERMINATE
|
|
214
|
+
stamp = stamp[0]
|
|
215
|
+
if not isinstance(stamp, dict):
|
|
216
|
+
return STAMP_INDETERMINATE
|
|
217
|
+
recorded = stamp.get("record_path")
|
|
218
|
+
if not isinstance(recorded, str) or not recorded:
|
|
219
|
+
return STAMP_INDETERMINATE
|
|
220
|
+
if recorded == marker_record_path:
|
|
221
|
+
return STAMP_MATCH
|
|
222
|
+
return STAMP_PROVEN_PREDECESSOR
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def may_fall_back_to_replacement(exc) -> bool:
|
|
226
|
+
"""Whether ``exc`` authorizes physically replacing the destination.
|
|
227
|
+
|
|
228
|
+
Fails closed: anything unrecognized returns False, leaving the old
|
|
229
|
+
generation live and the failure retryable. SQLite's numeric code is
|
|
230
|
+
preferred where the exception still carries one, mirroring
|
|
231
|
+
``_cctally_db._is_sqlite_corruption_error``; the canonical messages are the
|
|
232
|
+
string-only boundary.
|
|
233
|
+
"""
|
|
234
|
+
code = getattr(exc, "sqlite_errorcode", None)
|
|
235
|
+
if isinstance(code, int):
|
|
236
|
+
primary = code & 0xFF
|
|
237
|
+
if primary in {sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB}:
|
|
238
|
+
return True
|
|
239
|
+
return False
|
|
240
|
+
text = str(exc).casefold()
|
|
241
|
+
if any(token in text for token in _RETRYABLE):
|
|
242
|
+
return False
|
|
243
|
+
return any(token in text for token in _STRUCTURAL)
|
package/bin/cctally
CHANGED
|
@@ -967,7 +967,9 @@ ProdMigrationRefused = _cctally_db.ProdMigrationRefused
|
|
|
967
967
|
StatsDbCorruptError = _cctally_db.StatsDbCorruptError
|
|
968
968
|
StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
|
|
969
969
|
StatsEpochMismatchError = _cctally_db.StatsEpochMismatchError
|
|
970
|
+
StatsRebuildDeferred = _cctally_db.StatsRebuildDeferred
|
|
970
971
|
StatsEpochRebuildDeferred = _cctally_db.StatsEpochRebuildDeferred
|
|
972
|
+
StatsHealDeferred = _cctally_db.StatsHealDeferred
|
|
971
973
|
_is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
|
|
972
974
|
_stats_corruption_guidance = _cctally_db._stats_corruption_guidance
|
|
973
975
|
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
|
|
@@ -997,6 +999,9 @@ _cctally_store = _load_sibling("_cctally_store")
|
|
|
997
999
|
cmd_stats_epoch_rebuild_internal = (
|
|
998
1000
|
_cctally_store.cmd_stats_epoch_rebuild_internal
|
|
999
1001
|
)
|
|
1002
|
+
cmd_stats_corruption_heal_internal = (
|
|
1003
|
+
_cctally_store.cmd_stats_corruption_heal_internal
|
|
1004
|
+
)
|
|
1000
1005
|
_print_migration_error_banner_if_needed = _cctally_db._print_migration_error_banner_if_needed
|
|
1001
1006
|
cmd_db_status = _cctally_db.cmd_db_status
|
|
1002
1007
|
_db_status_for = _cctally_db._db_status_for
|
|
@@ -3355,7 +3360,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
3355
3360
|
# generic DatabaseError so it never renders as a raw traceback.
|
|
3356
3361
|
eprint(f"cctally: {exc}")
|
|
3357
3362
|
return 3
|
|
3358
|
-
except
|
|
3363
|
+
except StatsRebuildDeferred as exc:
|
|
3364
|
+
# One catch for both deferral classes (#496 S3 §6): the epoch worker
|
|
3365
|
+
# and the corruption-heal worker both mean "a rebuild is running; do
|
|
3366
|
+
# not print a partial report".
|
|
3359
3367
|
eprint(f"cctally: {exc}")
|
|
3360
3368
|
return 3
|
|
3361
3369
|
except AccountAttributionUnavailable as exc:
|
|
@@ -3477,8 +3485,9 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
3477
3485
|
# still cctally-dev at this point). Same rationale class as doctor.
|
|
3478
3486
|
return
|
|
3479
3487
|
if command in ("_update-check", "_telemetry-beat", "_codex-quota-verify",
|
|
3480
|
-
"_stats-epoch-rebuild", "
|
|
3481
|
-
|
|
3488
|
+
"_stats-epoch-rebuild", "_stats-corruption-heal",
|
|
3489
|
+
"_codex-replay-drain"):
|
|
3490
|
+
# All six hidden workers are detached and have already done their one job
|
|
3482
3491
|
# in their own command handler; none must re-enter this hook.
|
|
3483
3492
|
# Without the guard the ``_update-check`` worker would fall through to
|
|
3484
3493
|
# the telemetry gate below and (throttle-bounded) spawn a
|