cctally 1.92.2 → 1.92.3

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.3] - 2026-08-06
9
+
10
+ ### Changed
11
+ - `cctally db journal-repair` and `cctally db rederive` now read the journal once per pass instead of two to four times, which lowers their memory use and start-up time on large journals. Their output is unchanged (#496).
12
+
13
+ ### Fixed
14
+ - A crash while cctally was first converting its database to the journal format could leave a duplicate copy of the conversion file behind, and every later rebuild then read both copies. On one real install two such duplicates accounted for 366 MB, about a fifth of the journal. The conversion now reuses the most recent conversion file when that file is byte for byte what it was about to write, instead of writing a second copy; when the most recent file differs in any way it still writes its own. Existing duplicates are left untouched, because the journal is never rewritten (#496).
15
+
8
16
  ## [1.92.2] - 2026-08-06
9
17
 
10
18
  ### Changed
@@ -243,7 +243,7 @@ def _load_quota_dedup_keys() -> None:
243
243
  _QUOTA_DEDUP_KEYS.clear()
244
244
 
245
245
  for name in list_segments():
246
- with (journal_dir / name).open("rb") as fh:
246
+ with _open_segment_for_read(journal_dir / name) as fh:
247
247
  for raw in fh:
248
248
  if not raw.endswith(b"\n"):
249
249
  break
@@ -951,6 +951,24 @@ def _write_cursor(conn: sqlite3.Connection, segment: str, offset: int) -> None:
951
951
  _SEGMENT_READ_CHUNK = 256 * 1024
952
952
 
953
953
 
954
+ def _open_segment_for_read(seg_path):
955
+ """The single physical read boundary for a journal segment.
956
+
957
+ Both read routes go through here — the streaming line reader and the
958
+ prefix hasher — so a test can observe the exact sequence of segment opens
959
+ a pass performs. A per-pass line or byte counter cannot: it stays correct
960
+ while an implementation reopens a segment behind it, which is precisely the
961
+ hidden re-read this session removes (#496 S5 §4.2).
962
+
963
+ The cutover's bootstrap-reuse digest and the quota dedupe-index rebuild are
964
+ the module's other two read-only segment scans, and they come through here
965
+ as well, so "every physical read of a segment" is a property a test can
966
+ check rather than a claim in prose. The append path is deliberately NOT
967
+ routed here: it holds a read-write handle of its own for the torn-tail scan.
968
+ """
969
+ return open(seg_path, "rb")
970
+
971
+
954
972
  def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
955
973
  """Stream `(basename, absolute-offset, raw-line-without-newline)` for every
956
974
  complete line in `[lo, hi)`, holding at most one chunk plus one partial line
@@ -964,7 +982,7 @@ def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
964
982
  including a torn trailing partial line that is never yielded.
965
983
  """
966
984
  name = seg_path.name
967
- with open(seg_path, "rb") as fh:
985
+ with _open_segment_for_read(seg_path) as fh:
968
986
  fh.seek(lo)
969
987
  pos = lo
970
988
  buf = b""
@@ -1051,7 +1069,13 @@ def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
1051
1069
 
1052
1070
 
1053
1071
  def journal_prefix_hash(high_water) -> "str | None":
1054
- """Hash exact raw segment bytes through one canonical high-water."""
1072
+ """Hash exact raw segment bytes through one canonical high-water.
1073
+
1074
+ The framing is durable: per segment, the 4-byte big-endian name length, the
1075
+ name, the 8-byte big-endian data length, then the data. These digests are
1076
+ recorded inside `journal_protocol_resolution` payloads, so a change to the
1077
+ framing invalidates every acknowledgement already written.
1078
+ """
1055
1079
  if high_water is None:
1056
1080
  return None
1057
1081
  digest = hashlib.sha256()
@@ -1059,7 +1083,8 @@ def journal_prefix_hash(high_water) -> "str | None":
1059
1083
  for segment in list_segments():
1060
1084
  path = _cctally_core.JOURNAL_DIR / segment
1061
1085
  size = high_water[1] if segment == high_water[0] else path.stat().st_size
1062
- data = path.read_bytes()[:size]
1086
+ with _open_segment_for_read(path) as handle:
1087
+ data = handle.read(size)
1063
1088
  if len(data) != size:
1064
1089
  raise OSError(f"journal segment changed while reading: {segment}")
1065
1090
  name = segment.encode("utf-8")
@@ -6706,19 +6731,14 @@ def _cutover_segment_name(now_utc: dt.datetime) -> str:
6706
6731
  return f"{_lib_journal.BOOTSTRAP_PREFIX}{ts}.jsonl"
6707
6732
 
6708
6733
 
6709
- def _write_bootstrap_segment(seg_name: str, lines: list) -> int:
6710
- """Materialize the bootstrap segment atomically (spec §8 rename-then-stamp):
6711
- encode all lines, write to a `.partial` sibling, fsync file + dir, verify the
6712
- line count, then `os.replace` into `seg_name`. Returns the final byte size.
6713
- Every line must fit the torn-tail window (append discipline)."""
6714
- journal_dir = _cctally_core.JOURNAL_DIR
6715
- dir_created = not journal_dir.exists()
6716
- journal_dir.mkdir(parents=True, exist_ok=True)
6717
- if dir_created:
6718
- try:
6719
- os.chmod(journal_dir, 0o700)
6720
- except OSError:
6721
- pass
6734
+ def _encode_bootstrap_lines(lines: list) -> bytes:
6735
+ """The cutover export as one verified blob (spec §8 verify step).
6736
+
6737
+ Encoding is separated from writing because `run_cutover` digests the blob
6738
+ before it decides whether a byte-identical segment already exists (#496 S5
6739
+ §3). Encoding twice would compute the reuse digest over a different object
6740
+ than the one written, so this is the single encode both uses.
6741
+ """
6722
6742
  encoded = []
6723
6743
  for rec in lines:
6724
6744
  data = _lib_journal.encode_line(rec)
@@ -6731,6 +6751,81 @@ def _write_bootstrap_segment(seg_name: str, lines: list) -> int:
6731
6751
  if blob.count(b"\n") != len(lines):
6732
6752
  raise JournalError(
6733
6753
  "cutover export line count mismatch (spec §8 verify step)")
6754
+ return blob
6755
+
6756
+
6757
+ def _reusable_bootstrap(candidate_digest: str, candidate_size: int):
6758
+ """`(name, size)` of a published bootstrap identical to the candidate blob.
6759
+
6760
+ Every published bootstrap is REPORTED, because `reusable_bootstrap_name`
6761
+ refuses a match that is not the canonically newest one; only segments whose
6762
+ byte length already equals the candidate's are READ, so the comparison costs
6763
+ one pass over the same-size candidates rather than one over the journal.
6764
+ `list_segments` excludes `.partial` files, so a cutover that is still writing
6765
+ can never be reused (#496 S5 §3). A segment whose length cannot be read is
6766
+ reported with a `None` length rather than dropped, which refuses reuse
6767
+ instead of promoting an older segment to canonically newest.
6768
+ """
6769
+ journal_dir = _cctally_core.JOURNAL_DIR
6770
+ if not journal_dir.exists():
6771
+ return None
6772
+ existing = []
6773
+ for name in list_segments():
6774
+ if not name.startswith(_lib_journal.BOOTSTRAP_PREFIX):
6775
+ continue
6776
+ path = journal_dir / name
6777
+ try:
6778
+ size = os.path.getsize(path)
6779
+ except OSError:
6780
+ # Report it anyway. Dropping the entry would let an OLDER match
6781
+ # look canonically newest, which is the reuse this scan refuses.
6782
+ existing.append((name, None, None))
6783
+ continue
6784
+ if size != candidate_size:
6785
+ existing.append((name, size, None))
6786
+ continue
6787
+ digest = hashlib.sha256()
6788
+ with _open_segment_for_read(path) as handle:
6789
+ while True:
6790
+ chunk = handle.read(_SEGMENT_READ_CHUNK)
6791
+ if not chunk:
6792
+ break
6793
+ digest.update(chunk)
6794
+ existing.append((name, size, digest.hexdigest()))
6795
+ name = _lib_journal.reusable_bootstrap_name(
6796
+ candidate_digest, candidate_size, existing)
6797
+ return None if name is None else (name, candidate_size)
6798
+
6799
+
6800
+ def _fsync_published_segment(seg_name: str) -> None:
6801
+ """Make an already-renamed segment and its directory entry durable.
6802
+
6803
+ `_write_bootstrap_segment` establishes this for a segment it writes itself.
6804
+ A reused segment was published by a different attempt, which may have
6805
+ crashed anywhere in that sequence, so the reuse path repeats the file and
6806
+ directory fsyncs before anything durable is allowed to name the file.
6807
+ """
6808
+ journal_dir = _cctally_core.JOURNAL_DIR
6809
+ fd = os.open(str(journal_dir / seg_name), os.O_RDONLY)
6810
+ try:
6811
+ os.fsync(fd)
6812
+ finally:
6813
+ os.close(fd)
6814
+ _fsync_dir(journal_dir)
6815
+
6816
+
6817
+ def _write_bootstrap_segment(seg_name: str, blob: bytes) -> int:
6818
+ """Materialize the bootstrap segment atomically (spec §8 rename-then-stamp):
6819
+ write the verified blob to a `.partial` sibling, fsync file + dir, then
6820
+ `os.replace` into `seg_name`. Returns the final byte size."""
6821
+ journal_dir = _cctally_core.JOURNAL_DIR
6822
+ dir_created = not journal_dir.exists()
6823
+ journal_dir.mkdir(parents=True, exist_ok=True)
6824
+ if dir_created:
6825
+ try:
6826
+ os.chmod(journal_dir, 0o700)
6827
+ except OSError:
6828
+ pass
6734
6829
  partial = journal_dir / (seg_name + ".partial")
6735
6830
  fd = os.open(str(partial), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
6736
6831
  try:
@@ -6759,7 +6854,14 @@ def run_cutover(conn, *, now_utc: dt.datetime | None = None) -> "str | None":
6759
6854
  commit rolls the whole thing back (the legacy DB stays fully usable); the
6760
6855
  next open retries idempotently (stable bootstrap ids). A truly empty install
6761
6856
  (nothing to export) just stamps the epoch — no bootstrap file. Returns the
6762
- bootstrap segment basename, or None when nothing was exported."""
6857
+ bootstrap segment basename, or None when nothing was exported.
6858
+
6859
+ A crash between the `os.replace` and the commit leaves a published segment
6860
+ the rolled-back transaction never referenced. The retry re-exports the same
6861
+ rows byte for byte, so it reuses that orphan rather than publishing a twin
6862
+ (#496 S5 §3) — the retry is now idempotent on disk, not only on fold. When
6863
+ the retry's export genuinely differs, no digest matches and a new segment is
6864
+ written exactly as before."""
6763
6865
  if now_utc is None:
6764
6866
  now_utc = dt.datetime.now(dt.timezone.utc)
6765
6867
  epoch = _cctally_core.STATS_INDEX_EPOCH
@@ -6781,8 +6883,19 @@ def run_cutover(conn, *, now_utc: dt.datetime | None = None) -> "str | None":
6781
6883
  conn.commit()
6782
6884
  return None
6783
6885
 
6784
- seg_name = _cutover_segment_name(now_utc)
6785
- seg_size = _write_bootstrap_segment(seg_name, lines)
6886
+ blob = _encode_bootstrap_lines(lines)
6887
+ reuse = _reusable_bootstrap(
6888
+ hashlib.sha256(blob).hexdigest(), len(blob))
6889
+ if reuse is None:
6890
+ seg_name = _cutover_segment_name(now_utc)
6891
+ seg_size = _write_bootstrap_segment(seg_name, blob)
6892
+ else:
6893
+ seg_name, seg_size = reuse
6894
+ # The adopted segment was renamed by ANOTHER attempt, whose rename
6895
+ # may still be only in the page cache. The cursor stamped below is
6896
+ # made durable by SQLite's own commit fsync, so without this the
6897
+ # index could name a bootstrap that a power loss then leaves absent.
6898
+ _fsync_published_segment(seg_name)
6786
6899
 
6787
6900
  for table, rowid in stamp:
6788
6901
  conn.execute(
@@ -10,13 +10,31 @@ import pathlib
10
10
  import signal
11
11
  import sqlite3
12
12
  import sys
13
+ import typing
13
14
 
14
15
  import _cctally_core
15
16
  import _cctally_journal as _journal
17
+ import _lib_accounts
16
18
  import _lib_journal
19
+ import _lib_journal_router
17
20
  from _lib_json_envelope import stamp_schema_version
18
21
 
19
22
 
23
+ class _PrefixSnapshot(typing.NamedTuple):
24
+ """One pinned prefix, read exactly once (#496 S5 §4).
25
+
26
+ `audit_ends` maps each `journal_protocol_resolution` op id to the end
27
+ coordinate of its line. The already-resolved recovery branch needs those
28
+ coordinates and used to re-read the whole prefix through `_audit_high_water`
29
+ to find them, even though this pass had them in hand.
30
+ """
31
+
32
+ high_water: "tuple[str, int] | None"
33
+ prefix_hash: "str | None"
34
+ selection: object
35
+ audit_ends: dict
36
+
37
+
20
38
  def _read_only_high_water() -> "tuple[str, int] | None":
21
39
  """Capture the append-only prefix without creating a lock or sidecar."""
22
40
  segments = _journal.list_segments()
@@ -27,37 +45,99 @@ def _read_only_high_water() -> "tuple[str, int] | None":
27
45
 
28
46
 
29
47
  def _read_prefix(high_water):
48
+ """Stream the pinned prefix once, producing everything derived from it.
49
+
50
+ Returns `(records, evidence, prefix_hash, audit_ends)`. The prefix hash
51
+ comes from a `PrefixHashAccumulator` fed by the same bytes this pass reads,
52
+ the protocol evidence is captured from that accumulator rather than by
53
+ re-reading the prefix per resolution op, and the cutover account is captured
54
+ inline exactly as `rebuild_stats_index` captures it. Before this, those were
55
+ four separate whole-prefix traversals on top of this one (#496 S5 §4).
56
+
57
+ Every record stays decoded. Unlike the rebuild, the selector here feeds an
58
+ acknowledgement the repair command may then mint, and unlike the rebuild's
59
+ filtered retention there is no placeholder scheme to keep the `enumerate`
60
+ numbering identical — so the list is unfiltered, exactly as before.
61
+ """
30
62
  if high_water is None:
31
- return [], ()
63
+ return [], (), None, {}
64
+ segments = _journal.list_segments()
65
+ if high_water[0] not in segments:
66
+ raise OSError(
67
+ f"journal high-water segment is unavailable: {high_water[0]}"
68
+ )
32
69
  records = []
33
70
  evidence = []
71
+ audit_ends: dict = {}
34
72
  malformed = 0
35
73
  prior_high_water = None
36
- for segment, offset, raw in _journal._read_range(None, high_water):
74
+ cutover_captured = _journal._CUTOVER_UNSEEN
75
+ hasher = _lib_journal_router.PrefixHashAccumulator()
76
+ for segment, offset, raw in _journal._iter_range_with_segments(
77
+ None,
78
+ high_water,
79
+ segments,
80
+ on_segment=lambda name: hasher.begin_segment(name, prior_high_water),
81
+ on_bytes=hasher.extend,
82
+ ):
37
83
  record = _lib_journal.decode_line(raw)
38
84
  if record is None:
39
85
  malformed += 1
40
86
  prior_high_water = (segment, offset + len(raw) + 1)
41
87
  continue
42
- _journal._capture_protocol_prefix_evidence(
43
- record,
44
- prior_high_water,
45
- evidence,
46
- )
88
+ record_end = (segment, offset + len(raw) + 1)
89
+ if record.get("t") == "op":
90
+ _journal._capture_protocol_prefix_evidence(
91
+ record,
92
+ prior_high_water,
93
+ evidence,
94
+ hasher=hasher,
95
+ )
96
+ payload = record.get("payload")
97
+ if (
98
+ isinstance(payload, dict)
99
+ and payload.get("kind")
100
+ == _lib_journal._PROTOCOL_RESOLUTION_KIND
101
+ ):
102
+ audit_ends[record.get("id")] = record_end
103
+ # First cutover op wins, exactly as `find_accounts_cutover_op` scans.
104
+ if (
105
+ cutover_captured is _journal._CUTOVER_UNSEEN
106
+ and record.get("id") == _journal.CUTOVER_OP_ID
107
+ ):
108
+ cutover_captured = _journal._cutover_value_of(record)
47
109
  records.append(record)
48
- prior_high_water = (segment, offset + len(raw) + 1)
110
+ prior_high_water = record_end
111
+ prefix_hash = hasher.digest_at(high_water)
112
+ # The accumulator buffers the segment it is reading — 410 MB on the
113
+ # maintainer's journal — so it is dropped the moment its pass ends, before
114
+ # the normalization loop below, exactly as `rebuild_stats_index` drops it.
115
+ # It is dropped before the raise too, so a malformed prefix does not pin the
116
+ # buffer on the traceback while the exception unwinds.
117
+ hasher = None
49
118
  if malformed:
50
119
  raise _lib_journal.JournalProtocolError(
51
120
  f"journal prefix contains {malformed} malformed line(s)"
52
121
  )
53
- cutover_claude = _journal.resolve_cutover_claude_account()
122
+ # No suffix fallback, deliberately. `_read_only_high_water` pins the
123
+ # canonically-last segment at its full size, so this prefix IS the whole
124
+ # journal and an op the prefix does not contain is not in the journal at
125
+ # all — which is exactly what `resolve_cutover_claude_account` used to
126
+ # answer by re-reading every segment. An op appended in the window between
127
+ # that pin and this loop is therefore NOT seen, where the whole-journal scan
128
+ # would have found it; that divergence is accepted, because the account then
129
+ # matches the prefix the fingerprints are computed over and `_apply`'s
130
+ # conflict check catches a preview that has gone stale. The rebuild's
131
+ # `_resolve_cutover_for_rebuild` cannot be reused here: its fallback calls
132
+ # `journal_high_water`, which takes the leaf lock and so CREATES
133
+ # `journal.lock`, and the preview must leave no sidecar behind.
134
+ if cutover_captured is _journal._CUTOVER_UNSEEN or cutover_captured is None:
135
+ cutover_claude = _lib_accounts.UNATTRIBUTED
136
+ else:
137
+ cutover_claude = cutover_captured
54
138
  for record in records:
55
139
  _journal._normalize_legacy_account_stamp(record, cutover_claude)
56
- return records, tuple(evidence)
57
-
58
-
59
- def _prefix_hash(high_water) -> "str | None":
60
- return _journal.journal_prefix_hash(high_water)
140
+ return records, tuple(evidence), prefix_hash, audit_ends
61
141
 
62
142
 
63
143
  def _high_water_dict(high_water):
@@ -66,18 +146,21 @@ def _high_water_dict(high_water):
66
146
  return {"segment": high_water[0], "offset": high_water[1]}
67
147
 
68
148
 
69
- def _selection_snapshot():
149
+ def _selection_snapshot() -> _PrefixSnapshot:
70
150
  high_water = _read_only_high_water()
71
- records, evidence = _read_prefix(high_water)
151
+ records, evidence, prefix_hash, audit_ends = _read_prefix(high_water)
72
152
  selection = _lib_journal.resolve_effective_events(
73
153
  records,
74
154
  protocol_prefix_evidence=evidence,
75
155
  )
76
- return high_water, _prefix_hash(high_water), selection
156
+ return _PrefixSnapshot(high_water, prefix_hash, selection, audit_ends)
77
157
 
78
158
 
79
159
  def _preview_payload(requested=()):
80
- high_water, prefix_hash, selection = _selection_snapshot()
160
+ snapshot = _selection_snapshot()
161
+ high_water = snapshot.high_water
162
+ prefix_hash = snapshot.prefix_hash
163
+ selection = snapshot.selection
81
164
  unacknowledged = {
82
165
  violation.fingerprint: violation
83
166
  for violation in selection.protocol_violations
@@ -115,7 +198,7 @@ def _preview_payload(requested=()):
115
198
  "rebuild": None,
116
199
  "errors": errors,
117
200
  }
118
- return stamp_schema_version(body, version=1), selection
201
+ return stamp_schema_version(body, version=1), snapshot
119
202
 
120
203
 
121
204
  def _rebuild_dict(result):
@@ -230,7 +313,7 @@ def _repair_failure_guidance(exc: Exception) -> str:
230
313
 
231
314
  def _post_audit_failure(requested, audit_ids, exc):
232
315
  """Report durable acknowledgement truth when index publication declined."""
233
- payload, _selection = _preview_payload(requested)
316
+ payload, _snapshot = _preview_payload(requested)
234
317
  payload["status"] = "failed"
235
318
  payload["errors"] = [_repair_failure_guidance(exc)]
236
319
  if len(audit_ids) == 1:
@@ -261,12 +344,19 @@ def _stats_has_acknowledgements(fingerprints) -> bool:
261
344
  return False
262
345
 
263
346
 
264
- def _audit_high_water(audit_ids, high_water):
265
- found = {}
266
- for segment, offset, raw in _journal._read_range(None, high_water):
267
- record = _lib_journal.decode_line(raw)
268
- if record is not None and record.get("id") in audit_ids:
269
- found[record["id"]] = (segment, offset + len(raw) + 1)
347
+ def _recovery_high_water(audit_ids, audit_ends):
348
+ """The last acknowledged audit record's end coordinate.
349
+
350
+ `audit_ends` is produced by the same streaming pass that produced the
351
+ selection, so an already-resolved recovery no longer re-reads the whole
352
+ prefix to find coordinates that pass already held (#496 S5 §4.1). The
353
+ ordering rule is the pre-change one: canonical segment order, then offset.
354
+ """
355
+ found = {
356
+ audit_id: audit_ends[audit_id]
357
+ for audit_id in audit_ids
358
+ if audit_id in audit_ends
359
+ }
270
360
  if set(found) != set(audit_ids):
271
361
  raise _journal.JournalError(
272
362
  "acknowledged protocol audit record is missing from the journal"
@@ -279,7 +369,8 @@ def _audit_high_water(audit_ids, high_water):
279
369
  def _apply(requested, initial_preview):
280
370
  _call_pause_hook("before-lock")
281
371
  with _repair_locks():
282
- preview, selection = _preview_payload(requested)
372
+ preview, snapshot = _preview_payload(requested)
373
+ selection = snapshot.selection
283
374
  if preview["errors"]:
284
375
  preview["status"] = "conflict"
285
376
  return preview, 2
@@ -347,7 +438,7 @@ def _apply(requested, initial_preview):
347
438
  {audit["id"]},
348
439
  exc,
349
440
  )
350
- final_payload, _final_selection = _preview_payload(requested)
441
+ final_payload, _final_snapshot = _preview_payload(requested)
351
442
  final_payload["status"] = "applied"
352
443
  final_payload["selectedViolations"] = [
353
444
  violation.to_dict() for violation in to_acknowledge
@@ -374,8 +465,8 @@ def _apply(requested, initial_preview):
374
465
  if len(audit_ids) == 1:
375
466
  preview["auditId"] = next(iter(audit_ids))
376
467
  if not _stats_has_acknowledgements(requested):
377
- recovery_high_water = _audit_high_water(
378
- audit_ids, reviewed_high_water
468
+ recovery_high_water = _recovery_high_water(
469
+ audit_ids, snapshot.audit_ends
379
470
  )
380
471
  try:
381
472
  _call_rebuild_error_hook()
@@ -395,7 +486,7 @@ def _apply(requested, initial_preview):
395
486
  audit_ids,
396
487
  exc,
397
488
  )
398
- final_payload, _final_selection = _preview_payload(requested)
489
+ final_payload, _final_snapshot = _preview_payload(requested)
399
490
  final_payload["status"] = "recovered"
400
491
  if len(audit_ids) == 1:
401
492
  final_payload["auditId"] = next(iter(audit_ids))
@@ -408,7 +499,7 @@ def cmd_db_journal_repair(args) -> int:
408
499
  """Preview structural violations without mutating the journal or indexes."""
409
500
  try:
410
501
  requested = list(getattr(args, "violation", ()) or ())
411
- payload, _selection = _preview_payload(requested)
502
+ payload, _snapshot = _preview_payload(requested)
412
503
  except (OSError, _lib_journal.JournalProtocolError) as exc:
413
504
  if bool(getattr(args, "json", False)):
414
505
  try:
@@ -27,6 +27,7 @@ import _cctally_core
27
27
  import _cctally_journal as _journal
28
28
  import _cctally_record as _record
29
29
  import _lib_journal
30
+ import _lib_journal_router
30
31
  import _lib_json_envelope
31
32
  import _lib_rederive
32
33
 
@@ -441,40 +442,74 @@ def owned_conflicted_event_ids(selection) -> frozenset:
441
442
 
442
443
  def read_rederive_journal_prefix(
443
444
  high_water: "tuple[str, int] | None" = None,
444
- ) -> tuple[list[dict], "tuple[str, int] | None", list[tuple[str, int]]]:
445
- """Read and strictly decode one canonical journal prefix."""
445
+ ):
446
+ """Stream and strictly decode one canonical journal prefix.
447
+
448
+ Returns `(records, high_water, record_ends, protocol_prefix_evidence)`.
449
+ The evidence digests come from a `PrefixHashAccumulator` fed by the bytes
450
+ this pass is already reading. Before this, the prefix was materialized as
451
+ raw lines, materialized again as decoded records while the first form was
452
+ still referenced, walked a third time to produce the evidence, and re-read
453
+ from byte zero by `journal_prefix_hash` once per
454
+ `journal_protocol_resolution` op (#496 S5 §4).
455
+
456
+ Retention is DELIBERATELY unfiltered, unlike the rebuild's. The rebuild
457
+ keeps only the decision records and substitutes `None` placeholders for
458
+ everything else; the planner here reads every observation for cache
459
+ validation, desired-event derivation and preservation decisions, and walks
460
+ `records` in parallel with `record_ends`. Both lists stay complete and
461
+ aligned, so the win in this file is the removed double materialization and
462
+ the removed hash traversals, not reduced retention.
463
+ """
446
464
  if high_water is None:
447
465
  high_water = _journal.journal_high_water()
448
466
  if high_water is None:
449
- return [], None, []
467
+ return [], None, [], ()
468
+ segments = _journal.list_segments()
469
+ if high_water[0] not in segments:
470
+ raise OSError(
471
+ f"journal high-water segment is unavailable: {high_water[0]}"
472
+ )
450
473
  records: list[dict] = []
451
474
  record_ends: list[tuple[str, int]] = []
475
+ evidence: list = []
452
476
  malformed = 0
453
- for segment, offset, raw in _journal._read_range(None, high_water):
477
+ prior_high_water = None
478
+ hasher = _lib_journal_router.PrefixHashAccumulator()
479
+ for segment, offset, raw in _journal._iter_range_with_segments(
480
+ None,
481
+ high_water,
482
+ segments,
483
+ on_segment=lambda name: hasher.begin_segment(name, prior_high_water),
484
+ on_bytes=hasher.extend,
485
+ ):
454
486
  record = _lib_journal.decode_line(raw)
487
+ record_end = (segment, offset + len(raw) + 1)
455
488
  if record is None:
456
489
  malformed += 1
490
+ prior_high_water = record_end
457
491
  continue
492
+ if record.get("t") == "op":
493
+ _journal._capture_protocol_prefix_evidence(
494
+ record,
495
+ prior_high_water,
496
+ evidence,
497
+ hasher=hasher,
498
+ )
458
499
  records.append(record)
459
- record_ends.append((segment, offset + len(raw) + 1))
500
+ record_ends.append(record_end)
501
+ prior_high_water = record_end
502
+ # Released before the raise below, so a malformed prefix does not pin the
503
+ # accumulator's buffered segment — 410 MB on the maintainer's journal — on
504
+ # the traceback while the exception unwinds. On the success path the frame
505
+ # dies two statements later, so this mirrors the repair reader, where the
506
+ # release genuinely precedes a loop over every decoded record.
507
+ hasher = None
460
508
  if malformed:
461
509
  raise _lib_rederive.RederiveConflict(
462
510
  f"journal prefix contains {malformed} malformed line(s)"
463
511
  )
464
- return records, high_water, record_ends
465
-
466
-
467
- def _protocol_prefix_evidence(records, record_ends):
468
- evidence = []
469
- prior_high_water = None
470
- for record, record_end in zip(records, record_ends):
471
- _journal._capture_protocol_prefix_evidence(
472
- record,
473
- prior_high_water,
474
- evidence,
475
- )
476
- prior_high_water = record_end
477
- return tuple(evidence)
512
+ return records, high_water, record_ends, tuple(evidence)
478
513
 
479
514
 
480
515
  def _read_only_journal_high_water() -> "tuple[str, int] | None":
@@ -603,12 +638,11 @@ def _preview_from_snapshot(
603
638
  if journal_high_water is None:
604
639
  journal_high_water = _read_only_journal_high_water()
605
640
  if journal_high_water is None:
606
- records, high_water, record_ends = [], None, []
641
+ records, high_water, record_ends, protocol_evidence = [], None, [], ()
607
642
  else:
608
- records, high_water, record_ends = read_rederive_journal_prefix(
609
- journal_high_water
643
+ records, high_water, record_ends, protocol_evidence = (
644
+ read_rederive_journal_prefix(journal_high_water)
610
645
  )
611
- protocol_evidence = _protocol_prefix_evidence(records, record_ends)
612
646
  with _open_cache_read_view() as cache:
613
647
  plan = plan_claude_usage(
614
648
  records,
@@ -242,6 +242,46 @@ def bootstrap_id(table: str, rowid: int) -> str:
242
242
  return f"b:{table}:{rowid}"
243
243
 
244
244
 
245
+ def reusable_bootstrap_name(candidate_digest, candidate_size, existing):
246
+ """The already-published bootstrap segment a cutover may reuse verbatim.
247
+
248
+ `existing` is `(name, byte_length_or_None, sha256_hex_or_None)` for EVERY
249
+ published segment the caller found. A `None` digest means the caller did not
250
+ read that segment, which it does only when the length already differs; a
251
+ `None` length means it could not stat the file at all. Neither can match, and
252
+ reporting the segment anyway is required — see the ordering rule below.
253
+
254
+ Reuse requires the CANONICALLY NEWEST bootstrap to be the exact match, on
255
+ both length and digest. A crash-after-rename retry re-exports byte-identical
256
+ lines, so reusing that orphan makes the retry idempotent on disk instead of
257
+ only idempotent on fold (#496 S5 §3), and timestamps increase monotonically,
258
+ so the orphan an immediately-prior attempt left IS the newest bootstrap.
259
+
260
+ Reusing an older match instead would stamp the cursor behind a bootstrap the
261
+ cursor does not cover, and the next ingest would fold that stale bootstrap's
262
+ records into stats.db. Writing a fresh segment is the pre-reuse behaviour and
263
+ restores the pre-reuse invariant, because a minted name always sorts last.
264
+
265
+ Returns None when the newest bootstrap does not match, which covers the
266
+ ordinary first-cutover path, the genuinely-differing-export path, and the
267
+ stale-match path alike.
268
+ """
269
+ bootstraps = [
270
+ (name, size, digest)
271
+ for name, size, digest in existing
272
+ if name.startswith(BOOTSTRAP_PREFIX)
273
+ ]
274
+ if not bootstraps:
275
+ return None
276
+ name, size, digest = max(
277
+ bootstraps, key=lambda entry: segment_sort_key(entry[0]))
278
+ if size is None or digest is None:
279
+ return None
280
+ if size == candidate_size and digest == candidate_digest:
281
+ return name
282
+ return None
283
+
284
+
245
285
  def evt_id(kind: str, *parts: object) -> str:
246
286
  """Natural-key id for an evt line: ``"<kind>:" + ":".join(str(p) …)``.
247
287
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.92.2",
3
+ "version": "1.92.3",
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": {