cctally 1.92.1 → 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 +16 -0
- package/bin/_cctally_journal.py +588 -88
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_lib_journal.py +40 -0
- package/bin/_lib_journal_router.py +234 -0
- package/package.json +2 -1
package/bin/_cctally_journal.py
CHANGED
|
@@ -40,6 +40,7 @@ from dataclasses import dataclass, field, replace as _dc_replace
|
|
|
40
40
|
import _cctally_core
|
|
41
41
|
import _lib_accounts
|
|
42
42
|
import _lib_journal
|
|
43
|
+
import _lib_journal_router
|
|
43
44
|
import _lib_record
|
|
44
45
|
|
|
45
46
|
|
|
@@ -178,12 +179,10 @@ def _repair_torn_tail(fd: int) -> None:
|
|
|
178
179
|
# public append surface
|
|
179
180
|
# --------------------------------------------------------------------------
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
and (record.get("payload") or {}).get("kind") == "quota_window_snapshot"
|
|
186
|
-
)
|
|
182
|
+
# NOTE: `_is_codex_quota_obs` is defined ONCE, further down beside
|
|
183
|
+
# `_QUOTA_OBS_KIND`. A duplicate definition used to sit here and was shadowed by
|
|
184
|
+
# that one at import time, so it was dead code an edit here would silently not
|
|
185
|
+
# reach (#496 S4). Do not reintroduce a second definition.
|
|
187
186
|
|
|
188
187
|
|
|
189
188
|
def _codex_quota_natural_key(record: dict) -> str | None:
|
|
@@ -244,7 +243,7 @@ def _load_quota_dedup_keys() -> None:
|
|
|
244
243
|
_QUOTA_DEDUP_KEYS.clear()
|
|
245
244
|
|
|
246
245
|
for name in list_segments():
|
|
247
|
-
with (journal_dir / name)
|
|
246
|
+
with _open_segment_for_read(journal_dir / name) as fh:
|
|
248
247
|
for raw in fh:
|
|
249
248
|
if not raw.endswith(b"\n"):
|
|
250
249
|
break
|
|
@@ -952,13 +951,38 @@ def _write_cursor(conn: sqlite3.Connection, segment: str, offset: int) -> None:
|
|
|
952
951
|
_SEGMENT_READ_CHUNK = 256 * 1024
|
|
953
952
|
|
|
954
953
|
|
|
955
|
-
def
|
|
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
|
+
|
|
972
|
+
def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
|
|
956
973
|
"""Stream `(basename, absolute-offset, raw-line-without-newline)` for every
|
|
957
974
|
complete line in `[lo, hi)`, holding at most one chunk plus one partial line
|
|
958
975
|
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.
|
|
976
|
+
segment's full size), so no partial trailing line appears.
|
|
977
|
+
|
|
978
|
+
`on_bytes` receives each chunk exactly as it is read, before any line
|
|
979
|
+
splitting. It exists so a caller can reproduce `journal_prefix_hash` from
|
|
980
|
+
the bytes this pass is already reading (#496 S4 §5.2) rather than re-reading
|
|
981
|
+
the segment; it must therefore see the raw `[lo, hi)` range verbatim,
|
|
982
|
+
including a torn trailing partial line that is never yielded.
|
|
983
|
+
"""
|
|
960
984
|
name = seg_path.name
|
|
961
|
-
with
|
|
985
|
+
with _open_segment_for_read(seg_path) as fh:
|
|
962
986
|
fh.seek(lo)
|
|
963
987
|
pos = lo
|
|
964
988
|
buf = b""
|
|
@@ -968,6 +992,8 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
|
968
992
|
if not data:
|
|
969
993
|
break
|
|
970
994
|
pos += len(data)
|
|
995
|
+
if on_bytes is not None:
|
|
996
|
+
on_bytes(data)
|
|
971
997
|
buf = buf + data if buf else data
|
|
972
998
|
start = 0
|
|
973
999
|
while True:
|
|
@@ -981,14 +1007,6 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
|
981
1007
|
buf_at += start
|
|
982
1008
|
|
|
983
1009
|
|
|
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
1010
|
def iter_range(cursor, hw):
|
|
993
1011
|
"""Stream `cursor -> HW` across segments in canonical order (spec §5.2.2).
|
|
994
1012
|
|
|
@@ -1002,8 +1020,26 @@ def iter_range(cursor, hw):
|
|
|
1002
1020
|
materialized form for the ingest cycle, which genuinely needs the batch as
|
|
1003
1021
|
an indexable sequence (prefix-stop indices address into it).
|
|
1004
1022
|
"""
|
|
1023
|
+
yield from _iter_range_with_segments(cursor, hw, list_segments())
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
|
|
1027
|
+
on_bytes=None):
|
|
1028
|
+
"""`iter_range` over a segment list the CALLER snapshotted (#496 S4 §4).
|
|
1029
|
+
|
|
1030
|
+
`list_segments()` enumerates the journal directory at call time and orders
|
|
1031
|
+
bootstrap segments before observation segments, so a bootstrap segment
|
|
1032
|
+
appearing mid-rebuild would insert ahead of the high-water segment and shift
|
|
1033
|
+
the indices this function addresses by. A rebuild takes ONE snapshot at its
|
|
1034
|
+
pinned high-water and drives every pass from it, so two passes of the same
|
|
1035
|
+
rebuild cannot disagree about the journal's shape.
|
|
1036
|
+
|
|
1037
|
+
`on_segment` is called for EVERY segment in the range, including one this
|
|
1038
|
+
function then skips because it holds no bytes in range: `journal_prefix_hash`
|
|
1039
|
+
frames a zero-byte segment, so a hash accumulator has to be told it exists.
|
|
1040
|
+
`on_bytes` is forwarded to `_iter_segment_lines`.
|
|
1041
|
+
"""
|
|
1005
1042
|
hw_seg, hw_size = hw
|
|
1006
|
-
segments = list_segments()
|
|
1007
1043
|
if hw_seg not in segments:
|
|
1008
1044
|
return
|
|
1009
1045
|
hw_idx = segments.index(hw_seg)
|
|
@@ -1020,9 +1056,11 @@ def iter_range(cursor, hw):
|
|
|
1020
1056
|
seg_path = _cctally_core.JOURNAL_DIR / seg
|
|
1021
1057
|
lo = start_off if idx == start_idx else 0
|
|
1022
1058
|
hi = hw_size if idx == hw_idx else os.path.getsize(seg_path)
|
|
1059
|
+
if on_segment is not None:
|
|
1060
|
+
on_segment(seg)
|
|
1023
1061
|
if lo >= hi:
|
|
1024
1062
|
continue
|
|
1025
|
-
yield from _iter_segment_lines(seg_path, lo, hi)
|
|
1063
|
+
yield from _iter_segment_lines(seg_path, lo, hi, on_bytes=on_bytes)
|
|
1026
1064
|
|
|
1027
1065
|
|
|
1028
1066
|
def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
|
|
@@ -1031,7 +1069,13 @@ def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
|
|
|
1031
1069
|
|
|
1032
1070
|
|
|
1033
1071
|
def journal_prefix_hash(high_water) -> "str | None":
|
|
1034
|
-
"""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
|
+
"""
|
|
1035
1079
|
if high_water is None:
|
|
1036
1080
|
return None
|
|
1037
1081
|
digest = hashlib.sha256()
|
|
@@ -1039,7 +1083,8 @@ def journal_prefix_hash(high_water) -> "str | None":
|
|
|
1039
1083
|
for segment in list_segments():
|
|
1040
1084
|
path = _cctally_core.JOURNAL_DIR / segment
|
|
1041
1085
|
size = high_water[1] if segment == high_water[0] else path.stat().st_size
|
|
1042
|
-
|
|
1086
|
+
with _open_segment_for_read(path) as handle:
|
|
1087
|
+
data = handle.read(size)
|
|
1043
1088
|
if len(data) != size:
|
|
1044
1089
|
raise OSError(f"journal segment changed while reading: {segment}")
|
|
1045
1090
|
name = segment.encode("utf-8")
|
|
@@ -1057,20 +1102,28 @@ def journal_prefix_hash(high_water) -> "str | None":
|
|
|
1057
1102
|
return "sha256:" + digest.hexdigest()
|
|
1058
1103
|
|
|
1059
1104
|
|
|
1060
|
-
def _capture_protocol_prefix_evidence(
|
|
1061
|
-
|
|
1105
|
+
def _capture_protocol_prefix_evidence(
|
|
1106
|
+
record, prior_high_water, evidence, hasher=None
|
|
1107
|
+
) -> None:
|
|
1108
|
+
"""Capture the actual raw prefix immediately preceding one audit record.
|
|
1109
|
+
|
|
1110
|
+
`hasher` is a `_lib_journal_router.PrefixHashAccumulator` fed by the caller's
|
|
1111
|
+
streaming pass. When supplied, the digest comes from bytes that pass has
|
|
1112
|
+
already read; otherwise `journal_prefix_hash` re-reads the whole prefix from
|
|
1113
|
+
disk, which is what the streaming callers exist to avoid (#496 S4 §5.2). The
|
|
1114
|
+
two produce the identical durable digest.
|
|
1115
|
+
"""
|
|
1062
1116
|
if (
|
|
1063
1117
|
record.get("t") == "op"
|
|
1064
1118
|
and isinstance(record.get("payload"), dict)
|
|
1065
1119
|
and record["payload"].get("kind")
|
|
1066
1120
|
== _lib_journal._PROTOCOL_RESOLUTION_KIND
|
|
1067
1121
|
):
|
|
1068
|
-
|
|
1069
|
-
(
|
|
1070
|
-
|
|
1071
|
-
journal_prefix_hash(prior_high_water),
|
|
1072
|
-
)
|
|
1122
|
+
digest = (
|
|
1123
|
+
hasher.digest_at(prior_high_water) if hasher is not None
|
|
1124
|
+
else journal_prefix_hash(prior_high_water)
|
|
1073
1125
|
)
|
|
1126
|
+
evidence.append((prior_high_water, digest))
|
|
1074
1127
|
|
|
1075
1128
|
|
|
1076
1129
|
# --------------------------------------------------------------------------
|
|
@@ -2017,6 +2070,8 @@ def _derive_account_last_seen(conn, records) -> None:
|
|
|
2017
2070
|
prior observe already created (never invents an account row)."""
|
|
2018
2071
|
latest: dict = {}
|
|
2019
2072
|
for rec in records:
|
|
2073
|
+
if rec is None:
|
|
2074
|
+
continue
|
|
2020
2075
|
key = _account_of(rec)
|
|
2021
2076
|
at = rec.get("at")
|
|
2022
2077
|
if not key or not at:
|
|
@@ -2024,6 +2079,17 @@ def _derive_account_last_seen(conn, records) -> None:
|
|
|
2024
2079
|
prev = latest.get(key)
|
|
2025
2080
|
if prev is None or at > prev:
|
|
2026
2081
|
latest[key] = at
|
|
2082
|
+
_apply_account_last_seen(conn, latest)
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def _apply_account_last_seen(conn, latest) -> None:
|
|
2086
|
+
"""Apply a precomputed `{account_key: max_at}` map.
|
|
2087
|
+
|
|
2088
|
+
Split out so the rebuild can accumulate the map during its single streaming
|
|
2089
|
+
pass (#496 S4 §4.2) instead of walking every record again inside the
|
|
2090
|
+
publication transaction. The rebuild's retained list no longer contains
|
|
2091
|
+
observations at all, so calling `_derive_account_last_seen` over it would
|
|
2092
|
+
silently drop every observation's contribution."""
|
|
2027
2093
|
for key, at in latest.items():
|
|
2028
2094
|
conn.execute(
|
|
2029
2095
|
"UPDATE accounts SET last_seen_utc = ? WHERE account_key = ? "
|
|
@@ -3428,6 +3494,10 @@ def _correction_commit_high_water(batch_id, hw=None):
|
|
|
3428
3494
|
selector or by the live metadata row that names it. The earliest matching
|
|
3429
3495
|
commit is the narrowest complete prefix and remains stable even when later
|
|
3430
3496
|
journal bytes or crash-replayed duplicate markers exist.
|
|
3497
|
+
|
|
3498
|
+
Streams rather than materializing (#496 S4): the previous form built the
|
|
3499
|
+
whole prefix through `_read_range` before its first-match return, so a
|
|
3500
|
+
marker in the first segment still paid for every later one.
|
|
3431
3501
|
"""
|
|
3432
3502
|
if not batch_id:
|
|
3433
3503
|
return None
|
|
@@ -3435,7 +3505,7 @@ def _correction_commit_high_water(batch_id, hw=None):
|
|
|
3435
3505
|
hw = journal_high_water()
|
|
3436
3506
|
if hw is None:
|
|
3437
3507
|
return None
|
|
3438
|
-
for segment, offset, raw in
|
|
3508
|
+
for segment, offset, raw in iter_range(None, hw):
|
|
3439
3509
|
record = _lib_journal.decode_line(raw)
|
|
3440
3510
|
if (
|
|
3441
3511
|
record is not None
|
|
@@ -4259,6 +4329,25 @@ class RebuildResult:
|
|
|
4259
4329
|
# batches remain tainted; this is diagnostic/audit state, never validity.
|
|
4260
4330
|
acknowledged_protocol_violations: tuple = ()
|
|
4261
4331
|
quarantine_dir: "pathlib.Path | None" = None
|
|
4332
|
+
# #496 S4 §8.7 — ADDITIVE instrumentation. Names, units and pass boundaries
|
|
4333
|
+
# are fixed by the spec so the gate's assertions are unambiguous; adding
|
|
4334
|
+
# them does not bump the rebuild record's `schemaVersion` and no existing
|
|
4335
|
+
# field changes meaning.
|
|
4336
|
+
#: float seconds per phase. Keys: journal_read_decode, cutover_suffix,
|
|
4337
|
+
#: protocol_evidence, effective_selection, quota_cache_leg, stats_fold,
|
|
4338
|
+
#: scratch_validate, publication. The phases are DISJOINT — evidence hashing
|
|
4339
|
+
#: happens inside the read loop and is subtracted from journal_read_decode.
|
|
4340
|
+
phase_seconds: dict = field(default_factory=dict)
|
|
4341
|
+
#: per named pass, `{lines, bytes, decodes}`. Passes: stats_prefix (the
|
|
4342
|
+
#: router), cutover_suffix (zero unless the §5.1 fallback ran),
|
|
4343
|
+
#: protocol_evidence (`bytes` hashed and `lines` digests computed, both zero
|
|
4344
|
+
#: on a journal with no resolution op), quota_replay (the in-leg decode,
|
|
4345
|
+
#: where `bytes` is the retained byte total and `lines` equals `decodes`).
|
|
4346
|
+
traversal: dict = field(default_factory=dict)
|
|
4347
|
+
#: `tracemalloc` peak over the pre-publication window; 0 when not tracing.
|
|
4348
|
+
peak_heap_bytes: int = 0
|
|
4349
|
+
#: cache writer flock acquisition to release, in seconds.
|
|
4350
|
+
quota_lock_hold_seconds: float = 0.0
|
|
4262
4351
|
|
|
4263
4352
|
|
|
4264
4353
|
def _remove_db_sidecars_strict(path) -> None:
|
|
@@ -4482,26 +4571,56 @@ def stats_index_matches_journal_prefix(
|
|
|
4482
4571
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4483
4572
|
try:
|
|
4484
4573
|
_validate_rebuilt_stats_index(conn, high_water)
|
|
4485
|
-
|
|
4574
|
+
# Same streaming router as the rebuild (#496 S4 §7). This function's
|
|
4575
|
+
# only output is a selection compared against
|
|
4576
|
+
# `journal_effective_events`, so it needs the decision records and
|
|
4577
|
+
# nothing else: no observation retention, no quota bytes, no second
|
|
4578
|
+
# from-byte-zero cutover scan. The `None` placeholders keep every
|
|
4579
|
+
# `enumerate` sequence — and therefore every violation fingerprint —
|
|
4580
|
+
# identical to what the rebuild wrote.
|
|
4581
|
+
decoded: list = []
|
|
4486
4582
|
protocol_evidence = []
|
|
4487
4583
|
prior_high_water = None
|
|
4584
|
+
cutover_captured = _CUTOVER_UNSEEN
|
|
4585
|
+
all_segments = list_segments()
|
|
4586
|
+
segments = all_segments
|
|
4587
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
4488
4588
|
if high_water is not None:
|
|
4489
|
-
|
|
4589
|
+
if high_water[0] in segments:
|
|
4590
|
+
segments = segments[:segments.index(high_water[0]) + 1]
|
|
4591
|
+
for segment, offset, raw in _iter_range_with_segments(
|
|
4592
|
+
None, high_water, segments,
|
|
4593
|
+
on_segment=lambda name: hasher.begin_segment(
|
|
4594
|
+
name, prior_high_water),
|
|
4595
|
+
on_bytes=hasher.extend,
|
|
4596
|
+
):
|
|
4490
4597
|
record = _lib_journal.decode_line(raw)
|
|
4491
4598
|
if record is not None:
|
|
4492
4599
|
_capture_protocol_prefix_evidence(
|
|
4493
4600
|
record,
|
|
4494
4601
|
prior_high_water,
|
|
4495
4602
|
protocol_evidence,
|
|
4603
|
+
hasher=hasher,
|
|
4604
|
+
)
|
|
4605
|
+
if (cutover_captured is _CUTOVER_UNSEEN
|
|
4606
|
+
and record.get("id") == CUTOVER_OP_ID):
|
|
4607
|
+
cutover_captured = _cutover_value_of(record)
|
|
4608
|
+
decoded.append(
|
|
4609
|
+
record
|
|
4610
|
+
if record.get("t")
|
|
4611
|
+
in _lib_journal_router.RETAINED_RECORD_TYPES
|
|
4612
|
+
else None
|
|
4496
4613
|
)
|
|
4497
|
-
decoded.append(record)
|
|
4498
4614
|
prior_high_water = (
|
|
4499
4615
|
segment,
|
|
4500
4616
|
offset + len(raw) + 1,
|
|
4501
4617
|
)
|
|
4502
|
-
|
|
4618
|
+
hasher = None
|
|
4619
|
+
cutover_claude = _resolve_cutover_for_rebuild(
|
|
4620
|
+
cutover_captured, high_water, all_segments)
|
|
4503
4621
|
for record in decoded:
|
|
4504
|
-
|
|
4622
|
+
if record is not None:
|
|
4623
|
+
_normalize_legacy_account_stamp(record, cutover_claude)
|
|
4505
4624
|
selection = _lib_journal.resolve_effective_events(
|
|
4506
4625
|
decoded,
|
|
4507
4626
|
protocol_prefix_evidence=protocol_evidence,
|
|
@@ -5449,6 +5568,51 @@ def _publish_stats_index_in_place(
|
|
|
5449
5568
|
return _FALL_BACK
|
|
5450
5569
|
raise
|
|
5451
5570
|
|
|
5571
|
+
# Readability is not structural health. An integrity failure may consist
|
|
5572
|
+
# only of pages which no sqlite_schema object and no freelist entry names.
|
|
5573
|
+
# The table-by-table in-place swap cannot discover or reclaim such pages,
|
|
5574
|
+
# so publishing into that file would preserve the damage and fail its
|
|
5575
|
+
# post-publication verdict forever. Use the independently validated scratch
|
|
5576
|
+
# as a physical replacement before any live mutation instead.
|
|
5577
|
+
try:
|
|
5578
|
+
destination_integrity = [
|
|
5579
|
+
str(row[0]) for row in conn.execute("PRAGMA integrity_check")
|
|
5580
|
+
]
|
|
5581
|
+
except BaseException as exc:
|
|
5582
|
+
try:
|
|
5583
|
+
conn.close()
|
|
5584
|
+
except Exception:
|
|
5585
|
+
pass
|
|
5586
|
+
if sp.may_fall_back_to_replacement(exc):
|
|
5587
|
+
print(
|
|
5588
|
+
"[rebuild] the live stats index failed its integrity probe "
|
|
5589
|
+
f"({exc}); publishing by replacement instead",
|
|
5590
|
+
file=sys.stderr,
|
|
5591
|
+
)
|
|
5592
|
+
record["inPlaceAttempt"] = {
|
|
5593
|
+
"phase": sp.PRE_COMMIT,
|
|
5594
|
+
"stage": "destination_integrity",
|
|
5595
|
+
"error": f"{type(exc).__name__}: {exc}"[:500],
|
|
5596
|
+
}
|
|
5597
|
+
return _FALL_BACK
|
|
5598
|
+
raise
|
|
5599
|
+
if destination_integrity != ["ok"]:
|
|
5600
|
+
try:
|
|
5601
|
+
conn.close()
|
|
5602
|
+
except Exception:
|
|
5603
|
+
pass
|
|
5604
|
+
print(
|
|
5605
|
+
"[rebuild] the live stats index failed integrity_check; "
|
|
5606
|
+
"publishing by replacement instead",
|
|
5607
|
+
file=sys.stderr,
|
|
5608
|
+
)
|
|
5609
|
+
record["inPlaceAttempt"] = {
|
|
5610
|
+
"phase": sp.PRE_COMMIT,
|
|
5611
|
+
"stage": "destination_integrity",
|
|
5612
|
+
"error": "destination failed integrity_check",
|
|
5613
|
+
}
|
|
5614
|
+
return _FALL_BACK
|
|
5615
|
+
|
|
5452
5616
|
started_at = _utc_iso_now()
|
|
5453
5617
|
record_path = pathlib.Path(context.record_path)
|
|
5454
5618
|
live = dict(record)
|
|
@@ -5559,9 +5723,10 @@ def _publish_rebuilt_stats_index(
|
|
|
5559
5723
|
|
|
5560
5724
|
In-place transactional publication is the mechanism (#496 S3). Physical
|
|
5561
5725
|
replacement is the fallback, taken when the destination cannot be operated
|
|
5562
|
-
on structurally. The mechanism is chosen
|
|
5563
|
-
of this run, not by the trigger that
|
|
5564
|
-
|
|
5726
|
+
on structurally or fails an integrity check. The mechanism is chosen
|
|
5727
|
+
against the destination in front of this run, not by the trigger that
|
|
5728
|
+
reached it: readability alone does not prove that an object-level swap can
|
|
5729
|
+
reclaim every damaged page.
|
|
5565
5730
|
|
|
5566
5731
|
Publication is a two-phase durable transaction (#496 S1 F1) under either
|
|
5567
5732
|
mechanism. A published file carries the current epoch, so `open_db`'s
|
|
@@ -5714,9 +5879,36 @@ def _publish_rebuilt_stats_index(
|
|
|
5714
5879
|
return incident
|
|
5715
5880
|
|
|
5716
5881
|
|
|
5717
|
-
def
|
|
5882
|
+
def _decoded_quota_stream(quota_raw, cutover_claude, counters=None):
|
|
5883
|
+
"""Decode and normalize retained observation bytes ONE AT A TIME.
|
|
5884
|
+
|
|
5885
|
+
Peak heap therefore holds one record rather than the whole population.
|
|
5886
|
+
Normalization runs HERE, on the record decoded from the retained bytes: a
|
|
5887
|
+
dict normalized during the router pass is discarded with the pass, so
|
|
5888
|
+
stamping it there would be lost and every legacy observation would
|
|
5889
|
+
re-materialize with a NULL account_key. A Codex legacy line maps to
|
|
5890
|
+
`unattributed` regardless of the cutover value, so this does not depend on
|
|
5891
|
+
capture ordering (#496 S4 §6.3).
|
|
5892
|
+
"""
|
|
5893
|
+
for raw in quota_raw:
|
|
5894
|
+
if counters is not None:
|
|
5895
|
+
counters["lines"] += 1
|
|
5896
|
+
counters["bytes"] += len(raw) + 1
|
|
5897
|
+
record = _lib_journal.decode_line(raw)
|
|
5898
|
+
if record is None: # pragma: no cover — retained bytes decoded once already
|
|
5899
|
+
continue
|
|
5900
|
+
if counters is not None:
|
|
5901
|
+
counters["decodes"] += 1
|
|
5902
|
+
_normalize_legacy_account_stamp(record, cutover_claude)
|
|
5903
|
+
yield record
|
|
5904
|
+
|
|
5905
|
+
|
|
5906
|
+
def _rebuild_quota_cache_leg_raw(
|
|
5907
|
+
quota_raw, decoded, cutover_claude, counters=None
|
|
5908
|
+
) -> float:
|
|
5718
5909
|
"""Re-materialize cache.db `quota_window_snapshots` AND the #416 Codex
|
|
5719
|
-
attribution map from the journal (spec §5.4 + #416 spec §3.4)
|
|
5910
|
+
attribution map from the journal (spec §5.4 + #416 spec §3.4), fed RAW
|
|
5911
|
+
ENCODED LINES for the observations instead of decoded dicts.
|
|
5720
5912
|
|
|
5721
5913
|
The journal records are the DURABLE source (§1 latent data-loss hole — the
|
|
5722
5914
|
rollout JSONL evaporates); this INSERT-OR-IGNOREs the quota obs on their
|
|
@@ -5730,14 +5922,31 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5730
5922
|
followed by the `cache.db.codex.lock` provider flock (lock-order law).
|
|
5731
5923
|
Best-effort: a missing/busy cache.db is a clean skip (the records stay
|
|
5732
5924
|
durable in the journal; the stats quota projection pass then degrades
|
|
5733
|
-
cleanly).
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5925
|
+
cleanly).
|
|
5926
|
+
|
|
5927
|
+
Taking raw bytes is what makes the rebuild affordable. 1.81M decoded
|
|
5928
|
+
observation dictionaries are roughly six gigabytes against 1.64 GB of raw
|
|
5929
|
+
bytes, so retaining bytes removes about four gigabytes of peak heap while
|
|
5930
|
+
adding NO file input and NO second traversal — only the JSON decode of
|
|
5931
|
+
records already in memory moves inside the flocks (#496 S4 §6.3). That
|
|
5932
|
+
decode is why the measured hold is longer than it was before S4; see the
|
|
5933
|
+
spec's §6.3 for the measured figures.
|
|
5934
|
+
|
|
5935
|
+
Ordering is preserved: file-account decisions are ops, so the router already
|
|
5936
|
+
retains them decoded and they are available before the observation loop
|
|
5937
|
+
begins, exactly as the §3.5 precedence rule requires.
|
|
5938
|
+
|
|
5939
|
+
Returns the measured flock hold in seconds, which acceptance criterion 7
|
|
5940
|
+
caps.
|
|
5941
|
+
"""
|
|
5942
|
+
file_accounts = [
|
|
5943
|
+
r for r in decoded if r is not None and _is_codex_file_account_op(r)
|
|
5944
|
+
]
|
|
5945
|
+
if not quota_raw and not file_accounts:
|
|
5946
|
+
return 0.0
|
|
5738
5947
|
cache_path = _cctally_core.CACHE_DB_PATH
|
|
5739
5948
|
if not cache_path.exists():
|
|
5740
|
-
return
|
|
5949
|
+
return 0.0
|
|
5741
5950
|
from _lib_cache_writer_lock import (
|
|
5742
5951
|
acquire_cache_writer_flocks,
|
|
5743
5952
|
release_cache_writer_flocks,
|
|
@@ -5752,22 +5961,26 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5752
5961
|
)
|
|
5753
5962
|
except OSError as exc:
|
|
5754
5963
|
print(f"[rebuild] quota cache leg lock failed: {exc}", file=sys.stderr)
|
|
5755
|
-
return
|
|
5964
|
+
return 0.0
|
|
5756
5965
|
if held is None:
|
|
5757
5966
|
print("[rebuild] quota cache leg locks busy; skipping", file=sys.stderr)
|
|
5758
|
-
return
|
|
5967
|
+
return 0.0
|
|
5968
|
+
held_from = time.monotonic()
|
|
5759
5969
|
try:
|
|
5760
5970
|
try:
|
|
5761
5971
|
cache = sqlite3.connect(str(cache_path), timeout=15.0)
|
|
5762
5972
|
except sqlite3.Error as exc: # pragma: no cover — cache.db unopenable
|
|
5763
5973
|
print(f"[rebuild] quota cache leg connect failed: {exc}", file=sys.stderr)
|
|
5764
|
-
return
|
|
5974
|
+
return time.monotonic() - held_from
|
|
5765
5975
|
try:
|
|
5766
5976
|
cache.execute("PRAGMA busy_timeout=15000")
|
|
5767
5977
|
cache.execute("BEGIN IMMEDIATE")
|
|
5768
5978
|
# Decisions FIRST — same §3.5 precedence ordering as `_cache_applier`.
|
|
5769
5979
|
_, _file_conflicts = _apply_file_account_records(cache, file_accounts)
|
|
5770
|
-
_apply_quota_records(
|
|
5980
|
+
_apply_quota_records(
|
|
5981
|
+
cache,
|
|
5982
|
+
_decoded_quota_stream(quota_raw, cutover_claude, counters),
|
|
5983
|
+
)
|
|
5771
5984
|
cache.commit()
|
|
5772
5985
|
_report_file_account_conflicts(_file_conflicts)
|
|
5773
5986
|
except sqlite3.Error as exc:
|
|
@@ -5780,6 +5993,66 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5780
5993
|
cache.close()
|
|
5781
5994
|
finally:
|
|
5782
5995
|
release_cache_writer_flocks(held)
|
|
5996
|
+
return time.monotonic() - held_from
|
|
5997
|
+
|
|
5998
|
+
|
|
5999
|
+
#: `_resolve_cutover_for_rebuild` distinguishes "the streaming pass never saw the
|
|
6000
|
+
#: op" from "it saw the op and the op recorded no account". `find_accounts_cutover_op`
|
|
6001
|
+
#: makes the same distinction by returning at the first matching RECORD id, so a
|
|
6002
|
+
#: plain `None` cannot stand in for both without changing which answer wins.
|
|
6003
|
+
_CUTOVER_UNSEEN = object()
|
|
6004
|
+
|
|
6005
|
+
|
|
6006
|
+
def _cutover_value_of(record) -> "str | None":
|
|
6007
|
+
"""The cutover op's recorded `claude_legacy_account`, or None when this
|
|
6008
|
+
record is not the canonical cutover op. Shared by the rebuild's inline
|
|
6009
|
+
capture and `find_accounts_cutover_op` so the two cannot disagree."""
|
|
6010
|
+
if record is None or record.get("id") != CUTOVER_OP_ID:
|
|
6011
|
+
return None
|
|
6012
|
+
payload = record.get("payload")
|
|
6013
|
+
if not isinstance(payload, dict):
|
|
6014
|
+
return None
|
|
6015
|
+
return payload.get("claude_legacy_account")
|
|
6016
|
+
|
|
6017
|
+
|
|
6018
|
+
def _resolve_cutover_for_rebuild(captured, hw, segments, counters=None) -> str:
|
|
6019
|
+
"""The cutover account for one rebuild, reading each byte at most once.
|
|
6020
|
+
|
|
6021
|
+
`captured` is what the streaming pass saw inside the pinned prefix, or
|
|
6022
|
+
`_CUTOVER_UNSEEN`. When the prefix did not contain the op — reachable,
|
|
6023
|
+
because correction recovery, journal repair and rederive all pin
|
|
6024
|
+
high-waters, and the op sits at 92.9% of a production journal — scan ONLY
|
|
6025
|
+
the unvisited suffix, from the pinned high-water to the current one,
|
|
6026
|
+
stopping at the first match. Resolving from the prefix alone would flip
|
|
6027
|
+
those rebuilds to `unattributed` and restamp every legacy Claude
|
|
6028
|
+
observation, moving `accounts.last_seen_utc` with it (#496 S4 §5.1).
|
|
6029
|
+
"""
|
|
6030
|
+
if captured is not _CUTOVER_UNSEEN:
|
|
6031
|
+
return captured if captured is not None else _lib_accounts.UNATTRIBUTED
|
|
6032
|
+
if hw is None or not segments:
|
|
6033
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6034
|
+
current = journal_high_water()
|
|
6035
|
+
if current is None or current == hw:
|
|
6036
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6037
|
+
if current[0] not in segments:
|
|
6038
|
+
# A segment appended after this rebuild's snapshot. Every pass of one
|
|
6039
|
+
# rebuild reads the same snapshot (§4), so the suffix stops at its end
|
|
6040
|
+
# rather than silently adopting a different journal shape.
|
|
6041
|
+
last = segments[-1]
|
|
6042
|
+
current = (last, os.path.getsize(_cctally_core.JOURNAL_DIR / last))
|
|
6043
|
+
if current == hw:
|
|
6044
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6045
|
+
for _segment, _offset, raw in _iter_range_with_segments(hw, current, segments):
|
|
6046
|
+
if counters is not None:
|
|
6047
|
+
counters["lines"] += 1
|
|
6048
|
+
counters["bytes"] += len(raw) + 1
|
|
6049
|
+
record = _lib_journal.decode_line(raw)
|
|
6050
|
+
if counters is not None and record is not None:
|
|
6051
|
+
counters["decodes"] += 1
|
|
6052
|
+
if record is not None and record.get("id") == CUTOVER_OP_ID:
|
|
6053
|
+
value = _cutover_value_of(record)
|
|
6054
|
+
return value if value is not None else _lib_accounts.UNATTRIBUTED
|
|
6055
|
+
return _lib_accounts.UNATTRIBUTED
|
|
5783
6056
|
|
|
5784
6057
|
|
|
5785
6058
|
def rebuild_stats_index(
|
|
@@ -5812,6 +6085,11 @@ def rebuild_stats_index(
|
|
|
5812
6085
|
replaces the main file. A `target_path` build uses the same atomic
|
|
5813
6086
|
publication but does not create a live-family quarantine incident.
|
|
5814
6087
|
"""
|
|
6088
|
+
# Imported HERE, not at module scope: `_cctally_journal` is on the ingest
|
|
6089
|
+
# path every status-line tick reaches, and `import tracemalloc` measured
|
|
6090
|
+
# 2.9 ms. Only a rebuild reads the peak, so only a rebuild pays for it.
|
|
6091
|
+
import tracemalloc
|
|
6092
|
+
|
|
5815
6093
|
start = time.monotonic()
|
|
5816
6094
|
context = context.validate()
|
|
5817
6095
|
# Resolve the rebuild record's path ONCE, here, because preservation runs
|
|
@@ -5831,7 +6109,13 @@ def rebuild_stats_index(
|
|
|
5831
6109
|
# and belong to the next ingest cycle (they replay idempotently); mirrors the
|
|
5832
6110
|
# live cycle's §5.2.1 HW-prefix rule.
|
|
5833
6111
|
hw = high_water if high_water is not None else journal_high_water()
|
|
5834
|
-
|
|
6112
|
+
# ONE segment snapshot for the whole rebuild (#496 S4 §4). `list_segments()`
|
|
6113
|
+
# re-enumerates the directory at call time and orders bootstrap segments
|
|
6114
|
+
# first, so a bootstrap segment appearing mid-rebuild would shift the indices
|
|
6115
|
+
# `iter_range` addresses by; before this, the read pass and the cutover scan
|
|
6116
|
+
# each listed separately and could already disagree about the journal's shape.
|
|
6117
|
+
all_segments = list_segments()
|
|
6118
|
+
segments = all_segments
|
|
5835
6119
|
if hw is not None:
|
|
5836
6120
|
if hw[0] not in segments:
|
|
5837
6121
|
raise JournalError(
|
|
@@ -5854,12 +6138,42 @@ def rebuild_stats_index(
|
|
|
5854
6138
|
conn = _cctally_core.open_db(_target_path=str(scratch))
|
|
5855
6139
|
malformed = 0
|
|
5856
6140
|
lines_folded = 0
|
|
6141
|
+
phase_seconds: dict = {}
|
|
6142
|
+
traversal = {
|
|
6143
|
+
name: {"lines": 0, "bytes": 0, "decodes": 0}
|
|
6144
|
+
for name in ("stats_prefix", "cutover_suffix", "protocol_evidence",
|
|
6145
|
+
"quota_replay")
|
|
6146
|
+
}
|
|
6147
|
+
quota_lock_hold = 0.0
|
|
6148
|
+
tracing = tracemalloc.is_tracing()
|
|
6149
|
+
if tracing:
|
|
6150
|
+
tracemalloc.reset_peak()
|
|
5857
6151
|
try:
|
|
6152
|
+
# ONE streaming pass. Decode each line once, feed the account last-seen
|
|
6153
|
+
# accumulator, capture the cutover inline, and retain only what a
|
|
6154
|
+
# consumer actually needs: the decision records decoded (5.08% of a
|
|
6155
|
+
# production journal) and the Codex quota observations as RAW BYTES
|
|
6156
|
+
# (1.64 GB against roughly six gigabytes of dicts). Everything else is
|
|
6157
|
+
# dropped as soon as it has contributed (#496 S4 §4).
|
|
5858
6158
|
decoded: list = []
|
|
6159
|
+
quota_raw: list = []
|
|
5859
6160
|
protocol_evidence = []
|
|
5860
6161
|
prior_high_water = None
|
|
6162
|
+
cutover_captured = _CUTOVER_UNSEEN
|
|
6163
|
+
last_seen = _lib_journal_router.LastSeenAccumulator()
|
|
6164
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
6165
|
+
evidence_seconds = 0.0
|
|
6166
|
+
prefix = traversal["stats_prefix"]
|
|
6167
|
+
read_started = time.monotonic()
|
|
5861
6168
|
if hw is not None:
|
|
5862
|
-
for segment, offset, raw in
|
|
6169
|
+
for segment, offset, raw in _iter_range_with_segments(
|
|
6170
|
+
None, hw, segments,
|
|
6171
|
+
on_segment=lambda name: hasher.begin_segment(
|
|
6172
|
+
name, prior_high_water),
|
|
6173
|
+
on_bytes=hasher.extend,
|
|
6174
|
+
):
|
|
6175
|
+
prefix["lines"] += 1
|
|
6176
|
+
prefix["bytes"] += len(raw) + 1
|
|
5863
6177
|
rec = _lib_journal.decode_line(raw)
|
|
5864
6178
|
if rec is None:
|
|
5865
6179
|
malformed += 1
|
|
@@ -5868,46 +6182,104 @@ def rebuild_stats_index(
|
|
|
5868
6182
|
offset + len(raw) + 1,
|
|
5869
6183
|
)
|
|
5870
6184
|
continue
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
6185
|
+
prefix["decodes"] += 1
|
|
6186
|
+
# `_capture_protocol_prefix_evidence` returns immediately for
|
|
6187
|
+
# anything that is not an op, so this guard changes nothing it
|
|
6188
|
+
# does — it moves the phase attribution's two clock reads from
|
|
6189
|
+
# every record to every op. That is 195 ops against 1,954,007
|
|
6190
|
+
# lines on a production journal, where the phase itself measures
|
|
6191
|
+
# zero because the journal carries no resolution operation.
|
|
6192
|
+
if rec.get("t") == "op":
|
|
6193
|
+
evidence_started = time.monotonic()
|
|
6194
|
+
_capture_protocol_prefix_evidence(
|
|
6195
|
+
rec,
|
|
6196
|
+
prior_high_water,
|
|
6197
|
+
protocol_evidence,
|
|
6198
|
+
hasher=hasher,
|
|
6199
|
+
)
|
|
6200
|
+
evidence_seconds += time.monotonic() - evidence_started
|
|
6201
|
+
# First cutover op wins, exactly as `find_accounts_cutover_op`
|
|
6202
|
+
# scans — captured here so the rebuild reads the journal once.
|
|
6203
|
+
if (cutover_captured is _CUTOVER_UNSEEN
|
|
6204
|
+
and rec.get("id") == CUTOVER_OP_ID):
|
|
6205
|
+
cutover_captured = _cutover_value_of(rec)
|
|
6206
|
+
last_seen.observe(rec, classify_legacy_provider)
|
|
6207
|
+
if rec.get("t") in _lib_journal_router.RETAINED_RECORD_TYPES:
|
|
6208
|
+
decoded.append(rec)
|
|
6209
|
+
else:
|
|
6210
|
+
if update_quota_cache and _is_codex_quota_obs(rec):
|
|
6211
|
+
quota_raw.append(raw)
|
|
6212
|
+
# A PLACEHOLDER, not a dropped element. `resolve_effective_events`
|
|
6213
|
+
# numbers candidates with `enumerate(records)`, and three of the
|
|
6214
|
+
# seven structural violation kinds put that number inside
|
|
6215
|
+
# `ProtocolViolation.evidence` — which the fingerprint hashes.
|
|
6216
|
+
# That fingerprint is durable: it lands in
|
|
6217
|
+
# `journal_protocol_violations` and is referenced by name from a
|
|
6218
|
+
# `journal_protocol_resolution` op, which `_cctally_journal_repair`
|
|
6219
|
+
# mints from the UNFILTERED record list. Renumbering here would
|
|
6220
|
+
# therefore make a previously acknowledged violation unresolvable
|
|
6221
|
+
# and raise on every later rebuild. The selector skips a non-dict
|
|
6222
|
+
# element, so this costs one pointer and keeps every sequence
|
|
6223
|
+
# identical to the pre-change numbering (#496 S4; corrects §4.7).
|
|
6224
|
+
decoded.append(None)
|
|
5877
6225
|
prior_high_water = (
|
|
5878
6226
|
segment,
|
|
5879
6227
|
offset + len(raw) + 1,
|
|
5880
6228
|
)
|
|
6229
|
+
phase_seconds["journal_read_decode"] = round(
|
|
6230
|
+
max(0.0, time.monotonic() - read_started - evidence_seconds), 6)
|
|
6231
|
+
phase_seconds["protocol_evidence"] = round(evidence_seconds, 6)
|
|
6232
|
+
traversal["protocol_evidence"]["bytes"] = hasher.bytes_hashed
|
|
6233
|
+
traversal["protocol_evidence"]["lines"] = hasher.digests_computed
|
|
6234
|
+
hasher = None
|
|
5881
6235
|
|
|
5882
6236
|
# Legacy account normalisation (#341, spec §2 / handoff item 2): a
|
|
5883
6237
|
# pre-#341 real-account line lacks an account stamp — inject the cutover
|
|
5884
6238
|
# mapping BEFORE the fold (Claude legacy -> the cutover op's account;
|
|
5885
6239
|
# Codex legacy -> unattributed). `*`-families + already-stamped lines are
|
|
5886
|
-
# untouched. Resolved
|
|
5887
|
-
#
|
|
5888
|
-
#
|
|
5889
|
-
|
|
6240
|
+
# untouched. Resolved from the journal's own cutover op — inline when the
|
|
6241
|
+
# streamed prefix contained it, otherwise from the unvisited suffix alone
|
|
6242
|
+
# (falls back to `unattributed` when neither has it), so a fresh
|
|
6243
|
+
# single-account rebuild is byte-neutral.
|
|
6244
|
+
cutover_started = time.monotonic()
|
|
6245
|
+
cutover_claude = _resolve_cutover_for_rebuild(
|
|
6246
|
+
cutover_captured, hw, all_segments, traversal["cutover_suffix"])
|
|
6247
|
+
phase_seconds["cutover_suffix"] = round(
|
|
6248
|
+
time.monotonic() - cutover_started, 6)
|
|
5890
6249
|
for rec in decoded:
|
|
5891
|
-
|
|
6250
|
+
if rec is not None:
|
|
6251
|
+
_normalize_legacy_account_stamp(rec, cutover_claude)
|
|
5892
6252
|
|
|
5893
6253
|
# Resolve corrections BEFORE either disposable index is mutated. A
|
|
5894
6254
|
# malformed revision, divergent same-revision candidate, or invalid
|
|
5895
6255
|
# committed manifest leaves the existing destination untouched.
|
|
6256
|
+
selection_started = time.monotonic()
|
|
5896
6257
|
effective = _lib_journal.resolve_effective_events(
|
|
5897
6258
|
decoded,
|
|
5898
6259
|
protocol_prefix_evidence=protocol_evidence,
|
|
5899
6260
|
)
|
|
6261
|
+
phase_seconds["effective_selection"] = round(
|
|
6262
|
+
time.monotonic() - selection_started, 6)
|
|
5900
6263
|
|
|
5901
6264
|
# Cache leg BEFORE any stats txn (provider-flock lock-order): journal
|
|
5902
|
-
# Codex quota obs -> cache.db quota_window_snapshots.
|
|
6265
|
+
# Codex quota obs -> cache.db quota_window_snapshots. The retained bytes
|
|
6266
|
+
# are decoded inside the leg's existing transaction and freed here, so
|
|
6267
|
+
# the SQLite fold never runs on top of them.
|
|
6268
|
+
leg_started = time.monotonic()
|
|
5903
6269
|
if update_quota_cache:
|
|
5904
|
-
|
|
6270
|
+
quota_lock_hold = _rebuild_quota_cache_leg_raw(
|
|
6271
|
+
quota_raw, decoded, cutover_claude, traversal["quota_replay"])
|
|
6272
|
+
quota_raw = []
|
|
6273
|
+
phase_seconds["quota_cache_leg"] = round(
|
|
6274
|
+
time.monotonic() - leg_started, 6)
|
|
5905
6275
|
|
|
5906
6276
|
# One ordered fold stream: op-folds (order 5) + evts, keyed by
|
|
5907
6277
|
# (fold_order, canonical seq) so referenced families resolve before
|
|
5908
6278
|
# referencing ones and crash-replay duplicates fold idempotently.
|
|
5909
6279
|
stream: list = []
|
|
5910
6280
|
for seq, rec in enumerate(decoded):
|
|
6281
|
+
if rec is None:
|
|
6282
|
+
continue
|
|
5911
6283
|
t = rec.get("t")
|
|
5912
6284
|
kind = (rec.get("payload") or {}).get("kind")
|
|
5913
6285
|
if t == "op" and kind in FOLD_APPLIERS:
|
|
@@ -5919,6 +6291,7 @@ def rebuild_stats_index(
|
|
|
5919
6291
|
tail = [s for s in stream if s[0] >= _REBUILD_MILESTONE_ORDER]
|
|
5920
6292
|
|
|
5921
6293
|
_stats_rebuild_test_pause("rebuild_fold_started")
|
|
6294
|
+
fold_started = time.monotonic()
|
|
5922
6295
|
|
|
5923
6296
|
# Phase 1 (txn A) — structural folds: op floors, snapshot_accept, cost
|
|
5924
6297
|
# snapshots, resets+suppression, block_close, arming, credit effects.
|
|
@@ -5966,9 +6339,14 @@ def rebuild_stats_index(
|
|
|
5966
6339
|
_apply_evt(conn, rec)
|
|
5967
6340
|
lines_folded += 1
|
|
5968
6341
|
# Fold-time `last_seen_utc` derivation (#341): re-derive each
|
|
5969
|
-
# account's last-seen from the whole journal
|
|
5970
|
-
#
|
|
5971
|
-
|
|
6342
|
+
# account's last-seen from the whole journal. The map was
|
|
6343
|
+
# accumulated during the single read pass — `decoded` no longer
|
|
6344
|
+
# contains observations, so deriving from it here would silently
|
|
6345
|
+
# drop every observation's contribution (#496 S4 §4.6).
|
|
6346
|
+
_apply_account_last_seen(
|
|
6347
|
+
conn,
|
|
6348
|
+
last_seen.resolve(cutover_claude, _lib_accounts.UNATTRIBUTED),
|
|
6349
|
+
)
|
|
5972
6350
|
if hw is not None:
|
|
5973
6351
|
_write_cursor(conn, hw[0], hw[1])
|
|
5974
6352
|
conn.commit()
|
|
@@ -5978,7 +6356,9 @@ def rebuild_stats_index(
|
|
|
5978
6356
|
except Exception:
|
|
5979
6357
|
pass
|
|
5980
6358
|
raise
|
|
6359
|
+
phase_seconds["stats_fold"] = round(time.monotonic() - fold_started, 6)
|
|
5981
6360
|
|
|
6361
|
+
validate_started = time.monotonic()
|
|
5982
6362
|
rows_by_table = {}
|
|
5983
6363
|
for tbl in _REBUILD_COUNT_TABLES:
|
|
5984
6364
|
try:
|
|
@@ -5992,6 +6372,8 @@ def rebuild_stats_index(
|
|
|
5992
6372
|
raise JournalError("rebuilt stats index WAL could not be drained")
|
|
5993
6373
|
_validate_rebuilt_stats_index(conn, hw)
|
|
5994
6374
|
_stats_rebuild_test_pause("rebuild_scratch_complete")
|
|
6375
|
+
phase_seconds["scratch_validate"] = round(
|
|
6376
|
+
time.monotonic() - validate_started, 6)
|
|
5995
6377
|
finally:
|
|
5996
6378
|
conn.close()
|
|
5997
6379
|
|
|
@@ -6009,8 +6391,13 @@ def rebuild_stats_index(
|
|
|
6009
6391
|
conflicts = effective.conflicts
|
|
6010
6392
|
protocol_violations = effective.protocol_violations
|
|
6011
6393
|
acknowledged = effective.acknowledged_protocol_violations
|
|
6394
|
+
# The pre-publication window is what F9's memory acceptance is measured
|
|
6395
|
+
# over: everything after this point is publication, whose own WAL cost S3
|
|
6396
|
+
# already accounts for.
|
|
6397
|
+
peak_heap_bytes = (
|
|
6398
|
+
tracemalloc.get_traced_memory()[1] if tracing else 0)
|
|
6012
6399
|
decoded = effective = stream = structural = tail = None
|
|
6013
|
-
segments = protocol_evidence = None
|
|
6400
|
+
segments = protocol_evidence = last_seen = None
|
|
6014
6401
|
|
|
6015
6402
|
# First fresh-connection validation (#496 S1 F1). A failure here raises
|
|
6016
6403
|
# BEFORE any preservation, so no incident is created and the old family
|
|
@@ -6025,6 +6412,7 @@ def rebuild_stats_index(
|
|
|
6025
6412
|
# `os.replace` as a stray artifact.
|
|
6026
6413
|
_remove_db_sidecars_strict(scratch)
|
|
6027
6414
|
|
|
6415
|
+
publication_started = time.monotonic()
|
|
6028
6416
|
incident = _publish_rebuilt_stats_index(
|
|
6029
6417
|
scratch=scratch,
|
|
6030
6418
|
destination=dest,
|
|
@@ -6048,8 +6436,21 @@ def rebuild_stats_index(
|
|
|
6048
6436
|
"rowsByTable": rows_by_table,
|
|
6049
6437
|
"buildSeconds": round(time.monotonic() - start, 3),
|
|
6050
6438
|
"prePublicationValidation": {"ok": True, "error": None},
|
|
6439
|
+
# Additive instrumentation (#496 S4 §8.7). Additive keys do not bump
|
|
6440
|
+
# `schemaVersion` and no existing field changes meaning. `publication`
|
|
6441
|
+
# is absent HERE and present on `RebuildResult`: publication copies
|
|
6442
|
+
# this dict before it writes it, so its own duration cannot be known
|
|
6443
|
+
# at the time the record is written.
|
|
6444
|
+
"phaseSeconds": dict(phase_seconds),
|
|
6445
|
+
"traversal": {
|
|
6446
|
+
name: dict(counts) for name, counts in traversal.items()
|
|
6447
|
+
},
|
|
6448
|
+
"peakHeapBytes": peak_heap_bytes,
|
|
6449
|
+
"quotaLockHoldSeconds": round(quota_lock_hold, 6),
|
|
6051
6450
|
},
|
|
6052
6451
|
)
|
|
6452
|
+
phase_seconds["publication"] = round(
|
|
6453
|
+
time.monotonic() - publication_started, 6)
|
|
6053
6454
|
|
|
6054
6455
|
return RebuildResult(
|
|
6055
6456
|
rows_by_table=rows_by_table, malformed=malformed,
|
|
@@ -6058,6 +6459,10 @@ def rebuild_stats_index(
|
|
|
6058
6459
|
protocol_violations=protocol_violations,
|
|
6059
6460
|
acknowledged_protocol_violations=acknowledged,
|
|
6060
6461
|
quarantine_dir=incident,
|
|
6462
|
+
phase_seconds=phase_seconds,
|
|
6463
|
+
traversal=traversal,
|
|
6464
|
+
peak_heap_bytes=peak_heap_bytes,
|
|
6465
|
+
quota_lock_hold_seconds=round(quota_lock_hold, 6),
|
|
6061
6466
|
)
|
|
6062
6467
|
|
|
6063
6468
|
|
|
@@ -6326,19 +6731,14 @@ def _cutover_segment_name(now_utc: dt.datetime) -> str:
|
|
|
6326
6731
|
return f"{_lib_journal.BOOTSTRAP_PREFIX}{ts}.jsonl"
|
|
6327
6732
|
|
|
6328
6733
|
|
|
6329
|
-
def
|
|
6330
|
-
"""
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
if dir_created:
|
|
6338
|
-
try:
|
|
6339
|
-
os.chmod(journal_dir, 0o700)
|
|
6340
|
-
except OSError:
|
|
6341
|
-
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
|
+
"""
|
|
6342
6742
|
encoded = []
|
|
6343
6743
|
for rec in lines:
|
|
6344
6744
|
data = _lib_journal.encode_line(rec)
|
|
@@ -6351,6 +6751,81 @@ def _write_bootstrap_segment(seg_name: str, lines: list) -> int:
|
|
|
6351
6751
|
if blob.count(b"\n") != len(lines):
|
|
6352
6752
|
raise JournalError(
|
|
6353
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
|
|
6354
6829
|
partial = journal_dir / (seg_name + ".partial")
|
|
6355
6830
|
fd = os.open(str(partial), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
6356
6831
|
try:
|
|
@@ -6379,7 +6854,14 @@ def run_cutover(conn, *, now_utc: dt.datetime | None = None) -> "str | None":
|
|
|
6379
6854
|
commit rolls the whole thing back (the legacy DB stays fully usable); the
|
|
6380
6855
|
next open retries idempotently (stable bootstrap ids). A truly empty install
|
|
6381
6856
|
(nothing to export) just stamps the epoch — no bootstrap file. Returns the
|
|
6382
|
-
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."""
|
|
6383
6865
|
if now_utc is None:
|
|
6384
6866
|
now_utc = dt.datetime.now(dt.timezone.utc)
|
|
6385
6867
|
epoch = _cctally_core.STATS_INDEX_EPOCH
|
|
@@ -6401,8 +6883,19 @@ def run_cutover(conn, *, now_utc: dt.datetime | None = None) -> "str | None":
|
|
|
6401
6883
|
conn.commit()
|
|
6402
6884
|
return None
|
|
6403
6885
|
|
|
6404
|
-
|
|
6405
|
-
|
|
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)
|
|
6406
6899
|
|
|
6407
6900
|
for table, rowid in stamp:
|
|
6408
6901
|
conn.execute(
|
|
@@ -6472,17 +6965,24 @@ def _resolve_claude_cutover_identity(claude_json_path=None) -> str:
|
|
|
6472
6965
|
def find_accounts_cutover_op():
|
|
6473
6966
|
"""Scan the journal for the canonical cutover op; return its recorded
|
|
6474
6967
|
``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.
|
|
6968
|
+
appended yet. Cheap enough for the one-time transition + the retry check.
|
|
6969
|
+
|
|
6970
|
+
Streams rather than materializing (#496 S4): the previous form built each
|
|
6971
|
+
segment's whole line list before its first-match return, so the early exit
|
|
6972
|
+
could not stop reading inside the containing segment. The None-on-absence
|
|
6973
|
+
contract is UNCHANGED — the cache and conversations migrations depend on it
|
|
6974
|
+
to defer their backfill.
|
|
6975
|
+
"""
|
|
6476
6976
|
for seg in list_segments():
|
|
6477
6977
|
seg_path = _cctally_core.JOURNAL_DIR / seg
|
|
6478
6978
|
try:
|
|
6479
6979
|
size = os.path.getsize(seg_path)
|
|
6480
6980
|
except OSError:
|
|
6481
6981
|
continue
|
|
6482
|
-
for _name, _off, raw in
|
|
6982
|
+
for _name, _off, raw in _iter_segment_lines(seg_path, 0, size):
|
|
6483
6983
|
rec = _lib_journal.decode_line(raw)
|
|
6484
6984
|
if rec is not None and rec.get("id") == CUTOVER_OP_ID:
|
|
6485
|
-
return (rec
|
|
6985
|
+
return _cutover_value_of(rec)
|
|
6486
6986
|
return None
|
|
6487
6987
|
|
|
6488
6988
|
|