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 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
@@ -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
- def _is_codex_quota_obs(record: dict) -> bool:
182
- return (
183
- record.get("t") == "obs"
184
- and record.get("provider") == "codex"
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(record, prior_high_water, evidence) -> None:
1061
- """Capture the actual raw prefix immediately preceding one audit record."""
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
- evidence.append(
1069
- (
1070
- prior_high_water,
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 _read_range(None, hw):
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
- decoded: list[dict] = []
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
- for segment, offset, raw in _read_range(None, high_water):
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
- cutover_claude = resolve_cutover_claude_account()
4593
+ hasher = None
4594
+ cutover_claude = _resolve_cutover_for_rebuild(
4595
+ cutover_captured, high_water, all_segments)
4503
4596
  for record in decoded:
4504
- _normalize_legacy_account_stamp(record, cutover_claude)
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 against the destination in front
5563
- of this run, not by the trigger that reached it: corruption is not uniform,
5564
- and a readable-but-damaged destination publishes in place like any other.
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 _rebuild_quota_cache_leg(records) -> None:
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
- quota_obs = [r for r in records if _is_codex_quota_obs(r)]
5735
- file_accounts = [r for r in records if _is_codex_file_account_op(r)]
5736
- if not quota_obs and not file_accounts:
5737
- return
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(cache, quota_obs)
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
- segments = list_segments()
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 _read_range(None, hw):
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
- _capture_protocol_prefix_evidence(
5872
- rec,
5873
- prior_high_water,
5874
- protocol_evidence,
5875
- )
5876
- decoded.append(rec)
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 once from the journal's own cutover op (falls back
5887
- # to `unattributed` when none is present), so a fresh single-account
5888
- # rebuild is byte-neutral (everything is already `unattributed`).
5889
- cutover_claude = resolve_cutover_claude_account()
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
- _normalize_legacy_account_stamp(rec, cutover_claude)
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
- _rebuild_quota_cache_leg(decoded)
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 (the observe ops folded
5970
- # in the structural phase already created the rows).
5971
- _derive_account_last_seen(conn, decoded)
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 _read_segment_lines(seg_path, 0, size):
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.get("payload") or {}).get("claude_legacy_account")
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.1",
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",