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 +8 -0
- package/bin/_cctally_journal.py +133 -20
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_lib_journal.py +40 -0
- package/package.json +1 -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.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
|
package/bin/_cctally_journal.py
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
6710
|
-
"""
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
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
|
-
|
|
6785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
156
|
+
return _PrefixSnapshot(high_water, prefix_hash, selection, audit_ends)
|
|
77
157
|
|
|
78
158
|
|
|
79
159
|
def _preview_payload(requested=()):
|
|
80
|
-
|
|
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),
|
|
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,
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
378
|
-
audit_ids,
|
|
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,
|
|
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,
|
|
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:
|
package/bin/_cctally_rederive.py
CHANGED
|
@@ -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
|
-
)
|
|
445
|
-
"""
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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,
|
package/bin/_lib_journal.py
CHANGED
|
@@ -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.
|
|
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": {
|