cctally 1.92.2 → 1.93.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 +43 -0
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3482 -258
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +235 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-DnWdv8um.css +0 -1
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""Journal-to-cache coverage certificate — the pure kernel (#496 S5b, spec §4).
|
|
2
|
+
|
|
3
|
+
A stats rebuild replays every retained Codex quota observation into `cache.db`
|
|
4
|
+
under both cache writer flocks, whether or not the cache already holds them. On
|
|
5
|
+
the maintainer's store that is roughly 1.81 million observations and a 23.0 s
|
|
6
|
+
warm hold of the lock issue #297 blames for `database is locked`. The
|
|
7
|
+
certificate is what lets an intact cache be recognized instead of replayed.
|
|
8
|
+
|
|
9
|
+
**Its single promise is coverage, and nothing wider:**
|
|
10
|
+
|
|
11
|
+
every cache-relevant journal record in the covered prefix has already been
|
|
12
|
+
applied to `cache.db`.
|
|
13
|
+
|
|
14
|
+
It does **not** promise that `cache.db` contains only rows the journal explains.
|
|
15
|
+
`_append_codex_quota_obs` is deliberately best-effort — it catches every
|
|
16
|
+
exception so a failed journal append cannot break a sync, and it runs before the
|
|
17
|
+
cache write — so a swallowed failure leaves a cache row the journal lacks. That
|
|
18
|
+
is divergence in the `cache ⊃ journal` direction, which a coverage-only promise
|
|
19
|
+
does not cover and does not need to.
|
|
20
|
+
|
|
21
|
+
It does **not** promise that any individual row's values are correct. File-account
|
|
22
|
+
rows are first-wins, incarnation rows are MAX-set, and quota replay is
|
|
23
|
+
`INSERT OR IGNORE`, so a wrong pre-existing row survives a replay from byte zero
|
|
24
|
+
and would then be certified by it. A replay cannot prove correctness, so the
|
|
25
|
+
certificate does not claim it.
|
|
26
|
+
|
|
27
|
+
**The identity root binds the ordered vector of extents, not segment names.** A
|
|
28
|
+
late append into a non-last segment changes no name and moves no ordering, so a
|
|
29
|
+
name-only root stays valid while the cache lacks that observation, and the fast
|
|
30
|
+
path would skip a quota replay today's rebuild performs. #511's target
|
|
31
|
+
revalidation is what makes the extent vector stable for the duration of a pass;
|
|
32
|
+
this module is what makes a change to it invalidate the certificate.
|
|
33
|
+
|
|
34
|
+
**A covered extent is always a verified newline boundary, never a raw size.** The
|
|
35
|
+
promise concerns decoded records, so covering a raw torn-tail extent would let
|
|
36
|
+
`_repair_torn_tail` truncate that suffix and append a complete record ending at
|
|
37
|
+
the same size — leaving `(segment, size)` identical while the covered
|
|
38
|
+
contribution changed. The pinned vector therefore carries BOTH the raw `st_size`
|
|
39
|
+
and the complete-line offset per segment, and `coveredHighWater` is bounded to
|
|
40
|
+
the latter.
|
|
41
|
+
|
|
42
|
+
This module imports nothing outside the stdlib, so it is unit-testable without a
|
|
43
|
+
cache or a journal on disk — the same rule `bin/_lib_journal_router.py` follows.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: The `cache_meta` key. Distinct from `codex_quota_projection_certificate`:
|
|
52
|
+
#: coverage binds journal-to-cache, the projection certificate binds
|
|
53
|
+
#: cache-to-stats, and neither may satisfy the other's gate.
|
|
54
|
+
CERTIFICATE_KEY = "codex_journal_coverage_certificate"
|
|
55
|
+
|
|
56
|
+
#: The `cache_meta` key for a recovery pass's in-flight progress, which is
|
|
57
|
+
#: deliberately SEPARATE from the certificate (spec §4.5). A certificate asserts
|
|
58
|
+
#: coverage; progress asserts only how far one pass has got, and a pass that
|
|
59
|
+
#: stops leaves progress behind without ever asserting coverage. Storing the two
|
|
60
|
+
#: under one key would make a partial pass look like a coverage claim.
|
|
61
|
+
PROGRESS_KEY = "codex_journal_coverage_progress"
|
|
62
|
+
|
|
63
|
+
#: Bumped whenever the certificate's own shape or validation semantics change.
|
|
64
|
+
#: A mismatch is one of the states that falls back to a full replay silently.
|
|
65
|
+
#:
|
|
66
|
+
#: Adding the required `appliedThrough` field did NOT bump it, and that is
|
|
67
|
+
#: deliberate rather than an oversight: no released binary ever wrote the
|
|
68
|
+
#: one-coordinate shape, and a certificate lacking the field reads
|
|
69
|
+
#: `REASON_MALFORMED` and falls back to a full replay — the same outcome a bump
|
|
70
|
+
#: would produce. A bump would additionally invalidate certificates written by
|
|
71
|
+
#: an in-development binary that already carries the field, for no gain.
|
|
72
|
+
COVERAGE_VERSION = 1
|
|
73
|
+
|
|
74
|
+
#: Bumped whenever the journal-record-to-cache-row materialization changes —
|
|
75
|
+
#: `_apply_quota_records`, `_apply_file_account_records`, or the §3.5 precedence
|
|
76
|
+
#: rule between them. A certificate written under different semantics describes a
|
|
77
|
+
#: cache this binary would not have produced, so it is rejected rather than
|
|
78
|
+
#: compared.
|
|
79
|
+
INTERPRETATION_VERSION = 1
|
|
80
|
+
|
|
81
|
+
#: The reason strings `certificate_is_valid` returns. They are stable, because
|
|
82
|
+
#: the rebuild record reports them (spec §6.3, "recorded, not silent").
|
|
83
|
+
REASON_OK = "ok"
|
|
84
|
+
REASON_ABSENT = "absent"
|
|
85
|
+
REASON_MALFORMED = "malformed"
|
|
86
|
+
REASON_COVERAGE_VERSION = "coverageVersion"
|
|
87
|
+
REASON_INTERPRETATION_VERSION = "interpretationVersion"
|
|
88
|
+
REASON_PHYSICAL_SEQ = "physicalMutationSeq"
|
|
89
|
+
REASON_IDENTITY_ROOT = "identityRoot"
|
|
90
|
+
REASON_COVERED_HIGH_WATER = "coveredHighWater"
|
|
91
|
+
#: No boundary could be resolved at all — the pass has no high water, or its
|
|
92
|
+
#: high-water segment is absent from the pinned vector. Distinct from
|
|
93
|
+
#: `REASON_IDENTITY_ROOT`, which says the journal moved: this one says the
|
|
94
|
+
#: question was never askable, and reporting the former would tell an operator
|
|
95
|
+
#: the root changed when nothing about the certificate was even consulted.
|
|
96
|
+
REASON_NO_BOUNDARY = "noBoundary"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _canonical(value) -> str:
|
|
100
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True,
|
|
101
|
+
ensure_ascii=False)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def normalize_vector(pinned_vector):
|
|
105
|
+
"""``pinned_vector`` as a tuple of ``(name, raw_extent, covered_offset)``.
|
|
106
|
+
|
|
107
|
+
Accepts any iterable of three-element sequences so a caller may build it
|
|
108
|
+
from tuples or from lists decoded out of JSON, and rejects anything else
|
|
109
|
+
rather than hashing a shape nobody checked.
|
|
110
|
+
"""
|
|
111
|
+
normalized = []
|
|
112
|
+
for item in pinned_vector:
|
|
113
|
+
name, raw_extent, covered_offset = item
|
|
114
|
+
normalized.append((str(name), int(raw_extent), int(covered_offset)))
|
|
115
|
+
return tuple(normalized)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def identity_root(pinned_vector) -> str:
|
|
119
|
+
"""SHA-256 over the ORDERED ``(name, raw_extent, covered_offset)`` triples.
|
|
120
|
+
|
|
121
|
+
Ordered, because `list_segments` sorts bootstraps before observations and an
|
|
122
|
+
inserted bootstrap changes the canonical order without changing any existing
|
|
123
|
+
segment. Hashing the order is what makes that insertion invalidate the root.
|
|
124
|
+
"""
|
|
125
|
+
payload = _canonical([list(item) for item in normalize_vector(pinned_vector)])
|
|
126
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CoverageOutOfVector(ValueError):
|
|
130
|
+
"""A covered boundary the pinned vector does not offer.
|
|
131
|
+
|
|
132
|
+
Raised rather than asserted: `python -O` strips `assert`, and a guard that
|
|
133
|
+
disappears under an optimized interpreter would store an out-of-vector
|
|
134
|
+
certificate instead of refusing to build one. `certificate_is_valid` would
|
|
135
|
+
reject it on first use, so the outcome is a replay either way — but the
|
|
136
|
+
guard is stated as a real check so it holds in every interpreter.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def advance(prior, *, covered, applied_through, pinned_vector,
|
|
141
|
+
physical_seq) -> dict:
|
|
142
|
+
"""The certificate describing ``covered`` over ``pinned_vector``.
|
|
143
|
+
|
|
144
|
+
``covered`` is ``(segment, complete_line_offset)`` — a verified newline
|
|
145
|
+
boundary, never a raw size, for the reason the module docstring gives.
|
|
146
|
+
|
|
147
|
+
``applied_through`` is the RAW journal coordinate the writer consumed to,
|
|
148
|
+
and it is a separate field because the two are not the same number. The
|
|
149
|
+
ingest cycle advances its scalar cursor to the raw high water, while the
|
|
150
|
+
covered boundary is clamped down to the last line the pass actually decoded.
|
|
151
|
+
Storing only the clamped value made the next writer compare its cursor
|
|
152
|
+
against a boundary that was deliberately smaller, read the difference as a
|
|
153
|
+
gap, and discard the predecessor — so one torn or malformed trailing line
|
|
154
|
+
froze the certificate until the next rebuild. The contiguity check compares
|
|
155
|
+
`appliedThrough`; the coverage claim is `coveredHighWater`; neither operand
|
|
156
|
+
stands in for the other.
|
|
157
|
+
|
|
158
|
+
``prior`` is accepted and deliberately not merged into the result. A
|
|
159
|
+
certificate is a statement about the CURRENT physical state, not an
|
|
160
|
+
accumulation over previous ones, so a stale field cannot survive an advance.
|
|
161
|
+
It is a parameter only so a caller cannot advance without having read the
|
|
162
|
+
predecessor it is required to validate first.
|
|
163
|
+
"""
|
|
164
|
+
del prior
|
|
165
|
+
segment, offset = covered
|
|
166
|
+
applied_segment, applied_offset = applied_through
|
|
167
|
+
vector = normalize_vector(pinned_vector)
|
|
168
|
+
# A mint that `certificate_is_valid` would immediately reject is a caller
|
|
169
|
+
# bug, not a degraded state to fall back from: it means the covered boundary
|
|
170
|
+
# names a segment or an offset the pinned vector does not offer. Refuse here
|
|
171
|
+
# rather than storing it and discovering it one rebuild later.
|
|
172
|
+
if not _covered_within((segment, offset), vector):
|
|
173
|
+
raise CoverageOutOfVector(
|
|
174
|
+
f"coverage {(str(segment), int(offset))!r} is outside the pinned "
|
|
175
|
+
"vector"
|
|
176
|
+
)
|
|
177
|
+
return {
|
|
178
|
+
"coverageVersion": COVERAGE_VERSION,
|
|
179
|
+
"interpretationVersion": INTERPRETATION_VERSION,
|
|
180
|
+
"physicalMutationSeq": int(physical_seq),
|
|
181
|
+
"coveredHighWater": [str(segment), int(offset)],
|
|
182
|
+
"appliedThrough": [str(applied_segment), int(applied_offset)],
|
|
183
|
+
"identityRoot": identity_root(vector),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def prior_is_extendable(prior, *, applied_through) -> "tuple[bool, str]":
|
|
188
|
+
"""Whether a contiguous writer may EXTEND ``prior`` rather than discard it.
|
|
189
|
+
|
|
190
|
+
This is deliberately NOT `certificate_is_valid`. An advance's predecessor
|
|
191
|
+
necessarily describes an older, smaller journal — the writer is about to
|
|
192
|
+
certify records that grew a segment after the predecessor was stored — so
|
|
193
|
+
its identity root cannot match this pass's pinned vector and its
|
|
194
|
+
`physicalMutationSeq` predates the bump this transaction is about to make.
|
|
195
|
+
Requiring either would refuse every advance that has ever been correct.
|
|
196
|
+
|
|
197
|
+
What an advance CAN check is the part that does not move with the journal:
|
|
198
|
+
the two version fields, and contiguity against the coordinate the
|
|
199
|
+
predecessor was applied through. The versions matter because
|
|
200
|
+
`_lib_cache_coverage.advance` re-stamps the CURRENT module constants and
|
|
201
|
+
discards `prior`, so extending a certificate written under an older
|
|
202
|
+
`interpretationVersion` would launder it into a current-version one and the
|
|
203
|
+
next rebuild would skip exactly the replay the version bump exists to force.
|
|
204
|
+
"""
|
|
205
|
+
if prior is None:
|
|
206
|
+
return False, REASON_ABSENT
|
|
207
|
+
if not isinstance(prior, dict):
|
|
208
|
+
return False, REASON_MALFORMED
|
|
209
|
+
try:
|
|
210
|
+
coverage_version = int(prior["coverageVersion"])
|
|
211
|
+
interpretation_version = int(prior["interpretationVersion"])
|
|
212
|
+
stored_applied = prior["appliedThrough"]
|
|
213
|
+
stored_segment, stored_offset = str(stored_applied[0]), int(
|
|
214
|
+
stored_applied[1])
|
|
215
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
216
|
+
return False, REASON_MALFORMED
|
|
217
|
+
if coverage_version != COVERAGE_VERSION:
|
|
218
|
+
return False, REASON_COVERAGE_VERSION
|
|
219
|
+
if interpretation_version != INTERPRETATION_VERSION:
|
|
220
|
+
return False, REASON_INTERPRETATION_VERSION
|
|
221
|
+
try:
|
|
222
|
+
expected = (str(applied_through[0]), int(applied_through[1]))
|
|
223
|
+
except (IndexError, TypeError, ValueError):
|
|
224
|
+
return False, REASON_MALFORMED
|
|
225
|
+
if (stored_segment, stored_offset) != expected:
|
|
226
|
+
return False, REASON_COVERED_HIGH_WATER
|
|
227
|
+
return True, REASON_OK
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _vector_position(coordinate, vector):
|
|
231
|
+
"""``(segment_index, offset)`` for ``coordinate``, or None.
|
|
232
|
+
|
|
233
|
+
The ordering comes from the pinned vector rather than from the segment name,
|
|
234
|
+
because `list_segments` sorts bootstraps before observations and a name
|
|
235
|
+
comparison would order those two families lexically instead of canonically.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
name, offset = str(coordinate[0]), int(coordinate[1])
|
|
239
|
+
except (IndexError, KeyError, TypeError, ValueError):
|
|
240
|
+
return None
|
|
241
|
+
for index, (candidate, _raw_extent, _covered_offset) in enumerate(vector):
|
|
242
|
+
if candidate == name:
|
|
243
|
+
return (index, offset)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def applied_through_regresses(stored, applied_through, pinned_vector) -> bool:
|
|
248
|
+
"""Whether storing ``applied_through`` would move ``stored`` BACKWARD.
|
|
249
|
+
|
|
250
|
+
The recovery mint is the one writer allowed to establish a certificate where
|
|
251
|
+
none existed, and it stores with `prior=None`, so without this check it
|
|
252
|
+
overwrites whatever is present. That is unreachable today only because the
|
|
253
|
+
other certificate writer runs under the ingest lock `cmd_db_rebuild` holds
|
|
254
|
+
exclusively — safety supplied by a lock in another module rather than by the
|
|
255
|
+
mint. This applies the same monotonicity `progress_supersedes` already
|
|
256
|
+
applies to progress records.
|
|
257
|
+
|
|
258
|
+
An incomparable pair answers False: a stored coordinate naming a segment the
|
|
259
|
+
pinned vector does not offer describes a journal this pass is not looking
|
|
260
|
+
at, and `certificate_is_valid` already rejects it on the identity root.
|
|
261
|
+
"""
|
|
262
|
+
if not isinstance(stored, dict):
|
|
263
|
+
return False
|
|
264
|
+
try:
|
|
265
|
+
vector = normalize_vector(pinned_vector)
|
|
266
|
+
except (TypeError, ValueError):
|
|
267
|
+
return False
|
|
268
|
+
stored_position = _vector_position(stored.get("appliedThrough"), vector)
|
|
269
|
+
candidate_position = _vector_position(applied_through, vector)
|
|
270
|
+
if stored_position is None or candidate_position is None:
|
|
271
|
+
return False
|
|
272
|
+
return stored_position > candidate_position
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _covered_within(covered, vector) -> bool:
|
|
276
|
+
"""Whether ``covered`` names a boundary the pinned vector actually offers."""
|
|
277
|
+
if not covered or len(covered) != 2:
|
|
278
|
+
return False
|
|
279
|
+
name, offset = str(covered[0]), int(covered[1])
|
|
280
|
+
for candidate, _raw_extent, covered_offset in vector:
|
|
281
|
+
if candidate == name:
|
|
282
|
+
return 0 <= offset <= covered_offset
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def certificate_is_valid(cert, *, pinned_vector, physical_seq) -> "tuple[bool, str]":
|
|
287
|
+
"""``(verdict, reason)`` for one stored certificate against this pass.
|
|
288
|
+
|
|
289
|
+
The reason is returned rather than logged: every degraded state here falls
|
|
290
|
+
back to a full replay SILENTLY (spec §6.3), and the rebuild record is the one
|
|
291
|
+
surface that reports which state it was.
|
|
292
|
+
|
|
293
|
+
**Validity is an identity check, and the caller must still bound its elision
|
|
294
|
+
by `coveredHighWater`.** A certificate covering only the first of three
|
|
295
|
+
segments is VALID — `_covered_within` asks whether the boundary is one the
|
|
296
|
+
pinned vector offers, not whether it reaches the end of the journal. A caller
|
|
297
|
+
that reads this verdict as "the whole pinned prefix is covered" would skip a
|
|
298
|
+
replay for the two segments the certificate says nothing about.
|
|
299
|
+
"""
|
|
300
|
+
if cert is None:
|
|
301
|
+
return False, REASON_ABSENT
|
|
302
|
+
if not isinstance(cert, dict):
|
|
303
|
+
return False, REASON_MALFORMED
|
|
304
|
+
try:
|
|
305
|
+
vector = normalize_vector(pinned_vector)
|
|
306
|
+
except (TypeError, ValueError):
|
|
307
|
+
return False, REASON_MALFORMED
|
|
308
|
+
try:
|
|
309
|
+
coverage_version = int(cert["coverageVersion"])
|
|
310
|
+
interpretation_version = int(cert["interpretationVersion"])
|
|
311
|
+
stored_seq = int(cert["physicalMutationSeq"])
|
|
312
|
+
stored_root = str(cert["identityRoot"])
|
|
313
|
+
covered = cert["coveredHighWater"]
|
|
314
|
+
applied = cert["appliedThrough"]
|
|
315
|
+
str(applied[0]), int(applied[1])
|
|
316
|
+
except (KeyError, IndexError, TypeError, ValueError):
|
|
317
|
+
return False, REASON_MALFORMED
|
|
318
|
+
if coverage_version != COVERAGE_VERSION:
|
|
319
|
+
return False, REASON_COVERAGE_VERSION
|
|
320
|
+
if interpretation_version != INTERPRETATION_VERSION:
|
|
321
|
+
return False, REASON_INTERPRETATION_VERSION
|
|
322
|
+
if stored_seq != int(physical_seq):
|
|
323
|
+
return False, REASON_PHYSICAL_SEQ
|
|
324
|
+
if stored_root != identity_root(vector):
|
|
325
|
+
return False, REASON_IDENTITY_ROOT
|
|
326
|
+
try:
|
|
327
|
+
if not _covered_within(covered, vector):
|
|
328
|
+
return False, REASON_COVERED_HIGH_WATER
|
|
329
|
+
except (TypeError, ValueError):
|
|
330
|
+
return False, REASON_MALFORMED
|
|
331
|
+
return True, REASON_OK
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------
|
|
335
|
+
# recovery progress (spec §4.5)
|
|
336
|
+
# --------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
#: What a resumed recovery pass compares before continuing. Every one of these
|
|
339
|
+
#: is read again after each lock reacquisition, because releasing the flocks
|
|
340
|
+
#: admits a destructive writer.
|
|
341
|
+
PROGRESS_FIELDS = (
|
|
342
|
+
"passId", "startedAt", "chunks", "identityRoot",
|
|
343
|
+
"physicalMutationSeq", "sourceRootsDigest", "coveredHighWater",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
#: `resume` — the stored progress is this pass's own and still describes the
|
|
347
|
+
#: state it left behind, so the pass may keep going.
|
|
348
|
+
#:
|
|
349
|
+
#: **The record is a revalidation token, not a resume point, and the wording
|
|
350
|
+
#: matters because an earlier draft claimed the latter.** A fresh process always
|
|
351
|
+
#: starts at chunk zero: `_run_bounded_recovery` initializes `chunk_index = 0`
|
|
352
|
+
#: unconditionally and revalidates only from chunk 1 onward, so the stored
|
|
353
|
+
#: `chunks` is read by `progress_supersedes` for compare-and-swap ordering and
|
|
354
|
+
#: by nothing else. Recovery is therefore resumable WITHIN one process and
|
|
355
|
+
#: restart-from-zero ACROSS processes.
|
|
356
|
+
#:
|
|
357
|
+
#: That is the conservative direction and it was chosen over implementing the
|
|
358
|
+
#: cross-process resume. The reason is NOT that revalidation would have to move
|
|
359
|
+
#: ahead of chunk zero — an earlier draft said that and it does not follow, since
|
|
360
|
+
#: a process resuming at chunk k has `chunk_index = k > 0` and the existing
|
|
361
|
+
#: `if chunk_index > 0` revalidation already fires in its first transaction,
|
|
362
|
+
#: with §3.5's precedence satisfied for the earlier chunks by the earlier
|
|
363
|
+
#: process and every apply idempotent. The real awkwardness is that `chunks`
|
|
364
|
+
#: indexes a plan derived from THIS process's `quota_raw`, and a journal that
|
|
365
|
+
#: grew between the two passes gives the second process a different plan for
|
|
366
|
+
#: the same index — so the stored number would have to be re-expressed as a
|
|
367
|
+
#: journal coordinate to mean anything across processes. The identity-root
|
|
368
|
+
#: comparison already catches that mismatch, so nothing is unsafe today; the
|
|
369
|
+
#: cross-process resume is simply not worth the extra invariant, because
|
|
370
|
+
#: restarting is idempotent and costs repeated work and nothing else.
|
|
371
|
+
RESUME = "resume"
|
|
372
|
+
#: `restart` — no usable progress. Either a destructive writer deleted it in the
|
|
373
|
+
#: same transaction as its deletes, or it describes a state this pass cannot
|
|
374
|
+
#: continue from. The pass starts again from chunk zero, which is always sound
|
|
375
|
+
#: because every apply is idempotent on its natural key.
|
|
376
|
+
RESTART = "restart"
|
|
377
|
+
#: `yield` — a NEWER pass owns the progress record. This pass stops rather than
|
|
378
|
+
#: restarting, because two passes that each restart on seeing the other make no
|
|
379
|
+
#: progress at all.
|
|
380
|
+
YIELD = "yield"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def make_progress(*, pass_id, started_at, chunks, identity_root,
|
|
384
|
+
physical_seq, source_roots_digest, covered) -> dict:
|
|
385
|
+
"""One pass's progress record, in the canonical field order.
|
|
386
|
+
|
|
387
|
+
There is no applied-record count. An earlier shape carried one and nothing
|
|
388
|
+
ever read it, and a durable field nobody consumes is a field a later reader
|
|
389
|
+
will mistake for state the mechanism depends on.
|
|
390
|
+
"""
|
|
391
|
+
segment, offset = covered
|
|
392
|
+
return {
|
|
393
|
+
"passId": str(pass_id),
|
|
394
|
+
"startedAt": int(started_at),
|
|
395
|
+
"chunks": int(chunks),
|
|
396
|
+
"identityRoot": str(identity_root),
|
|
397
|
+
"physicalMutationSeq": int(physical_seq),
|
|
398
|
+
"sourceRootsDigest": str(source_roots_digest),
|
|
399
|
+
"coveredHighWater": [str(segment), int(offset)],
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def source_roots_digest(root_keys) -> str:
|
|
404
|
+
"""A stable digest over the cache's Codex source roots.
|
|
405
|
+
|
|
406
|
+
`_clear_codex_derived_rows` empties `codex_source_roots` along with the
|
|
407
|
+
quota rows, so this is a second, independent witness that the destructive
|
|
408
|
+
path ran — one that does not depend on the progress delete having happened.
|
|
409
|
+
"""
|
|
410
|
+
payload = _canonical(sorted(str(key) for key in root_keys))
|
|
411
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
#: How far AHEAD of this pass's own start a foreign progress record may sit and
|
|
415
|
+
#: still be treated as a live concurrent pass rather than an orphan.
|
|
416
|
+
#:
|
|
417
|
+
#: `startedAt` is a wall clock compared with `>` across processes, and there is
|
|
418
|
+
#: no pid, heartbeat or TTL behind it. Two passes on one machine read the same
|
|
419
|
+
#: clock, so a genuinely live competitor's start time is at most seconds ahead.
|
|
420
|
+
#: A record arbitrarily far in the future means the clock stepped BACKWARD
|
|
421
|
+
#: between the two passes — an NTP correction or a VM restore — and without this
|
|
422
|
+
#: bound every later pass would look older than that orphan and yield to it on
|
|
423
|
+
#: every rebuild until the clock caught up. One hour is far past any real
|
|
424
|
+
#: inter-pass skew and far short of the clock steps that produce the failure.
|
|
425
|
+
PROGRESS_YIELD_MAX_SKEW_US = 3_600 * 1_000_000
|
|
426
|
+
|
|
427
|
+
#: A foreign record so far in the future that it cannot describe a live pass.
|
|
428
|
+
REASON_ORPHANED_PASS = "orphanedPass"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def resume_verdict(stored, *, pass_id, started_at, identity_root,
|
|
432
|
+
physical_seq, source_roots_digest) -> "tuple[str, str, bool]":
|
|
433
|
+
"""``(RESUME | RESTART | YIELD, reason, concurrent_writer)`` after a lock
|
|
434
|
+
reacquisition.
|
|
435
|
+
|
|
436
|
+
Ordering matters. A FOREIGN newer pass is checked before anything else,
|
|
437
|
+
because yielding to it must not be turned into a restart by some other
|
|
438
|
+
mismatch; a foreign OLDER pass is simply overwritten, which is what makes
|
|
439
|
+
the compare-and-swap monotonic.
|
|
440
|
+
|
|
441
|
+
**DELIBERATE DEVIATION from spec §4.5, which restarts on any mismatch of
|
|
442
|
+
the four compared quantities. It narrows ONE of the four, not two.** A moved
|
|
443
|
+
`physicalMutationSeq` is REPORTED — the third element of the result — but
|
|
444
|
+
does not by itself restart the pass; a changed `sourceRootsDigest` restarts
|
|
445
|
+
like every other mismatch. The liveness argument covers only the sequence.
|
|
446
|
+
An ordinary rollout batch bumps the sequence on every status-line tick, and
|
|
447
|
+
what it reports is an ADDITIVE writer whose rows this pass's `INSERT OR
|
|
448
|
+
IGNORE` applies over without any loss of coverage, and whose bump the final
|
|
449
|
+
transaction's mint reads anyway, so it cannot produce a stale-valid
|
|
450
|
+
certificate. Restarting on it would abandon a pass every time a tick landed
|
|
451
|
+
mid-recovery, and after the restart limit would report an uncovered
|
|
452
|
+
remainder for a cache that has no shortfall at all.
|
|
453
|
+
|
|
454
|
+
**The digest does not behave that way and must not share the narrowing.**
|
|
455
|
+
It is computed over the SET of `source_root_key` values, so an ordinary
|
|
456
|
+
batch's `INSERT … ON CONFLICT DO UPDATE SET last_seen_utc` does not move it;
|
|
457
|
+
only a new root appearing or `_prune_inactive_codex_source_roots` deleting
|
|
458
|
+
one does. `_clear_codex_derived_rows` empties `codex_source_roots` along
|
|
459
|
+
with the quota rows, which makes the digest a second, independent witness
|
|
460
|
+
that the destructive path ran — one that does not depend on the progress
|
|
461
|
+
delete having happened. Folding that witness into a report would leave the
|
|
462
|
+
single mechanism designed to catch a destructive clear whose progress delete
|
|
463
|
+
was missed present and deliberately not acted on, so it restarts, and
|
|
464
|
+
restarting on it costs essentially nothing.
|
|
465
|
+
|
|
466
|
+
Everything that does restart is a restart rather than a failure. Restarting
|
|
467
|
+
is always sound — every apply is idempotent on its natural key — so the cost
|
|
468
|
+
of being wrong in that direction is repeated work, while the cost of
|
|
469
|
+
resuming over a cleared cache is a certificate claiming coverage the cache
|
|
470
|
+
does not have.
|
|
471
|
+
"""
|
|
472
|
+
if stored is None:
|
|
473
|
+
return RESTART, REASON_ABSENT, False
|
|
474
|
+
if not isinstance(stored, dict):
|
|
475
|
+
return RESTART, REASON_MALFORMED, False
|
|
476
|
+
try:
|
|
477
|
+
stored_pass = str(stored["passId"])
|
|
478
|
+
stored_started = int(stored["startedAt"])
|
|
479
|
+
int(stored["chunks"])
|
|
480
|
+
stored_root = str(stored["identityRoot"])
|
|
481
|
+
stored_seq = int(stored["physicalMutationSeq"])
|
|
482
|
+
stored_digest = str(stored["sourceRootsDigest"])
|
|
483
|
+
except (KeyError, TypeError, ValueError):
|
|
484
|
+
return RESTART, REASON_MALFORMED, False
|
|
485
|
+
if stored_pass != str(pass_id):
|
|
486
|
+
if stored_started > int(started_at):
|
|
487
|
+
if stored_started - int(started_at) > PROGRESS_YIELD_MAX_SKEW_US:
|
|
488
|
+
return RESTART, REASON_ORPHANED_PASS, False
|
|
489
|
+
return YIELD, "newerPass", True
|
|
490
|
+
# A dead pass's leftover record is not a concurrent writer, and
|
|
491
|
+
# reporting one would put a writer that does not exist on the rebuild
|
|
492
|
+
# record.
|
|
493
|
+
return RESTART, "foreignPass", False
|
|
494
|
+
concurrent = stored_seq != int(physical_seq)
|
|
495
|
+
if stored_digest != str(source_roots_digest):
|
|
496
|
+
return RESTART, "sourceRootsDigest", concurrent
|
|
497
|
+
if stored_root != str(identity_root):
|
|
498
|
+
return RESTART, REASON_IDENTITY_ROOT, concurrent
|
|
499
|
+
return RESUME, REASON_OK, concurrent
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def progress_supersedes(stored, candidate) -> bool:
|
|
503
|
+
"""Whether ``candidate`` may replace ``stored`` under the monotonic CAS.
|
|
504
|
+
|
|
505
|
+
An older worker cannot overwrite a newer pass's progress, and a pass cannot
|
|
506
|
+
move its own progress backwards.
|
|
507
|
+
"""
|
|
508
|
+
if stored is None:
|
|
509
|
+
return True
|
|
510
|
+
try:
|
|
511
|
+
stored_pass = str(stored["passId"])
|
|
512
|
+
stored_started = int(stored["startedAt"])
|
|
513
|
+
stored_chunks = int(stored["chunks"])
|
|
514
|
+
candidate_pass = str(candidate["passId"])
|
|
515
|
+
candidate_started = int(candidate["startedAt"])
|
|
516
|
+
candidate_chunks = int(candidate["chunks"])
|
|
517
|
+
except (KeyError, TypeError, ValueError):
|
|
518
|
+
return True
|
|
519
|
+
if stored_pass == candidate_pass:
|
|
520
|
+
return candidate_chunks > stored_chunks
|
|
521
|
+
return candidate_started > stored_started
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def chunk_spans(sizes, *, byte_cap, record_cap):
|
|
525
|
+
"""``[(start, stop, encoded_bytes), ...]`` over ``sizes``, capped BOTH ways.
|
|
526
|
+
|
|
527
|
+
Capping by records alone lets one chunk of large observations blow the
|
|
528
|
+
memory bound, and capping by bytes alone lets a chunk of tiny ones carry far
|
|
529
|
+
more rows than one transaction should. A single record larger than the byte
|
|
530
|
+
cap still gets its own chunk rather than none — refusing it would stall the
|
|
531
|
+
pass on a record it is required to apply.
|
|
532
|
+
"""
|
|
533
|
+
spans = []
|
|
534
|
+
start = 0
|
|
535
|
+
total = 0
|
|
536
|
+
for index, size in enumerate(sizes):
|
|
537
|
+
size = int(size)
|
|
538
|
+
if index > start and (
|
|
539
|
+
total + size > int(byte_cap) or index - start >= int(record_cap)
|
|
540
|
+
):
|
|
541
|
+
spans.append((start, index, total))
|
|
542
|
+
start = index
|
|
543
|
+
total = 0
|
|
544
|
+
total += size
|
|
545
|
+
if start < len(sizes):
|
|
546
|
+
spans.append((start, len(sizes), total))
|
|
547
|
+
return spans
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -232,6 +232,12 @@ class DoctorState:
|
|
|
232
232
|
# DOCTOR_WAL_WARN_BYTES (2x the WAL cap) — only when the journal_size_limit
|
|
233
233
|
# + forced-checkpoint machinery has genuinely failed to contain the WAL.
|
|
234
234
|
cache_db_wal_bytes: Optional[int] = None
|
|
235
|
+
# #496 S5b: the durable incomplete-quota-projection flag carried inside the
|
|
236
|
+
# published stats generation. True = every quota-projection read is refused
|
|
237
|
+
# until a reconciliation runs; False = the generation is complete; None =
|
|
238
|
+
# there is no epoch-1009 index to ask (absent file, pre-1009 index, or an
|
|
239
|
+
# unreadable DB), which the check reports as not applicable.
|
|
240
|
+
stats_quota_projection_incomplete: Optional[bool] = None
|
|
235
241
|
# #315: read-only PRAGMA page_count/freelist_count evidence. The pure
|
|
236
242
|
# db.reclaimable check warns when free pages reach 25% of cache.db and
|
|
237
243
|
# points at the already-guarded explicit vacuum command. None means the
|
|
@@ -923,8 +929,12 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
|
|
|
923
929
|
if epoch is None:
|
|
924
930
|
# Fallback kept in lockstep with _cctally_core.STATS_INDEX_EPOCH; the
|
|
925
931
|
# gather layer injects the real constant, so this only guards a hand-
|
|
926
|
-
# built DoctorState that omitted it.
|
|
927
|
-
|
|
932
|
+
# built DoctorState that omitted it. It sat at 1000 against a current
|
|
933
|
+
# constant of 1008 for eight epochs, which made this guard report a
|
|
934
|
+
# current index as a mismatch; corrected with the 1009 bump
|
|
935
|
+
# (#496 S5b §6.1). It stays a literal because this kernel is pure and
|
|
936
|
+
# must not import `_cctally_core`.
|
|
937
|
+
epoch = 1009
|
|
928
938
|
mismatch = uv > legacy_head and uv != epoch
|
|
929
939
|
return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
|
|
930
940
|
"mismatch": mismatch}
|
|
@@ -2702,6 +2712,47 @@ def _check_journal_protocol(s: DoctorState) -> CheckResult:
|
|
|
2702
2712
|
)
|
|
2703
2713
|
|
|
2704
2714
|
|
|
2715
|
+
def _check_journal_quota_projection(s: DoctorState) -> CheckResult:
|
|
2716
|
+
"""Report a published stats generation whose quota projection is incomplete
|
|
2717
|
+
(#496 S5b §4.7).
|
|
2718
|
+
|
|
2719
|
+
The flag is set by a rebuild whose Codex quota cache recovery stopped short,
|
|
2720
|
+
and only two things clear it: a reconciliation armed by `cctally cache-sync`
|
|
2721
|
+
or the dashboard server, or a later rebuild whose coverage came back
|
|
2722
|
+
complete. No ingest path clears it, so it can stay set indefinitely while
|
|
2723
|
+
every quota-projection read is refused — and every consumer surface renders
|
|
2724
|
+
that refusal as absent or stale data. Nothing else reported it, which is why
|
|
2725
|
+
this leg exists.
|
|
2726
|
+
|
|
2727
|
+
WARN rather than FAIL: the index is valid, the remedy is one ordinary
|
|
2728
|
+
command, and no data is lost. This check is read-only and never reconciles.
|
|
2729
|
+
"""
|
|
2730
|
+
incomplete = s.stats_quota_projection_incomplete
|
|
2731
|
+
details = {"incomplete": incomplete}
|
|
2732
|
+
if incomplete is True:
|
|
2733
|
+
return CheckResult(
|
|
2734
|
+
id="journal.quota_projection", title="Quota projection",
|
|
2735
|
+
severity="warn",
|
|
2736
|
+
summary="incomplete — quota projection reads are refused",
|
|
2737
|
+
remediation=(
|
|
2738
|
+
"Run `cctally cache-sync` to reconcile the quota projection "
|
|
2739
|
+
"against the journal"
|
|
2740
|
+
),
|
|
2741
|
+
details=details,
|
|
2742
|
+
)
|
|
2743
|
+
if incomplete is False:
|
|
2744
|
+
return CheckResult(
|
|
2745
|
+
id="journal.quota_projection", title="Quota projection",
|
|
2746
|
+
severity="ok", summary="complete", remediation=None,
|
|
2747
|
+
details=details,
|
|
2748
|
+
)
|
|
2749
|
+
return CheckResult(
|
|
2750
|
+
id="journal.quota_projection", title="Quota projection",
|
|
2751
|
+
severity="ok", summary="not applicable", remediation=None,
|
|
2752
|
+
details=details,
|
|
2753
|
+
)
|
|
2754
|
+
|
|
2755
|
+
|
|
2705
2756
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
2706
2757
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
2707
2758
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -2987,6 +3038,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
2987
3038
|
("journal.writer_guard", "_check_journal_writer_guard"),
|
|
2988
3039
|
("journal.conflicts", "_check_journal_conflicts"),
|
|
2989
3040
|
("journal.protocol", "_check_journal_protocol"),
|
|
3041
|
+
("journal.quota_projection", "_check_journal_quota_projection"),
|
|
2990
3042
|
)),
|
|
2991
3043
|
("data", "Data", (
|
|
2992
3044
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|