cctally 1.92.3 → 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 +37 -2
- 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 +3326 -215
- 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_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 +195 -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
|
@@ -30,7 +30,7 @@ import sys
|
|
|
30
30
|
from dataclasses import replace
|
|
31
31
|
|
|
32
32
|
from _cctally_core import make_week_ref, parse_iso_datetime
|
|
33
|
-
from _cctally_quota import codex_quota_breakdown
|
|
33
|
+
from _cctally_quota import assert_projection_readable, codex_quota_breakdown
|
|
34
34
|
from _lib_accounts import UNATTRIBUTED
|
|
35
35
|
from _lib_codex_pools import is_model_scoped_codex_quota
|
|
36
36
|
from _lib_dashboard_sources import dashboard_resource_key
|
|
@@ -677,6 +677,8 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
|
|
|
677
677
|
# `quota_window_blocks.account_key` is NOT NULL DEFAULT 'unattributed'.
|
|
678
678
|
account_clause = "" if account_key is None else "AND account_key=? "
|
|
679
679
|
account_params = () if account_key is None else (account_key,)
|
|
680
|
+
# BEFORE the SQL, per #496 S5b section 4.7.
|
|
681
|
+
assert_projection_readable(stats_conn)
|
|
680
682
|
rows = stats_conn.execute(
|
|
681
683
|
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
682
684
|
" window_minutes, limit_id, limit_name, account_key, "
|
|
@@ -808,6 +810,7 @@ def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
|
|
|
808
810
|
never-combine rule.
|
|
809
811
|
"""
|
|
810
812
|
orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
|
|
813
|
+
assert_projection_readable(stats_conn)
|
|
811
814
|
return stats_conn.execute(
|
|
812
815
|
"SELECT source_root_key, logical_limit_key, observed_slot, "
|
|
813
816
|
" window_minutes, limit_id, limit_name, account_key, "
|
package/bin/_cctally_project.py
CHANGED
|
@@ -768,12 +768,14 @@ def cmd_project(args: argparse.Namespace) -> int:
|
|
|
768
768
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
769
769
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
770
770
|
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
771
|
-
# Privacy invariant (Section 8.4 / 5.3):
|
|
772
|
-
#
|
|
773
|
-
# `project-1` / `project-2` / ...; `--reveal-projects`
|
|
774
|
-
# The builder populates `ProjectCell.label` /
|
|
775
|
-
# / `ChartPoint.x_label` with REAL names;
|
|
776
|
-
# the
|
|
771
|
+
# Privacy invariant (Section 8.4 / 5.3): `_lib_share.render()` prepares
|
|
772
|
+
# the RAW snapshot the wrapper hands it, so default output anonymizes
|
|
773
|
+
# project labels to `project-1` / `project-2` / ...; `--reveal-projects`
|
|
774
|
+
# opts back in. The builder populates `ProjectCell.label` /
|
|
775
|
+
# `ChartPoint.project_label` / `ChartPoint.x_label` with REAL names;
|
|
776
|
+
# `render()` is the chokepoint that rewrites them. (It is NOT `_scrub()`:
|
|
777
|
+
# that function is retained for backward compatibility and no production
|
|
778
|
+
# path calls it.)
|
|
777
779
|
if getattr(args, "format", None):
|
|
778
780
|
# Note: --breakdown is a no-op under --format (snapshot focuses on
|
|
779
781
|
# the headline per-project usage table + HBar chart; per-model
|
package/bin/_cctally_quota.py
CHANGED
|
@@ -15,7 +15,7 @@ import sqlite3
|
|
|
15
15
|
import sys
|
|
16
16
|
import time
|
|
17
17
|
from dataclasses import dataclass
|
|
18
|
-
from typing import Callable, Iterable, Mapping, Sequence
|
|
18
|
+
from typing import Callable, Iterable, Mapping, NoReturn, Sequence
|
|
19
19
|
|
|
20
20
|
import _cctally_core
|
|
21
21
|
import _lib_accounts
|
|
@@ -62,6 +62,354 @@ import _lib_quota_ledger as _ledger
|
|
|
62
62
|
|
|
63
63
|
UTC = dt.timezone.utc
|
|
64
64
|
_DASHBOARD_PROJECTION_CERTIFICATE_KEY = "codex_quota_projection_certificate"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
# the incomplete-quota-projection read gate (#496 S5b §4.7)
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
#
|
|
71
|
+
# The stats quota projection is materialized FROM cache.db, so a rebuild that
|
|
72
|
+
# could not fully recover the cache publishes a semantically PARTIAL projection
|
|
73
|
+
# inside an otherwise valid generation. Completing the cache later does not by
|
|
74
|
+
# itself reconcile that projection.
|
|
75
|
+
#
|
|
76
|
+
# "The next open reconciles it" is not enforceable: `RebuildResult` is
|
|
77
|
+
# process-local, a current-epoch `open_db` returns early without any
|
|
78
|
+
# reconciliation gate, and — decisively — in-place publication deliberately
|
|
79
|
+
# keeps already-open readers alive, so a connection can finish its old read
|
|
80
|
+
# transaction and observe the incomplete new generation without ever calling
|
|
81
|
+
# `open_db` again. The gate is therefore durable and PER TRANSACTION.
|
|
82
|
+
#
|
|
83
|
+
# Three properties decide its shape:
|
|
84
|
+
#
|
|
85
|
+
# 1. It cannot be an authorizer-style denial, because projection reads are
|
|
86
|
+
# scattered across the dashboard, milestone-history, quota and library
|
|
87
|
+
# modules rather than centralized.
|
|
88
|
+
# 2. It must run BEFORE any fallback-catching SQL. Most of those reads sit
|
|
89
|
+
# inside `except sqlite3.Error` handlers that would render a denial as empty
|
|
90
|
+
# data instead of as an error.
|
|
91
|
+
# 3. It never acquires a lock. Inside a caller transaction it raises a typed
|
|
92
|
+
# retry signal rather than starting reconciliation, because taking the
|
|
93
|
+
# maintenance and cache locks after a SQLite transaction has opened inverts
|
|
94
|
+
# the repository's lock order. The caller ends its transaction and retries.
|
|
95
|
+
|
|
96
|
+
#: The one remedy every refusal names. Only two things clear the durable
|
|
97
|
+
#: incomplete flag — a reconciliation, which only `cctally cache-sync` and the
|
|
98
|
+
#: dashboard server arm, and a later rebuild whose coverage came back complete —
|
|
99
|
+
#: so a surface that says only "incomplete" leaves the user with no next step.
|
|
100
|
+
#: It is appended to every `QuotaProjectionIncomplete` message rather than left
|
|
101
|
+
#: to each caller, because the ten raise sites reach surfaces that render the
|
|
102
|
+
#: exception STRING (the TUI's `last_sync_error`, the dashboard's error paths)
|
|
103
|
+
#: and would otherwise each have to restate it.
|
|
104
|
+
QUOTA_PROJECTION_REMEDY = (
|
|
105
|
+
"run `cctally cache-sync` to reconcile it"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class QuotaProjectionIncomplete(Exception):
|
|
110
|
+
"""The published quota projection is incomplete — a RETRY signal.
|
|
111
|
+
|
|
112
|
+
Not an error verdict: the projection is reconcilable, and the caller's job
|
|
113
|
+
is to end its transaction and retry rather than to report a failure. It
|
|
114
|
+
carries the VERSIONED recovery target rather than a bare coordinate, so a
|
|
115
|
+
target written by one binary is never misread by another.
|
|
116
|
+
|
|
117
|
+
The decided contract for a gated caller is: end the transaction, let a
|
|
118
|
+
maintenance-capable process reconcile (`cctally cache-sync`, or the
|
|
119
|
+
dashboard server, which arms
|
|
120
|
+
`_cctally_journal.reconcile_incomplete_quota_projection` at its own opens),
|
|
121
|
+
and retry once — render a "reconciling" state naming that remedy rather than
|
|
122
|
+
empty data if the retry is still refused.
|
|
123
|
+
|
|
124
|
+
**Where the contract is implemented, by path rather than by module.** Every
|
|
125
|
+
message carries `QUOTA_PROJECTION_REMEDY`, so any surface that renders the
|
|
126
|
+
exception string names the remedy.
|
|
127
|
+
|
|
128
|
+
The three handlers in `bin/_cctally_tui.py` catch this ahead of their
|
|
129
|
+
`except Exception` neighbours, so the refusal is attributed to its own
|
|
130
|
+
`quota-projection` leg instead of being sanitized into a generic
|
|
131
|
+
stats-or-cache failure. **Those handlers cover the dashboard's snapshot
|
|
132
|
+
build too**, and an earlier version of this docstring wrongly said they did
|
|
133
|
+
not: `bin/_cctally_dashboard.py` builds its snapshot by calling
|
|
134
|
+
`_tui_build_snapshot(..., precompute_envelope=True)`, and
|
|
135
|
+
`_tui_build_source_bundle` — which reaches all four
|
|
136
|
+
`bin/_cctally_dashboard_sources.py` sites — is reachable only under that
|
|
137
|
+
flag. `_sync_failure_envelope` turns the resulting attribution into a
|
|
138
|
+
rendered `quota_projection_incomplete` state whose `action` names
|
|
139
|
+
`cctally cache-sync`, on the existing rendered contract, so no
|
|
140
|
+
`dashboard/web/` change and no real-browser QA gate is involved.
|
|
141
|
+
|
|
142
|
+
The two `bin/_cctally_dashboard.py` HTTP route handlers — `/api/milestones`
|
|
143
|
+
cycle detail and the `/api/source/…` route that reaches
|
|
144
|
+
`_build_codex_block_detail`, and through it the two
|
|
145
|
+
`bin/_cctally_milestone_history.py` sites — are a separate path with no
|
|
146
|
+
snapshot and no envelope. They answer a typed **503**
|
|
147
|
+
`quota_projection_incomplete` carrying the same `action`, instead of the
|
|
148
|
+
generic 500 and 400 that reported a server fault and named no remedy.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, message, *, target_version=0, recovery_target=None):
|
|
152
|
+
super().__init__(message)
|
|
153
|
+
self.target_version = target_version
|
|
154
|
+
self.recovery_target = recovery_target
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
#: Every direct read of `quota_window_blocks` or `quota_projection_state` whose
|
|
158
|
+
#: TABLE NAME IS A LITERAL, as `<file>::<function>::<table>`. A static guard
|
|
159
|
+
#: keeps this complete, the same discipline `FROZEN_WRITE_SITES` applies to
|
|
160
|
+
#: writes — a new site has to be a deliberate act rather than a silent one.
|
|
161
|
+
#:
|
|
162
|
+
#: The name says "read sites" and one member is a `DELETE FROM
|
|
163
|
+
#: quota_projection_state`: the scanner resolves a target after `FROM` or
|
|
164
|
+
#: `JOIN`, and a DELETE writes through the same `FROM`. It is classified
|
|
165
|
+
#: `projector` so the outcome is identical either way, and it is left in rather
|
|
166
|
+
#: than special-cased, because a scanner that skipped `DELETE ... FROM` would
|
|
167
|
+
#: also have to decide what to do with every other statement form and would
|
|
168
|
+
#: acquire a blind spot doing it.
|
|
169
|
+
#:
|
|
170
|
+
#: A site whose target is INTERPOLATED is invisible to this set — `FROM {table}`
|
|
171
|
+
#: has no literal table name. `PROJECTION_DYNAMIC_READ_SITES` below freezes
|
|
172
|
+
#: those by count, which is the only property a static scan can freeze.
|
|
173
|
+
PROJECTION_READ_CHOKEPOINTS: "frozenset[str]" = frozenset({
|
|
174
|
+
"_cctally_dashboard.py::_build_codex_block_detail::quota_window_blocks",
|
|
175
|
+
"_cctally_dashboard.py::_handle_get_milestones_week::quota_window_blocks",
|
|
176
|
+
"_cctally_dashboard_sources.py::_codex_weekly_periods::quota_window_blocks",
|
|
177
|
+
"_cctally_dashboard_sources.py::codex_projection_coherence::"
|
|
178
|
+
"quota_projection_state",
|
|
179
|
+
"_cctally_dashboard_sources.py::_quota_wire::quota_window_blocks",
|
|
180
|
+
"_cctally_dashboard_sources.py::_codex_block_account_keys::"
|
|
181
|
+
"quota_window_blocks",
|
|
182
|
+
"_cctally_milestone_history.py::_load_codex_cycles::quota_window_blocks",
|
|
183
|
+
"_cctally_milestone_history.py::_codex_five_hour_rows::quota_window_blocks",
|
|
184
|
+
"_lib_dashboard_sources.py::<module>::quota_projection_state",
|
|
185
|
+
"_lib_dashboard_sources.py::<module>::quota_window_blocks",
|
|
186
|
+
"_cctally_quota.py::_stats_projection_signatures_match::"
|
|
187
|
+
"quota_projection_state",
|
|
188
|
+
"_cctally_quota.py::_blocks_missing_reverse_map::quota_window_blocks",
|
|
189
|
+
"_cctally_quota.py::_root_group_pairs::quota_window_blocks",
|
|
190
|
+
"_cctally_quota.py::_root_accounts::quota_window_blocks",
|
|
191
|
+
"_cctally_quota.py::<module>::quota_window_blocks",
|
|
192
|
+
"_cctally_quota.py::_orphan_unseen::quota_window_blocks",
|
|
193
|
+
"_cctally_quota.py::_orphan_unseen_scoped::quota_window_blocks",
|
|
194
|
+
"_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state",
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
#: What each enumerated site does about the gate.
|
|
198
|
+
#:
|
|
199
|
+
#: `gate` — `assert_projection_readable` runs in that function before its SQL.
|
|
200
|
+
#: `gate_at_caller` — the read is in a pure kernel that may not import this
|
|
201
|
+
#: module, so its callers gate it instead. Every such site MUST name those
|
|
202
|
+
#: callers in `PROJECTION_GATE_CALLERS`, and the guard resolves each named
|
|
203
|
+
#: caller and fails when that function does not call the gate.
|
|
204
|
+
#: `projector` — a read by the projection MACHINERY itself. Gating these would
|
|
205
|
+
#: deadlock recovery: they are what re-materializes the projection and clears
|
|
206
|
+
#: the flag, so they must be able to read while it is set.
|
|
207
|
+
#: `diagnostic` — a debug surface that reports a raw row COUNT and renders no
|
|
208
|
+
#: projection value. Gating one would replace a diagnostic answer with an
|
|
209
|
+
#: exception at exactly the moment an operator is diagnosing the incomplete
|
|
210
|
+
#: projection, which inverts what the surface is for.
|
|
211
|
+
PROJECTION_READ_SITE_ACTIONS: "dict[str, str]" = {
|
|
212
|
+
"_cctally_dashboard.py::_build_codex_block_detail::quota_window_blocks":
|
|
213
|
+
"gate",
|
|
214
|
+
"_cctally_dashboard.py::_handle_get_milestones_week::quota_window_blocks":
|
|
215
|
+
"gate",
|
|
216
|
+
"_cctally_dashboard_sources.py::_codex_weekly_periods::quota_window_blocks":
|
|
217
|
+
"gate",
|
|
218
|
+
"_cctally_dashboard_sources.py::codex_projection_coherence::"
|
|
219
|
+
"quota_projection_state": "gate",
|
|
220
|
+
"_cctally_dashboard_sources.py::_quota_wire::quota_window_blocks": "gate",
|
|
221
|
+
"_cctally_dashboard_sources.py::_codex_block_account_keys::"
|
|
222
|
+
"quota_window_blocks": "gate",
|
|
223
|
+
"_cctally_milestone_history.py::_load_codex_cycles::quota_window_blocks":
|
|
224
|
+
"gate",
|
|
225
|
+
"_cctally_milestone_history.py::_codex_five_hour_rows::quota_window_blocks":
|
|
226
|
+
"gate",
|
|
227
|
+
# `codex_stats_digest`'s relation table is module-level in a pure kernel
|
|
228
|
+
# that must not import this module, so its callers gate it. They are named
|
|
229
|
+
# in `PROJECTION_GATE_CALLERS` and the guard verifies each one.
|
|
230
|
+
"_lib_dashboard_sources.py::<module>::quota_projection_state":
|
|
231
|
+
"gate_at_caller",
|
|
232
|
+
"_lib_dashboard_sources.py::<module>::quota_window_blocks":
|
|
233
|
+
"gate_at_caller",
|
|
234
|
+
"_cctally_quota.py::_stats_projection_signatures_match::"
|
|
235
|
+
"quota_projection_state": "projector",
|
|
236
|
+
"_cctally_quota.py::_blocks_missing_reverse_map::quota_window_blocks":
|
|
237
|
+
"projector",
|
|
238
|
+
"_cctally_quota.py::_root_group_pairs::quota_window_blocks": "projector",
|
|
239
|
+
"_cctally_quota.py::_root_accounts::quota_window_blocks": "projector",
|
|
240
|
+
# A module-level sweep-scoping SQL constant, not a function body.
|
|
241
|
+
"_cctally_quota.py::<module>::quota_window_blocks": "projector",
|
|
242
|
+
"_cctally_quota.py::_orphan_unseen::quota_window_blocks": "projector",
|
|
243
|
+
"_cctally_quota.py::_orphan_unseen_scoped::quota_window_blocks":
|
|
244
|
+
"projector",
|
|
245
|
+
"_cctally_quota.py::_apply_quota_projection_rows::quota_projection_state":
|
|
246
|
+
"projector",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#: For each `gate_at_caller` site, the `<file>::<function>` callers that run the
|
|
250
|
+
#: gate on its behalf. Naming them is what makes the classification checkable:
|
|
251
|
+
#: an unnamed caller reduces `gate_at_caller` to an assertion nothing tests, and
|
|
252
|
+
#: that is how the first version of this map came to claim a gating caller that
|
|
253
|
+
#: neither called the kernel nor called the gate.
|
|
254
|
+
PROJECTION_GATE_CALLERS: "dict[str, tuple[str, ...]]" = {
|
|
255
|
+
"_lib_dashboard_sources.py::<module>::quota_projection_state": (
|
|
256
|
+
"_cctally_tui.py::_tui_build_source_bundle",
|
|
257
|
+
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
258
|
+
),
|
|
259
|
+
"_lib_dashboard_sources.py::<module>::quota_window_blocks": (
|
|
260
|
+
"_cctally_tui.py::_tui_build_source_bundle",
|
|
261
|
+
"_cctally_tui.py::_tui_compute_dispatch_signature",
|
|
262
|
+
),
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#: Dynamic-target read sites per file — a `FROM`/`JOIN` whose target is
|
|
266
|
+
#: interpolated, so only the COUNT can be frozen. `bin/cctally` carries none.
|
|
267
|
+
#:
|
|
268
|
+
#: This exists because `PROJECTION_READ_CHOKEPOINTS`' scanner reads string
|
|
269
|
+
#: LITERALS, and a read written as `f"SELECT COUNT(*) FROM {table} WHERE
|
|
270
|
+
#: {where}"` therefore reaches `quota_window_blocks` while being invisible to
|
|
271
|
+
#: it — which is exactly what happened to `_cctally_dashboard._debug_source_
|
|
272
|
+
#: counts`. The count cannot say which table a site reaches, but it does make a
|
|
273
|
+
#: NEW dynamic read impossible to add silently, and the author then has to
|
|
274
|
+
#: classify it in `PROJECTION_DYNAMIC_READ_ACTIONS` if it reaches a projection
|
|
275
|
+
#: family. It mirrors `FROZEN_DYNAMIC_SITES` in
|
|
276
|
+
#: `tests/test_stats_writer_surface_386.py`, which solved the same problem for
|
|
277
|
+
#: the write surface.
|
|
278
|
+
PROJECTION_DYNAMIC_READ_SITES: "dict[str, int]" = {
|
|
279
|
+
"_cctally_account.py": 1,
|
|
280
|
+
# Three, all in `_import_legacy_conversation_rows`: `FROM main.{table}`,
|
|
281
|
+
# `FROM cache_db.{table}` and the `SELECT … FROM cache_db.{table}` of the
|
|
282
|
+
# copy. They were invisible to BOTH guards until #496 S5b gave the read
|
|
283
|
+
# patterns the schema-qualifier prefix the write scan already had, which is
|
|
284
|
+
# the hole `PROJECTION_DYNAMIC_READ_SITES` exists to close. `{table}` there
|
|
285
|
+
# iterates a hardcoded conversation-table tuple, so none of them reaches a
|
|
286
|
+
# projection family and none needs an entry below.
|
|
287
|
+
"_cctally_cache.py": 3,
|
|
288
|
+
"_cctally_core.py": 2,
|
|
289
|
+
"_cctally_dashboard.py": 3,
|
|
290
|
+
"_cctally_dashboard_envelope.py": 5,
|
|
291
|
+
"_cctally_db.py": 7,
|
|
292
|
+
"_cctally_doctor.py": 1,
|
|
293
|
+
"_cctally_five_hour.py": 1,
|
|
294
|
+
"_cctally_journal.py": 18,
|
|
295
|
+
"_cctally_pricing_check.py": 1,
|
|
296
|
+
"_cctally_quota.py": 1,
|
|
297
|
+
"_cctally_record.py": 1,
|
|
298
|
+
"_cctally_release.py": 4,
|
|
299
|
+
"_cctally_setup.py": 3,
|
|
300
|
+
"_cctally_tui.py": 1,
|
|
301
|
+
"_lib_conversation_query.py": 1,
|
|
302
|
+
"_lib_conversation_retention.py": 2,
|
|
303
|
+
"_lib_doctor.py": 1,
|
|
304
|
+
"_lib_snapshot_cache.py": 1,
|
|
305
|
+
"_lib_subscription_weeks.py": 1,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
#: The dynamic-target reads that provably reach a projection family, named by
|
|
309
|
+
#: hand as `<file>::<function>`, with the same action vocabulary as
|
|
310
|
+
#: `PROJECTION_READ_SITE_ACTIONS`. The scan cannot resolve `{table}`, so this is
|
|
311
|
+
#: the human half of the count freeze above.
|
|
312
|
+
PROJECTION_DYNAMIC_READ_ACTIONS: "dict[str, str]" = {
|
|
313
|
+
# `_DEBUG_SOURCE_STATS_TABLES` carries `("quota_window_blocks",
|
|
314
|
+
# "source='codex'")` and the query is built as
|
|
315
|
+
# `f"SELECT COUNT(*) FROM {table} WHERE {where}"`. It answers a debug
|
|
316
|
+
# endpoint with a row count and renders no projection value, so it is
|
|
317
|
+
# `diagnostic` rather than `gate` — see the vocabulary above.
|
|
318
|
+
"_cctally_dashboard.py::_debug_source_counts": "diagnostic",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _refuse_unreadable_flag(exc: "sqlite3.Error") -> NoReturn:
|
|
323
|
+
"""Fail closed on an unreadable flag — but never OWN a corrupt index.
|
|
324
|
+
|
|
325
|
+
Annotated `NoReturn` because both call sites depend on it: each is an
|
|
326
|
+
`except sqlite3.Error` arm followed immediately by a statement reading the
|
|
327
|
+
name the failed query would have bound, so a return here would raise
|
|
328
|
+
`UnboundLocalError` instead of failing closed. The annotation makes "always
|
|
329
|
+
raises" checkable rather than a property a reader has to infer.
|
|
330
|
+
|
|
331
|
+
A corrupt `stats.db` fails this probe like any other unreadable one, and
|
|
332
|
+
wrapping it would file it as "the quota projection is incomplete". That is
|
|
333
|
+
the wrong owner and it costs the right one its signal: `_cctally_tui`
|
|
334
|
+
catches `QuotaProjectionIncomplete` ahead of the corruption branch, so a
|
|
335
|
+
wrapped corruption error never became `_StatsSnapshotCorruption` and never
|
|
336
|
+
reached the #407 heal — the index stayed corrupt while every surface
|
|
337
|
+
reported a reconcilable quota view. A corruption error is therefore
|
|
338
|
+
re-raised UNCHANGED, which is still fail-closed: no projection value is
|
|
339
|
+
served either way, and the caller that owns corruption gets to see it.
|
|
340
|
+
"""
|
|
341
|
+
import _cctally_db
|
|
342
|
+
|
|
343
|
+
if _cctally_db._is_sqlite_corruption_error(exc):
|
|
344
|
+
raise exc
|
|
345
|
+
raise QuotaProjectionIncomplete(
|
|
346
|
+
f"the quota projection flag could not be read ({exc}); "
|
|
347
|
+
+ QUOTA_PROJECTION_REMEDY
|
|
348
|
+
) from exc
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def assert_projection_readable(conn) -> None:
|
|
352
|
+
"""Refuse a projection read while the published projection is incomplete.
|
|
353
|
+
|
|
354
|
+
Raises :class:`QuotaProjectionIncomplete`. Acquires no lock and starts no
|
|
355
|
+
reconciliation, so it is safe inside a caller transaction — which is the
|
|
356
|
+
only placement that covers a connection opened BEFORE the publication.
|
|
357
|
+
|
|
358
|
+
Exactly ONE condition is read as "readable": a MISSING
|
|
359
|
+
`stats_quota_projection_state` table. That table arrived with the
|
|
360
|
+
epoch-1009 stats index, so its absence means the index predates the flag
|
|
361
|
+
and there is no incomplete projection for the flag to describe. Every other
|
|
362
|
+
`sqlite3.Error` fails CLOSED and raises, because "I could not read the flag"
|
|
363
|
+
is not "the flag is clear".
|
|
364
|
+
|
|
365
|
+
Absence is decided STRUCTURALLY, by asking `sqlite_master`, rather than by
|
|
366
|
+
matching `no such table` against an error message. The message form has no
|
|
367
|
+
constructible false negative, but it does have a false POSITIVE — a message
|
|
368
|
+
embedding that phrase for another reason, such as a view or trigger
|
|
369
|
+
resolving through a missing table — and a false positive here fails OPEN,
|
|
370
|
+
which is the direction this gate exists to prevent. The extra query is one
|
|
371
|
+
cheap lookup, and it still raises in the unreadable-database case because
|
|
372
|
+
`sqlite_master` is unreadable there too.
|
|
373
|
+
|
|
374
|
+
The distinction matters because the old justification — that an epoch-1009
|
|
375
|
+
index always carries the table, so a failing probe cannot be serving an
|
|
376
|
+
incomplete projection — is false. A connection can be alive, hold the
|
|
377
|
+
epoch-1009 index open and still fail its reads for a reason that is not
|
|
378
|
+
absence, and that connection is exactly the one §4.7's per-transaction gate
|
|
379
|
+
exists to cover.
|
|
380
|
+
"""
|
|
381
|
+
if conn is None:
|
|
382
|
+
return
|
|
383
|
+
try:
|
|
384
|
+
present = conn.execute(
|
|
385
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
|
386
|
+
"AND name='stats_quota_projection_state'"
|
|
387
|
+
).fetchone()
|
|
388
|
+
except sqlite3.Error as exc:
|
|
389
|
+
_refuse_unreadable_flag(exc)
|
|
390
|
+
if present is None:
|
|
391
|
+
return
|
|
392
|
+
try:
|
|
393
|
+
row = conn.execute(
|
|
394
|
+
"SELECT incomplete, target_version, recovery_target_json "
|
|
395
|
+
"FROM stats_quota_projection_state WHERE id = 1"
|
|
396
|
+
).fetchone()
|
|
397
|
+
except sqlite3.Error as exc:
|
|
398
|
+
_refuse_unreadable_flag(exc)
|
|
399
|
+
if row is None or not int(row[0] or 0):
|
|
400
|
+
return
|
|
401
|
+
target = None
|
|
402
|
+
if row[2]:
|
|
403
|
+
try:
|
|
404
|
+
target = json.loads(row[2])
|
|
405
|
+
except ValueError:
|
|
406
|
+
target = None
|
|
407
|
+
raise QuotaProjectionIncomplete(
|
|
408
|
+
"the published quota projection is incomplete; "
|
|
409
|
+
+ QUOTA_PROJECTION_REMEDY,
|
|
410
|
+
target_version=int(row[1] or 0),
|
|
411
|
+
recovery_target=target,
|
|
412
|
+
)
|
|
65
413
|
# 2 -> 3 (public #5): the root physical signature is now COMPOSED from per-group
|
|
66
414
|
# digests instead of digested over a whole root's observation tuples. The
|
|
67
415
|
# semantics are unchanged — still an exact-equality function of the physical
|
|
@@ -717,7 +1065,7 @@ def _blocks_missing_reverse_map(stats_conn: sqlite3.Connection) -> bool:
|
|
|
717
1065
|
|
|
718
1066
|
A scoped sweep matches on ``physical_group_key``, so a NULL there would
|
|
719
1067
|
silently escape it and the stale block would survive indefinitely. The
|
|
720
|
-
epoch rebuild (1005 introduced the column; current epoch
|
|
1068
|
+
epoch rebuild (1005 introduced the column; current epoch 1009) stamps every row, and
|
|
721
1069
|
this is the guard that turns the
|
|
722
1070
|
one shape it cannot reach — a block written by an older binary against an
|
|
723
1071
|
already-current index — into a full pass rather than a missed one.
|
|
@@ -2180,7 +2528,56 @@ def _apply_quota_projection_rows(
|
|
|
2180
2528
|
)
|
|
2181
2529
|
|
|
2182
2530
|
|
|
2183
|
-
|
|
2531
|
+
@dataclass(frozen=True)
|
|
2532
|
+
class QuotaProjectionBundle:
|
|
2533
|
+
"""Everything the rebuild's projection pass reads out of `cache.db`.
|
|
2534
|
+
|
|
2535
|
+
Spec §4.4 requires the rebuild to capture the coverage certificate, the
|
|
2536
|
+
physical mutation sequence, the source roots, the observations and the
|
|
2537
|
+
ledger state from ONE read-only WAL snapshot. Before this existed the leg
|
|
2538
|
+
captured the first two and the projection opened its own connection later,
|
|
2539
|
+
so a destructive clear landing between them published a generation whose
|
|
2540
|
+
quota projection was materialized from a cleared cache while the coverage
|
|
2541
|
+
verdict already read `covered`.
|
|
2542
|
+
|
|
2543
|
+
The projection CERTIFICATE §4.4 also names is not carried here, and that is
|
|
2544
|
+
not an omission: the rebuild pass runs `_apply_quota_projection_rows` with
|
|
2545
|
+
`holder=None`, so it neither reads nor writes that certificate. Adding a
|
|
2546
|
+
field nothing consumes would be the same defect the recovery progress
|
|
2547
|
+
record's applied-count was.
|
|
2548
|
+
"""
|
|
2549
|
+
|
|
2550
|
+
active_roots: "set[str]"
|
|
2551
|
+
observations: object
|
|
2552
|
+
watermark: "int | None"
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
def load_quota_projection_bundle(cache_conn) -> QuotaProjectionBundle:
|
|
2556
|
+
"""Read the bundle from ``cache_conn``, inside whatever transaction it holds.
|
|
2557
|
+
|
|
2558
|
+
The caller owns the transaction, which is the whole point: on the rebuild's
|
|
2559
|
+
intact path the connection is the one that already read the coverage
|
|
2560
|
+
certificate, and under WAL its read snapshot is fixed at that first read, so
|
|
2561
|
+
these three reads and that certificate describe one cache state.
|
|
2562
|
+
"""
|
|
2563
|
+
active_roots = _cache_root_keys(cache_conn)
|
|
2564
|
+
observations = load_codex_quota_observations(
|
|
2565
|
+
source_root_keys=None, cache_conn=cache_conn,
|
|
2566
|
+
)
|
|
2567
|
+
# A rebuild is a whole-history pass by definition, so it also initializes
|
|
2568
|
+
# the watermark: every ledger entry up to here is already reflected in what
|
|
2569
|
+
# it materialized, and leaving the watermark at zero would make the next
|
|
2570
|
+
# tick replay the entire ledger for nothing.
|
|
2571
|
+
watermark = _ledger_max_seq(cache_conn)
|
|
2572
|
+
return QuotaProjectionBundle(
|
|
2573
|
+
active_roots=active_roots, observations=observations,
|
|
2574
|
+
watermark=watermark,
|
|
2575
|
+
)
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
def rematerialize_quota_projection_for_rebuild(
|
|
2579
|
+
stats_conn, *, now=None, bundle=None,
|
|
2580
|
+
) -> None:
|
|
2184
2581
|
"""Rebuild path (spec §5.4 / §5.3 "projection"): re-run the Codex quota
|
|
2185
2582
|
projection over the materialized cache.db ``quota_window_snapshots`` directly
|
|
2186
2583
|
onto the fresh rebuilt ``stats_conn``, side-effect-free.
|
|
@@ -2193,28 +2590,31 @@ def rematerialize_quota_projection_for_rebuild(stats_conn, *, now=None) -> None:
|
|
|
2193
2590
|
SAME ``active_roots`` source as the live reconcile (``_cache_root_keys`` over
|
|
2194
2591
|
the cache) so the materialized projection matches live. A missing cache.db is
|
|
2195
2592
|
a clean no-op (the journal quota obs remain the durable source; a later
|
|
2196
|
-
``cache-sync`` + reconcile re-materializes).
|
|
2593
|
+
``cache-sync`` + reconcile re-materializes).
|
|
2594
|
+
|
|
2595
|
+
``bundle`` is the §4.4 single snapshot. When the caller supplies one this
|
|
2596
|
+
function opens NO cache connection of its own, so the projection is
|
|
2597
|
+
materialized from the same cache state the coverage verdict was decided
|
|
2598
|
+
against. When it is absent — the recovery path, where the leg wrote to the
|
|
2599
|
+
cache and a snapshot taken before those writes would miss them — the
|
|
2600
|
+
function reads its own, which is what it always did."""
|
|
2197
2601
|
if now is None:
|
|
2198
2602
|
now = dt.datetime.now(UTC)
|
|
2199
2603
|
if now.tzinfo is None or now.utcoffset() is None:
|
|
2200
2604
|
raise ValueError("now must be timezone-aware")
|
|
2201
2605
|
now_iso = _utc_iso(now)
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
# zero would make the next tick replay the entire ledger for nothing.
|
|
2215
|
-
watermark = _ledger_max_seq(cache)
|
|
2216
|
-
finally:
|
|
2217
|
-
cache.close()
|
|
2606
|
+
if bundle is None:
|
|
2607
|
+
try:
|
|
2608
|
+
cache = _cache_connection()
|
|
2609
|
+
except (FileNotFoundError, sqlite3.Error):
|
|
2610
|
+
return
|
|
2611
|
+
try:
|
|
2612
|
+
bundle = load_quota_projection_bundle(cache)
|
|
2613
|
+
finally:
|
|
2614
|
+
cache.close()
|
|
2615
|
+
active_roots = bundle.active_roots
|
|
2616
|
+
observations = bundle.observations
|
|
2617
|
+
watermark = bundle.watermark
|
|
2218
2618
|
_apply_quota_projection_rows(
|
|
2219
2619
|
stats_conn, observations=observations, active_roots=active_roots,
|
|
2220
2620
|
now=now, now_iso=now_iso, sink=None,
|
|
@@ -573,12 +573,14 @@ def cmd_session(args: argparse.Namespace) -> int:
|
|
|
573
573
|
# Shareable-reports gate: --format short-circuits the JSON / table
|
|
574
574
|
# dispatch via `_share_render_and_emit`. The mutex in
|
|
575
575
|
# `_add_share_args` keeps `--format` and `--json` from coexisting.
|
|
576
|
-
# Privacy invariant (Section 8.4 / 5.3):
|
|
577
|
-
#
|
|
578
|
-
# `project-1` / `project-2` / ...; `--reveal-projects`
|
|
579
|
-
# The builder populates `ProjectCell.label` /
|
|
580
|
-
# / `ChartPoint.x_label` with REAL basenames;
|
|
581
|
-
# is the
|
|
576
|
+
# Privacy invariant (Section 8.4 / 5.3): `_lib_share.render()` prepares
|
|
577
|
+
# the RAW snapshot the wrapper hands it, so default output anonymizes
|
|
578
|
+
# project labels to `project-1` / `project-2` / ...; `--reveal-projects`
|
|
579
|
+
# opts back in. The builder populates `ProjectCell.label` /
|
|
580
|
+
# `ChartPoint.project_label` / `ChartPoint.x_label` with REAL basenames;
|
|
581
|
+
# `render()` is the chokepoint that rewrites them. (It is NOT `_scrub()`:
|
|
582
|
+
# that function is retained for backward compatibility and no production
|
|
583
|
+
# path calls it.)
|
|
582
584
|
if getattr(args, "format", None):
|
|
583
585
|
# --top-n validation. Spec convention (Implementor 6 fix-loop):
|
|
584
586
|
# invalid flag combinations exit 2; the soft-warn upper threshold
|