cctally 1.92.1 → 1.92.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/bin/_cctally_journal.py +455 -68
- package/bin/_lib_journal_router.py +234 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.92.2] - 2026-08-06
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Rebuilding the stats index now uses about half the memory it used to. On a 1.7 GB journal the rebuild's peak memory dropped from 9.0 GB to 4.6 GB, and the memory Python itself holds dropped from 6.5 GB to 2.1 GB. The rebuild used to read the whole journal twice — once to hold every raw line and once to hold every parsed record — and then read it a third time from the beginning to find one operator record; it now reads each byte at most once and keeps only the records it actually replays. Reading and decoding the journal got faster (12.2 s to 9.5 s), the separate search for that operator record disappeared entirely (6.3 s to none), and a whole rebuild finished in 62.9 s instead of 68.9 s. Nothing about the resulting index changed: the same records are selected, the same rows are written, and the same corrections win. One thing did get worse, and you may notice it: while a rebuild replays Codex quota history it now blocks other commands from writing the Codex cache for longer than before — about 38% longer on a run whose data is already in memory (16.7 s to 23.0 s) and about 18% longer on a cold one (41.6 s to 48.9 s) — because the quota records are now decoded while that lock is held instead of before it is taken (#496).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- A readable but structurally damaged stats index no longer traps an upgrade or recovery in an endless rebuild loop. Version 1.92.1 could copy a healthy rebuilt generation into a file that still contained unreferenced pages, preserve those pages because no schema object named them, fail its post-publication integrity check, and leave the dashboard at `server sync error`; CLI block reports then lost their authoritative reset anchors and marked every block approximate. Publication now checks the destination before mutating it and uses the validated replacement path when the existing file fails integrity, while healthy files keep the in-place transactional path (#496).
|
|
15
|
+
|
|
8
16
|
## [1.92.1] - 2026-08-06
|
|
9
17
|
|
|
10
18
|
### Changed
|
package/bin/_cctally_journal.py
CHANGED
|
@@ -40,6 +40,7 @@ from dataclasses import dataclass, field, replace as _dc_replace
|
|
|
40
40
|
import _cctally_core
|
|
41
41
|
import _lib_accounts
|
|
42
42
|
import _lib_journal
|
|
43
|
+
import _lib_journal_router
|
|
43
44
|
import _lib_record
|
|
44
45
|
|
|
45
46
|
|
|
@@ -178,12 +179,10 @@ def _repair_torn_tail(fd: int) -> None:
|
|
|
178
179
|
# public append surface
|
|
179
180
|
# --------------------------------------------------------------------------
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
and (record.get("payload") or {}).get("kind") == "quota_window_snapshot"
|
|
186
|
-
)
|
|
182
|
+
# NOTE: `_is_codex_quota_obs` is defined ONCE, further down beside
|
|
183
|
+
# `_QUOTA_OBS_KIND`. A duplicate definition used to sit here and was shadowed by
|
|
184
|
+
# that one at import time, so it was dead code an edit here would silently not
|
|
185
|
+
# reach (#496 S4). Do not reintroduce a second definition.
|
|
187
186
|
|
|
188
187
|
|
|
189
188
|
def _codex_quota_natural_key(record: dict) -> str | None:
|
|
@@ -952,11 +951,18 @@ def _write_cursor(conn: sqlite3.Connection, segment: str, offset: int) -> None:
|
|
|
952
951
|
_SEGMENT_READ_CHUNK = 256 * 1024
|
|
953
952
|
|
|
954
953
|
|
|
955
|
-
def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
954
|
+
def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
|
|
956
955
|
"""Stream `(basename, absolute-offset, raw-line-without-newline)` for every
|
|
957
956
|
complete line in `[lo, hi)`, holding at most one chunk plus one partial line
|
|
958
957
|
in memory. `hi` is a line boundary (a HW snapshot size or an immutable prior
|
|
959
|
-
segment's full size), so no partial trailing line appears.
|
|
958
|
+
segment's full size), so no partial trailing line appears.
|
|
959
|
+
|
|
960
|
+
`on_bytes` receives each chunk exactly as it is read, before any line
|
|
961
|
+
splitting. It exists so a caller can reproduce `journal_prefix_hash` from
|
|
962
|
+
the bytes this pass is already reading (#496 S4 §5.2) rather than re-reading
|
|
963
|
+
the segment; it must therefore see the raw `[lo, hi)` range verbatim,
|
|
964
|
+
including a torn trailing partial line that is never yielded.
|
|
965
|
+
"""
|
|
960
966
|
name = seg_path.name
|
|
961
967
|
with open(seg_path, "rb") as fh:
|
|
962
968
|
fh.seek(lo)
|
|
@@ -968,6 +974,8 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
|
968
974
|
if not data:
|
|
969
975
|
break
|
|
970
976
|
pos += len(data)
|
|
977
|
+
if on_bytes is not None:
|
|
978
|
+
on_bytes(data)
|
|
971
979
|
buf = buf + data if buf else data
|
|
972
980
|
start = 0
|
|
973
981
|
while True:
|
|
@@ -981,14 +989,6 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
|
981
989
|
buf_at += start
|
|
982
990
|
|
|
983
991
|
|
|
984
|
-
def _read_segment_lines(seg_path, lo: int, hi: int) -> list[tuple[str, int, bytes]]:
|
|
985
|
-
"""Materialized form of :func:`_iter_segment_lines` (see it for the
|
|
986
|
-
contract). Callers that walk a whole range at once should prefer
|
|
987
|
-
:func:`iter_range`; this list form is retained for the ingest cycle, which
|
|
988
|
-
needs the batch as an indexable sequence."""
|
|
989
|
-
return list(_iter_segment_lines(seg_path, lo, hi))
|
|
990
|
-
|
|
991
|
-
|
|
992
992
|
def iter_range(cursor, hw):
|
|
993
993
|
"""Stream `cursor -> HW` across segments in canonical order (spec §5.2.2).
|
|
994
994
|
|
|
@@ -1002,8 +1002,26 @@ def iter_range(cursor, hw):
|
|
|
1002
1002
|
materialized form for the ingest cycle, which genuinely needs the batch as
|
|
1003
1003
|
an indexable sequence (prefix-stop indices address into it).
|
|
1004
1004
|
"""
|
|
1005
|
+
yield from _iter_range_with_segments(cursor, hw, list_segments())
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
|
|
1009
|
+
on_bytes=None):
|
|
1010
|
+
"""`iter_range` over a segment list the CALLER snapshotted (#496 S4 §4).
|
|
1011
|
+
|
|
1012
|
+
`list_segments()` enumerates the journal directory at call time and orders
|
|
1013
|
+
bootstrap segments before observation segments, so a bootstrap segment
|
|
1014
|
+
appearing mid-rebuild would insert ahead of the high-water segment and shift
|
|
1015
|
+
the indices this function addresses by. A rebuild takes ONE snapshot at its
|
|
1016
|
+
pinned high-water and drives every pass from it, so two passes of the same
|
|
1017
|
+
rebuild cannot disagree about the journal's shape.
|
|
1018
|
+
|
|
1019
|
+
`on_segment` is called for EVERY segment in the range, including one this
|
|
1020
|
+
function then skips because it holds no bytes in range: `journal_prefix_hash`
|
|
1021
|
+
frames a zero-byte segment, so a hash accumulator has to be told it exists.
|
|
1022
|
+
`on_bytes` is forwarded to `_iter_segment_lines`.
|
|
1023
|
+
"""
|
|
1005
1024
|
hw_seg, hw_size = hw
|
|
1006
|
-
segments = list_segments()
|
|
1007
1025
|
if hw_seg not in segments:
|
|
1008
1026
|
return
|
|
1009
1027
|
hw_idx = segments.index(hw_seg)
|
|
@@ -1020,9 +1038,11 @@ def iter_range(cursor, hw):
|
|
|
1020
1038
|
seg_path = _cctally_core.JOURNAL_DIR / seg
|
|
1021
1039
|
lo = start_off if idx == start_idx else 0
|
|
1022
1040
|
hi = hw_size if idx == hw_idx else os.path.getsize(seg_path)
|
|
1041
|
+
if on_segment is not None:
|
|
1042
|
+
on_segment(seg)
|
|
1023
1043
|
if lo >= hi:
|
|
1024
1044
|
continue
|
|
1025
|
-
yield from _iter_segment_lines(seg_path, lo, hi)
|
|
1045
|
+
yield from _iter_segment_lines(seg_path, lo, hi, on_bytes=on_bytes)
|
|
1026
1046
|
|
|
1027
1047
|
|
|
1028
1048
|
def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
|
|
@@ -1057,20 +1077,28 @@ def journal_prefix_hash(high_water) -> "str | None":
|
|
|
1057
1077
|
return "sha256:" + digest.hexdigest()
|
|
1058
1078
|
|
|
1059
1079
|
|
|
1060
|
-
def _capture_protocol_prefix_evidence(
|
|
1061
|
-
|
|
1080
|
+
def _capture_protocol_prefix_evidence(
|
|
1081
|
+
record, prior_high_water, evidence, hasher=None
|
|
1082
|
+
) -> None:
|
|
1083
|
+
"""Capture the actual raw prefix immediately preceding one audit record.
|
|
1084
|
+
|
|
1085
|
+
`hasher` is a `_lib_journal_router.PrefixHashAccumulator` fed by the caller's
|
|
1086
|
+
streaming pass. When supplied, the digest comes from bytes that pass has
|
|
1087
|
+
already read; otherwise `journal_prefix_hash` re-reads the whole prefix from
|
|
1088
|
+
disk, which is what the streaming callers exist to avoid (#496 S4 §5.2). The
|
|
1089
|
+
two produce the identical durable digest.
|
|
1090
|
+
"""
|
|
1062
1091
|
if (
|
|
1063
1092
|
record.get("t") == "op"
|
|
1064
1093
|
and isinstance(record.get("payload"), dict)
|
|
1065
1094
|
and record["payload"].get("kind")
|
|
1066
1095
|
== _lib_journal._PROTOCOL_RESOLUTION_KIND
|
|
1067
1096
|
):
|
|
1068
|
-
|
|
1069
|
-
(
|
|
1070
|
-
|
|
1071
|
-
journal_prefix_hash(prior_high_water),
|
|
1072
|
-
)
|
|
1097
|
+
digest = (
|
|
1098
|
+
hasher.digest_at(prior_high_water) if hasher is not None
|
|
1099
|
+
else journal_prefix_hash(prior_high_water)
|
|
1073
1100
|
)
|
|
1101
|
+
evidence.append((prior_high_water, digest))
|
|
1074
1102
|
|
|
1075
1103
|
|
|
1076
1104
|
# --------------------------------------------------------------------------
|
|
@@ -2017,6 +2045,8 @@ def _derive_account_last_seen(conn, records) -> None:
|
|
|
2017
2045
|
prior observe already created (never invents an account row)."""
|
|
2018
2046
|
latest: dict = {}
|
|
2019
2047
|
for rec in records:
|
|
2048
|
+
if rec is None:
|
|
2049
|
+
continue
|
|
2020
2050
|
key = _account_of(rec)
|
|
2021
2051
|
at = rec.get("at")
|
|
2022
2052
|
if not key or not at:
|
|
@@ -2024,6 +2054,17 @@ def _derive_account_last_seen(conn, records) -> None:
|
|
|
2024
2054
|
prev = latest.get(key)
|
|
2025
2055
|
if prev is None or at > prev:
|
|
2026
2056
|
latest[key] = at
|
|
2057
|
+
_apply_account_last_seen(conn, latest)
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def _apply_account_last_seen(conn, latest) -> None:
|
|
2061
|
+
"""Apply a precomputed `{account_key: max_at}` map.
|
|
2062
|
+
|
|
2063
|
+
Split out so the rebuild can accumulate the map during its single streaming
|
|
2064
|
+
pass (#496 S4 §4.2) instead of walking every record again inside the
|
|
2065
|
+
publication transaction. The rebuild's retained list no longer contains
|
|
2066
|
+
observations at all, so calling `_derive_account_last_seen` over it would
|
|
2067
|
+
silently drop every observation's contribution."""
|
|
2027
2068
|
for key, at in latest.items():
|
|
2028
2069
|
conn.execute(
|
|
2029
2070
|
"UPDATE accounts SET last_seen_utc = ? WHERE account_key = ? "
|
|
@@ -3428,6 +3469,10 @@ def _correction_commit_high_water(batch_id, hw=None):
|
|
|
3428
3469
|
selector or by the live metadata row that names it. The earliest matching
|
|
3429
3470
|
commit is the narrowest complete prefix and remains stable even when later
|
|
3430
3471
|
journal bytes or crash-replayed duplicate markers exist.
|
|
3472
|
+
|
|
3473
|
+
Streams rather than materializing (#496 S4): the previous form built the
|
|
3474
|
+
whole prefix through `_read_range` before its first-match return, so a
|
|
3475
|
+
marker in the first segment still paid for every later one.
|
|
3431
3476
|
"""
|
|
3432
3477
|
if not batch_id:
|
|
3433
3478
|
return None
|
|
@@ -3435,7 +3480,7 @@ def _correction_commit_high_water(batch_id, hw=None):
|
|
|
3435
3480
|
hw = journal_high_water()
|
|
3436
3481
|
if hw is None:
|
|
3437
3482
|
return None
|
|
3438
|
-
for segment, offset, raw in
|
|
3483
|
+
for segment, offset, raw in iter_range(None, hw):
|
|
3439
3484
|
record = _lib_journal.decode_line(raw)
|
|
3440
3485
|
if (
|
|
3441
3486
|
record is not None
|
|
@@ -4259,6 +4304,25 @@ class RebuildResult:
|
|
|
4259
4304
|
# batches remain tainted; this is diagnostic/audit state, never validity.
|
|
4260
4305
|
acknowledged_protocol_violations: tuple = ()
|
|
4261
4306
|
quarantine_dir: "pathlib.Path | None" = None
|
|
4307
|
+
# #496 S4 §8.7 — ADDITIVE instrumentation. Names, units and pass boundaries
|
|
4308
|
+
# are fixed by the spec so the gate's assertions are unambiguous; adding
|
|
4309
|
+
# them does not bump the rebuild record's `schemaVersion` and no existing
|
|
4310
|
+
# field changes meaning.
|
|
4311
|
+
#: float seconds per phase. Keys: journal_read_decode, cutover_suffix,
|
|
4312
|
+
#: protocol_evidence, effective_selection, quota_cache_leg, stats_fold,
|
|
4313
|
+
#: scratch_validate, publication. The phases are DISJOINT — evidence hashing
|
|
4314
|
+
#: happens inside the read loop and is subtracted from journal_read_decode.
|
|
4315
|
+
phase_seconds: dict = field(default_factory=dict)
|
|
4316
|
+
#: per named pass, `{lines, bytes, decodes}`. Passes: stats_prefix (the
|
|
4317
|
+
#: router), cutover_suffix (zero unless the §5.1 fallback ran),
|
|
4318
|
+
#: protocol_evidence (`bytes` hashed and `lines` digests computed, both zero
|
|
4319
|
+
#: on a journal with no resolution op), quota_replay (the in-leg decode,
|
|
4320
|
+
#: where `bytes` is the retained byte total and `lines` equals `decodes`).
|
|
4321
|
+
traversal: dict = field(default_factory=dict)
|
|
4322
|
+
#: `tracemalloc` peak over the pre-publication window; 0 when not tracing.
|
|
4323
|
+
peak_heap_bytes: int = 0
|
|
4324
|
+
#: cache writer flock acquisition to release, in seconds.
|
|
4325
|
+
quota_lock_hold_seconds: float = 0.0
|
|
4262
4326
|
|
|
4263
4327
|
|
|
4264
4328
|
def _remove_db_sidecars_strict(path) -> None:
|
|
@@ -4482,26 +4546,56 @@ def stats_index_matches_journal_prefix(
|
|
|
4482
4546
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4483
4547
|
try:
|
|
4484
4548
|
_validate_rebuilt_stats_index(conn, high_water)
|
|
4485
|
-
|
|
4549
|
+
# Same streaming router as the rebuild (#496 S4 §7). This function's
|
|
4550
|
+
# only output is a selection compared against
|
|
4551
|
+
# `journal_effective_events`, so it needs the decision records and
|
|
4552
|
+
# nothing else: no observation retention, no quota bytes, no second
|
|
4553
|
+
# from-byte-zero cutover scan. The `None` placeholders keep every
|
|
4554
|
+
# `enumerate` sequence — and therefore every violation fingerprint —
|
|
4555
|
+
# identical to what the rebuild wrote.
|
|
4556
|
+
decoded: list = []
|
|
4486
4557
|
protocol_evidence = []
|
|
4487
4558
|
prior_high_water = None
|
|
4559
|
+
cutover_captured = _CUTOVER_UNSEEN
|
|
4560
|
+
all_segments = list_segments()
|
|
4561
|
+
segments = all_segments
|
|
4562
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
4488
4563
|
if high_water is not None:
|
|
4489
|
-
|
|
4564
|
+
if high_water[0] in segments:
|
|
4565
|
+
segments = segments[:segments.index(high_water[0]) + 1]
|
|
4566
|
+
for segment, offset, raw in _iter_range_with_segments(
|
|
4567
|
+
None, high_water, segments,
|
|
4568
|
+
on_segment=lambda name: hasher.begin_segment(
|
|
4569
|
+
name, prior_high_water),
|
|
4570
|
+
on_bytes=hasher.extend,
|
|
4571
|
+
):
|
|
4490
4572
|
record = _lib_journal.decode_line(raw)
|
|
4491
4573
|
if record is not None:
|
|
4492
4574
|
_capture_protocol_prefix_evidence(
|
|
4493
4575
|
record,
|
|
4494
4576
|
prior_high_water,
|
|
4495
4577
|
protocol_evidence,
|
|
4578
|
+
hasher=hasher,
|
|
4579
|
+
)
|
|
4580
|
+
if (cutover_captured is _CUTOVER_UNSEEN
|
|
4581
|
+
and record.get("id") == CUTOVER_OP_ID):
|
|
4582
|
+
cutover_captured = _cutover_value_of(record)
|
|
4583
|
+
decoded.append(
|
|
4584
|
+
record
|
|
4585
|
+
if record.get("t")
|
|
4586
|
+
in _lib_journal_router.RETAINED_RECORD_TYPES
|
|
4587
|
+
else None
|
|
4496
4588
|
)
|
|
4497
|
-
decoded.append(record)
|
|
4498
4589
|
prior_high_water = (
|
|
4499
4590
|
segment,
|
|
4500
4591
|
offset + len(raw) + 1,
|
|
4501
4592
|
)
|
|
4502
|
-
|
|
4593
|
+
hasher = None
|
|
4594
|
+
cutover_claude = _resolve_cutover_for_rebuild(
|
|
4595
|
+
cutover_captured, high_water, all_segments)
|
|
4503
4596
|
for record in decoded:
|
|
4504
|
-
|
|
4597
|
+
if record is not None:
|
|
4598
|
+
_normalize_legacy_account_stamp(record, cutover_claude)
|
|
4505
4599
|
selection = _lib_journal.resolve_effective_events(
|
|
4506
4600
|
decoded,
|
|
4507
4601
|
protocol_prefix_evidence=protocol_evidence,
|
|
@@ -5449,6 +5543,51 @@ def _publish_stats_index_in_place(
|
|
|
5449
5543
|
return _FALL_BACK
|
|
5450
5544
|
raise
|
|
5451
5545
|
|
|
5546
|
+
# Readability is not structural health. An integrity failure may consist
|
|
5547
|
+
# only of pages which no sqlite_schema object and no freelist entry names.
|
|
5548
|
+
# The table-by-table in-place swap cannot discover or reclaim such pages,
|
|
5549
|
+
# so publishing into that file would preserve the damage and fail its
|
|
5550
|
+
# post-publication verdict forever. Use the independently validated scratch
|
|
5551
|
+
# as a physical replacement before any live mutation instead.
|
|
5552
|
+
try:
|
|
5553
|
+
destination_integrity = [
|
|
5554
|
+
str(row[0]) for row in conn.execute("PRAGMA integrity_check")
|
|
5555
|
+
]
|
|
5556
|
+
except BaseException as exc:
|
|
5557
|
+
try:
|
|
5558
|
+
conn.close()
|
|
5559
|
+
except Exception:
|
|
5560
|
+
pass
|
|
5561
|
+
if sp.may_fall_back_to_replacement(exc):
|
|
5562
|
+
print(
|
|
5563
|
+
"[rebuild] the live stats index failed its integrity probe "
|
|
5564
|
+
f"({exc}); publishing by replacement instead",
|
|
5565
|
+
file=sys.stderr,
|
|
5566
|
+
)
|
|
5567
|
+
record["inPlaceAttempt"] = {
|
|
5568
|
+
"phase": sp.PRE_COMMIT,
|
|
5569
|
+
"stage": "destination_integrity",
|
|
5570
|
+
"error": f"{type(exc).__name__}: {exc}"[:500],
|
|
5571
|
+
}
|
|
5572
|
+
return _FALL_BACK
|
|
5573
|
+
raise
|
|
5574
|
+
if destination_integrity != ["ok"]:
|
|
5575
|
+
try:
|
|
5576
|
+
conn.close()
|
|
5577
|
+
except Exception:
|
|
5578
|
+
pass
|
|
5579
|
+
print(
|
|
5580
|
+
"[rebuild] the live stats index failed integrity_check; "
|
|
5581
|
+
"publishing by replacement instead",
|
|
5582
|
+
file=sys.stderr,
|
|
5583
|
+
)
|
|
5584
|
+
record["inPlaceAttempt"] = {
|
|
5585
|
+
"phase": sp.PRE_COMMIT,
|
|
5586
|
+
"stage": "destination_integrity",
|
|
5587
|
+
"error": "destination failed integrity_check",
|
|
5588
|
+
}
|
|
5589
|
+
return _FALL_BACK
|
|
5590
|
+
|
|
5452
5591
|
started_at = _utc_iso_now()
|
|
5453
5592
|
record_path = pathlib.Path(context.record_path)
|
|
5454
5593
|
live = dict(record)
|
|
@@ -5559,9 +5698,10 @@ def _publish_rebuilt_stats_index(
|
|
|
5559
5698
|
|
|
5560
5699
|
In-place transactional publication is the mechanism (#496 S3). Physical
|
|
5561
5700
|
replacement is the fallback, taken when the destination cannot be operated
|
|
5562
|
-
on structurally. The mechanism is chosen
|
|
5563
|
-
of this run, not by the trigger that
|
|
5564
|
-
|
|
5701
|
+
on structurally or fails an integrity check. The mechanism is chosen
|
|
5702
|
+
against the destination in front of this run, not by the trigger that
|
|
5703
|
+
reached it: readability alone does not prove that an object-level swap can
|
|
5704
|
+
reclaim every damaged page.
|
|
5565
5705
|
|
|
5566
5706
|
Publication is a two-phase durable transaction (#496 S1 F1) under either
|
|
5567
5707
|
mechanism. A published file carries the current epoch, so `open_db`'s
|
|
@@ -5714,9 +5854,36 @@ def _publish_rebuilt_stats_index(
|
|
|
5714
5854
|
return incident
|
|
5715
5855
|
|
|
5716
5856
|
|
|
5717
|
-
def
|
|
5857
|
+
def _decoded_quota_stream(quota_raw, cutover_claude, counters=None):
|
|
5858
|
+
"""Decode and normalize retained observation bytes ONE AT A TIME.
|
|
5859
|
+
|
|
5860
|
+
Peak heap therefore holds one record rather than the whole population.
|
|
5861
|
+
Normalization runs HERE, on the record decoded from the retained bytes: a
|
|
5862
|
+
dict normalized during the router pass is discarded with the pass, so
|
|
5863
|
+
stamping it there would be lost and every legacy observation would
|
|
5864
|
+
re-materialize with a NULL account_key. A Codex legacy line maps to
|
|
5865
|
+
`unattributed` regardless of the cutover value, so this does not depend on
|
|
5866
|
+
capture ordering (#496 S4 §6.3).
|
|
5867
|
+
"""
|
|
5868
|
+
for raw in quota_raw:
|
|
5869
|
+
if counters is not None:
|
|
5870
|
+
counters["lines"] += 1
|
|
5871
|
+
counters["bytes"] += len(raw) + 1
|
|
5872
|
+
record = _lib_journal.decode_line(raw)
|
|
5873
|
+
if record is None: # pragma: no cover — retained bytes decoded once already
|
|
5874
|
+
continue
|
|
5875
|
+
if counters is not None:
|
|
5876
|
+
counters["decodes"] += 1
|
|
5877
|
+
_normalize_legacy_account_stamp(record, cutover_claude)
|
|
5878
|
+
yield record
|
|
5879
|
+
|
|
5880
|
+
|
|
5881
|
+
def _rebuild_quota_cache_leg_raw(
|
|
5882
|
+
quota_raw, decoded, cutover_claude, counters=None
|
|
5883
|
+
) -> float:
|
|
5718
5884
|
"""Re-materialize cache.db `quota_window_snapshots` AND the #416 Codex
|
|
5719
|
-
attribution map from the journal (spec §5.4 + #416 spec §3.4)
|
|
5885
|
+
attribution map from the journal (spec §5.4 + #416 spec §3.4), fed RAW
|
|
5886
|
+
ENCODED LINES for the observations instead of decoded dicts.
|
|
5720
5887
|
|
|
5721
5888
|
The journal records are the DURABLE source (§1 latent data-loss hole — the
|
|
5722
5889
|
rollout JSONL evaporates); this INSERT-OR-IGNOREs the quota obs on their
|
|
@@ -5730,14 +5897,31 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5730
5897
|
followed by the `cache.db.codex.lock` provider flock (lock-order law).
|
|
5731
5898
|
Best-effort: a missing/busy cache.db is a clean skip (the records stay
|
|
5732
5899
|
durable in the journal; the stats quota projection pass then degrades
|
|
5733
|
-
cleanly).
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5900
|
+
cleanly).
|
|
5901
|
+
|
|
5902
|
+
Taking raw bytes is what makes the rebuild affordable. 1.81M decoded
|
|
5903
|
+
observation dictionaries are roughly six gigabytes against 1.64 GB of raw
|
|
5904
|
+
bytes, so retaining bytes removes about four gigabytes of peak heap while
|
|
5905
|
+
adding NO file input and NO second traversal — only the JSON decode of
|
|
5906
|
+
records already in memory moves inside the flocks (#496 S4 §6.3). That
|
|
5907
|
+
decode is why the measured hold is longer than it was before S4; see the
|
|
5908
|
+
spec's §6.3 for the measured figures.
|
|
5909
|
+
|
|
5910
|
+
Ordering is preserved: file-account decisions are ops, so the router already
|
|
5911
|
+
retains them decoded and they are available before the observation loop
|
|
5912
|
+
begins, exactly as the §3.5 precedence rule requires.
|
|
5913
|
+
|
|
5914
|
+
Returns the measured flock hold in seconds, which acceptance criterion 7
|
|
5915
|
+
caps.
|
|
5916
|
+
"""
|
|
5917
|
+
file_accounts = [
|
|
5918
|
+
r for r in decoded if r is not None and _is_codex_file_account_op(r)
|
|
5919
|
+
]
|
|
5920
|
+
if not quota_raw and not file_accounts:
|
|
5921
|
+
return 0.0
|
|
5738
5922
|
cache_path = _cctally_core.CACHE_DB_PATH
|
|
5739
5923
|
if not cache_path.exists():
|
|
5740
|
-
return
|
|
5924
|
+
return 0.0
|
|
5741
5925
|
from _lib_cache_writer_lock import (
|
|
5742
5926
|
acquire_cache_writer_flocks,
|
|
5743
5927
|
release_cache_writer_flocks,
|
|
@@ -5752,22 +5936,26 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5752
5936
|
)
|
|
5753
5937
|
except OSError as exc:
|
|
5754
5938
|
print(f"[rebuild] quota cache leg lock failed: {exc}", file=sys.stderr)
|
|
5755
|
-
return
|
|
5939
|
+
return 0.0
|
|
5756
5940
|
if held is None:
|
|
5757
5941
|
print("[rebuild] quota cache leg locks busy; skipping", file=sys.stderr)
|
|
5758
|
-
return
|
|
5942
|
+
return 0.0
|
|
5943
|
+
held_from = time.monotonic()
|
|
5759
5944
|
try:
|
|
5760
5945
|
try:
|
|
5761
5946
|
cache = sqlite3.connect(str(cache_path), timeout=15.0)
|
|
5762
5947
|
except sqlite3.Error as exc: # pragma: no cover — cache.db unopenable
|
|
5763
5948
|
print(f"[rebuild] quota cache leg connect failed: {exc}", file=sys.stderr)
|
|
5764
|
-
return
|
|
5949
|
+
return time.monotonic() - held_from
|
|
5765
5950
|
try:
|
|
5766
5951
|
cache.execute("PRAGMA busy_timeout=15000")
|
|
5767
5952
|
cache.execute("BEGIN IMMEDIATE")
|
|
5768
5953
|
# Decisions FIRST — same §3.5 precedence ordering as `_cache_applier`.
|
|
5769
5954
|
_, _file_conflicts = _apply_file_account_records(cache, file_accounts)
|
|
5770
|
-
_apply_quota_records(
|
|
5955
|
+
_apply_quota_records(
|
|
5956
|
+
cache,
|
|
5957
|
+
_decoded_quota_stream(quota_raw, cutover_claude, counters),
|
|
5958
|
+
)
|
|
5771
5959
|
cache.commit()
|
|
5772
5960
|
_report_file_account_conflicts(_file_conflicts)
|
|
5773
5961
|
except sqlite3.Error as exc:
|
|
@@ -5780,6 +5968,66 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5780
5968
|
cache.close()
|
|
5781
5969
|
finally:
|
|
5782
5970
|
release_cache_writer_flocks(held)
|
|
5971
|
+
return time.monotonic() - held_from
|
|
5972
|
+
|
|
5973
|
+
|
|
5974
|
+
#: `_resolve_cutover_for_rebuild` distinguishes "the streaming pass never saw the
|
|
5975
|
+
#: op" from "it saw the op and the op recorded no account". `find_accounts_cutover_op`
|
|
5976
|
+
#: makes the same distinction by returning at the first matching RECORD id, so a
|
|
5977
|
+
#: plain `None` cannot stand in for both without changing which answer wins.
|
|
5978
|
+
_CUTOVER_UNSEEN = object()
|
|
5979
|
+
|
|
5980
|
+
|
|
5981
|
+
def _cutover_value_of(record) -> "str | None":
|
|
5982
|
+
"""The cutover op's recorded `claude_legacy_account`, or None when this
|
|
5983
|
+
record is not the canonical cutover op. Shared by the rebuild's inline
|
|
5984
|
+
capture and `find_accounts_cutover_op` so the two cannot disagree."""
|
|
5985
|
+
if record is None or record.get("id") != CUTOVER_OP_ID:
|
|
5986
|
+
return None
|
|
5987
|
+
payload = record.get("payload")
|
|
5988
|
+
if not isinstance(payload, dict):
|
|
5989
|
+
return None
|
|
5990
|
+
return payload.get("claude_legacy_account")
|
|
5991
|
+
|
|
5992
|
+
|
|
5993
|
+
def _resolve_cutover_for_rebuild(captured, hw, segments, counters=None) -> str:
|
|
5994
|
+
"""The cutover account for one rebuild, reading each byte at most once.
|
|
5995
|
+
|
|
5996
|
+
`captured` is what the streaming pass saw inside the pinned prefix, or
|
|
5997
|
+
`_CUTOVER_UNSEEN`. When the prefix did not contain the op — reachable,
|
|
5998
|
+
because correction recovery, journal repair and rederive all pin
|
|
5999
|
+
high-waters, and the op sits at 92.9% of a production journal — scan ONLY
|
|
6000
|
+
the unvisited suffix, from the pinned high-water to the current one,
|
|
6001
|
+
stopping at the first match. Resolving from the prefix alone would flip
|
|
6002
|
+
those rebuilds to `unattributed` and restamp every legacy Claude
|
|
6003
|
+
observation, moving `accounts.last_seen_utc` with it (#496 S4 §5.1).
|
|
6004
|
+
"""
|
|
6005
|
+
if captured is not _CUTOVER_UNSEEN:
|
|
6006
|
+
return captured if captured is not None else _lib_accounts.UNATTRIBUTED
|
|
6007
|
+
if hw is None or not segments:
|
|
6008
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6009
|
+
current = journal_high_water()
|
|
6010
|
+
if current is None or current == hw:
|
|
6011
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6012
|
+
if current[0] not in segments:
|
|
6013
|
+
# A segment appended after this rebuild's snapshot. Every pass of one
|
|
6014
|
+
# rebuild reads the same snapshot (§4), so the suffix stops at its end
|
|
6015
|
+
# rather than silently adopting a different journal shape.
|
|
6016
|
+
last = segments[-1]
|
|
6017
|
+
current = (last, os.path.getsize(_cctally_core.JOURNAL_DIR / last))
|
|
6018
|
+
if current == hw:
|
|
6019
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6020
|
+
for _segment, _offset, raw in _iter_range_with_segments(hw, current, segments):
|
|
6021
|
+
if counters is not None:
|
|
6022
|
+
counters["lines"] += 1
|
|
6023
|
+
counters["bytes"] += len(raw) + 1
|
|
6024
|
+
record = _lib_journal.decode_line(raw)
|
|
6025
|
+
if counters is not None and record is not None:
|
|
6026
|
+
counters["decodes"] += 1
|
|
6027
|
+
if record is not None and record.get("id") == CUTOVER_OP_ID:
|
|
6028
|
+
value = _cutover_value_of(record)
|
|
6029
|
+
return value if value is not None else _lib_accounts.UNATTRIBUTED
|
|
6030
|
+
return _lib_accounts.UNATTRIBUTED
|
|
5783
6031
|
|
|
5784
6032
|
|
|
5785
6033
|
def rebuild_stats_index(
|
|
@@ -5812,6 +6060,11 @@ def rebuild_stats_index(
|
|
|
5812
6060
|
replaces the main file. A `target_path` build uses the same atomic
|
|
5813
6061
|
publication but does not create a live-family quarantine incident.
|
|
5814
6062
|
"""
|
|
6063
|
+
# Imported HERE, not at module scope: `_cctally_journal` is on the ingest
|
|
6064
|
+
# path every status-line tick reaches, and `import tracemalloc` measured
|
|
6065
|
+
# 2.9 ms. Only a rebuild reads the peak, so only a rebuild pays for it.
|
|
6066
|
+
import tracemalloc
|
|
6067
|
+
|
|
5815
6068
|
start = time.monotonic()
|
|
5816
6069
|
context = context.validate()
|
|
5817
6070
|
# Resolve the rebuild record's path ONCE, here, because preservation runs
|
|
@@ -5831,7 +6084,13 @@ def rebuild_stats_index(
|
|
|
5831
6084
|
# and belong to the next ingest cycle (they replay idempotently); mirrors the
|
|
5832
6085
|
# live cycle's §5.2.1 HW-prefix rule.
|
|
5833
6086
|
hw = high_water if high_water is not None else journal_high_water()
|
|
5834
|
-
|
|
6087
|
+
# ONE segment snapshot for the whole rebuild (#496 S4 §4). `list_segments()`
|
|
6088
|
+
# re-enumerates the directory at call time and orders bootstrap segments
|
|
6089
|
+
# first, so a bootstrap segment appearing mid-rebuild would shift the indices
|
|
6090
|
+
# `iter_range` addresses by; before this, the read pass and the cutover scan
|
|
6091
|
+
# each listed separately and could already disagree about the journal's shape.
|
|
6092
|
+
all_segments = list_segments()
|
|
6093
|
+
segments = all_segments
|
|
5835
6094
|
if hw is not None:
|
|
5836
6095
|
if hw[0] not in segments:
|
|
5837
6096
|
raise JournalError(
|
|
@@ -5854,12 +6113,42 @@ def rebuild_stats_index(
|
|
|
5854
6113
|
conn = _cctally_core.open_db(_target_path=str(scratch))
|
|
5855
6114
|
malformed = 0
|
|
5856
6115
|
lines_folded = 0
|
|
6116
|
+
phase_seconds: dict = {}
|
|
6117
|
+
traversal = {
|
|
6118
|
+
name: {"lines": 0, "bytes": 0, "decodes": 0}
|
|
6119
|
+
for name in ("stats_prefix", "cutover_suffix", "protocol_evidence",
|
|
6120
|
+
"quota_replay")
|
|
6121
|
+
}
|
|
6122
|
+
quota_lock_hold = 0.0
|
|
6123
|
+
tracing = tracemalloc.is_tracing()
|
|
6124
|
+
if tracing:
|
|
6125
|
+
tracemalloc.reset_peak()
|
|
5857
6126
|
try:
|
|
6127
|
+
# ONE streaming pass. Decode each line once, feed the account last-seen
|
|
6128
|
+
# accumulator, capture the cutover inline, and retain only what a
|
|
6129
|
+
# consumer actually needs: the decision records decoded (5.08% of a
|
|
6130
|
+
# production journal) and the Codex quota observations as RAW BYTES
|
|
6131
|
+
# (1.64 GB against roughly six gigabytes of dicts). Everything else is
|
|
6132
|
+
# dropped as soon as it has contributed (#496 S4 §4).
|
|
5858
6133
|
decoded: list = []
|
|
6134
|
+
quota_raw: list = []
|
|
5859
6135
|
protocol_evidence = []
|
|
5860
6136
|
prior_high_water = None
|
|
6137
|
+
cutover_captured = _CUTOVER_UNSEEN
|
|
6138
|
+
last_seen = _lib_journal_router.LastSeenAccumulator()
|
|
6139
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
6140
|
+
evidence_seconds = 0.0
|
|
6141
|
+
prefix = traversal["stats_prefix"]
|
|
6142
|
+
read_started = time.monotonic()
|
|
5861
6143
|
if hw is not None:
|
|
5862
|
-
for segment, offset, raw in
|
|
6144
|
+
for segment, offset, raw in _iter_range_with_segments(
|
|
6145
|
+
None, hw, segments,
|
|
6146
|
+
on_segment=lambda name: hasher.begin_segment(
|
|
6147
|
+
name, prior_high_water),
|
|
6148
|
+
on_bytes=hasher.extend,
|
|
6149
|
+
):
|
|
6150
|
+
prefix["lines"] += 1
|
|
6151
|
+
prefix["bytes"] += len(raw) + 1
|
|
5863
6152
|
rec = _lib_journal.decode_line(raw)
|
|
5864
6153
|
if rec is None:
|
|
5865
6154
|
malformed += 1
|
|
@@ -5868,46 +6157,104 @@ def rebuild_stats_index(
|
|
|
5868
6157
|
offset + len(raw) + 1,
|
|
5869
6158
|
)
|
|
5870
6159
|
continue
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
6160
|
+
prefix["decodes"] += 1
|
|
6161
|
+
# `_capture_protocol_prefix_evidence` returns immediately for
|
|
6162
|
+
# anything that is not an op, so this guard changes nothing it
|
|
6163
|
+
# does — it moves the phase attribution's two clock reads from
|
|
6164
|
+
# every record to every op. That is 195 ops against 1,954,007
|
|
6165
|
+
# lines on a production journal, where the phase itself measures
|
|
6166
|
+
# zero because the journal carries no resolution operation.
|
|
6167
|
+
if rec.get("t") == "op":
|
|
6168
|
+
evidence_started = time.monotonic()
|
|
6169
|
+
_capture_protocol_prefix_evidence(
|
|
6170
|
+
rec,
|
|
6171
|
+
prior_high_water,
|
|
6172
|
+
protocol_evidence,
|
|
6173
|
+
hasher=hasher,
|
|
6174
|
+
)
|
|
6175
|
+
evidence_seconds += time.monotonic() - evidence_started
|
|
6176
|
+
# First cutover op wins, exactly as `find_accounts_cutover_op`
|
|
6177
|
+
# scans — captured here so the rebuild reads the journal once.
|
|
6178
|
+
if (cutover_captured is _CUTOVER_UNSEEN
|
|
6179
|
+
and rec.get("id") == CUTOVER_OP_ID):
|
|
6180
|
+
cutover_captured = _cutover_value_of(rec)
|
|
6181
|
+
last_seen.observe(rec, classify_legacy_provider)
|
|
6182
|
+
if rec.get("t") in _lib_journal_router.RETAINED_RECORD_TYPES:
|
|
6183
|
+
decoded.append(rec)
|
|
6184
|
+
else:
|
|
6185
|
+
if update_quota_cache and _is_codex_quota_obs(rec):
|
|
6186
|
+
quota_raw.append(raw)
|
|
6187
|
+
# A PLACEHOLDER, not a dropped element. `resolve_effective_events`
|
|
6188
|
+
# numbers candidates with `enumerate(records)`, and three of the
|
|
6189
|
+
# seven structural violation kinds put that number inside
|
|
6190
|
+
# `ProtocolViolation.evidence` — which the fingerprint hashes.
|
|
6191
|
+
# That fingerprint is durable: it lands in
|
|
6192
|
+
# `journal_protocol_violations` and is referenced by name from a
|
|
6193
|
+
# `journal_protocol_resolution` op, which `_cctally_journal_repair`
|
|
6194
|
+
# mints from the UNFILTERED record list. Renumbering here would
|
|
6195
|
+
# therefore make a previously acknowledged violation unresolvable
|
|
6196
|
+
# and raise on every later rebuild. The selector skips a non-dict
|
|
6197
|
+
# element, so this costs one pointer and keeps every sequence
|
|
6198
|
+
# identical to the pre-change numbering (#496 S4; corrects §4.7).
|
|
6199
|
+
decoded.append(None)
|
|
5877
6200
|
prior_high_water = (
|
|
5878
6201
|
segment,
|
|
5879
6202
|
offset + len(raw) + 1,
|
|
5880
6203
|
)
|
|
6204
|
+
phase_seconds["journal_read_decode"] = round(
|
|
6205
|
+
max(0.0, time.monotonic() - read_started - evidence_seconds), 6)
|
|
6206
|
+
phase_seconds["protocol_evidence"] = round(evidence_seconds, 6)
|
|
6207
|
+
traversal["protocol_evidence"]["bytes"] = hasher.bytes_hashed
|
|
6208
|
+
traversal["protocol_evidence"]["lines"] = hasher.digests_computed
|
|
6209
|
+
hasher = None
|
|
5881
6210
|
|
|
5882
6211
|
# Legacy account normalisation (#341, spec §2 / handoff item 2): a
|
|
5883
6212
|
# pre-#341 real-account line lacks an account stamp — inject the cutover
|
|
5884
6213
|
# mapping BEFORE the fold (Claude legacy -> the cutover op's account;
|
|
5885
6214
|
# Codex legacy -> unattributed). `*`-families + already-stamped lines are
|
|
5886
|
-
# untouched. Resolved
|
|
5887
|
-
#
|
|
5888
|
-
#
|
|
5889
|
-
|
|
6215
|
+
# untouched. Resolved from the journal's own cutover op — inline when the
|
|
6216
|
+
# streamed prefix contained it, otherwise from the unvisited suffix alone
|
|
6217
|
+
# (falls back to `unattributed` when neither has it), so a fresh
|
|
6218
|
+
# single-account rebuild is byte-neutral.
|
|
6219
|
+
cutover_started = time.monotonic()
|
|
6220
|
+
cutover_claude = _resolve_cutover_for_rebuild(
|
|
6221
|
+
cutover_captured, hw, all_segments, traversal["cutover_suffix"])
|
|
6222
|
+
phase_seconds["cutover_suffix"] = round(
|
|
6223
|
+
time.monotonic() - cutover_started, 6)
|
|
5890
6224
|
for rec in decoded:
|
|
5891
|
-
|
|
6225
|
+
if rec is not None:
|
|
6226
|
+
_normalize_legacy_account_stamp(rec, cutover_claude)
|
|
5892
6227
|
|
|
5893
6228
|
# Resolve corrections BEFORE either disposable index is mutated. A
|
|
5894
6229
|
# malformed revision, divergent same-revision candidate, or invalid
|
|
5895
6230
|
# committed manifest leaves the existing destination untouched.
|
|
6231
|
+
selection_started = time.monotonic()
|
|
5896
6232
|
effective = _lib_journal.resolve_effective_events(
|
|
5897
6233
|
decoded,
|
|
5898
6234
|
protocol_prefix_evidence=protocol_evidence,
|
|
5899
6235
|
)
|
|
6236
|
+
phase_seconds["effective_selection"] = round(
|
|
6237
|
+
time.monotonic() - selection_started, 6)
|
|
5900
6238
|
|
|
5901
6239
|
# Cache leg BEFORE any stats txn (provider-flock lock-order): journal
|
|
5902
|
-
# Codex quota obs -> cache.db quota_window_snapshots.
|
|
6240
|
+
# Codex quota obs -> cache.db quota_window_snapshots. The retained bytes
|
|
6241
|
+
# are decoded inside the leg's existing transaction and freed here, so
|
|
6242
|
+
# the SQLite fold never runs on top of them.
|
|
6243
|
+
leg_started = time.monotonic()
|
|
5903
6244
|
if update_quota_cache:
|
|
5904
|
-
|
|
6245
|
+
quota_lock_hold = _rebuild_quota_cache_leg_raw(
|
|
6246
|
+
quota_raw, decoded, cutover_claude, traversal["quota_replay"])
|
|
6247
|
+
quota_raw = []
|
|
6248
|
+
phase_seconds["quota_cache_leg"] = round(
|
|
6249
|
+
time.monotonic() - leg_started, 6)
|
|
5905
6250
|
|
|
5906
6251
|
# One ordered fold stream: op-folds (order 5) + evts, keyed by
|
|
5907
6252
|
# (fold_order, canonical seq) so referenced families resolve before
|
|
5908
6253
|
# referencing ones and crash-replay duplicates fold idempotently.
|
|
5909
6254
|
stream: list = []
|
|
5910
6255
|
for seq, rec in enumerate(decoded):
|
|
6256
|
+
if rec is None:
|
|
6257
|
+
continue
|
|
5911
6258
|
t = rec.get("t")
|
|
5912
6259
|
kind = (rec.get("payload") or {}).get("kind")
|
|
5913
6260
|
if t == "op" and kind in FOLD_APPLIERS:
|
|
@@ -5919,6 +6266,7 @@ def rebuild_stats_index(
|
|
|
5919
6266
|
tail = [s for s in stream if s[0] >= _REBUILD_MILESTONE_ORDER]
|
|
5920
6267
|
|
|
5921
6268
|
_stats_rebuild_test_pause("rebuild_fold_started")
|
|
6269
|
+
fold_started = time.monotonic()
|
|
5922
6270
|
|
|
5923
6271
|
# Phase 1 (txn A) — structural folds: op floors, snapshot_accept, cost
|
|
5924
6272
|
# snapshots, resets+suppression, block_close, arming, credit effects.
|
|
@@ -5966,9 +6314,14 @@ def rebuild_stats_index(
|
|
|
5966
6314
|
_apply_evt(conn, rec)
|
|
5967
6315
|
lines_folded += 1
|
|
5968
6316
|
# Fold-time `last_seen_utc` derivation (#341): re-derive each
|
|
5969
|
-
# account's last-seen from the whole journal
|
|
5970
|
-
#
|
|
5971
|
-
|
|
6317
|
+
# account's last-seen from the whole journal. The map was
|
|
6318
|
+
# accumulated during the single read pass — `decoded` no longer
|
|
6319
|
+
# contains observations, so deriving from it here would silently
|
|
6320
|
+
# drop every observation's contribution (#496 S4 §4.6).
|
|
6321
|
+
_apply_account_last_seen(
|
|
6322
|
+
conn,
|
|
6323
|
+
last_seen.resolve(cutover_claude, _lib_accounts.UNATTRIBUTED),
|
|
6324
|
+
)
|
|
5972
6325
|
if hw is not None:
|
|
5973
6326
|
_write_cursor(conn, hw[0], hw[1])
|
|
5974
6327
|
conn.commit()
|
|
@@ -5978,7 +6331,9 @@ def rebuild_stats_index(
|
|
|
5978
6331
|
except Exception:
|
|
5979
6332
|
pass
|
|
5980
6333
|
raise
|
|
6334
|
+
phase_seconds["stats_fold"] = round(time.monotonic() - fold_started, 6)
|
|
5981
6335
|
|
|
6336
|
+
validate_started = time.monotonic()
|
|
5982
6337
|
rows_by_table = {}
|
|
5983
6338
|
for tbl in _REBUILD_COUNT_TABLES:
|
|
5984
6339
|
try:
|
|
@@ -5992,6 +6347,8 @@ def rebuild_stats_index(
|
|
|
5992
6347
|
raise JournalError("rebuilt stats index WAL could not be drained")
|
|
5993
6348
|
_validate_rebuilt_stats_index(conn, hw)
|
|
5994
6349
|
_stats_rebuild_test_pause("rebuild_scratch_complete")
|
|
6350
|
+
phase_seconds["scratch_validate"] = round(
|
|
6351
|
+
time.monotonic() - validate_started, 6)
|
|
5995
6352
|
finally:
|
|
5996
6353
|
conn.close()
|
|
5997
6354
|
|
|
@@ -6009,8 +6366,13 @@ def rebuild_stats_index(
|
|
|
6009
6366
|
conflicts = effective.conflicts
|
|
6010
6367
|
protocol_violations = effective.protocol_violations
|
|
6011
6368
|
acknowledged = effective.acknowledged_protocol_violations
|
|
6369
|
+
# The pre-publication window is what F9's memory acceptance is measured
|
|
6370
|
+
# over: everything after this point is publication, whose own WAL cost S3
|
|
6371
|
+
# already accounts for.
|
|
6372
|
+
peak_heap_bytes = (
|
|
6373
|
+
tracemalloc.get_traced_memory()[1] if tracing else 0)
|
|
6012
6374
|
decoded = effective = stream = structural = tail = None
|
|
6013
|
-
segments = protocol_evidence = None
|
|
6375
|
+
segments = protocol_evidence = last_seen = None
|
|
6014
6376
|
|
|
6015
6377
|
# First fresh-connection validation (#496 S1 F1). A failure here raises
|
|
6016
6378
|
# BEFORE any preservation, so no incident is created and the old family
|
|
@@ -6025,6 +6387,7 @@ def rebuild_stats_index(
|
|
|
6025
6387
|
# `os.replace` as a stray artifact.
|
|
6026
6388
|
_remove_db_sidecars_strict(scratch)
|
|
6027
6389
|
|
|
6390
|
+
publication_started = time.monotonic()
|
|
6028
6391
|
incident = _publish_rebuilt_stats_index(
|
|
6029
6392
|
scratch=scratch,
|
|
6030
6393
|
destination=dest,
|
|
@@ -6048,8 +6411,21 @@ def rebuild_stats_index(
|
|
|
6048
6411
|
"rowsByTable": rows_by_table,
|
|
6049
6412
|
"buildSeconds": round(time.monotonic() - start, 3),
|
|
6050
6413
|
"prePublicationValidation": {"ok": True, "error": None},
|
|
6414
|
+
# Additive instrumentation (#496 S4 §8.7). Additive keys do not bump
|
|
6415
|
+
# `schemaVersion` and no existing field changes meaning. `publication`
|
|
6416
|
+
# is absent HERE and present on `RebuildResult`: publication copies
|
|
6417
|
+
# this dict before it writes it, so its own duration cannot be known
|
|
6418
|
+
# at the time the record is written.
|
|
6419
|
+
"phaseSeconds": dict(phase_seconds),
|
|
6420
|
+
"traversal": {
|
|
6421
|
+
name: dict(counts) for name, counts in traversal.items()
|
|
6422
|
+
},
|
|
6423
|
+
"peakHeapBytes": peak_heap_bytes,
|
|
6424
|
+
"quotaLockHoldSeconds": round(quota_lock_hold, 6),
|
|
6051
6425
|
},
|
|
6052
6426
|
)
|
|
6427
|
+
phase_seconds["publication"] = round(
|
|
6428
|
+
time.monotonic() - publication_started, 6)
|
|
6053
6429
|
|
|
6054
6430
|
return RebuildResult(
|
|
6055
6431
|
rows_by_table=rows_by_table, malformed=malformed,
|
|
@@ -6058,6 +6434,10 @@ def rebuild_stats_index(
|
|
|
6058
6434
|
protocol_violations=protocol_violations,
|
|
6059
6435
|
acknowledged_protocol_violations=acknowledged,
|
|
6060
6436
|
quarantine_dir=incident,
|
|
6437
|
+
phase_seconds=phase_seconds,
|
|
6438
|
+
traversal=traversal,
|
|
6439
|
+
peak_heap_bytes=peak_heap_bytes,
|
|
6440
|
+
quota_lock_hold_seconds=round(quota_lock_hold, 6),
|
|
6061
6441
|
)
|
|
6062
6442
|
|
|
6063
6443
|
|
|
@@ -6472,17 +6852,24 @@ def _resolve_claude_cutover_identity(claude_json_path=None) -> str:
|
|
|
6472
6852
|
def find_accounts_cutover_op():
|
|
6473
6853
|
"""Scan the journal for the canonical cutover op; return its recorded
|
|
6474
6854
|
``claude_legacy_account`` (spec §2 payload), or None when it has not been
|
|
6475
|
-
appended yet. Cheap enough for the one-time transition + the retry check.
|
|
6855
|
+
appended yet. Cheap enough for the one-time transition + the retry check.
|
|
6856
|
+
|
|
6857
|
+
Streams rather than materializing (#496 S4): the previous form built each
|
|
6858
|
+
segment's whole line list before its first-match return, so the early exit
|
|
6859
|
+
could not stop reading inside the containing segment. The None-on-absence
|
|
6860
|
+
contract is UNCHANGED — the cache and conversations migrations depend on it
|
|
6861
|
+
to defer their backfill.
|
|
6862
|
+
"""
|
|
6476
6863
|
for seg in list_segments():
|
|
6477
6864
|
seg_path = _cctally_core.JOURNAL_DIR / seg
|
|
6478
6865
|
try:
|
|
6479
6866
|
size = os.path.getsize(seg_path)
|
|
6480
6867
|
except OSError:
|
|
6481
6868
|
continue
|
|
6482
|
-
for _name, _off, raw in
|
|
6869
|
+
for _name, _off, raw in _iter_segment_lines(seg_path, 0, size):
|
|
6483
6870
|
rec = _lib_journal.decode_line(raw)
|
|
6484
6871
|
if rec is not None and rec.get("id") == CUTOVER_OP_ID:
|
|
6485
|
-
return (rec
|
|
6872
|
+
return _cutover_value_of(rec)
|
|
6486
6873
|
return None
|
|
6487
6874
|
|
|
6488
6875
|
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Pure record-routing kernel for the stats rebuild's journal read pass (#496 S4).
|
|
2
|
+
|
|
3
|
+
The rebuild used to materialize the whole journal twice: `_read_range` built a
|
|
4
|
+
list of every raw line, and the decode loop built a second list of every parsed
|
|
5
|
+
record while the first was still referenced. On the maintainer's install that is
|
|
6
|
+
1,954,007 lines and 1.72 GB producing an 8.08 GiB peak, of which the stats fold
|
|
7
|
+
consumes 99,289 records (5.08%).
|
|
8
|
+
|
|
9
|
+
`bin/_cctally_doctor.py`'s conflict scan already established the shape this
|
|
10
|
+
kernel generalizes: retain only what the effective selector consumes and drop
|
|
11
|
+
everything else as it is decoded (#374 review, measured at 4.3 GB of peak RSS
|
|
12
|
+
for an identical result).
|
|
13
|
+
|
|
14
|
+
No I/O and no imports from `_cctally_journal`, so the rules here are unit
|
|
15
|
+
testable without a journal on disk.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: The record types the stats rebuild retains decoded. `resolve_effective_events`
|
|
23
|
+
#: acts only on evt / correction / correction_batch and the protocol-resolution
|
|
24
|
+
#: op; the op-fold stream takes only ops whose kind is in `FOLD_APPLIERS`.
|
|
25
|
+
#: Deliberately identical to `_cctally_doctor._CONFLICT_SCAN_RECORD_TYPES` — the
|
|
26
|
+
#: two must not drift, because both feed the same shared selector.
|
|
27
|
+
RETAINED_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch", "op"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LastSeenAccumulator:
|
|
31
|
+
"""Reproduce `_derive_account_last_seen`'s contribution set from a stream.
|
|
32
|
+
|
|
33
|
+
The rebuild normalizes every record and then takes `_account_of`, which
|
|
34
|
+
reads a top-level ``account`` or an ``account_observe`` op's
|
|
35
|
+
``payload.account_key``. `_normalize_legacy_account_stamp` writes a
|
|
36
|
+
top-level ``account`` ONLY for ``t == "obs"``; a legacy evt or op instead
|
|
37
|
+
gets ``payload.account_key``, which `_account_of` does not read.
|
|
38
|
+
|
|
39
|
+
So exactly three classes contribute, and a provider-wide maximum over every
|
|
40
|
+
legacy line would over-count — advancing `last_seen_utc` from legacy events
|
|
41
|
+
and vendor-tagged budget events that contribute nothing today. The Claude
|
|
42
|
+
legacy bucket is deferred because the cutover account is not known until the
|
|
43
|
+
stream reaches it, at 92.9% of a production journal.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("stamped", "legacy_claude_at", "legacy_codex_at")
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self.stamped: dict = {}
|
|
50
|
+
self.legacy_claude_at = None
|
|
51
|
+
self.legacy_codex_at = None
|
|
52
|
+
|
|
53
|
+
def observe(self, record, provider_of_legacy) -> None:
|
|
54
|
+
"""Fold one record. `provider_of_legacy` is `classify_legacy_provider`."""
|
|
55
|
+
at = record.get("at")
|
|
56
|
+
if not at:
|
|
57
|
+
return
|
|
58
|
+
account = record.get("account")
|
|
59
|
+
if isinstance(account, str) and account:
|
|
60
|
+
self._bump(account, at)
|
|
61
|
+
return
|
|
62
|
+
record_type = record.get("t")
|
|
63
|
+
if record_type == "op":
|
|
64
|
+
payload = record.get("payload") or {}
|
|
65
|
+
if payload.get("kind") == "account_observe":
|
|
66
|
+
key = payload.get("account_key")
|
|
67
|
+
if isinstance(key, str) and key:
|
|
68
|
+
self._bump(key, at)
|
|
69
|
+
return
|
|
70
|
+
if record_type != "obs":
|
|
71
|
+
# A legacy evt normalizes into `payload.account_key`, which
|
|
72
|
+
# `_account_of` ignores. Contributing here would move last-seen.
|
|
73
|
+
return
|
|
74
|
+
provider = provider_of_legacy(record)
|
|
75
|
+
if provider == "claude":
|
|
76
|
+
if self.legacy_claude_at is None or at > self.legacy_claude_at:
|
|
77
|
+
self.legacy_claude_at = at
|
|
78
|
+
elif provider == "codex":
|
|
79
|
+
if self.legacy_codex_at is None or at > self.legacy_codex_at:
|
|
80
|
+
self.legacy_codex_at = at
|
|
81
|
+
|
|
82
|
+
def _bump(self, key: str, at: str) -> None:
|
|
83
|
+
previous = self.stamped.get(key)
|
|
84
|
+
if previous is None or at > previous:
|
|
85
|
+
self.stamped[key] = at
|
|
86
|
+
|
|
87
|
+
def resolve(self, cutover_claude: str, unattributed: str) -> dict:
|
|
88
|
+
"""Apply the deferred legacy buckets and return the final MAX map."""
|
|
89
|
+
out = dict(self.stamped)
|
|
90
|
+
if self.legacy_claude_at is not None:
|
|
91
|
+
previous = out.get(cutover_claude)
|
|
92
|
+
if previous is None or self.legacy_claude_at > previous:
|
|
93
|
+
out[cutover_claude] = self.legacy_claude_at
|
|
94
|
+
if self.legacy_codex_at is not None:
|
|
95
|
+
previous = out.get(unattributed)
|
|
96
|
+
if previous is None or self.legacy_codex_at > previous:
|
|
97
|
+
out[unattributed] = self.legacy_codex_at
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PrefixEvidenceUnavailable(LookupError):
|
|
102
|
+
"""A protocol-evidence digest was asked for a prefix the stream dropped.
|
|
103
|
+
|
|
104
|
+
Never expected: an evidence point is always the end of the line immediately
|
|
105
|
+
preceding a `journal_protocol_resolution` op, so it is either inside the
|
|
106
|
+
segment being streamed or the boundary registered at the last segment
|
|
107
|
+
transition. Raised rather than silently degraded, because the alternative is
|
|
108
|
+
a rebuild that quietly disagrees with `journal_prefix_hash`.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PrefixHashAccumulator:
|
|
113
|
+
"""`journal_prefix_hash` computed from the bytes the rebuild already read.
|
|
114
|
+
|
|
115
|
+
`journal_prefix_hash(prior_high_water)` does `path.read_bytes()[:size]` on
|
|
116
|
+
every segment through the prefix, so one `journal_protocol_resolution` op
|
|
117
|
+
re-reads the whole journal up to its own position and builds a full-segment
|
|
118
|
+
bytes transient. This accumulator reproduces the identical durable digest
|
|
119
|
+
from the bytes the single streaming pass is reading anyway (#496 S4 §5.2).
|
|
120
|
+
|
|
121
|
+
The framing is `journal_prefix_hash`'s, verbatim: per segment, the 4-byte
|
|
122
|
+
big-endian name length, the name, the 8-byte big-endian data length, then
|
|
123
|
+
the data. Completed segments are absorbed into a running `sha256`; only the
|
|
124
|
+
segment currently being streamed is buffered, so residency is bounded by one
|
|
125
|
+
segment. At a segment transition the caller passes the boundary the next
|
|
126
|
+
record's `prior_high_water` will name, which is the ONE offset in the
|
|
127
|
+
outgoing segment that can still be asked for; its digest is precomputed
|
|
128
|
+
before the buffer is released.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# A transition can only ever register one boundary, and consecutive empty
|
|
132
|
+
# segments re-register the same one. A handful of slots is therefore already
|
|
133
|
+
# generous; the cap exists so a pathological journal cannot grow this map.
|
|
134
|
+
# Eviction is safe because a registered boundary is only ever READ
|
|
135
|
+
# IMMEDIATELY AFTER the `begin_segment` that registered it: a resolution op
|
|
136
|
+
# can name a previous segment's end only when it is the first line of the
|
|
137
|
+
# new segment, and every later position falls in the `_current_name` branch
|
|
138
|
+
# of `digest_at`. So the cap bounds a map that never needs more than the
|
|
139
|
+
# most recent entry; widening it buys nothing and narrowing it below the
|
|
140
|
+
# runs of empty segments a journal can contain would start dropping the one
|
|
141
|
+
# entry that is still live.
|
|
142
|
+
_MAX_BOUNDARIES = 16
|
|
143
|
+
|
|
144
|
+
def __init__(self) -> None:
|
|
145
|
+
self._running = hashlib.sha256()
|
|
146
|
+
self._current_name = None
|
|
147
|
+
self._current = bytearray()
|
|
148
|
+
self._boundaries: dict = {}
|
|
149
|
+
self._boundary_order: list = []
|
|
150
|
+
#: Bytes fed into an evidence digest, reported as the `protocol_evidence`
|
|
151
|
+
#: traversal pass. Zero on any journal with no resolution op.
|
|
152
|
+
self.bytes_hashed = 0
|
|
153
|
+
self.digests_computed = 0
|
|
154
|
+
|
|
155
|
+
# -- feeding ----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def begin_segment(self, name: str, boundary=None) -> None:
|
|
158
|
+
"""Start `name`, absorbing whatever segment was open before it.
|
|
159
|
+
|
|
160
|
+
`boundary` is the `(segment, offset)` the next record's evidence would
|
|
161
|
+
name — i.e. the streaming loop's current `prior_high_water`. It is
|
|
162
|
+
resolved and cached HERE because the outgoing segment's bytes are gone
|
|
163
|
+
immediately afterwards.
|
|
164
|
+
"""
|
|
165
|
+
if boundary is not None:
|
|
166
|
+
self._register_boundary(boundary)
|
|
167
|
+
if self._current_name is not None:
|
|
168
|
+
# `hashlib.update` accepts the buffer directly, so the outgoing
|
|
169
|
+
# segment is framed WITHOUT a copy of itself. The maintainer's
|
|
170
|
+
# largest segment is 410 MB, and copying it here briefly doubled
|
|
171
|
+
# that at every transition.
|
|
172
|
+
with memoryview(self._current) as data:
|
|
173
|
+
self._absorb(self._current_name, data)
|
|
174
|
+
self._current_name = name
|
|
175
|
+
self._current = bytearray()
|
|
176
|
+
|
|
177
|
+
def extend(self, data: bytes) -> None:
|
|
178
|
+
"""Absorb raw bytes read from the segment currently open."""
|
|
179
|
+
self._current.extend(data)
|
|
180
|
+
|
|
181
|
+
# -- reading ----------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def digest_at(self, high_water) -> "str | None":
|
|
184
|
+
"""`journal_prefix_hash(high_water)`, from the streamed bytes."""
|
|
185
|
+
if high_water is None:
|
|
186
|
+
return None
|
|
187
|
+
segment, offset = high_water
|
|
188
|
+
if segment == self._current_name:
|
|
189
|
+
digest = self._running.copy()
|
|
190
|
+
with memoryview(self._current)[:offset] as data:
|
|
191
|
+
self._frame(digest, segment, data)
|
|
192
|
+
self.bytes_hashed += len(data)
|
|
193
|
+
self.digests_computed += 1
|
|
194
|
+
return "sha256:" + digest.hexdigest()
|
|
195
|
+
cached = self._boundaries.get((segment, offset))
|
|
196
|
+
if cached is None:
|
|
197
|
+
raise PrefixEvidenceUnavailable(
|
|
198
|
+
f"no streamed prefix evidence for {segment}@{offset}"
|
|
199
|
+
)
|
|
200
|
+
self.bytes_hashed += cached[1]
|
|
201
|
+
self.digests_computed += 1
|
|
202
|
+
return cached[0]
|
|
203
|
+
|
|
204
|
+
# -- internals --------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _register_boundary(self, boundary) -> None:
|
|
207
|
+
segment, offset = boundary
|
|
208
|
+
if segment != self._current_name:
|
|
209
|
+
# Already registered at an earlier transition (a run of empty
|
|
210
|
+
# segments leaves `prior_high_water` unchanged), or never streamed.
|
|
211
|
+
return
|
|
212
|
+
key = (segment, offset)
|
|
213
|
+
if key in self._boundaries:
|
|
214
|
+
return
|
|
215
|
+
digest = self._running.copy()
|
|
216
|
+
with memoryview(self._current)[:offset] as data:
|
|
217
|
+
self._frame(digest, segment, data)
|
|
218
|
+
size = len(data)
|
|
219
|
+
self._boundaries[key] = ("sha256:" + digest.hexdigest(), size)
|
|
220
|
+
self._boundary_order.append(key)
|
|
221
|
+
while len(self._boundary_order) > self._MAX_BOUNDARIES:
|
|
222
|
+
self._boundaries.pop(self._boundary_order.pop(0), None)
|
|
223
|
+
|
|
224
|
+
def _absorb(self, name, data) -> None:
|
|
225
|
+
self._frame(self._running, name, data)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _frame(digest, name: str, data) -> None:
|
|
229
|
+
"""`data` is any bytes-like buffer; `hashlib` consumes it in place."""
|
|
230
|
+
encoded = name.encode("utf-8")
|
|
231
|
+
digest.update(len(encoded).to_bytes(4, "big"))
|
|
232
|
+
digest.update(encoded)
|
|
233
|
+
digest.update(len(data).to_bytes(8, "big"))
|
|
234
|
+
digest.update(data)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.92.
|
|
3
|
+
"version": "1.92.2",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"bin/_lib_fmt.py",
|
|
104
104
|
"bin/_lib_forecast.py",
|
|
105
105
|
"bin/_lib_journal.py",
|
|
106
|
+
"bin/_lib_journal_router.py",
|
|
106
107
|
"bin/_lib_json_envelope.py",
|
|
107
108
|
"bin/_lib_jsonl.py",
|
|
108
109
|
"bin/_lib_log.py",
|