cctally 1.98.0 → 1.99.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 +23 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +375 -19
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1501 -118
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +65 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +508 -47
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +26 -1
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +236 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +12 -0
- package/dashboard/static/assets/index-Bcbm-DNP.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CC8TTZUC.css +0 -1
- package/dashboard/static/assets/index-CChXFhs_.js +0 -97
package/bin/_cctally_journal.py
CHANGED
|
@@ -1698,7 +1698,7 @@ def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
|
|
|
1698
1698
|
buf_at += start
|
|
1699
1699
|
|
|
1700
1700
|
|
|
1701
|
-
def iter_range(cursor, hw):
|
|
1701
|
+
def iter_range(cursor, hw, segments=None):
|
|
1702
1702
|
"""Stream `cursor -> HW` across segments in canonical order (spec §5.2.2).
|
|
1703
1703
|
|
|
1704
1704
|
Prior segments (before HW's) are immutable and read to their full size;
|
|
@@ -1710,8 +1710,14 @@ def iter_range(cursor, hw):
|
|
|
1710
1710
|
the size of the whole journal on the hot path. `_read_range` remains the
|
|
1711
1711
|
materialized form for the ingest cycle, which genuinely needs the batch as
|
|
1712
1712
|
an indexable sequence (prefix-stop indices address into it).
|
|
1713
|
+
|
|
1714
|
+
`segments` lets a caller supply the enumeration it already took. The fused
|
|
1715
|
+
Codex rehydration needs it: it derives per-family range floors as indices
|
|
1716
|
+
into a segment list, and those keys only address the same positions the
|
|
1717
|
+
traversal yields while both come from ONE enumeration.
|
|
1713
1718
|
"""
|
|
1714
|
-
yield from _iter_range_with_segments(
|
|
1719
|
+
yield from _iter_range_with_segments(
|
|
1720
|
+
cursor, hw, list_segments() if segments is None else segments)
|
|
1715
1721
|
|
|
1716
1722
|
|
|
1717
1723
|
def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
|
|
@@ -1746,13 +1752,21 @@ def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
|
|
|
1746
1752
|
if hw_seg not in segments:
|
|
1747
1753
|
return
|
|
1748
1754
|
hw_idx = segments.index(hw_seg)
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1755
|
+
start_idx, start_off = 0, 0
|
|
1756
|
+
if cursor is not None:
|
|
1757
|
+
# The unpack sits INSIDE the guard, which is what
|
|
1758
|
+
# `_journal_cursor_order_key`'s docstring already claims the two
|
|
1759
|
+
# functions do alike: a cursor this enumeration cannot place restarts
|
|
1760
|
+
# the pass from the beginning rather than raising. Unreachable today
|
|
1761
|
+
# because both loaders validate before calling, and a from-zero pass is
|
|
1762
|
+
# always sound because every apply is idempotent on its natural key —
|
|
1763
|
+
# but a raise here would turn an unreadable cursor into a failed pass
|
|
1764
|
+
# instead of a complete one.
|
|
1765
|
+
try:
|
|
1766
|
+
cur_seg, cur_off = cursor
|
|
1767
|
+
if cur_seg in segments:
|
|
1768
|
+
start_idx, start_off = segments.index(cur_seg), int(cur_off)
|
|
1769
|
+
except (TypeError, ValueError):
|
|
1756
1770
|
start_idx, start_off = 0, 0
|
|
1757
1771
|
for idx in range(start_idx, hw_idx + 1):
|
|
1758
1772
|
seg = segments[idx]
|
|
@@ -2005,6 +2019,269 @@ def _file_account_values(rec: dict) -> tuple:
|
|
|
2005
2019
|
)
|
|
2006
2020
|
|
|
2007
2021
|
|
|
2022
|
+
# --------------------------------------------------------------------------
|
|
2023
|
+
# #500: the third family this leg carries — the operator's durable attribution
|
|
2024
|
+
# of one recorded Codex quota window group to one account (spec §5, §6.1). It
|
|
2025
|
+
# shares the leg for the same reason the attribution map does: `run_stats_ingest`
|
|
2026
|
+
# invokes exactly ONE applier and truncates the batch afterwards, so a second
|
|
2027
|
+
# independent prefix-stopping applier could commit past this one's stop.
|
|
2028
|
+
# --------------------------------------------------------------------------
|
|
2029
|
+
|
|
2030
|
+
_WINDOW_ATTRIBUTION_KIND = _lib_journal.WINDOW_ATTRIBUTION_KIND
|
|
2031
|
+
_WINDOW_ATTRIBUTION_RETRACT_KIND = _lib_journal.WINDOW_ATTRIBUTION_RETRACT_KIND
|
|
2032
|
+
# Byte prefilter for the streamed replay, exactly as the file-account family
|
|
2033
|
+
# uses: the canonical encoder is `json.dumps(..., ensure_ascii=False)`, which
|
|
2034
|
+
# never escapes an ASCII token, so a genuine op carries the kind verbatim. The
|
|
2035
|
+
# assertion kind is a PREFIX of the retraction kind, so one marker matches both.
|
|
2036
|
+
_WINDOW_ATTRIBUTION_KIND_MARKER = f'"{_WINDOW_ATTRIBUTION_KIND}'.encode("ascii")
|
|
2037
|
+
|
|
2038
|
+
# FIRST-WINS on the op id, which is a content digest — so a duplicate line is
|
|
2039
|
+
# byte-identical and the insert is a genuine no-op rather than a silent
|
|
2040
|
+
# overwrite. `OR IGNORE` for the same reason both attribution-map statements
|
|
2041
|
+
# carry it: an `IntegrityError` raised here prefix-stops `_cache_applier`, and
|
|
2042
|
+
# the scalar cursor could then never advance past that record.
|
|
2043
|
+
#
|
|
2044
|
+
# `OR IGNORE` suppresses EVERY constraint violation, not only the op-id
|
|
2045
|
+
# conflict, so it cannot be the gate on a malformed record: the cursor advances
|
|
2046
|
+
# past that record in the same transaction, which makes the drop permanent.
|
|
2047
|
+
# `_window_attribution_defect` runs BEFORE the statement and routes such a
|
|
2048
|
+
# record to a counted, reported skip instead — the silence removed, the
|
|
2049
|
+
# tolerance kept (review finding F2).
|
|
2050
|
+
_WINDOW_ATTRIBUTION_INSERT = (
|
|
2051
|
+
"INSERT OR IGNORE INTO codex_window_attributions "
|
|
2052
|
+
"(op_id, account_key, source_root_key, logical_limit_key, observed_slot, "
|
|
2053
|
+
" window_minutes, raw_resets_at_utc, canonical_resets_at_utc, "
|
|
2054
|
+
" asserted_at_utc) "
|
|
2055
|
+
"VALUES (?,?,?,?,?,?,?,?,?) "
|
|
2056
|
+
"ON CONFLICT(op_id) DO NOTHING"
|
|
2057
|
+
)
|
|
2058
|
+
|
|
2059
|
+
# A retraction stamps only assertions that are not already retracted, so replay
|
|
2060
|
+
# is order-deterministic and idempotent: the FIRST tombstone in journal order
|
|
2061
|
+
# owns the row, and re-running the same prefix changes nothing.
|
|
2062
|
+
_WINDOW_ATTRIBUTION_RETRACT_UPDATE = (
|
|
2063
|
+
"UPDATE codex_window_attributions SET retracted_by_op_id = ? "
|
|
2064
|
+
"WHERE op_id = ? AND retracted_by_op_id IS NULL"
|
|
2065
|
+
)
|
|
2066
|
+
|
|
2067
|
+
|
|
2068
|
+
class CodexWindowAttributionReplayFailed(RuntimeError):
|
|
2069
|
+
"""The #500 attribution replay could not materialize its records.
|
|
2070
|
+
|
|
2071
|
+
Typed rather than bare so the quota cache leg can distinguish it from an
|
|
2072
|
+
ordinary SQLite write failure and report `attributionReplayFailed`, which
|
|
2073
|
+
lands in `stats_quota_projection_state.incomplete` inside the publication
|
|
2074
|
+
transaction. The surrounding rebuild code deliberately flattens failures
|
|
2075
|
+
into logged no-ops (spec §6.2), so a replay failure that was NOT typed and
|
|
2076
|
+
routed here would be published as a complete projection that silently omits
|
|
2077
|
+
the operator's attribution.
|
|
2078
|
+
"""
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
def _is_codex_window_attribution_op(rec: dict) -> bool:
|
|
2082
|
+
return (
|
|
2083
|
+
rec.get("t") == "op"
|
|
2084
|
+
and (rec.get("payload") or {}).get("kind") == _WINDOW_ATTRIBUTION_KIND
|
|
2085
|
+
)
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def _is_codex_window_attribution_retract_op(rec: dict) -> bool:
|
|
2089
|
+
return (
|
|
2090
|
+
rec.get("t") == "op"
|
|
2091
|
+
and (rec.get("payload") or {}).get("kind")
|
|
2092
|
+
== _WINDOW_ATTRIBUTION_RETRACT_KIND
|
|
2093
|
+
)
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
def _is_codex_window_attribution_record(rec: dict) -> bool:
|
|
2097
|
+
return (
|
|
2098
|
+
_is_codex_window_attribution_op(rec)
|
|
2099
|
+
or _is_codex_window_attribution_retract_op(rec)
|
|
2100
|
+
)
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
#: Payload fields an assertion row cannot be built without. Each is NOT NULL in
|
|
2104
|
+
#: `codex_window_attributions`, and the builder already refuses to mint an op
|
|
2105
|
+
#: that omits one — so a record missing any of them is damaged, not merely old.
|
|
2106
|
+
_WINDOW_ATTRIBUTION_REQUIRED_TEXT = (
|
|
2107
|
+
"account_key", "source_root_key", "logical_limit_key", "observed_slot",
|
|
2108
|
+
)
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
def _window_attribution_defect(rec: dict) -> "str | None":
|
|
2112
|
+
"""Why this record cannot be materialized, or `None` when it is sound.
|
|
2113
|
+
|
|
2114
|
+
Runs BEFORE the INSERT, because `OR IGNORE` cannot distinguish a duplicate
|
|
2115
|
+
op id (a genuine no-op) from a NOT NULL violation (a permanent silent
|
|
2116
|
+
drop). The two shapes that motivated it, review finding F2:
|
|
2117
|
+
|
|
2118
|
+
* a payload missing a required field inserts nothing, raises nothing and
|
|
2119
|
+
leaves `asserted` at 0, so the batch looks like a clean no-op;
|
|
2120
|
+
* `raw_resets_at_utc: null` is WORSE — `json.dumps(None)` is the literal
|
|
2121
|
+
text `null`, which satisfies NOT NULL, so the row lands and
|
|
2122
|
+
`load_active_window_attributions` then skips it as undecodable. The
|
|
2123
|
+
assertion is durable, dormant and invisible.
|
|
2124
|
+
|
|
2125
|
+
Returns a short reason rather than a bool so the caller can name the defect
|
|
2126
|
+
in its diagnostic instead of reporting an anonymous count.
|
|
2127
|
+
"""
|
|
2128
|
+
if not isinstance(rec.get("id"), str) or not rec.get("id"):
|
|
2129
|
+
return "record carries no op id"
|
|
2130
|
+
if not isinstance(rec.get("at"), str) or not rec.get("at"):
|
|
2131
|
+
return "record carries no `at` instant"
|
|
2132
|
+
payload = rec.get("payload")
|
|
2133
|
+
if not isinstance(payload, dict):
|
|
2134
|
+
return "record carries no payload"
|
|
2135
|
+
for field in _WINDOW_ATTRIBUTION_REQUIRED_TEXT:
|
|
2136
|
+
value = payload.get(field)
|
|
2137
|
+
if not isinstance(value, str) or not value:
|
|
2138
|
+
return f"payload field {field!r} is missing or not a non-empty string"
|
|
2139
|
+
minutes = payload.get("window_minutes")
|
|
2140
|
+
if not isinstance(minutes, int) or isinstance(minutes, bool):
|
|
2141
|
+
return "payload field 'window_minutes' is missing or not an integer"
|
|
2142
|
+
witnesses = payload.get("raw_resets_at_utc")
|
|
2143
|
+
if not isinstance(witnesses, list) or not witnesses:
|
|
2144
|
+
return ("payload field 'raw_resets_at_utc' is missing or not a "
|
|
2145
|
+
"non-empty list")
|
|
2146
|
+
for witness in witnesses:
|
|
2147
|
+
if not isinstance(witness, str) or not witness:
|
|
2148
|
+
return "payload field 'raw_resets_at_utc' holds a non-string witness"
|
|
2149
|
+
if _is_codex_window_attribution_retract_op(rec):
|
|
2150
|
+
targets = payload.get("retracted_assertion_ids")
|
|
2151
|
+
if not isinstance(targets, list) or not targets:
|
|
2152
|
+
return ("payload field 'retracted_assertion_ids' is missing or "
|
|
2153
|
+
"not a non-empty list")
|
|
2154
|
+
for target in targets:
|
|
2155
|
+
if not isinstance(target, str) or not target:
|
|
2156
|
+
return ("payload field 'retracted_assertion_ids' holds a "
|
|
2157
|
+
"non-string assertion id")
|
|
2158
|
+
return None
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
def _report_window_attribution_skips(skipped: int, *,
|
|
2162
|
+
quiet: bool = False) -> None:
|
|
2163
|
+
"""One stderr line for a run of replayed attribution records that were
|
|
2164
|
+
STRUCTURALLY INVALID and therefore not applied (review finding F2).
|
|
2165
|
+
|
|
2166
|
+
The journal is append-only and the builders refuse every shape
|
|
2167
|
+
`_window_attribution_defect` rejects, so reaching this line means a journal
|
|
2168
|
+
line was damaged or hand-edited. The record is skipped rather than raised
|
|
2169
|
+
on for the reason its `OR IGNORE` sibling exists: an `IntegrityError` here
|
|
2170
|
+
would prefix-stop the ingest cycle forever on one bad record. What is
|
|
2171
|
+
removed is the silence, not the tolerance.
|
|
2172
|
+
|
|
2173
|
+
Every call site must invoke this AFTER its commit, the rule
|
|
2174
|
+
`_report_file_account_conflicts` states: a rolled-back transaction applied
|
|
2175
|
+
nothing, so a line printed from inside it would describe a skip on a cycle
|
|
2176
|
+
that is about to be retried.
|
|
2177
|
+
|
|
2178
|
+
`quiet` is the reconciliation's caller, exactly as it is for the sibling.
|
|
2179
|
+
"""
|
|
2180
|
+
if skipped > 0 and not quiet:
|
|
2181
|
+
print(
|
|
2182
|
+
f"[ingest] codex window attribution replay skipped {skipped} "
|
|
2183
|
+
"structurally invalid record(s); each is missing a field the "
|
|
2184
|
+
"assertion cannot be built without, so no attribution was applied "
|
|
2185
|
+
"for it — re-record the attribution, and report the journal damage "
|
|
2186
|
+
"if it recurs",
|
|
2187
|
+
file=sys.stderr,
|
|
2188
|
+
)
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
def _window_attribution_values(rec: dict) -> tuple:
|
|
2192
|
+
"""INSERT values for one assertion.
|
|
2193
|
+
|
|
2194
|
+
`raw_resets_at_utc` is stored as canonical JSON — the same separators and
|
|
2195
|
+
key order the journal encoder uses — so the stored text is byte-stable
|
|
2196
|
+
across replays and a reader can decode it back to the exact witness list.
|
|
2197
|
+
"""
|
|
2198
|
+
p = rec.get("payload") or {}
|
|
2199
|
+
witnesses = p.get("raw_resets_at_utc")
|
|
2200
|
+
return (
|
|
2201
|
+
rec.get("id"),
|
|
2202
|
+
p.get("account_key"),
|
|
2203
|
+
p.get("source_root_key"),
|
|
2204
|
+
p.get("logical_limit_key"),
|
|
2205
|
+
p.get("observed_slot"),
|
|
2206
|
+
p.get("window_minutes"),
|
|
2207
|
+
json.dumps(witnesses, separators=(",", ":"), sort_keys=True,
|
|
2208
|
+
ensure_ascii=False),
|
|
2209
|
+
p.get("canonical_resets_at_utc"),
|
|
2210
|
+
rec.get("at"),
|
|
2211
|
+
)
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def _apply_window_attribution_records(cache, records) -> "tuple[int, int, int]":
|
|
2215
|
+
"""Materialize `codex_window_attribution` / `..._retract` ops into an OPEN
|
|
2216
|
+
cache.db transaction; return `(asserted, retracted, skipped)` — how many
|
|
2217
|
+
assertion rows were ABSENT before and actually landed, how many assertions a
|
|
2218
|
+
retraction in this batch tombstoned, and how many records were structurally
|
|
2219
|
+
invalid and therefore not applied at all.
|
|
2220
|
+
|
|
2221
|
+
Never opens or commits a transaction itself: every call site owns the flocks
|
|
2222
|
+
and the single `BEGIN IMMEDIATE`, so the attribution family stays atomic
|
|
2223
|
+
with the quota rows and the attribution map beside it.
|
|
2224
|
+
|
|
2225
|
+
Records apply in the order given, which is journal order at every call site.
|
|
2226
|
+
That is what makes a retraction reach the assertion it names: the assertion
|
|
2227
|
+
was recorded first, so its row exists by the time the tombstone runs.
|
|
2228
|
+
|
|
2229
|
+
A record `_window_attribution_defect` rejects is SKIPPED and counted rather
|
|
2230
|
+
than inserted or raised on (review finding F2). Raising would prefix-stop
|
|
2231
|
+
the ingest cycle forever on one damaged line, and inserting is what produced
|
|
2232
|
+
the permanently dormant `raw_resets_at_utc = 'null'` row. Every caller must
|
|
2233
|
+
pass the count to `_report_window_attribution_skips` after its commit.
|
|
2234
|
+
|
|
2235
|
+
Any OTHER failure is re-raised as `CodexWindowAttributionReplayFailed` so
|
|
2236
|
+
the rebuild's quota leg reports an incomplete projection rather than
|
|
2237
|
+
flattening it into a successful publish (spec §6.2).
|
|
2238
|
+
"""
|
|
2239
|
+
asserted = retracted = skipped = 0
|
|
2240
|
+
try:
|
|
2241
|
+
for rec in records:
|
|
2242
|
+
# Kind first, so a record of some other family that reached this
|
|
2243
|
+
# list is ignored as before rather than counted as damaged.
|
|
2244
|
+
if not _is_codex_window_attribution_record(rec):
|
|
2245
|
+
continue
|
|
2246
|
+
if _window_attribution_defect(rec) is not None:
|
|
2247
|
+
skipped += 1
|
|
2248
|
+
continue
|
|
2249
|
+
if _is_codex_window_attribution_op(rec):
|
|
2250
|
+
before = cache.total_changes
|
|
2251
|
+
cache.execute(_WINDOW_ATTRIBUTION_INSERT,
|
|
2252
|
+
_window_attribution_values(rec))
|
|
2253
|
+
if cache.total_changes > before:
|
|
2254
|
+
asserted += 1
|
|
2255
|
+
continue
|
|
2256
|
+
if not _is_codex_window_attribution_retract_op(rec):
|
|
2257
|
+
continue
|
|
2258
|
+
payload = rec.get("payload") or {}
|
|
2259
|
+
targets = payload.get("retracted_assertion_ids") or ()
|
|
2260
|
+
for target in targets:
|
|
2261
|
+
before = cache.total_changes
|
|
2262
|
+
cache.execute(_WINDOW_ATTRIBUTION_RETRACT_UPDATE,
|
|
2263
|
+
(rec.get("id"), target))
|
|
2264
|
+
if cache.total_changes > before:
|
|
2265
|
+
retracted += 1
|
|
2266
|
+
if asserted or retracted:
|
|
2267
|
+
# #500 §8.3: the semantic revision the quota projection certificate
|
|
2268
|
+
# binds. Advanced HERE and only here, in the same transaction as the
|
|
2269
|
+
# rows, so a certificate can never claim to have been computed
|
|
2270
|
+
# against attributions this replay had not yet applied. Deliberately
|
|
2271
|
+
# not the journal replay cursor, which advances on all traffic and
|
|
2272
|
+
# would invalidate the quota projection continuously.
|
|
2273
|
+
import _cctally_cache as _cc
|
|
2274
|
+
|
|
2275
|
+
_cc.bump_codex_window_attribution_revision(cache)
|
|
2276
|
+
except CodexWindowAttributionReplayFailed:
|
|
2277
|
+
raise
|
|
2278
|
+
except Exception as exc:
|
|
2279
|
+
raise CodexWindowAttributionReplayFailed(
|
|
2280
|
+
f"could not replay a Codex window attribution record: {exc}"
|
|
2281
|
+
) from exc
|
|
2282
|
+
return asserted, retracted, skipped
|
|
2283
|
+
|
|
2284
|
+
|
|
2008
2285
|
def _apply_file_account_records(cache, records) -> "tuple[int, int]":
|
|
2009
2286
|
"""Materialize the given ``codex_file_account`` ops into an OPEN cache.db
|
|
2010
2287
|
transaction; return ``(restored, conflicts)`` — how many rows were ABSENT
|
|
@@ -2359,9 +2636,17 @@ class CoverageInvariantViolation(RuntimeError):
|
|
|
2359
2636
|
"""
|
|
2360
2637
|
|
|
2361
2638
|
|
|
2362
|
-
def _assert_coverage_already_invalidated(
|
|
2639
|
+
def _assert_coverage_already_invalidated(
|
|
2640
|
+
cache_conn, *,
|
|
2641
|
+
caller: str = "rehydrate_codex_file_accounts(authoritative=True)",
|
|
2642
|
+
) -> None:
|
|
2363
2643
|
"""Refuse an authoritative replay while a coverage certificate stands.
|
|
2364
2644
|
|
|
2645
|
+
``caller`` names the authoritative replay in the refusal. #500 added a
|
|
2646
|
+
second one (`rehydrate_codex_window_attributions`) over the same covered
|
|
2647
|
+
families, and a message naming the wrong function would send the next
|
|
2648
|
+
debugger to the wrong call site.
|
|
2649
|
+
|
|
2365
2650
|
`rehydrate_codex_file_accounts(authoritative=True)` empties
|
|
2366
2651
|
`codex_file_accounts`, a member of `COVERAGE_CACHE_FAMILIES`, before
|
|
2367
2652
|
replaying, yet its inventory entry is `preserve`. That holds only because
|
|
@@ -2393,12 +2678,230 @@ def _assert_coverage_already_invalidated(cache_conn) -> None:
|
|
|
2393
2678
|
return
|
|
2394
2679
|
if row is not None:
|
|
2395
2680
|
raise CoverageInvariantViolation(
|
|
2396
|
-
"
|
|
2681
|
+
f"{caller} clears a covered "
|
|
2397
2682
|
f"family while {row[0]!r} is still stored; the caller must "
|
|
2398
2683
|
"invalidate coverage in the transaction that clears"
|
|
2399
2684
|
)
|
|
2400
2685
|
|
|
2401
2686
|
|
|
2687
|
+
#: What one fused rehydration pass materialized, per family (#500 review
|
|
2688
|
+
#: finding F4). A record rather than a flat tuple because the two families
|
|
2689
|
+
#: report different things and a positional 5-tuple would be unreadable at the
|
|
2690
|
+
#: call sites.
|
|
2691
|
+
CodexJournalRehydration = collections.namedtuple(
|
|
2692
|
+
"CodexJournalRehydration",
|
|
2693
|
+
("file_accounts_applied", "file_accounts_high_water",
|
|
2694
|
+
"file_accounts_declined",
|
|
2695
|
+
"window_attributions_applied", "window_attributions_skipped"),
|
|
2696
|
+
)
|
|
2697
|
+
|
|
2698
|
+
|
|
2699
|
+
def _journal_cursor_order_key(cursor, segments) -> "tuple[int, int]":
|
|
2700
|
+
"""Sort key for a `(segment, offset)` journal cursor within `segments`.
|
|
2701
|
+
|
|
2702
|
+
A cursor this enumeration cannot place sorts FIRST — a segment the list does
|
|
2703
|
+
not carry, and equally a value that is not a `(segment, offset)` pair at all.
|
|
2704
|
+
That is the same thing `_iter_range_with_segments` does with it: it restarts
|
|
2705
|
+
from the beginning. Ordering it any later would skip bytes, and raising on it
|
|
2706
|
+
would turn an unreadable cursor into a failed pass rather than a from-zero
|
|
2707
|
+
one. The unpack therefore sits INSIDE the guard (review round 2, finding
|
|
2708
|
+
R2-11), where the docstring always claimed it was.
|
|
2709
|
+
|
|
2710
|
+
`(-1, 0)` is below every real record position, whose segment index is `>= 0`,
|
|
2711
|
+
so a family floor derived from an unplaceable cursor admits every record.
|
|
2712
|
+
"""
|
|
2713
|
+
try:
|
|
2714
|
+
seg, off = cursor
|
|
2715
|
+
return (segments.index(seg), int(off))
|
|
2716
|
+
except (ValueError, TypeError):
|
|
2717
|
+
return (-1, 0)
|
|
2718
|
+
|
|
2719
|
+
|
|
2720
|
+
def _earliest_journal_cursor(cursors, segments) -> "tuple[str, int] | None":
|
|
2721
|
+
"""The earliest of several per-family cursors, or `None` if any is `None`.
|
|
2722
|
+
|
|
2723
|
+
`None` means "replay from the beginning", so it dominates: a pass that must
|
|
2724
|
+
satisfy a from-zero family has to start at zero regardless of how far the
|
|
2725
|
+
other family has already been carried.
|
|
2726
|
+
|
|
2727
|
+
This decides only where the TRAVERSAL starts. It does not decide what each
|
|
2728
|
+
family applies — that is a per-family floor, because a from-zero family must
|
|
2729
|
+
not drag its sibling back over settled history (review round 2, finding
|
|
2730
|
+
R2-2).
|
|
2731
|
+
|
|
2732
|
+
`segments` is the caller's enumeration, taken once and shared with
|
|
2733
|
+
`iter_range`, so the keys this returns address the same positions the
|
|
2734
|
+
traversal yields.
|
|
2735
|
+
"""
|
|
2736
|
+
chosen = None
|
|
2737
|
+
chosen_key = None
|
|
2738
|
+
for cursor in cursors:
|
|
2739
|
+
if cursor is None:
|
|
2740
|
+
return None
|
|
2741
|
+
key = _journal_cursor_order_key(cursor, segments)
|
|
2742
|
+
if chosen_key is None or key < chosen_key:
|
|
2743
|
+
chosen, chosen_key = cursor, key
|
|
2744
|
+
return chosen
|
|
2745
|
+
|
|
2746
|
+
|
|
2747
|
+
def rehydrate_codex_journal_families(
|
|
2748
|
+
cache_conn, *, authoritative: bool = False,
|
|
2749
|
+
file_account_since=None, window_attribution_since=None,
|
|
2750
|
+
want_file_accounts: bool = True, want_window_attributions: bool = True,
|
|
2751
|
+
caller: str = "rehydrate_codex_journal_families(authoritative=True)",
|
|
2752
|
+
) -> CodexJournalRehydration:
|
|
2753
|
+
"""ONE journal traversal that materializes BOTH journal-derived Codex cache
|
|
2754
|
+
families — the ``codex_file_account`` attribution map and the #500 operator
|
|
2755
|
+
window attributions (review finding F4).
|
|
2756
|
+
|
|
2757
|
+
Why fused. Both families are rehydrated inside the SAME locked phase of
|
|
2758
|
+
``sync_codex_cache``, and with no cursor — a fresh install, ``rm cache.db``,
|
|
2759
|
+
the corruption auto-heal's re-sync, or ``cache-sync --rebuild``, which forces
|
|
2760
|
+
a from-zero replay on both — two independent passes stream the whole journal
|
|
2761
|
+
while the global ``cache.db.lock`` AND the Codex provider flock are held.
|
|
2762
|
+
``rehydrate_codex_file_accounts`` calls a single such traversal "a
|
|
2763
|
+
multi-second global cache-writer stall — itself a ``database is locked``
|
|
2764
|
+
trigger" (#297). One pass, two byte prefilters, two appliers.
|
|
2765
|
+
|
|
2766
|
+
The two families keep INDEPENDENT cursors, because each is advanced by call
|
|
2767
|
+
sites the other never reaches (``_cache_applier`` carries the window cursor
|
|
2768
|
+
forward on an ordinary ingest tick; ``sync_codex_cache`` carries the map's).
|
|
2769
|
+
The pass therefore starts at the EARLIER of the two, and each family then
|
|
2770
|
+
applies only from its OWN cursor within that one traversal (review round 2,
|
|
2771
|
+
finding R2-2). The traversal range and the application range are separate
|
|
2772
|
+
decisions and must stay separate: one family's absent cursor forces the
|
|
2773
|
+
traversal to zero, but letting it also force the SIBLING's appliers to zero
|
|
2774
|
+
couples the two families' replay ranges to each other's cursor health.
|
|
2775
|
+
|
|
2776
|
+
Row idempotence is not enough to make that coupling safe, which is why the
|
|
2777
|
+
first round's argument for it was wrong. ``_apply_file_account_records`` is
|
|
2778
|
+
idempotent in its rows but not in its REPORTING: it re-counts every
|
|
2779
|
+
historical first-wins decline inside whatever range it re-reads, and
|
|
2780
|
+
``sync_codex_cache`` prints that count, telling the operator to run
|
|
2781
|
+
``cache-sync --rebuild`` over decisions settled months ago. A from-zero
|
|
2782
|
+
sibling would also reinstate exactly the whole-journal traversal under both
|
|
2783
|
+
cache flocks that this fusion exists to remove.
|
|
2784
|
+
|
|
2785
|
+
The caller owns the flocks, the transaction and the commit; this function
|
|
2786
|
+
only runs the idempotent statements, so it can sit inside
|
|
2787
|
+
``sync_codex_cache``'s already-locked phases without inverting the lock
|
|
2788
|
+
order. Both declined/skipped counts are RETURNED rather than reported here,
|
|
2789
|
+
the rule ``_report_file_account_conflicts`` states: this runs inside a
|
|
2790
|
+
transaction the caller may roll back.
|
|
2791
|
+
"""
|
|
2792
|
+
import _cctally_cache
|
|
2793
|
+
|
|
2794
|
+
if authoritative:
|
|
2795
|
+
_assert_coverage_already_invalidated(cache_conn, caller=caller)
|
|
2796
|
+
hw = journal_high_water()
|
|
2797
|
+
if hw is None:
|
|
2798
|
+
# No journal at all: an authoritative pass still says "the journal is
|
|
2799
|
+
# the truth", and the truth is that there is nothing to attribute.
|
|
2800
|
+
if authoritative and want_file_accounts:
|
|
2801
|
+
cache_conn.execute("DELETE FROM codex_file_accounts")
|
|
2802
|
+
# Review round 2, finding R2-4: the same rule F10 states for the
|
|
2803
|
+
# window cursor, and the file family needs it stated here because
|
|
2804
|
+
# `sync_codex_cache` writes no replacement when the pass returns no
|
|
2805
|
+
# high-water. `_iter_range_with_segments` restarting from zero for a
|
|
2806
|
+
# vanished segment does not cover this: `segment_name` is
|
|
2807
|
+
# `observations-YYYY-MM.jsonl`, so a wiped journal that receives a
|
|
2808
|
+
# record in the same calendar month re-creates the SAME segment name
|
|
2809
|
+
# at offset 0 and a stale non-zero cursor skips the new bytes.
|
|
2810
|
+
cache_conn.execute(
|
|
2811
|
+
"DELETE FROM cache_meta WHERE key = ?",
|
|
2812
|
+
(_cctally_cache.CODEX_FILE_ACCOUNT_CURSOR_KEY,))
|
|
2813
|
+
if authoritative and want_window_attributions:
|
|
2814
|
+
cache_conn.execute("DELETE FROM codex_window_attributions")
|
|
2815
|
+
# #500 review finding F10: the cursor describes a table that no
|
|
2816
|
+
# longer has any rows, so leaving it would let the NEXT delta pass
|
|
2817
|
+
# skip journal bytes on the strength of a claim this branch just
|
|
2818
|
+
# falsified.
|
|
2819
|
+
cache_conn.execute(
|
|
2820
|
+
"DELETE FROM cache_meta WHERE key = ?",
|
|
2821
|
+
(_cctally_cache.CODEX_WINDOW_ATTRIBUTION_CURSOR_KEY,))
|
|
2822
|
+
return CodexJournalRehydration(0, None, 0, 0, 0)
|
|
2823
|
+
if authoritative:
|
|
2824
|
+
if want_file_accounts:
|
|
2825
|
+
cache_conn.execute("DELETE FROM codex_file_accounts")
|
|
2826
|
+
if want_window_attributions:
|
|
2827
|
+
cache_conn.execute("DELETE FROM codex_window_attributions")
|
|
2828
|
+
# A clear-then-replay is only correct from the beginning of the journal.
|
|
2829
|
+
file_account_since = window_attribution_since = None
|
|
2830
|
+
|
|
2831
|
+
# ONE enumeration, shared by the range floors below and by the traversal, so
|
|
2832
|
+
# a floor's segment index and a yielded record's segment index name the same
|
|
2833
|
+
# position. Two enumerations could disagree by a segment appended between
|
|
2834
|
+
# them, and a floor computed against the other list would then admit or
|
|
2835
|
+
# exclude the wrong records.
|
|
2836
|
+
segments = list_segments()
|
|
2837
|
+
starts = []
|
|
2838
|
+
if want_file_accounts:
|
|
2839
|
+
starts.append(file_account_since)
|
|
2840
|
+
if want_window_attributions:
|
|
2841
|
+
starts.append(window_attribution_since)
|
|
2842
|
+
since = _earliest_journal_cursor(starts, segments) if starts else hw
|
|
2843
|
+
|
|
2844
|
+
# The traversal starts at the EARLIER cursor; each family applies only from
|
|
2845
|
+
# its OWN (review round 2, finding R2-2). Without this, one family's absent
|
|
2846
|
+
# cursor drags the other back over settled history — and
|
|
2847
|
+
# `_apply_file_account_records` is idempotent in its ROWS but not in its
|
|
2848
|
+
# REPORTING, so it re-counts every historical first-wins decline and
|
|
2849
|
+
# `sync_codex_cache` tells the operator to `cache-sync --rebuild` over
|
|
2850
|
+
# decisions settled long ago. It also reinstates the whole-journal traversal
|
|
2851
|
+
# under both cache flocks that this fusion exists to remove. `None` means no
|
|
2852
|
+
# floor at all, which is a from-zero replay for that family.
|
|
2853
|
+
file_floor = (None if file_account_since is None
|
|
2854
|
+
else _journal_cursor_order_key(file_account_since, segments))
|
|
2855
|
+
window_floor = (
|
|
2856
|
+
None if window_attribution_since is None
|
|
2857
|
+
else _journal_cursor_order_key(window_attribution_since, segments))
|
|
2858
|
+
segment_positions = {name: idx for idx, name in enumerate(segments)}
|
|
2859
|
+
|
|
2860
|
+
applied = conflicts = attributed = skipped = 0
|
|
2861
|
+
# Streamed, never materialized, for the reason above: this runs on the FIRST
|
|
2862
|
+
# ordinary sync of every cache.db while both cache flocks are held. The
|
|
2863
|
+
# cheap byte prefilters skip the JSON decode for every uninteresting line;
|
|
2864
|
+
# the canonical encoder is `json.dumps(..., ensure_ascii=False)`, which never
|
|
2865
|
+
# escapes an ASCII kind token, so a genuine op always carries its marker
|
|
2866
|
+
# substring verbatim. A false positive is harmless — it is decoded and
|
|
2867
|
+
# rejected by the real predicate.
|
|
2868
|
+
for _seg, _off, raw in iter_range(since, hw, segments):
|
|
2869
|
+
position = (segment_positions[_seg], int(_off))
|
|
2870
|
+
want_file_here = want_file_accounts and (
|
|
2871
|
+
file_floor is None or position >= file_floor)
|
|
2872
|
+
want_window_here = want_window_attributions and (
|
|
2873
|
+
window_floor is None or position >= window_floor)
|
|
2874
|
+
file_hit = want_file_here and _FILE_ACCOUNT_KIND_MARKER in raw
|
|
2875
|
+
window_hit = want_window_here and _WINDOW_ATTRIBUTION_KIND_MARKER in raw
|
|
2876
|
+
if not (file_hit or window_hit):
|
|
2877
|
+
continue
|
|
2878
|
+
# ONE decode per line (review round 2, finding R2-10). The prefilters are
|
|
2879
|
+
# substring tests, so a single line can match both; decoding inside each
|
|
2880
|
+
# branch parsed such a line twice.
|
|
2881
|
+
rec = _lib_journal.decode_line(raw)
|
|
2882
|
+
if rec is None:
|
|
2883
|
+
continue
|
|
2884
|
+
if file_hit and _is_codex_file_account_op(rec):
|
|
2885
|
+
_restored, _conflicts = _apply_file_account_records(
|
|
2886
|
+
cache_conn, (rec,))
|
|
2887
|
+
applied += _restored
|
|
2888
|
+
conflicts += _conflicts
|
|
2889
|
+
continue
|
|
2890
|
+
if window_hit and _is_codex_window_attribution_record(rec):
|
|
2891
|
+
_asserted, _retracted, _skipped = (
|
|
2892
|
+
_apply_window_attribution_records(cache_conn, (rec,)))
|
|
2893
|
+
attributed += _asserted
|
|
2894
|
+
skipped += _skipped
|
|
2895
|
+
if want_window_attributions and (
|
|
2896
|
+
authoritative or window_attribution_since != (str(hw[0]), int(hw[1]))):
|
|
2897
|
+
# Only when it actually moves. This runs on every ordinary Codex sync,
|
|
2898
|
+
# and a cursor rewritten per tick would dirty the transaction each time
|
|
2899
|
+
# for no change at all.
|
|
2900
|
+
_cctally_cache.store_codex_window_attribution_cursor(cache_conn, hw)
|
|
2901
|
+
return CodexJournalRehydration(
|
|
2902
|
+
applied, hw, conflicts, attributed, skipped)
|
|
2903
|
+
|
|
2904
|
+
|
|
2402
2905
|
def rehydrate_codex_file_accounts(
|
|
2403
2906
|
cache_conn, *, authoritative: bool = False, since=None,
|
|
2404
2907
|
) -> "tuple[int, tuple[str, int] | None, int]":
|
|
@@ -2465,39 +2968,23 @@ def rehydrate_codex_file_accounts(
|
|
|
2465
2968
|
``auth.json`` branch and re-decides — the original defect. Since the MAX-set
|
|
2466
2969
|
upsert already converges the counter, a clear has no upside and that
|
|
2467
2970
|
downside.
|
|
2971
|
+
Since #500 review finding F4 this is a thin wrapper over
|
|
2972
|
+
``rehydrate_codex_journal_families``, which owns the one traversal both
|
|
2973
|
+
journal-derived Codex families share. The semantics above are unchanged —
|
|
2974
|
+
the window-attribution family is simply switched off — so a caller that
|
|
2975
|
+
needs only the map still pays for only the map.
|
|
2468
2976
|
"""
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
since = None
|
|
2481
|
-
applied = 0
|
|
2482
|
-
conflicts = 0
|
|
2483
|
-
# Streamed, never materialized: this runs on the FIRST ordinary sync of
|
|
2484
|
-
# every cache.db (hook-tick, the dashboard, the corruption auto-heal's
|
|
2485
|
-
# re-sync) while both cache flocks are held, so a whole-journal transient
|
|
2486
|
-
# here is a multi-second global cache-writer stall — itself a
|
|
2487
|
-
# `database is locked` trigger. The cheap byte prefilter skips the JSON
|
|
2488
|
-
# decode for every non-decision line; the canonical encoder is
|
|
2489
|
-
# `json.dumps(..., ensure_ascii=False)`, which never escapes an ASCII kind
|
|
2490
|
-
# token, so a genuine op always carries this substring verbatim. A false
|
|
2491
|
-
# positive is harmless — it is decoded and rejected by the real predicate.
|
|
2492
|
-
for _seg, _off, raw in iter_range(since, hw):
|
|
2493
|
-
if _FILE_ACCOUNT_KIND_MARKER not in raw:
|
|
2494
|
-
continue
|
|
2495
|
-
rec = _lib_journal.decode_line(raw)
|
|
2496
|
-
if rec is not None and _is_codex_file_account_op(rec):
|
|
2497
|
-
_restored, _conflicts = _apply_file_account_records(cache_conn, (rec,))
|
|
2498
|
-
applied += _restored
|
|
2499
|
-
conflicts += _conflicts
|
|
2500
|
-
return applied, hw, conflicts
|
|
2977
|
+
result = rehydrate_codex_journal_families(
|
|
2978
|
+
cache_conn,
|
|
2979
|
+
authoritative=authoritative,
|
|
2980
|
+
file_account_since=since,
|
|
2981
|
+
want_file_accounts=True,
|
|
2982
|
+
want_window_attributions=False,
|
|
2983
|
+
caller="rehydrate_codex_file_accounts(authoritative=True)",
|
|
2984
|
+
)
|
|
2985
|
+
return (result.file_accounts_applied,
|
|
2986
|
+
result.file_accounts_high_water,
|
|
2987
|
+
result.file_accounts_declined)
|
|
2501
2988
|
|
|
2502
2989
|
|
|
2503
2990
|
def _bounded_covered_offset(segment, raw_offset, covered_offset, decoded_end):
|
|
@@ -2572,22 +3059,33 @@ def _coverage_advance_plan(cursor, covered_to, decoded_end=None):
|
|
|
2572
3059
|
|
|
2573
3060
|
def _cache_applier(decoded, *, cursor=None, covered_to=None,
|
|
2574
3061
|
decoded_end=None) -> int | None:
|
|
2575
|
-
"""Composite cache leg (spec §5.2 step 3 + #416 spec §3.4):
|
|
2576
|
-
batch's Codex quota obs into
|
|
2577
|
-
`codex_file_account` ops into the attribution map,
|
|
2578
|
-
|
|
2579
|
-
`
|
|
3062
|
+
"""Composite cache leg (spec §5.2 step 3 + #416 spec §3.4 + #500 spec §6.2):
|
|
3063
|
+
materialize this batch's THREE Codex families — quota obs into
|
|
3064
|
+
`quota_window_snapshots`, `codex_file_account` ops into the attribution map,
|
|
3065
|
+
and `codex_window_attribution`/`..._retract` ops into
|
|
3066
|
+
`codex_window_attributions` — under the NON-BLOCKING global cache writer
|
|
3067
|
+
lock followed by `cache.db.codex.lock`, in ONE `BEGIN IMMEDIATE`. Contract
|
|
3068
|
+
(journal seam): `(decoded) -> stop | None`,
|
|
2580
3069
|
`decoded = [(record, segment, offset), ...]` in canonical order.
|
|
2581
3070
|
|
|
2582
|
-
-
|
|
3071
|
+
- NO family present in the batch → return None (no flock taken).
|
|
2583
3072
|
- Busy global/provider flock, OR a cache write it cannot complete → PREFIX-STOP:
|
|
2584
|
-
return the EARLIEST index across
|
|
2585
|
-
the cycle processes only `decoded[:stop]` and advances the
|
|
2586
|
-
`decoded[stop]`'s offset, retrying the remainder next cycle (the
|
|
2587
|
-
cursor never advances past an unmaterialized record — spec §5.2
|
|
3073
|
+
return the EARLIEST index across ALL THREE families having committed NONE
|
|
3074
|
+
of them, so the cycle processes only `decoded[:stop]` and advances the
|
|
3075
|
+
cursor to `decoded[stop]`'s offset, retrying the remainder next cycle (the
|
|
3076
|
+
scalar cursor never advances past an unmaterialized record — spec §5.2
|
|
3077
|
+
step 3). "A cache write it cannot complete" covers
|
|
3078
|
+
`CodexWindowAttributionReplayFailed` as well as `sqlite3.Error` since
|
|
3079
|
+
review finding F3: the typed attribution failure takes the same
|
|
3080
|
+
prefix-stop, rather than escaping to `_run_stats_ingest_once`, which
|
|
3081
|
+
re-raises under `mode="authoritative"` and would hard-fail `record-usage`,
|
|
3082
|
+
`record-credit`, `sync-week` and statusline publication.
|
|
2588
3083
|
- Flock acquired + everything upserted → return None (full consumption).
|
|
2589
3084
|
A quota-row change advances ``codex_physical_mutation_seq`` in the same
|
|
2590
|
-
transaction; an idempotent replay leaves the sequence unchanged.
|
|
3085
|
+
transaction; an idempotent replay leaves the sequence unchanged. A
|
|
3086
|
+
structurally invalid attribution record is SKIPPED, counted, and reported
|
|
3087
|
+
after the commit (review finding F2) — it never prefix-stops, because a
|
|
3088
|
+
damaged line would otherwise wedge the cursor forever.
|
|
2591
3089
|
|
|
2592
3090
|
``cursor``, ``covered_to`` and ``decoded_end`` carry the cycle's journal
|
|
2593
3091
|
range so this leg can ADVANCE the #496 S5b coverage certificate (spec §4.3).
|
|
@@ -2604,15 +3102,22 @@ def _cache_applier(decoded, *, cursor=None, covered_to=None,
|
|
|
2604
3102
|
if _is_codex_quota_obs(rec)]
|
|
2605
3103
|
file_idx = [i for i, (rec, _s, _o) in enumerate(decoded)
|
|
2606
3104
|
if _is_codex_file_account_op(rec)]
|
|
2607
|
-
|
|
3105
|
+
# #500: the operator's window attributions are a covered family too, so the
|
|
3106
|
+
# certificate this leg advances would otherwise claim coverage for records
|
|
3107
|
+
# nobody materialized — and a later rebuild would trust it and skip them.
|
|
3108
|
+
# Materializing here is also what makes spec §6.2's "ingest reconciles the
|
|
3109
|
+
# tail" true on the ordinary status-line tick.
|
|
3110
|
+
attr_idx = [i for i, (rec, _s, _o) in enumerate(decoded)
|
|
3111
|
+
if _is_codex_window_attribution_record(rec)]
|
|
3112
|
+
if not quota_idx and not file_idx and not attr_idx:
|
|
2608
3113
|
return None
|
|
2609
3114
|
# BEFORE the flocks, for the reason `_coverage_advance_plan` states, and
|
|
2610
3115
|
# before them for a second reason too: it is journal file I/O, and the leg's
|
|
2611
3116
|
# whole purpose is to hold the global cache writer lock as briefly as it can.
|
|
2612
3117
|
plan = _coverage_advance_plan(cursor, covered_to, decoded_end)
|
|
2613
|
-
# All-or-nothing across the
|
|
2614
|
-
stop_idx = min(
|
|
2615
|
-
|
|
3118
|
+
# All-or-nothing across the three families: one stop, the earliest of any.
|
|
3119
|
+
stop_idx = min(
|
|
3120
|
+
idx[0] for idx in (quota_idx, file_idx, attr_idx) if idx)
|
|
2616
3121
|
from _lib_cache_writer_lock import (
|
|
2617
3122
|
acquire_cache_writer_flocks,
|
|
2618
3123
|
release_cache_writer_flocks,
|
|
@@ -2660,6 +3165,24 @@ def _cache_applier(decoded, *, cursor=None, covered_to=None,
|
|
|
2660
3165
|
# must already govern the observations it covers.
|
|
2661
3166
|
_, _file_conflicts = _apply_file_account_records(
|
|
2662
3167
|
cache, [decoded[i][0] for i in file_idx])
|
|
3168
|
+
_attr_skipped = 0
|
|
3169
|
+
if attr_idx:
|
|
3170
|
+
# In journal order, so a retraction reaches the assertion it
|
|
3171
|
+
# names.
|
|
3172
|
+
_, _, _attr_skipped = _apply_window_attribution_records(
|
|
3173
|
+
cache, [decoded[i][0] for i in attr_idx])
|
|
3174
|
+
# The cursor moves only when the CYCLE supplied its contiguous
|
|
3175
|
+
# range. A direct call (a test, or any caller that does not know
|
|
3176
|
+
# the range) leaves it alone: a cursor behind the table is
|
|
3177
|
+
# harmless, because every apply is idempotent on its op id, while
|
|
3178
|
+
# a cursor ahead of it skips a durable assertion forever. Bounded
|
|
3179
|
+
# by what the cycle DECODED, for the same reason the coverage
|
|
3180
|
+
# claim is.
|
|
3181
|
+
_attr_through = decoded_end if decoded_end is not None else covered_to
|
|
3182
|
+
if _attr_through is not None:
|
|
3183
|
+
_cctally_cache.store_codex_window_attribution_cursor(
|
|
3184
|
+
cache,
|
|
3185
|
+
(str(_attr_through[0]), int(_attr_through[1])))
|
|
2663
3186
|
quota_changes_before = cache.total_changes
|
|
2664
3187
|
_apply_quota_records(cache, [decoded[i][0] for i in quota_idx])
|
|
2665
3188
|
if cache.total_changes != quota_changes_before:
|
|
@@ -2677,14 +3200,29 @@ def _cache_applier(decoded, *, cursor=None, covered_to=None,
|
|
|
2677
3200
|
applied_through=applied_through, pinned_vector=vector)
|
|
2678
3201
|
cache.commit()
|
|
2679
3202
|
_report_file_account_conflicts(_file_conflicts)
|
|
2680
|
-
|
|
3203
|
+
_report_window_attribution_skips(_attr_skipped)
|
|
3204
|
+
except (sqlite3.Error, CodexWindowAttributionReplayFailed) as exc:
|
|
2681
3205
|
try:
|
|
2682
3206
|
cache.rollback()
|
|
2683
3207
|
except sqlite3.Error:
|
|
2684
3208
|
pass
|
|
2685
3209
|
# Could not materialize -> prefix-stop so the cursor holds and the
|
|
2686
3210
|
# next cycle retries (the records stay durable in the journal
|
|
2687
|
-
# regardless).
|
|
3211
|
+
# regardless). NO family is committed.
|
|
3212
|
+
#
|
|
3213
|
+
# #500 review finding F3: the typed attribution failure belongs
|
|
3214
|
+
# HERE, not escaping to the caller. It subclasses `RuntimeError`, so
|
|
3215
|
+
# without this clause it propagated out of `_cache_applier`, out of
|
|
3216
|
+
# `_run_cycle`, and reached only `_run_stats_ingest_once`'s broad
|
|
3217
|
+
# handler — which RE-RAISES under `mode="authoritative"`. That mode
|
|
3218
|
+
# is `record-usage`, `record-credit`, `sync-week` and statusline
|
|
3219
|
+
# publication, so a transient `database is locked` on this one table
|
|
3220
|
+
# would hard-fail those commands while the two families beside it
|
|
3221
|
+
# merely held the cursor. Spec §6.2's fail-loud obligation is about
|
|
3222
|
+
# stats-rebuild PUBLICATION and `_run_bounded_recovery` discharges
|
|
3223
|
+
# it separately at its own typed handler; the ingest leg's fail-safe
|
|
3224
|
+
# is the prefix-stop, and holding the cursor gives the identical
|
|
3225
|
+
# guarantee.
|
|
2688
3226
|
print(f"[ingest] cache leg write failed: {exc}", file=sys.stderr)
|
|
2689
3227
|
return stop_idx
|
|
2690
3228
|
finally:
|
|
@@ -3039,9 +3577,26 @@ _EVT_KIND_PROVIDER = {
|
|
|
3039
3577
|
# `op_kinds` set, so the kind MUST additionally carry a
|
|
3040
3578
|
# `_lib_rederive._OP_CLASSIFICATIONS` entry or the re-derive planner raises
|
|
3041
3579
|
# `RederiveConflict` on every run.
|
|
3580
|
+
# #500: the operator's attribution of an already-recorded Codex quota window,
|
|
3581
|
+
# and its retraction. Machinery, not data-bearing: the payload's `account_key`
|
|
3582
|
+
# is the SUBJECT of the assertion — the account the operator says the window
|
|
3583
|
+
# belongs to — rather than the two-shaped stamp naming which account wrote the
|
|
3584
|
+
# record.
|
|
3585
|
+
#
|
|
3586
|
+
# Registration is NOT what makes `classify_legacy_provider` and
|
|
3587
|
+
# `_normalize_legacy_account_stamp` leave that field alone (review finding F7).
|
|
3588
|
+
# Both already do: the former's `t == "op"` branch returns None for every kind
|
|
3589
|
+
# except `weekly_credit_floor`, and the latter is gated on
|
|
3590
|
+
# `_REAL_ACCOUNT_EVT_OP_KINDS`, which these kinds were never in. Removing these
|
|
3591
|
+
# two names changes neither function's answer — verified by experiment.
|
|
3592
|
+
# Registration's real effect is that `_cctally_rederive` unions this set into
|
|
3593
|
+
# the `op_kinds` it hands `validate_family_registry`, so a registered kind MUST
|
|
3594
|
+
# also carry a `_lib_rederive._OP_CLASSIFICATIONS` entry or the re-derive
|
|
3595
|
+
# planner raises `RederiveConflict` on every run.
|
|
3042
3596
|
_ACCOUNTS_MACHINERY_KINDS = frozenset(
|
|
3043
3597
|
("account_observe", "account_label", "accounts_cutover",
|
|
3044
|
-
"codex_file_account"
|
|
3598
|
+
"codex_file_account",
|
|
3599
|
+
"codex_window_attribution", "codex_window_attribution_retract"))
|
|
3045
3600
|
|
|
3046
3601
|
# Legacy-classifier exhaustiveness guard (#341, review finding P2-1). EVERY evt
|
|
3047
3602
|
# kind in `_EVT_SPECS` and every harvest kind in `_HARVEST_SPECS` must carry a
|
|
@@ -5712,6 +6267,7 @@ def _run_stats_ingest_once(
|
|
|
5712
6267
|
reconcile_config=None,
|
|
5713
6268
|
codex_apply=None,
|
|
5714
6269
|
post_commit=None,
|
|
6270
|
+
locks_held: bool = False,
|
|
5715
6271
|
) -> IngestResult:
|
|
5716
6272
|
"""Run one single-flight attempt, without correction-recovery orchestration.
|
|
5717
6273
|
|
|
@@ -5745,7 +6301,33 @@ def _run_stats_ingest_once(
|
|
|
5745
6301
|
returns `IngestResult(ran=True, error=<exc>)` so a statusline/hook tick is
|
|
5746
6302
|
never broken; an AUTHORITATIVE ingest re-raises so its caller (record-usage,
|
|
5747
6303
|
record-credit, sync-week, statusline publication) sees the failure.
|
|
6304
|
+
|
|
6305
|
+
`locks_held=True` (#500 spec §8.1) declares that the CALLER already owns
|
|
6306
|
+
stats maintenance and `journal.ingest.lock` for the duration, so this cycle
|
|
6307
|
+
neither acquires nor releases them. Without it, `account attribute`'s apply
|
|
6308
|
+
sequence — which has to hold both across its cache transaction and its stats
|
|
6309
|
+
transaction — would call in here and block on locks it already owns, and a
|
|
6310
|
+
same-process flock request on a second descriptor is a timeout rather than a
|
|
6311
|
+
reentrant no-op. It is deliberately a PARAMETER on this one function and not
|
|
6312
|
+
a second entry point: the cycle, its transaction boundaries, its
|
|
6313
|
+
correction-recovery signal and its alert dispatch are all the same, because a
|
|
6314
|
+
second subtly different ingest path is a worse outcome than the deadlock it
|
|
6315
|
+
was written to avoid. The one behavioural difference lives in
|
|
6316
|
+
`run_stats_ingest`, which declines automatic correction recovery under this
|
|
6317
|
+
flag exactly as it already declines it for a caller-owned connection.
|
|
5748
6318
|
"""
|
|
6319
|
+
if locks_held and _cctally_core.holds_attribution_apply_cache_flocks():
|
|
6320
|
+
# #500 §8.1 / the lock-order law in `docs/journal-gotchas.md`: every
|
|
6321
|
+
# cache write must be committed and unlocked BEFORE the stats
|
|
6322
|
+
# transaction opens. `codex_attribution_apply_locks` yields an owner
|
|
6323
|
+
# whose `release_cache_flocks()` is what satisfies that; a caller that
|
|
6324
|
+
# forgets it would violate the law silently, so the violation is refused
|
|
6325
|
+
# here rather than diagnosed later from a deadlock or a torn generation.
|
|
6326
|
+
raise ValueError(
|
|
6327
|
+
"run_stats_ingest(locks_held=True) requires the apply set's cache "
|
|
6328
|
+
"writer flocks to be released first — call "
|
|
6329
|
+
"owner.release_cache_flocks() before the stats transaction"
|
|
6330
|
+
)
|
|
5749
6331
|
own_conn = conn is None
|
|
5750
6332
|
maintenance_fd = None
|
|
5751
6333
|
lock_fd = None
|
|
@@ -5758,7 +6340,17 @@ def _run_stats_ingest_once(
|
|
|
5758
6340
|
# For a current/mismatched epoch, open first, then take maintenance SH
|
|
5759
6341
|
# and verify the main-file identity did not change across the open; if
|
|
5760
6342
|
# a sibling rebuilt in that gap, discard the stale handle and retry.
|
|
5761
|
-
if
|
|
6343
|
+
if locks_held:
|
|
6344
|
+
# #500 §8.1: the caller already owns maintenance EXCLUSIVE, which is
|
|
6345
|
+
# strictly stronger than the SHARED hold this branch would take, so
|
|
6346
|
+
# the whole resolution dance has nothing left to serialize — no
|
|
6347
|
+
# sibling can rebuild the main file underneath this open. Requesting
|
|
6348
|
+
# either lock again on a second descriptor would be a same-process
|
|
6349
|
+
# timeout, not a reentrant no-op, which is the deadlock this
|
|
6350
|
+
# parameter exists to avoid.
|
|
6351
|
+
if own_conn:
|
|
6352
|
+
conn = _cctally_core.open_db()
|
|
6353
|
+
elif own_conn:
|
|
5762
6354
|
while True:
|
|
5763
6355
|
raw_epoch = _stats_db_user_version()
|
|
5764
6356
|
if (
|
|
@@ -5822,18 +6414,19 @@ def _run_stats_ingest_once(
|
|
|
5822
6414
|
alerts=[],
|
|
5823
6415
|
)
|
|
5824
6416
|
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
if
|
|
5828
|
-
conn
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
6417
|
+
if not locks_held:
|
|
6418
|
+
lock_fd = _acquire_ingest_lock(mode, timeout_s)
|
|
6419
|
+
if lock_fd is None:
|
|
6420
|
+
if own_conn and conn is not None:
|
|
6421
|
+
conn.close()
|
|
6422
|
+
conn = None
|
|
6423
|
+
return IngestResult(
|
|
6424
|
+
ran=False,
|
|
6425
|
+
consumed=0,
|
|
6426
|
+
malformed=0,
|
|
6427
|
+
events_emitted=0,
|
|
6428
|
+
alerts=[],
|
|
6429
|
+
)
|
|
5837
6430
|
try:
|
|
5838
6431
|
# #386: declare the sanctioned steady-state write regime for the
|
|
5839
6432
|
# duration of the cycle. Two consumers: the Stage 3 authorizer, and
|
|
@@ -6112,6 +6705,7 @@ def run_stats_ingest(
|
|
|
6112
6705
|
reconcile_config=None,
|
|
6113
6706
|
codex_apply=None,
|
|
6114
6707
|
post_commit=None,
|
|
6708
|
+
locks_held: bool = False,
|
|
6115
6709
|
) -> IngestResult:
|
|
6116
6710
|
"""Run one cycle, healing one completed-correction mismatch when safe.
|
|
6117
6711
|
|
|
@@ -6120,6 +6714,14 @@ def run_stats_ingest(
|
|
|
6120
6714
|
triggering commit, releases both locks, and retries once on a freshly opened
|
|
6121
6715
|
current-family connection. Caller-owned connections are never closed or
|
|
6122
6716
|
replaced. A second correction signal is surfaced with the manual remedy.
|
|
6717
|
+
|
|
6718
|
+
`locks_held=True` (#500 spec §8.1) says the caller already owns stats
|
|
6719
|
+
maintenance and `journal.ingest.lock`, so the cycle neither acquires nor
|
|
6720
|
+
releases them. It declines automatic correction recovery for the same reason
|
|
6721
|
+
a caller-owned CONNECTION does — recovery works by unwinding every lock and
|
|
6722
|
+
then seeking maintenance EXCLUSIVE in total order, which it cannot do while
|
|
6723
|
+
the caller owns the set — and surfaces the same signal with the same manual
|
|
6724
|
+
remedy rather than inventing a second recovery path.
|
|
6123
6725
|
"""
|
|
6124
6726
|
kwargs = {
|
|
6125
6727
|
"mode": mode,
|
|
@@ -6128,17 +6730,21 @@ def run_stats_ingest(
|
|
|
6128
6730
|
"reconcile_config": reconcile_config,
|
|
6129
6731
|
"codex_apply": codex_apply,
|
|
6130
6732
|
"post_commit": post_commit,
|
|
6733
|
+
"locks_held": locks_held,
|
|
6131
6734
|
}
|
|
6132
6735
|
try:
|
|
6133
6736
|
return _run_stats_ingest_once(**kwargs)
|
|
6134
6737
|
except CorrectionRebuildRequired as signal:
|
|
6135
6738
|
if not signal.recovery_eligible:
|
|
6136
6739
|
raise
|
|
6137
|
-
if conn is not None:
|
|
6740
|
+
if conn is not None or locks_held:
|
|
6138
6741
|
raise CorrectionRebuildRequired(
|
|
6139
6742
|
_correction_recovery_guidance(
|
|
6140
6743
|
"automatic correction recovery cannot replace a "
|
|
6141
6744
|
"caller-owned stats.db connection"
|
|
6745
|
+
if conn is not None else
|
|
6746
|
+
"automatic correction recovery cannot run while the "
|
|
6747
|
+
"caller holds the stats maintenance and ingest locks"
|
|
6142
6748
|
),
|
|
6143
6749
|
batch_id=signal.batch_id,
|
|
6144
6750
|
event_id=signal.event_id,
|
|
@@ -8544,6 +9150,16 @@ def _rebuild_quota_cache_leg_raw(
|
|
|
8544
9150
|
file_accounts = [
|
|
8545
9151
|
r for r in decoded if r is not None and _is_codex_file_account_op(r)
|
|
8546
9152
|
]
|
|
9153
|
+
# #500 spec §6.3: the operator's attribution assertions ride the SAME leg,
|
|
9154
|
+
# so they are replayed under the same flocks, in the same transaction, and
|
|
9155
|
+
# under the same coverage certificate as the quota rows and the attribution
|
|
9156
|
+
# map. A separate pass after this one could not be covered by that
|
|
9157
|
+
# certificate, and a failure in it would be flattened into a successful
|
|
9158
|
+
# publish (§6.2).
|
|
9159
|
+
attributions = [
|
|
9160
|
+
r for r in decoded
|
|
9161
|
+
if r is not None and _is_codex_window_attribution_record(r)
|
|
9162
|
+
]
|
|
8547
9163
|
if coverage is not None:
|
|
8548
9164
|
# `complete` is TRUE for a skip, and that is not a slip. Spec §4.7
|
|
8549
9165
|
# distinguishes stats publication success from cache-recovery
|
|
@@ -8559,7 +9175,7 @@ def _rebuild_quota_cache_leg_raw(
|
|
|
8559
9175
|
# that elided every quota-bearing segment has an empty `quota_raw` and a
|
|
8560
9176
|
# cache that may still need those observations if the certificate stopped
|
|
8561
9177
|
# being valid while the pass ran.
|
|
8562
|
-
if not quota_raw and not file_accounts and not elision_gaps:
|
|
9178
|
+
if not quota_raw and not file_accounts and not attributions and not elision_gaps:
|
|
8563
9179
|
return 0.0
|
|
8564
9180
|
cache_path = _cctally_core.CACHE_DB_PATH
|
|
8565
9181
|
if not cache_path.exists():
|
|
@@ -8630,6 +9246,7 @@ def _rebuild_quota_cache_leg_raw(
|
|
|
8630
9246
|
quota_raw, file_accounts, cutover_claude, counters,
|
|
8631
9247
|
cache_path=cache_path, vector=vector, covered=covered,
|
|
8632
9248
|
high_water=high_water, coverage=coverage, quiet=quiet,
|
|
9249
|
+
attributions=attributions,
|
|
8633
9250
|
)
|
|
8634
9251
|
|
|
8635
9252
|
|
|
@@ -8688,6 +9305,7 @@ def _recovery_state(cache):
|
|
|
8688
9305
|
def _run_bounded_recovery(
|
|
8689
9306
|
quota_raw, file_accounts, cutover_claude, counters, *,
|
|
8690
9307
|
cache_path, vector, covered, high_water, coverage, quiet=False,
|
|
9308
|
+
attributions=(),
|
|
8691
9309
|
) -> float:
|
|
8692
9310
|
"""Recovery as resumable chunks, each capped by bytes AND record count.
|
|
8693
9311
|
|
|
@@ -8854,11 +9472,27 @@ def _run_bounded_recovery(
|
|
|
8854
9472
|
counters.update(counter_baseline)
|
|
8855
9473
|
continue
|
|
8856
9474
|
file_conflicts = 0
|
|
9475
|
+
attr_skipped = 0
|
|
8857
9476
|
if with_decisions:
|
|
8858
9477
|
# Decisions FIRST inside this transaction — the same §3.5
|
|
8859
9478
|
# precedence ordering `_cache_applier` keeps.
|
|
8860
9479
|
_restored, file_conflicts = _apply_file_account_records(
|
|
8861
9480
|
cache, file_accounts)
|
|
9481
|
+
# #500: the whole attribution population rides chunk 0 for
|
|
9482
|
+
# the same reason the decisions do — it is bounded by the
|
|
9483
|
+
# operator's assertions (dozens of records on a store with
|
|
9484
|
+
# years of history), not by the observations, so it cannot
|
|
9485
|
+
# reintroduce the unbounded hold the chunking removed. The
|
|
9486
|
+
# cursor commits with them: once this transaction lands, the
|
|
9487
|
+
# table IS current through the pinned high water, because
|
|
9488
|
+
# every attribution record in the prefix is in this list.
|
|
9489
|
+
if attributions:
|
|
9490
|
+
_, _, attr_skipped = _apply_window_attribution_records(
|
|
9491
|
+
cache, attributions)
|
|
9492
|
+
if attributions and high_water is not None:
|
|
9493
|
+
_cctally_cache.store_codex_window_attribution_cursor(
|
|
9494
|
+
cache,
|
|
9495
|
+
(str(high_water[0]), int(high_water[1])))
|
|
8862
9496
|
if decoded_chunk is not None:
|
|
8863
9497
|
_apply_quota_records(
|
|
8864
9498
|
cache, decoded_chunk,
|
|
@@ -8878,6 +9512,7 @@ def _run_bounded_recovery(
|
|
|
8878
9512
|
committed_chunks += 1
|
|
8879
9513
|
chunk_index += 1
|
|
8880
9514
|
_report_file_account_conflicts(file_conflicts, quiet=quiet)
|
|
9515
|
+
_report_window_attribution_skips(attr_skipped, quiet=quiet)
|
|
8881
9516
|
outcome, stop_reason = "incomplete", "noCoverageEstablished"
|
|
8882
9517
|
break
|
|
8883
9518
|
if last and covered is not None:
|
|
@@ -8907,6 +9542,7 @@ def _run_bounded_recovery(
|
|
|
8907
9542
|
committed_chunks += 1
|
|
8908
9543
|
chunk_index += 1
|
|
8909
9544
|
_report_file_account_conflicts(file_conflicts, quiet=quiet)
|
|
9545
|
+
_report_window_attribution_skips(attr_skipped, quiet=quiet)
|
|
8910
9546
|
outcome, stop_reason = "incomplete", "mintRefused"
|
|
8911
9547
|
break
|
|
8912
9548
|
else:
|
|
@@ -8925,6 +9561,26 @@ def _run_bounded_recovery(
|
|
|
8925
9561
|
committed_chunks += 1
|
|
8926
9562
|
chunk_index += 1
|
|
8927
9563
|
_report_file_account_conflicts(file_conflicts, quiet=quiet)
|
|
9564
|
+
_report_window_attribution_skips(attr_skipped, quiet=quiet)
|
|
9565
|
+
except CodexWindowAttributionReplayFailed as exc:
|
|
9566
|
+
# #500 spec §6.2. Typed, and reported through the SAME coverage
|
|
9567
|
+
# channel a write failure uses, so `_write_quota_projection_state`
|
|
9568
|
+
# stamps `stats_quota_projection_state.incomplete` inside the
|
|
9569
|
+
# publication transaction. It must never be flattened into a
|
|
9570
|
+
# successful empty result: a generation published with
|
|
9571
|
+
# `incomplete = 0` while omitting the operator's attribution is
|
|
9572
|
+
# exactly the silent under-attribution this design exists to
|
|
9573
|
+
# prevent.
|
|
9574
|
+
if cache is not None:
|
|
9575
|
+
try:
|
|
9576
|
+
cache.rollback()
|
|
9577
|
+
except sqlite3.Error:
|
|
9578
|
+
pass
|
|
9579
|
+
if not quiet:
|
|
9580
|
+
print("[rebuild] Codex window attribution replay failed: "
|
|
9581
|
+
f"{exc}", file=sys.stderr)
|
|
9582
|
+
outcome, stop_reason = "failed", "attributionReplayFailed"
|
|
9583
|
+
break
|
|
8928
9584
|
except sqlite3.Error as exc:
|
|
8929
9585
|
if cache is not None:
|
|
8930
9586
|
try:
|