cctally 1.92.0 → 1.92.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/README.md +4 -2
- package/bin/_cctally_cache.py +44 -4
- package/bin/_cctally_core.py +56 -7
- package/bin/_cctally_dashboard.py +71 -14
- package/bin/_cctally_dashboard_conversation.py +8 -4
- package/bin/_cctally_db.py +111 -11
- package/bin/_cctally_journal.py +1102 -102
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +2 -2
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +783 -67
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_codex_conversation_query.py +30 -5
- package/bin/_lib_codex_find_projection.py +147 -0
- package/bin/_lib_conversation_dispatch.py +15 -1
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_journal_router.py +234 -0
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +12 -3
- package/dashboard/static/assets/{index-BEzzJtUd.js → index-Dat-mza6.js} +51 -51
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +3 -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:
|
|
@@ -952,11 +951,18 @@ def _write_cursor(conn: sqlite3.Connection, segment: str, offset: int) -> None:
|
|
|
952
951
|
_SEGMENT_READ_CHUNK = 256 * 1024
|
|
953
952
|
|
|
954
953
|
|
|
955
|
-
def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
954
|
+
def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
|
|
956
955
|
"""Stream `(basename, absolute-offset, raw-line-without-newline)` for every
|
|
957
956
|
complete line in `[lo, hi)`, holding at most one chunk plus one partial line
|
|
958
957
|
in memory. `hi` is a line boundary (a HW snapshot size or an immutable prior
|
|
959
|
-
segment's full size), so no partial trailing line appears.
|
|
958
|
+
segment's full size), so no partial trailing line appears.
|
|
959
|
+
|
|
960
|
+
`on_bytes` receives each chunk exactly as it is read, before any line
|
|
961
|
+
splitting. It exists so a caller can reproduce `journal_prefix_hash` from
|
|
962
|
+
the bytes this pass is already reading (#496 S4 §5.2) rather than re-reading
|
|
963
|
+
the segment; it must therefore see the raw `[lo, hi)` range verbatim,
|
|
964
|
+
including a torn trailing partial line that is never yielded.
|
|
965
|
+
"""
|
|
960
966
|
name = seg_path.name
|
|
961
967
|
with open(seg_path, "rb") as fh:
|
|
962
968
|
fh.seek(lo)
|
|
@@ -968,6 +974,8 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
|
968
974
|
if not data:
|
|
969
975
|
break
|
|
970
976
|
pos += len(data)
|
|
977
|
+
if on_bytes is not None:
|
|
978
|
+
on_bytes(data)
|
|
971
979
|
buf = buf + data if buf else data
|
|
972
980
|
start = 0
|
|
973
981
|
while True:
|
|
@@ -981,14 +989,6 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
|
|
|
981
989
|
buf_at += start
|
|
982
990
|
|
|
983
991
|
|
|
984
|
-
def _read_segment_lines(seg_path, lo: int, hi: int) -> list[tuple[str, int, bytes]]:
|
|
985
|
-
"""Materialized form of :func:`_iter_segment_lines` (see it for the
|
|
986
|
-
contract). Callers that walk a whole range at once should prefer
|
|
987
|
-
:func:`iter_range`; this list form is retained for the ingest cycle, which
|
|
988
|
-
needs the batch as an indexable sequence."""
|
|
989
|
-
return list(_iter_segment_lines(seg_path, lo, hi))
|
|
990
|
-
|
|
991
|
-
|
|
992
992
|
def iter_range(cursor, hw):
|
|
993
993
|
"""Stream `cursor -> HW` across segments in canonical order (spec §5.2.2).
|
|
994
994
|
|
|
@@ -1002,8 +1002,26 @@ def iter_range(cursor, hw):
|
|
|
1002
1002
|
materialized form for the ingest cycle, which genuinely needs the batch as
|
|
1003
1003
|
an indexable sequence (prefix-stop indices address into it).
|
|
1004
1004
|
"""
|
|
1005
|
+
yield from _iter_range_with_segments(cursor, hw, list_segments())
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
|
|
1009
|
+
on_bytes=None):
|
|
1010
|
+
"""`iter_range` over a segment list the CALLER snapshotted (#496 S4 §4).
|
|
1011
|
+
|
|
1012
|
+
`list_segments()` enumerates the journal directory at call time and orders
|
|
1013
|
+
bootstrap segments before observation segments, so a bootstrap segment
|
|
1014
|
+
appearing mid-rebuild would insert ahead of the high-water segment and shift
|
|
1015
|
+
the indices this function addresses by. A rebuild takes ONE snapshot at its
|
|
1016
|
+
pinned high-water and drives every pass from it, so two passes of the same
|
|
1017
|
+
rebuild cannot disagree about the journal's shape.
|
|
1018
|
+
|
|
1019
|
+
`on_segment` is called for EVERY segment in the range, including one this
|
|
1020
|
+
function then skips because it holds no bytes in range: `journal_prefix_hash`
|
|
1021
|
+
frames a zero-byte segment, so a hash accumulator has to be told it exists.
|
|
1022
|
+
`on_bytes` is forwarded to `_iter_segment_lines`.
|
|
1023
|
+
"""
|
|
1005
1024
|
hw_seg, hw_size = hw
|
|
1006
|
-
segments = list_segments()
|
|
1007
1025
|
if hw_seg not in segments:
|
|
1008
1026
|
return
|
|
1009
1027
|
hw_idx = segments.index(hw_seg)
|
|
@@ -1020,9 +1038,11 @@ def iter_range(cursor, hw):
|
|
|
1020
1038
|
seg_path = _cctally_core.JOURNAL_DIR / seg
|
|
1021
1039
|
lo = start_off if idx == start_idx else 0
|
|
1022
1040
|
hi = hw_size if idx == hw_idx else os.path.getsize(seg_path)
|
|
1041
|
+
if on_segment is not None:
|
|
1042
|
+
on_segment(seg)
|
|
1023
1043
|
if lo >= hi:
|
|
1024
1044
|
continue
|
|
1025
|
-
yield from _iter_segment_lines(seg_path, lo, hi)
|
|
1045
|
+
yield from _iter_segment_lines(seg_path, lo, hi, on_bytes=on_bytes)
|
|
1026
1046
|
|
|
1027
1047
|
|
|
1028
1048
|
def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
|
|
@@ -1057,20 +1077,28 @@ def journal_prefix_hash(high_water) -> "str | None":
|
|
|
1057
1077
|
return "sha256:" + digest.hexdigest()
|
|
1058
1078
|
|
|
1059
1079
|
|
|
1060
|
-
def _capture_protocol_prefix_evidence(
|
|
1061
|
-
|
|
1080
|
+
def _capture_protocol_prefix_evidence(
|
|
1081
|
+
record, prior_high_water, evidence, hasher=None
|
|
1082
|
+
) -> None:
|
|
1083
|
+
"""Capture the actual raw prefix immediately preceding one audit record.
|
|
1084
|
+
|
|
1085
|
+
`hasher` is a `_lib_journal_router.PrefixHashAccumulator` fed by the caller's
|
|
1086
|
+
streaming pass. When supplied, the digest comes from bytes that pass has
|
|
1087
|
+
already read; otherwise `journal_prefix_hash` re-reads the whole prefix from
|
|
1088
|
+
disk, which is what the streaming callers exist to avoid (#496 S4 §5.2). The
|
|
1089
|
+
two produce the identical durable digest.
|
|
1090
|
+
"""
|
|
1062
1091
|
if (
|
|
1063
1092
|
record.get("t") == "op"
|
|
1064
1093
|
and isinstance(record.get("payload"), dict)
|
|
1065
1094
|
and record["payload"].get("kind")
|
|
1066
1095
|
== _lib_journal._PROTOCOL_RESOLUTION_KIND
|
|
1067
1096
|
):
|
|
1068
|
-
|
|
1069
|
-
(
|
|
1070
|
-
|
|
1071
|
-
journal_prefix_hash(prior_high_water),
|
|
1072
|
-
)
|
|
1097
|
+
digest = (
|
|
1098
|
+
hasher.digest_at(prior_high_water) if hasher is not None
|
|
1099
|
+
else journal_prefix_hash(prior_high_water)
|
|
1073
1100
|
)
|
|
1101
|
+
evidence.append((prior_high_water, digest))
|
|
1074
1102
|
|
|
1075
1103
|
|
|
1076
1104
|
# --------------------------------------------------------------------------
|
|
@@ -2017,6 +2045,8 @@ def _derive_account_last_seen(conn, records) -> None:
|
|
|
2017
2045
|
prior observe already created (never invents an account row)."""
|
|
2018
2046
|
latest: dict = {}
|
|
2019
2047
|
for rec in records:
|
|
2048
|
+
if rec is None:
|
|
2049
|
+
continue
|
|
2020
2050
|
key = _account_of(rec)
|
|
2021
2051
|
at = rec.get("at")
|
|
2022
2052
|
if not key or not at:
|
|
@@ -2024,6 +2054,17 @@ def _derive_account_last_seen(conn, records) -> None:
|
|
|
2024
2054
|
prev = latest.get(key)
|
|
2025
2055
|
if prev is None or at > prev:
|
|
2026
2056
|
latest[key] = at
|
|
2057
|
+
_apply_account_last_seen(conn, latest)
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def _apply_account_last_seen(conn, latest) -> None:
|
|
2061
|
+
"""Apply a precomputed `{account_key: max_at}` map.
|
|
2062
|
+
|
|
2063
|
+
Split out so the rebuild can accumulate the map during its single streaming
|
|
2064
|
+
pass (#496 S4 §4.2) instead of walking every record again inside the
|
|
2065
|
+
publication transaction. The rebuild's retained list no longer contains
|
|
2066
|
+
observations at all, so calling `_derive_account_last_seen` over it would
|
|
2067
|
+
silently drop every observation's contribution."""
|
|
2027
2068
|
for key, at in latest.items():
|
|
2028
2069
|
conn.execute(
|
|
2029
2070
|
"UPDATE accounts SET last_seen_utc = ? WHERE account_key = ? "
|
|
@@ -3428,6 +3469,10 @@ def _correction_commit_high_water(batch_id, hw=None):
|
|
|
3428
3469
|
selector or by the live metadata row that names it. The earliest matching
|
|
3429
3470
|
commit is the narrowest complete prefix and remains stable even when later
|
|
3430
3471
|
journal bytes or crash-replayed duplicate markers exist.
|
|
3472
|
+
|
|
3473
|
+
Streams rather than materializing (#496 S4): the previous form built the
|
|
3474
|
+
whole prefix through `_read_range` before its first-match return, so a
|
|
3475
|
+
marker in the first segment still paid for every later one.
|
|
3431
3476
|
"""
|
|
3432
3477
|
if not batch_id:
|
|
3433
3478
|
return None
|
|
@@ -3435,7 +3480,7 @@ def _correction_commit_high_water(batch_id, hw=None):
|
|
|
3435
3480
|
hw = journal_high_water()
|
|
3436
3481
|
if hw is None:
|
|
3437
3482
|
return None
|
|
3438
|
-
for segment, offset, raw in
|
|
3483
|
+
for segment, offset, raw in iter_range(None, hw):
|
|
3439
3484
|
record = _lib_journal.decode_line(raw)
|
|
3440
3485
|
if (
|
|
3441
3486
|
record is not None
|
|
@@ -4259,6 +4304,25 @@ class RebuildResult:
|
|
|
4259
4304
|
# batches remain tainted; this is diagnostic/audit state, never validity.
|
|
4260
4305
|
acknowledged_protocol_violations: tuple = ()
|
|
4261
4306
|
quarantine_dir: "pathlib.Path | None" = None
|
|
4307
|
+
# #496 S4 §8.7 — ADDITIVE instrumentation. Names, units and pass boundaries
|
|
4308
|
+
# are fixed by the spec so the gate's assertions are unambiguous; adding
|
|
4309
|
+
# them does not bump the rebuild record's `schemaVersion` and no existing
|
|
4310
|
+
# field changes meaning.
|
|
4311
|
+
#: float seconds per phase. Keys: journal_read_decode, cutover_suffix,
|
|
4312
|
+
#: protocol_evidence, effective_selection, quota_cache_leg, stats_fold,
|
|
4313
|
+
#: scratch_validate, publication. The phases are DISJOINT — evidence hashing
|
|
4314
|
+
#: happens inside the read loop and is subtracted from journal_read_decode.
|
|
4315
|
+
phase_seconds: dict = field(default_factory=dict)
|
|
4316
|
+
#: per named pass, `{lines, bytes, decodes}`. Passes: stats_prefix (the
|
|
4317
|
+
#: router), cutover_suffix (zero unless the §5.1 fallback ran),
|
|
4318
|
+
#: protocol_evidence (`bytes` hashed and `lines` digests computed, both zero
|
|
4319
|
+
#: on a journal with no resolution op), quota_replay (the in-leg decode,
|
|
4320
|
+
#: where `bytes` is the retained byte total and `lines` equals `decodes`).
|
|
4321
|
+
traversal: dict = field(default_factory=dict)
|
|
4322
|
+
#: `tracemalloc` peak over the pre-publication window; 0 when not tracing.
|
|
4323
|
+
peak_heap_bytes: int = 0
|
|
4324
|
+
#: cache writer flock acquisition to release, in seconds.
|
|
4325
|
+
quota_lock_hold_seconds: float = 0.0
|
|
4262
4326
|
|
|
4263
4327
|
|
|
4264
4328
|
def _remove_db_sidecars_strict(path) -> None:
|
|
@@ -4315,6 +4379,7 @@ _REBUILD_REQUIRED_TABLES = frozenset(
|
|
|
4315
4379
|
"schema_migrations",
|
|
4316
4380
|
"schema_migrations_skipped",
|
|
4317
4381
|
"stats_open_fixups",
|
|
4382
|
+
"stats_publication_stamp",
|
|
4318
4383
|
"week_reset_events",
|
|
4319
4384
|
"weekly_cost_snapshots",
|
|
4320
4385
|
"weekly_credit_floors",
|
|
@@ -4363,7 +4428,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
|
|
|
4363
4428
|
# omitted column, constraint, partial predicate, or index definition. An epoch
|
|
4364
4429
|
# schema change must update this contract alongside STATS_INDEX_EPOCH.
|
|
4365
4430
|
_REBUILD_SCHEMA_FINGERPRINT = (
|
|
4366
|
-
"
|
|
4431
|
+
"7dde5a7995f441558d08b0204136824d6ff7208b221e576c79a76854b76aa178"
|
|
4367
4432
|
)
|
|
4368
4433
|
|
|
4369
4434
|
|
|
@@ -4481,26 +4546,56 @@ def stats_index_matches_journal_prefix(
|
|
|
4481
4546
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4482
4547
|
try:
|
|
4483
4548
|
_validate_rebuilt_stats_index(conn, high_water)
|
|
4484
|
-
|
|
4549
|
+
# Same streaming router as the rebuild (#496 S4 §7). This function's
|
|
4550
|
+
# only output is a selection compared against
|
|
4551
|
+
# `journal_effective_events`, so it needs the decision records and
|
|
4552
|
+
# nothing else: no observation retention, no quota bytes, no second
|
|
4553
|
+
# from-byte-zero cutover scan. The `None` placeholders keep every
|
|
4554
|
+
# `enumerate` sequence — and therefore every violation fingerprint —
|
|
4555
|
+
# identical to what the rebuild wrote.
|
|
4556
|
+
decoded: list = []
|
|
4485
4557
|
protocol_evidence = []
|
|
4486
4558
|
prior_high_water = None
|
|
4559
|
+
cutover_captured = _CUTOVER_UNSEEN
|
|
4560
|
+
all_segments = list_segments()
|
|
4561
|
+
segments = all_segments
|
|
4562
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
4487
4563
|
if high_water is not None:
|
|
4488
|
-
|
|
4564
|
+
if high_water[0] in segments:
|
|
4565
|
+
segments = segments[:segments.index(high_water[0]) + 1]
|
|
4566
|
+
for segment, offset, raw in _iter_range_with_segments(
|
|
4567
|
+
None, high_water, segments,
|
|
4568
|
+
on_segment=lambda name: hasher.begin_segment(
|
|
4569
|
+
name, prior_high_water),
|
|
4570
|
+
on_bytes=hasher.extend,
|
|
4571
|
+
):
|
|
4489
4572
|
record = _lib_journal.decode_line(raw)
|
|
4490
4573
|
if record is not None:
|
|
4491
4574
|
_capture_protocol_prefix_evidence(
|
|
4492
4575
|
record,
|
|
4493
4576
|
prior_high_water,
|
|
4494
4577
|
protocol_evidence,
|
|
4578
|
+
hasher=hasher,
|
|
4579
|
+
)
|
|
4580
|
+
if (cutover_captured is _CUTOVER_UNSEEN
|
|
4581
|
+
and record.get("id") == CUTOVER_OP_ID):
|
|
4582
|
+
cutover_captured = _cutover_value_of(record)
|
|
4583
|
+
decoded.append(
|
|
4584
|
+
record
|
|
4585
|
+
if record.get("t")
|
|
4586
|
+
in _lib_journal_router.RETAINED_RECORD_TYPES
|
|
4587
|
+
else None
|
|
4495
4588
|
)
|
|
4496
|
-
decoded.append(record)
|
|
4497
4589
|
prior_high_water = (
|
|
4498
4590
|
segment,
|
|
4499
4591
|
offset + len(raw) + 1,
|
|
4500
4592
|
)
|
|
4501
|
-
|
|
4593
|
+
hasher = None
|
|
4594
|
+
cutover_claude = _resolve_cutover_for_rebuild(
|
|
4595
|
+
cutover_captured, high_water, all_segments)
|
|
4502
4596
|
for record in decoded:
|
|
4503
|
-
|
|
4597
|
+
if record is not None:
|
|
4598
|
+
_normalize_legacy_account_stamp(record, cutover_claude)
|
|
4504
4599
|
selection = _lib_journal.resolve_effective_events(
|
|
4505
4600
|
decoded,
|
|
4506
4601
|
protocol_prefix_evidence=protocol_evidence,
|
|
@@ -4633,8 +4728,89 @@ def _utc_iso_now() -> str:
|
|
|
4633
4728
|
).replace("+00:00", "Z")
|
|
4634
4729
|
|
|
4635
4730
|
|
|
4731
|
+
def read_publication_stamp(path):
|
|
4732
|
+
"""Read `stats_publication_stamp` from ``path`` on a fresh read-only conn.
|
|
4733
|
+
|
|
4734
|
+
Never raises. Returns the input `_lib_stats_publish.resolve_stamp` expects:
|
|
4735
|
+
|
|
4736
|
+
- the exception that prevented the read, which resolves INDETERMINATE;
|
|
4737
|
+
- `None` when the read succeeded and named no publication;
|
|
4738
|
+
- the list of row mappings the table held.
|
|
4739
|
+
|
|
4740
|
+
**A destination whose `user_version` is not this binary's
|
|
4741
|
+
`STATS_INDEX_EPOCH` returns `None`, and that is a proof rather than a
|
|
4742
|
+
convenience.** Every scratch eligible for publication has already been
|
|
4743
|
+
validated at `STATS_INDEX_EPOCH`, and the publication transaction stamps
|
|
4744
|
+
that epoch onto the destination in the same commit as the stamp row, so a
|
|
4745
|
+
committed publication always leaves the destination at this epoch. A
|
|
4746
|
+
destination at any other epoch therefore proves this publication did not
|
|
4747
|
+
commit — which is exactly what makes an interrupted upgrade rebuild
|
|
4748
|
+
recoverable: the epoch-1007 index it was publishing into has no stamp
|
|
4749
|
+
table at all, and reading that absence as INDETERMINATE would condemn a
|
|
4750
|
+
perfectly healthy index instead of discarding a marker that never became
|
|
4751
|
+
live. When the epochs differ in the other direction, a newer binary reading
|
|
4752
|
+
an older destination, the same conclusion holds and the epoch gate refuses
|
|
4753
|
+
the destination anyway.
|
|
4754
|
+
"""
|
|
4755
|
+
try:
|
|
4756
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4757
|
+
try:
|
|
4758
|
+
epoch = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
4759
|
+
if epoch != _cctally_core.STATS_INDEX_EPOCH:
|
|
4760
|
+
return None
|
|
4761
|
+
rows = conn.execute(
|
|
4762
|
+
"SELECT record_path FROM stats_publication_stamp"
|
|
4763
|
+
).fetchall()
|
|
4764
|
+
finally:
|
|
4765
|
+
conn.close()
|
|
4766
|
+
except BaseException as exc:
|
|
4767
|
+
return exc
|
|
4768
|
+
if not rows:
|
|
4769
|
+
return None
|
|
4770
|
+
return [{"record_path": row[0]} for row in rows]
|
|
4771
|
+
|
|
4772
|
+
|
|
4773
|
+
def in_place_publication_proven_predecessor(destination, state) -> bool:
|
|
4774
|
+
"""Whether a PENDING in-place marker's publication provably never committed.
|
|
4775
|
+
|
|
4776
|
+
True only on `PROVEN_PREDECESSOR`: the stamp was read and does not name
|
|
4777
|
+
this marker's record, so the live bytes are the untouched predecessor and
|
|
4778
|
+
the marker may be discarded. `MATCH` means the verdict is still owed, and
|
|
4779
|
+
`INDETERMINATE` fails closed — it must never collapse into either of the
|
|
4780
|
+
other two states, because discarding on an unreadable stamp is exactly the
|
|
4781
|
+
silent-acceptance class the publication transaction exists to close.
|
|
4782
|
+
|
|
4783
|
+
Public because both discriminator sites consume it: the opener's
|
|
4784
|
+
`_cctally_store._pending_stats_publication_never_replaced` and this
|
|
4785
|
+
module's `_settle_prior_publication_verdict`.
|
|
4786
|
+
"""
|
|
4787
|
+
import _lib_stats_publish as sp
|
|
4788
|
+
|
|
4789
|
+
record_path = state.get("recordPath")
|
|
4790
|
+
verdict = sp.resolve_stamp(
|
|
4791
|
+
read_publication_stamp(destination),
|
|
4792
|
+
record_path if isinstance(record_path, str) else None,
|
|
4793
|
+
)
|
|
4794
|
+
return verdict == sp.STAMP_PROVEN_PREDECESSOR
|
|
4795
|
+
|
|
4796
|
+
|
|
4797
|
+
def _stamp_identity_error(path, expected_record_path: str) -> "str | None":
|
|
4798
|
+
import _lib_stats_publish as sp
|
|
4799
|
+
|
|
4800
|
+
verdict = sp.resolve_stamp(
|
|
4801
|
+
read_publication_stamp(path), expected_record_path
|
|
4802
|
+
)
|
|
4803
|
+
if verdict == sp.STAMP_MATCH:
|
|
4804
|
+
return None
|
|
4805
|
+
return (
|
|
4806
|
+
"published stats index does not carry this publication's stamp "
|
|
4807
|
+
f"({verdict}); expected {expected_record_path}"
|
|
4808
|
+
)
|
|
4809
|
+
|
|
4810
|
+
|
|
4636
4811
|
def validate_published_stats_index(
|
|
4637
|
-
path, high_water: "tuple[str, int] | None"
|
|
4812
|
+
path, high_water: "tuple[str, int] | None", *,
|
|
4813
|
+
expected_record_path: "str | None" = None,
|
|
4638
4814
|
) -> "str | None":
|
|
4639
4815
|
"""Validate an index on a FRESH read-only connection (#496 S1 F1).
|
|
4640
4816
|
|
|
@@ -4644,6 +4820,16 @@ def validate_published_stats_index(
|
|
|
4644
4820
|
mechanism is already proven by `stats_index_matches_journal_prefix`, which
|
|
4645
4821
|
runs the same check on the same kind of connection.
|
|
4646
4822
|
|
|
4823
|
+
``expected_record_path`` names the publication whose bytes these are meant
|
|
4824
|
+
to be, and the stamp row is verified against it (#496 S3 §5): the
|
|
4825
|
+
high-water alone answers "is this index a correct materialization of the
|
|
4826
|
+
journal prefix", not "is this index the generation THIS publication just
|
|
4827
|
+
installed". The in-place publisher passes it because it has just written
|
|
4828
|
+
that identity inside the publication transaction. The opener deliberately
|
|
4829
|
+
does NOT: it has already resolved the stamp as a three-state question, and
|
|
4830
|
+
folding that into a boolean validation error would turn an INDETERMINATE
|
|
4831
|
+
read into a settled `failed` verdict.
|
|
4832
|
+
|
|
4647
4833
|
Public because `stats_open_guarded` runs exactly this check when it
|
|
4648
4834
|
resolves a pending publication marker.
|
|
4649
4835
|
"""
|
|
@@ -4655,6 +4841,8 @@ def validate_published_stats_index(
|
|
|
4655
4841
|
conn.close()
|
|
4656
4842
|
except BaseException as exc:
|
|
4657
4843
|
return f"{type(exc).__name__}: {exc}"[:500]
|
|
4844
|
+
if expected_record_path is not None:
|
|
4845
|
+
return _stamp_identity_error(path, expected_record_path)
|
|
4658
4846
|
return None
|
|
4659
4847
|
|
|
4660
4848
|
|
|
@@ -4665,7 +4853,7 @@ def _publication_marker_path(destination) -> pathlib.Path:
|
|
|
4665
4853
|
def _write_publication_marker(
|
|
4666
4854
|
destination, record_path, *, started_at: str, scratch_path,
|
|
4667
4855
|
status: str = "pending", error: "str | None" = None,
|
|
4668
|
-
prior: "dict | None" = None,
|
|
4856
|
+
prior: "dict | None" = None, mechanism: str = "replace",
|
|
4669
4857
|
) -> None:
|
|
4670
4858
|
"""Publish the durable marker a later opener honours (#496 S1 F1).
|
|
4671
4859
|
|
|
@@ -4681,6 +4869,14 @@ def _write_publication_marker(
|
|
|
4681
4869
|
(Within one process the claim is weaker; see
|
|
4682
4870
|
`_cctally_store._pending_stats_publication_never_replaced`.)
|
|
4683
4871
|
|
|
4872
|
+
`mechanism` states which publication protocol this marker belongs to, so
|
|
4873
|
+
the opener SELECTS its discriminator instead of inferring one (#496 S3 §5).
|
|
4874
|
+
`replace` keeps the `scratchPath` proxy above, which remains exactly
|
|
4875
|
+
correct there. `in_place` attaches the scratch read-only and leaves it on
|
|
4876
|
+
disk whether the transaction committed or rolled back, so the proxy
|
|
4877
|
+
inverts and the publication's own `stats_publication_stamp` row answers
|
|
4878
|
+
instead.
|
|
4879
|
+
|
|
4684
4880
|
`priorFailure` carries a settled verdict this publication is about to
|
|
4685
4881
|
overwrite, so a crash before `os.replace` cannot discard it — see
|
|
4686
4882
|
`_settle_prior_publication_verdict`.
|
|
@@ -4693,6 +4889,7 @@ def _write_publication_marker(
|
|
|
4693
4889
|
"recordPath": str(record_path),
|
|
4694
4890
|
"startedAtUtc": started_at,
|
|
4695
4891
|
"scratchPath": str(scratch_path),
|
|
4892
|
+
"mechanism": mechanism,
|
|
4696
4893
|
}
|
|
4697
4894
|
if error is not None:
|
|
4698
4895
|
payload["error"] = error
|
|
@@ -4719,6 +4916,30 @@ def _read_publication_marker(destination) -> "dict | None":
|
|
|
4719
4916
|
return state if isinstance(state, dict) else {}
|
|
4720
4917
|
|
|
4721
4918
|
|
|
4919
|
+
def _pending_publication_owes_nothing(destination, state) -> bool:
|
|
4920
|
+
"""Whether a PENDING marker's own publication never reached the live bytes.
|
|
4921
|
+
|
|
4922
|
+
The marker STATES its mechanism, so the discriminator is selected rather
|
|
4923
|
+
than inferred (#496 S3 §5). `replace` keeps the `scratchPath` proxy, which
|
|
4924
|
+
is exactly correct there because across processes `os.replace` is the only
|
|
4925
|
+
thing that consumes a scratch. `in_place` attaches its scratch read-only
|
|
4926
|
+
and leaves it on disk whether the transaction committed or rolled back, so
|
|
4927
|
+
that proxy INVERTS and the publication's own stamp answers instead. Neither
|
|
4928
|
+
is generalized over the other.
|
|
4929
|
+
|
|
4930
|
+
A marker written before the mechanism field existed reads as `replace`,
|
|
4931
|
+
which is what those binaries did.
|
|
4932
|
+
"""
|
|
4933
|
+
if str(state.get("mechanism") or "replace") == "in_place":
|
|
4934
|
+
return in_place_publication_proven_predecessor(destination, state)
|
|
4935
|
+
scratch = state.get("scratchPath")
|
|
4936
|
+
return (
|
|
4937
|
+
isinstance(scratch, str)
|
|
4938
|
+
and bool(scratch)
|
|
4939
|
+
and pathlib.Path(scratch).exists()
|
|
4940
|
+
)
|
|
4941
|
+
|
|
4942
|
+
|
|
4722
4943
|
def _settle_prior_publication_verdict(destination) -> "dict | None":
|
|
4723
4944
|
"""Settle the verdict a PREVIOUS publication still owes, before this one
|
|
4724
4945
|
overwrites the single marker slot (#496 S1 F1).
|
|
@@ -4738,15 +4959,15 @@ def _settle_prior_publication_verdict(destination) -> "dict | None":
|
|
|
4738
4959
|
when nothing is owed:
|
|
4739
4960
|
|
|
4740
4961
|
- a `failed` marker is already settled and is carried verbatim;
|
|
4741
|
-
- a `pending` marker whose own
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
the
|
|
4749
|
-
|
|
4962
|
+
- a `pending` marker whose own publication provably never became live owes
|
|
4963
|
+
nothing about the live bytes of its own — but it may still be CARRYING an
|
|
4964
|
+
older run's verdict, which is passed through so a third consecutive
|
|
4965
|
+
crashed run cannot drop it;
|
|
4966
|
+
- any other `pending` marker is settled HERE, by validating the destination
|
|
4967
|
+
against its record's pinned high-water — the same check the opener would
|
|
4968
|
+
have run. Success clears it; failure is written to both the marker and
|
|
4969
|
+
the record before this run touches the destination, and is then carried
|
|
4970
|
+
forward.
|
|
4750
4971
|
|
|
4751
4972
|
A marker that cannot be judged (no record, or no pinned high-water) is left
|
|
4752
4973
|
to the opener's existing discard policy rather than wedging the rebuild.
|
|
@@ -4761,8 +4982,7 @@ def _settle_prior_publication_verdict(destination) -> "dict | None":
|
|
|
4761
4982
|
return state
|
|
4762
4983
|
if status != "pending":
|
|
4763
4984
|
return None
|
|
4764
|
-
|
|
4765
|
-
if isinstance(scratch, str) and scratch and pathlib.Path(scratch).exists():
|
|
4985
|
+
if _pending_publication_owes_nothing(destination, state):
|
|
4766
4986
|
# This marker owes nothing itself, but dropping what it carries would
|
|
4767
4987
|
# lose an older run's verdict once a third run crashes the same way.
|
|
4768
4988
|
carried = state.get("priorFailure")
|
|
@@ -4802,12 +5022,30 @@ def _settle_prior_publication_verdict(destination) -> "dict | None":
|
|
|
4802
5022
|
_write_rebuild_record(record_path, record)
|
|
4803
5023
|
except OSError:
|
|
4804
5024
|
pass
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
5025
|
+
# #496 exists partly because corruption gets MISATTRIBUTED, so the wording
|
|
5026
|
+
# must not accuse an earlier publication of failing a check that never ran.
|
|
5027
|
+
# A destination that cannot be read fails this validation for a reason that
|
|
5028
|
+
# is not evidence about the publication: the stamp read is INDETERMINATE
|
|
5029
|
+
# and the high-water check then trips on the unrelated damage.
|
|
5030
|
+
unreadable = _cctally_db._is_sqlite_corruption_error(error) or error.startswith(
|
|
5031
|
+
("DatabaseError:", "OperationalError:", "InterfaceError:", "sqlite3.")
|
|
4810
5032
|
)
|
|
5033
|
+
if unreadable:
|
|
5034
|
+
print(
|
|
5035
|
+
"[stats] an earlier stats.db publication was interrupted before it "
|
|
5036
|
+
"could validate what it published, and that check could not be "
|
|
5037
|
+
f"completed because the index could not be read: {error}. The read "
|
|
5038
|
+
"failure is not evidence about that publication. Rebuild record: "
|
|
5039
|
+
f"{record_path}.",
|
|
5040
|
+
file=sys.stderr,
|
|
5041
|
+
)
|
|
5042
|
+
else:
|
|
5043
|
+
print(
|
|
5044
|
+
"[stats] an earlier stats.db publication was interrupted before it "
|
|
5045
|
+
f"could validate what it published, and it FAILED that check: "
|
|
5046
|
+
f"{error}. Rebuild record: {record_path}.",
|
|
5047
|
+
file=sys.stderr,
|
|
5048
|
+
)
|
|
4811
5049
|
return settled
|
|
4812
5050
|
|
|
4813
5051
|
|
|
@@ -4969,6 +5207,483 @@ def _preserve_stats_family_for_cutover(
|
|
|
4969
5207
|
return incident
|
|
4970
5208
|
|
|
4971
5209
|
|
|
5210
|
+
#: Set on the exception a failed in-place publication raises, so the caller can
|
|
5211
|
+
#: read the phase it had reached. "The transaction raised" is not a safe
|
|
5212
|
+
#: discriminator: a failure before the commit can roll back while a failure
|
|
5213
|
+
#: after it cannot, and a commit-time I/O error leaves the outcome unknown.
|
|
5214
|
+
_PUBLICATION_PHASE_ATTR = "_cctally_publication_phase"
|
|
5215
|
+
|
|
5216
|
+
|
|
5217
|
+
def publication_phase_of(exc) -> "str | None":
|
|
5218
|
+
"""The publication phase ``exc`` was raised in, or None when unrecorded."""
|
|
5219
|
+
return getattr(exc, _PUBLICATION_PHASE_ATTR, None)
|
|
5220
|
+
|
|
5221
|
+
|
|
5222
|
+
def _open_publication_connection(destination) -> sqlite3.Connection:
|
|
5223
|
+
"""Open the LIVE destination for an in-place publish.
|
|
5224
|
+
|
|
5225
|
+
`stats_open_guarded` skips its own flock when the caller already holds
|
|
5226
|
+
stats maintenance, which every production trigger does. Interrupted-rebuild
|
|
5227
|
+
recovery is suppressed because this run's own `.rebuilding-*` scratch is on
|
|
5228
|
+
disk right now and is not an interruption to recover from.
|
|
5229
|
+
|
|
5230
|
+
`stats_open_guarded` does NOT apply connection policy — `open_db` does that
|
|
5231
|
+
separately — so the busy timeout, journal mode and WAL size limit are
|
|
5232
|
+
applied here rather than assumed.
|
|
5233
|
+
|
|
5234
|
+
The connection is opened with `uri=True` because the publisher ATTACHes the
|
|
5235
|
+
scratch through a `file:...?mode=ro` URI. SQLite honours a URI filename in
|
|
5236
|
+
`ATTACH` only when the main connection carries `SQLITE_OPEN_URI`, or when
|
|
5237
|
+
the library happens to be built with `SQLITE_USE_URI`. Relying on the
|
|
5238
|
+
latter would make the read-only attach ambient rather than guaranteed.
|
|
5239
|
+
"""
|
|
5240
|
+
import _cctally_store
|
|
5241
|
+
|
|
5242
|
+
conn = _cctally_store.stats_open_guarded(
|
|
5243
|
+
pathlib.Path(destination),
|
|
5244
|
+
connect=lambda path: sqlite3.connect(
|
|
5245
|
+
pathlib.Path(path).resolve().as_uri(), uri=True
|
|
5246
|
+
),
|
|
5247
|
+
recover_interruptions=False,
|
|
5248
|
+
)
|
|
5249
|
+
try:
|
|
5250
|
+
_cctally_store.apply_policy(conn, "stats")
|
|
5251
|
+
except BaseException:
|
|
5252
|
+
try:
|
|
5253
|
+
conn.close()
|
|
5254
|
+
except Exception:
|
|
5255
|
+
pass
|
|
5256
|
+
raise
|
|
5257
|
+
return conn
|
|
5258
|
+
|
|
5259
|
+
|
|
5260
|
+
def _carry_sqlite_sequence(conn: sqlite3.Connection) -> None:
|
|
5261
|
+
"""Install the scratch's AUTOINCREMENT watermarks, not the copy's.
|
|
5262
|
+
|
|
5263
|
+
A table-by-table row copy sets each counter to `max(rowid)`, whereas
|
|
5264
|
+
`os.replace` publishes the scratch's `sqlite_sequence` verbatim. Measured on
|
|
5265
|
+
SQLite 3.53.4: a scratch with `max(id)=6` and a counter of 10 — the shape
|
|
5266
|
+
produced whenever the fold inserts rows and later deletes them, as the
|
|
5267
|
+
`five_hour_block_close` fold's exact-child DELETE/INSERT does — published a
|
|
5268
|
+
counter of 6, and the next insert took id 7, an id a deleted row had
|
|
5269
|
+
already used.
|
|
5270
|
+
|
|
5271
|
+
Delete-then-insert rather than update, because `DROP TABLE` removes the
|
|
5272
|
+
table's `sqlite_sequence` row and `CREATE TABLE` does not put one back, so
|
|
5273
|
+
an UPDATE has nothing of its own to act on. Measured on SQLite 3.53.4, a
|
|
5274
|
+
zero-row `INSERT ... SELECT` does create the row with seq 0 — so on that
|
|
5275
|
+
version an UPDATE would in fact land — but that is an undocumented
|
|
5276
|
+
implementation detail rather than a contract, and silently losing an
|
|
5277
|
+
AUTOINCREMENT watermark hands out an id a deleted row already used.
|
|
5278
|
+
"""
|
|
5279
|
+
present = conn.execute(
|
|
5280
|
+
"SELECT (SELECT 1 FROM src.sqlite_schema WHERE type = 'table' "
|
|
5281
|
+
"AND name = 'sqlite_sequence'), "
|
|
5282
|
+
"(SELECT 1 FROM main.sqlite_schema WHERE type = 'table' "
|
|
5283
|
+
"AND name = 'sqlite_sequence')"
|
|
5284
|
+
).fetchone()
|
|
5285
|
+
if present is None or present[0] is None or present[1] is None:
|
|
5286
|
+
return
|
|
5287
|
+
rows = conn.execute("SELECT name, seq FROM src.sqlite_sequence").fetchall()
|
|
5288
|
+
for name, seq in rows:
|
|
5289
|
+
conn.execute("DELETE FROM main.sqlite_sequence WHERE name = ?", (name,))
|
|
5290
|
+
conn.execute(
|
|
5291
|
+
"INSERT INTO main.sqlite_sequence (name, seq) VALUES (?, ?)",
|
|
5292
|
+
(name, seq),
|
|
5293
|
+
)
|
|
5294
|
+
|
|
5295
|
+
|
|
5296
|
+
def _publish_generation_in_place(
|
|
5297
|
+
conn: sqlite3.Connection, scratch, *, record_path, started_at: str
|
|
5298
|
+
) -> str:
|
|
5299
|
+
"""Install the validated scratch's generation into the LIVE database.
|
|
5300
|
+
|
|
5301
|
+
Returns the terminating phase. The whole swap is ONE `BEGIN IMMEDIATE`, so
|
|
5302
|
+
a reader inside a transaction keeps the generation it opened on, an
|
|
5303
|
+
abandoned attempt leaves the prior generation live and sound, and
|
|
5304
|
+
`PRAGMA user_version` flips atomically at the commit.
|
|
5305
|
+
"""
|
|
5306
|
+
import _lib_stats_publish as sp
|
|
5307
|
+
|
|
5308
|
+
if int(conn.execute("PRAGMA foreign_keys").fetchone()[0]) != 0:
|
|
5309
|
+
# `apply_policy` never enables foreign keys and the schema documents
|
|
5310
|
+
# them as enforcement-off, so the derived-FK seam for
|
|
5311
|
+
# `five_hour_milestones.block_id` is a fold ordering contract rather
|
|
5312
|
+
# than an enforced constraint. Assert that rather than depend on it
|
|
5313
|
+
# silently, so a future change that turns them on fails loudly here.
|
|
5314
|
+
raise JournalError(
|
|
5315
|
+
"stats publication requires foreign_keys=0; the schema's derived-FK "
|
|
5316
|
+
"seam is a fold ordering contract, not an enforced constraint"
|
|
5317
|
+
)
|
|
5318
|
+
resolved = pathlib.Path(scratch).resolve()
|
|
5319
|
+
conn.execute("ATTACH DATABASE ? AS src", (resolved.as_uri() + "?mode=ro",))
|
|
5320
|
+
attached = conn.execute(
|
|
5321
|
+
"SELECT file FROM pragma_database_list WHERE name = 'src'"
|
|
5322
|
+
).fetchone()
|
|
5323
|
+
if attached is None or pathlib.Path(str(attached[0])) != resolved:
|
|
5324
|
+
# A connection without SQLITE_OPEN_URI treats the URI as a literal
|
|
5325
|
+
# filename and silently attaches a new, EMPTY database under that name.
|
|
5326
|
+
# Publishing from it would install an empty generation, so the identity
|
|
5327
|
+
# of what was attached is checked rather than assumed.
|
|
5328
|
+
try:
|
|
5329
|
+
conn.execute("DETACH DATABASE src")
|
|
5330
|
+
except Exception:
|
|
5331
|
+
pass
|
|
5332
|
+
raise JournalError(
|
|
5333
|
+
"stats publication attached the wrong file as its scratch: "
|
|
5334
|
+
f"expected {resolved}, got {attached[0] if attached else '<none>'}"
|
|
5335
|
+
)
|
|
5336
|
+
phase = sp.PRE_COMMIT
|
|
5337
|
+
try:
|
|
5338
|
+
try:
|
|
5339
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
5340
|
+
# Both schemas are read INSIDE the transaction, so the drop list
|
|
5341
|
+
# describes the generation actually being retired.
|
|
5342
|
+
src_objects = conn.execute(
|
|
5343
|
+
"SELECT type, name, sql FROM src.sqlite_schema"
|
|
5344
|
+
).fetchall()
|
|
5345
|
+
dest_objects = conn.execute(
|
|
5346
|
+
"SELECT type, name, sql FROM main.sqlite_schema"
|
|
5347
|
+
).fetchall()
|
|
5348
|
+
plan = sp.plan_generation_swap(dest_objects, src_objects)
|
|
5349
|
+
if plan.rejected:
|
|
5350
|
+
raise JournalError(
|
|
5351
|
+
"stats publication cannot copy unsupported object(s): "
|
|
5352
|
+
+ ", ".join(plan.rejected)
|
|
5353
|
+
)
|
|
5354
|
+
for statement in plan.drop_statements:
|
|
5355
|
+
conn.execute(statement)
|
|
5356
|
+
for statement in plan.create_table_statements:
|
|
5357
|
+
conn.execute(statement)
|
|
5358
|
+
for name in plan.copy_tables:
|
|
5359
|
+
conn.execute(
|
|
5360
|
+
f'INSERT INTO main."{name}" SELECT * FROM src."{name}"'
|
|
5361
|
+
)
|
|
5362
|
+
for statement in plan.create_index_statements:
|
|
5363
|
+
conn.execute(statement)
|
|
5364
|
+
_carry_sqlite_sequence(conn)
|
|
5365
|
+
epoch = int(conn.execute("PRAGMA src.user_version").fetchone()[0])
|
|
5366
|
+
if epoch != _cctally_core.STATS_INDEX_EPOCH:
|
|
5367
|
+
# `read_publication_stamp`'s entire short-circuit rests on the
|
|
5368
|
+
# claim that a committed publication always leaves the
|
|
5369
|
+
# destination at THIS binary's epoch. Upstream validation
|
|
5370
|
+
# already guarantees the scratch carries it; asserting it here
|
|
5371
|
+
# costs nothing and turns the argument into an invariant.
|
|
5372
|
+
raise JournalError(
|
|
5373
|
+
"stats publication refuses to stamp a scratch at index "
|
|
5374
|
+
f"epoch {epoch}; this binary builds "
|
|
5375
|
+
f"{_cctally_core.STATS_INDEX_EPOCH}"
|
|
5376
|
+
)
|
|
5377
|
+
conn.execute(f"PRAGMA main.user_version={epoch:d}")
|
|
5378
|
+
# The publication's own identity, committed atomically with the
|
|
5379
|
+
# content and the epoch it describes (#496 S3 §5).
|
|
5380
|
+
conn.execute("DELETE FROM main.stats_publication_stamp")
|
|
5381
|
+
conn.execute(
|
|
5382
|
+
"INSERT INTO main.stats_publication_stamp "
|
|
5383
|
+
"(record_path, started_at_utc, stamped_at_utc) VALUES (?, ?, ?)",
|
|
5384
|
+
(str(record_path), started_at, _utc_iso_now()),
|
|
5385
|
+
)
|
|
5386
|
+
phase = sp.COMMIT_UNKNOWN
|
|
5387
|
+
conn.commit()
|
|
5388
|
+
phase = sp.COMMITTED
|
|
5389
|
+
_stats_rebuild_test_pause("publication_after_commit_before_detach")
|
|
5390
|
+
except BaseException as exc:
|
|
5391
|
+
if phase == sp.PRE_COMMIT:
|
|
5392
|
+
try:
|
|
5393
|
+
conn.rollback()
|
|
5394
|
+
except Exception:
|
|
5395
|
+
pass
|
|
5396
|
+
try:
|
|
5397
|
+
setattr(exc, _PUBLICATION_PHASE_ATTR, phase)
|
|
5398
|
+
except Exception: # pragma: no cover — some exceptions are frozen
|
|
5399
|
+
pass
|
|
5400
|
+
raise
|
|
5401
|
+
finally:
|
|
5402
|
+
# DETACH cannot run inside a transaction, so this is best-effort: a
|
|
5403
|
+
# failure that left one open is already being raised.
|
|
5404
|
+
try:
|
|
5405
|
+
conn.execute("DETACH DATABASE src")
|
|
5406
|
+
except Exception:
|
|
5407
|
+
pass
|
|
5408
|
+
return phase
|
|
5409
|
+
|
|
5410
|
+
|
|
5411
|
+
def _checkpoint_after_publication(conn: sqlite3.Connection) -> str:
|
|
5412
|
+
"""Drain the WAL after a committed in-place publish — BEST EFFORT.
|
|
5413
|
+
|
|
5414
|
+
`wal_checkpoint(TRUNCATE)` returns a busy ROW rather than raising, and this
|
|
5415
|
+
repository has measured it taking about 16 seconds against a 15-second
|
|
5416
|
+
`busy_timeout` under a pinned reader. Its result is recorded and never
|
|
5417
|
+
interpreted as a transaction failure, and it is never a reason to fall back
|
|
5418
|
+
after a commit.
|
|
5419
|
+
"""
|
|
5420
|
+
try:
|
|
5421
|
+
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
5422
|
+
except sqlite3.Error as exc:
|
|
5423
|
+
return f"error:{type(exc).__name__}"
|
|
5424
|
+
if row is None:
|
|
5425
|
+
return "unknown"
|
|
5426
|
+
return "checkpointed" if int(row[0]) == 0 else "busy"
|
|
5427
|
+
|
|
5428
|
+
|
|
5429
|
+
def _remove_empty_db_sidecars(path) -> None:
|
|
5430
|
+
"""Remove the sidecars a read-only validation open leaves behind.
|
|
5431
|
+
|
|
5432
|
+
Unlike the physical path, an in-place publish leaves a LEGITIMATE WAL: it
|
|
5433
|
+
belongs to the live database, and deleting a non-empty one would discard
|
|
5434
|
+
committed frames. Only a zero-length WAL is removed — the state a
|
|
5435
|
+
successful TRUNCATE checkpoint and a clean last close leave — so the
|
|
5436
|
+
documented sidecar-free end state still holds without ever risking data.
|
|
5437
|
+
"""
|
|
5438
|
+
path = pathlib.Path(path)
|
|
5439
|
+
wal = pathlib.Path(str(path) + "-wal")
|
|
5440
|
+
try:
|
|
5441
|
+
if wal.stat().st_size:
|
|
5442
|
+
return
|
|
5443
|
+
except OSError:
|
|
5444
|
+
pass
|
|
5445
|
+
for suffix in ("-wal", "-shm"):
|
|
5446
|
+
try:
|
|
5447
|
+
pathlib.Path(str(path) + suffix).unlink()
|
|
5448
|
+
except FileNotFoundError:
|
|
5449
|
+
pass
|
|
5450
|
+
except OSError:
|
|
5451
|
+
return
|
|
5452
|
+
_fsync_dir(path.parent)
|
|
5453
|
+
|
|
5454
|
+
|
|
5455
|
+
#: Probe 2 measured the live WAL peaking at 1.01x the main file across five
|
|
5456
|
+
#: successive in-place publishes, so the projection is that plus a margin.
|
|
5457
|
+
_PUBLICATION_WAL_PROJECTION = 1.05
|
|
5458
|
+
#: Headroom for the rollback journal and freelist churn of an abandoned attempt.
|
|
5459
|
+
_PUBLICATION_ROLLBACK_MARGIN = 0.25
|
|
5460
|
+
|
|
5461
|
+
|
|
5462
|
+
def _free_disk_bytes(directory) -> int:
|
|
5463
|
+
"""Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
|
|
5464
|
+
import _cctally_db
|
|
5465
|
+
|
|
5466
|
+
return _cctally_db._free_disk_bytes(directory)
|
|
5467
|
+
|
|
5468
|
+
|
|
5469
|
+
def _db_family_bytes(path) -> int:
|
|
5470
|
+
total = 0
|
|
5471
|
+
for suffix in ("", "-wal", "-shm"):
|
|
5472
|
+
try:
|
|
5473
|
+
total += pathlib.Path(str(path) + suffix).stat().st_size
|
|
5474
|
+
except OSError:
|
|
5475
|
+
pass
|
|
5476
|
+
return total
|
|
5477
|
+
|
|
5478
|
+
|
|
5479
|
+
def _publication_required_free_bytes(scratch, destination) -> int:
|
|
5480
|
+
"""Conservative free-space floor for publishing ``scratch``.
|
|
5481
|
+
|
|
5482
|
+
The live attempt adds a database-sized WAL while the scratch still exists,
|
|
5483
|
+
and a physical fallback would then add a full quarantine copy of the old
|
|
5484
|
+
family on top of that. All three are counted, because the run cannot know
|
|
5485
|
+
at this point which mechanism it will end up using.
|
|
5486
|
+
"""
|
|
5487
|
+
scratch_bytes = _db_family_bytes(scratch)
|
|
5488
|
+
projected = _PUBLICATION_WAL_PROJECTION + _PUBLICATION_ROLLBACK_MARGIN
|
|
5489
|
+
return int(scratch_bytes * projected) + _db_family_bytes(destination)
|
|
5490
|
+
|
|
5491
|
+
|
|
5492
|
+
def _preflight_publication_space(scratch, destination) -> None:
|
|
5493
|
+
"""Abort before any live mutation when the disk cannot hold the publish.
|
|
5494
|
+
|
|
5495
|
+
Aborting is deliberately NOT a reason to fall back to replacement: a full
|
|
5496
|
+
disk leaves a perfectly good generation live, and physically replacing it
|
|
5497
|
+
is exactly the outcome §12 refuses.
|
|
5498
|
+
"""
|
|
5499
|
+
parent = pathlib.Path(destination).parent
|
|
5500
|
+
needed = _publication_required_free_bytes(scratch, destination)
|
|
5501
|
+
try:
|
|
5502
|
+
free = _free_disk_bytes(parent)
|
|
5503
|
+
except OSError as exc: # pragma: no cover — statvfs failing is exotic
|
|
5504
|
+
raise JournalError(
|
|
5505
|
+
f"could not determine free space on {parent} before publishing "
|
|
5506
|
+
f"the rebuilt stats index: {exc}"
|
|
5507
|
+
) from exc
|
|
5508
|
+
if free < needed:
|
|
5509
|
+
raise JournalError(
|
|
5510
|
+
"publishing the rebuilt stats index needs about "
|
|
5511
|
+
f"{needed / (1024 * 1024):.1f} MB free on {parent}, but only "
|
|
5512
|
+
f"{free / (1024 * 1024):.1f} MB is available. The existing index "
|
|
5513
|
+
"is untouched; free space and retry."
|
|
5514
|
+
)
|
|
5515
|
+
|
|
5516
|
+
|
|
5517
|
+
#: Returned by the in-place publisher when the destination cannot be operated
|
|
5518
|
+
#: on structurally and physical replacement is the sanctioned fallback.
|
|
5519
|
+
_FALL_BACK = object()
|
|
5520
|
+
|
|
5521
|
+
|
|
5522
|
+
def _publish_stats_index_in_place(
|
|
5523
|
+
*, scratch, destination, context, high_water, record, fire_before_swap,
|
|
5524
|
+
prior,
|
|
5525
|
+
):
|
|
5526
|
+
"""Publish transactionally into the live file (#496 S3 §4).
|
|
5527
|
+
|
|
5528
|
+
Returns `None` on success (an in-place publish never preserves, so there is
|
|
5529
|
+
no incident directory), or `_FALL_BACK` when physical replacement is the
|
|
5530
|
+
sanctioned response.
|
|
5531
|
+
"""
|
|
5532
|
+
import _lib_stats_publish as sp
|
|
5533
|
+
|
|
5534
|
+
try:
|
|
5535
|
+
conn = _open_publication_connection(destination)
|
|
5536
|
+
except BaseException as exc:
|
|
5537
|
+
if sp.may_fall_back_to_replacement(exc):
|
|
5538
|
+
print(
|
|
5539
|
+
"[rebuild] the live stats index cannot be opened "
|
|
5540
|
+
f"({exc}); publishing by replacement instead",
|
|
5541
|
+
file=sys.stderr,
|
|
5542
|
+
)
|
|
5543
|
+
return _FALL_BACK
|
|
5544
|
+
raise
|
|
5545
|
+
|
|
5546
|
+
# Readability is not structural health. An integrity failure may consist
|
|
5547
|
+
# only of pages which no sqlite_schema object and no freelist entry names.
|
|
5548
|
+
# The table-by-table in-place swap cannot discover or reclaim such pages,
|
|
5549
|
+
# so publishing into that file would preserve the damage and fail its
|
|
5550
|
+
# post-publication verdict forever. Use the independently validated scratch
|
|
5551
|
+
# as a physical replacement before any live mutation instead.
|
|
5552
|
+
try:
|
|
5553
|
+
destination_integrity = [
|
|
5554
|
+
str(row[0]) for row in conn.execute("PRAGMA integrity_check")
|
|
5555
|
+
]
|
|
5556
|
+
except BaseException as exc:
|
|
5557
|
+
try:
|
|
5558
|
+
conn.close()
|
|
5559
|
+
except Exception:
|
|
5560
|
+
pass
|
|
5561
|
+
if sp.may_fall_back_to_replacement(exc):
|
|
5562
|
+
print(
|
|
5563
|
+
"[rebuild] the live stats index failed its integrity probe "
|
|
5564
|
+
f"({exc}); publishing by replacement instead",
|
|
5565
|
+
file=sys.stderr,
|
|
5566
|
+
)
|
|
5567
|
+
record["inPlaceAttempt"] = {
|
|
5568
|
+
"phase": sp.PRE_COMMIT,
|
|
5569
|
+
"stage": "destination_integrity",
|
|
5570
|
+
"error": f"{type(exc).__name__}: {exc}"[:500],
|
|
5571
|
+
}
|
|
5572
|
+
return _FALL_BACK
|
|
5573
|
+
raise
|
|
5574
|
+
if destination_integrity != ["ok"]:
|
|
5575
|
+
try:
|
|
5576
|
+
conn.close()
|
|
5577
|
+
except Exception:
|
|
5578
|
+
pass
|
|
5579
|
+
print(
|
|
5580
|
+
"[rebuild] the live stats index failed integrity_check; "
|
|
5581
|
+
"publishing by replacement instead",
|
|
5582
|
+
file=sys.stderr,
|
|
5583
|
+
)
|
|
5584
|
+
record["inPlaceAttempt"] = {
|
|
5585
|
+
"phase": sp.PRE_COMMIT,
|
|
5586
|
+
"stage": "destination_integrity",
|
|
5587
|
+
"error": "destination failed integrity_check",
|
|
5588
|
+
}
|
|
5589
|
+
return _FALL_BACK
|
|
5590
|
+
|
|
5591
|
+
started_at = _utc_iso_now()
|
|
5592
|
+
record_path = pathlib.Path(context.record_path)
|
|
5593
|
+
live = dict(record)
|
|
5594
|
+
live.update({
|
|
5595
|
+
"status": "pending",
|
|
5596
|
+
"startedAtUtc": started_at,
|
|
5597
|
+
"completedAtUtc": None,
|
|
5598
|
+
# An in-place publish never preserves. Preservation is a consequence of
|
|
5599
|
+
# destroying a file; `db backup --db stats` is the supported snapshot.
|
|
5600
|
+
"incidentPath": None,
|
|
5601
|
+
"damageShapeTokens": None,
|
|
5602
|
+
"postPublicationValidation": None,
|
|
5603
|
+
"publicationMechanism": "in_place",
|
|
5604
|
+
})
|
|
5605
|
+
try:
|
|
5606
|
+
fire_before_swap()
|
|
5607
|
+
# Phase 1 of the publication transaction: the record and then the
|
|
5608
|
+
# marker, each fsynced, BEFORE any live byte changes.
|
|
5609
|
+
_write_rebuild_record(record_path, live)
|
|
5610
|
+
_stats_rebuild_test_pause("publication_before_marker")
|
|
5611
|
+
_write_publication_marker(
|
|
5612
|
+
destination, record_path, started_at=started_at,
|
|
5613
|
+
scratch_path=scratch, prior=prior, mechanism="in_place",
|
|
5614
|
+
)
|
|
5615
|
+
_stats_rebuild_test_pause("rebuild_before_cutover")
|
|
5616
|
+
_publish_generation_in_place(
|
|
5617
|
+
conn, scratch, record_path=record_path, started_at=started_at,
|
|
5618
|
+
)
|
|
5619
|
+
except BaseException as exc:
|
|
5620
|
+
# Rollback, detach and CLOSE are all mandatory before any fallback: the
|
|
5621
|
+
# drain gate is a whole-system handle scan, and this connection would
|
|
5622
|
+
# either fail it or hollow out the invariant it exists to enforce.
|
|
5623
|
+
try:
|
|
5624
|
+
conn.close()
|
|
5625
|
+
except Exception:
|
|
5626
|
+
pass
|
|
5627
|
+
phase = publication_phase_of(exc)
|
|
5628
|
+
if phase == sp.PRE_COMMIT and sp.may_fall_back_to_replacement(exc):
|
|
5629
|
+
print(
|
|
5630
|
+
"[rebuild] the in-place stats publication rolled back "
|
|
5631
|
+
f"({exc}); publishing by replacement instead",
|
|
5632
|
+
file=sys.stderr,
|
|
5633
|
+
)
|
|
5634
|
+
record["inPlaceAttempt"] = {
|
|
5635
|
+
"phase": phase, "error": f"{type(exc).__name__}: {exc}"[:500],
|
|
5636
|
+
}
|
|
5637
|
+
return _FALL_BACK
|
|
5638
|
+
raise
|
|
5639
|
+
|
|
5640
|
+
checkpoint_outcome = _checkpoint_after_publication(conn)
|
|
5641
|
+
try:
|
|
5642
|
+
conn.close()
|
|
5643
|
+
except Exception:
|
|
5644
|
+
pass
|
|
5645
|
+
_stats_rebuild_test_pause("rebuild_after_publication_replace")
|
|
5646
|
+
|
|
5647
|
+
# Phase 2: validate the bytes that are now live, on a connection that never
|
|
5648
|
+
# saw them being written. The expected publication identity goes with it:
|
|
5649
|
+
# the high-water alone cannot distinguish this generation from an equally
|
|
5650
|
+
# journal-consistent one some other run installed.
|
|
5651
|
+
post_error = validate_published_stats_index(
|
|
5652
|
+
destination, high_water, expected_record_path=str(record_path),
|
|
5653
|
+
)
|
|
5654
|
+
_remove_empty_db_sidecars(destination)
|
|
5655
|
+
|
|
5656
|
+
live["publicationCheckpoint"] = checkpoint_outcome
|
|
5657
|
+
live["postPublicationValidation"] = {
|
|
5658
|
+
"ok": post_error is None, "error": post_error,
|
|
5659
|
+
}
|
|
5660
|
+
live["completedAtUtc"] = _utc_iso_now()
|
|
5661
|
+
live["status"] = "ok" if post_error is None else "failed"
|
|
5662
|
+
_write_rebuild_record(record_path, live)
|
|
5663
|
+
if post_error is not None:
|
|
5664
|
+
# The scratch is deliberately NOT removed here: it is the last
|
|
5665
|
+
# independently validated copy of this generation, and the live bytes
|
|
5666
|
+
# just failed.
|
|
5667
|
+
_write_publication_marker(
|
|
5668
|
+
destination, record_path, started_at=started_at,
|
|
5669
|
+
scratch_path=scratch, status="failed", error=post_error,
|
|
5670
|
+
mechanism="in_place",
|
|
5671
|
+
)
|
|
5672
|
+
raise JournalError(
|
|
5673
|
+
"published stats index failed post-publication validation: "
|
|
5674
|
+
f"{post_error}; rebuild record: {record_path}"
|
|
5675
|
+
)
|
|
5676
|
+
_stats_rebuild_test_pause("publication_after_verdict_before_marker_removal")
|
|
5677
|
+
_remove_publication_marker(destination)
|
|
5678
|
+
# Removal follows verdict settlement. A surviving `.rebuilding-*` family is
|
|
5679
|
+
# classified FIRST by the next opener and would route this healthy index
|
|
5680
|
+
# through interrupted-rebuild recovery.
|
|
5681
|
+
_stats_rebuild_test_pause("publication_before_scratch_removal")
|
|
5682
|
+
_remove_db_family(scratch)
|
|
5683
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
5684
|
+
return None
|
|
5685
|
+
|
|
5686
|
+
|
|
4972
5687
|
def _publish_rebuilt_stats_index(
|
|
4973
5688
|
*,
|
|
4974
5689
|
scratch: pathlib.Path,
|
|
@@ -4979,24 +5694,52 @@ def _publish_rebuilt_stats_index(
|
|
|
4979
5694
|
record: dict,
|
|
4980
5695
|
before_swap=None,
|
|
4981
5696
|
) -> "pathlib.Path | None":
|
|
4982
|
-
"""Publish one validated, closed, sidecar-free scratch index
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
5697
|
+
"""Publish one validated, closed, sidecar-free scratch index.
|
|
5698
|
+
|
|
5699
|
+
In-place transactional publication is the mechanism (#496 S3). Physical
|
|
5700
|
+
replacement is the fallback, taken when the destination cannot be operated
|
|
5701
|
+
on structurally or fails an integrity check. The mechanism is chosen
|
|
5702
|
+
against the destination in front of this run, not by the trigger that
|
|
5703
|
+
reached it: readability alone does not prove that an object-level swap can
|
|
5704
|
+
reclaim every damaged page.
|
|
5705
|
+
|
|
5706
|
+
Publication is a two-phase durable transaction (#496 S1 F1) under either
|
|
5707
|
+
mechanism. A published file carries the current epoch, so `open_db`'s
|
|
5708
|
+
zero-DDL fast path returns it with no validation and a post-publication
|
|
5709
|
+
failure that only RAISED would leave a known-bad index accepted by every
|
|
5710
|
+
later command. The record and the marker are what make the verdict outlive
|
|
5711
|
+
this process.
|
|
4991
5712
|
"""
|
|
4992
5713
|
import _cctally_store
|
|
4993
5714
|
|
|
5715
|
+
# Read-only, and first: a short disk must abort while the destination is
|
|
5716
|
+
# still untouched, not part-way through a publication.
|
|
5717
|
+
_preflight_publication_space(scratch, destination)
|
|
5718
|
+
|
|
4994
5719
|
# A marker already beside the destination may still owe a verdict on bytes
|
|
4995
5720
|
# that are live right now. Settle it BEFORE Phase 1 overwrites the only
|
|
4996
5721
|
# marker slot, while the destination is still exactly what that publication
|
|
4997
5722
|
# left there.
|
|
4998
5723
|
prior = _settle_prior_publication_verdict(destination)
|
|
4999
5724
|
|
|
5725
|
+
# The `before_swap` seam fires ONCE, for either mechanism: it is the
|
|
5726
|
+
# `db rederive` crash seam, and a fallback must not re-enter it.
|
|
5727
|
+
fired = []
|
|
5728
|
+
|
|
5729
|
+
def fire_before_swap() -> None:
|
|
5730
|
+
if before_swap is not None and not fired:
|
|
5731
|
+
fired.append(True)
|
|
5732
|
+
before_swap()
|
|
5733
|
+
|
|
5734
|
+
if pathlib.Path(destination).exists():
|
|
5735
|
+
published = _publish_stats_index_in_place(
|
|
5736
|
+
scratch=scratch, destination=destination, context=context,
|
|
5737
|
+
high_water=high_water, record=record,
|
|
5738
|
+
fire_before_swap=fire_before_swap, prior=prior,
|
|
5739
|
+
)
|
|
5740
|
+
if published is not _FALL_BACK:
|
|
5741
|
+
return published
|
|
5742
|
+
|
|
5000
5743
|
family_exists = any(
|
|
5001
5744
|
pathlib.Path(str(destination) + suffix).exists()
|
|
5002
5745
|
for suffix in ("", "-wal", "-shm")
|
|
@@ -5050,8 +5793,7 @@ def _publish_rebuilt_stats_index(
|
|
|
5050
5793
|
_remove_db_sidecars_strict(destination)
|
|
5051
5794
|
_cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
|
|
5052
5795
|
|
|
5053
|
-
|
|
5054
|
-
before_swap()
|
|
5796
|
+
fire_before_swap()
|
|
5055
5797
|
|
|
5056
5798
|
# Phase 1 of the publication transaction: the record and then the marker,
|
|
5057
5799
|
# each fsynced, BEFORE the replacement becomes visible.
|
|
@@ -5064,12 +5806,13 @@ def _publish_rebuilt_stats_index(
|
|
|
5064
5806
|
"incidentPath": str(incident) if incident is not None else None,
|
|
5065
5807
|
"damageShapeTokens": damage_tokens,
|
|
5066
5808
|
"postPublicationValidation": None,
|
|
5809
|
+
"publicationMechanism": "replace",
|
|
5067
5810
|
})
|
|
5068
5811
|
record_path = pathlib.Path(context.record_path)
|
|
5069
5812
|
_write_rebuild_record(record_path, record)
|
|
5070
5813
|
_write_publication_marker(
|
|
5071
5814
|
destination, record_path, started_at=started_at, scratch_path=scratch,
|
|
5072
|
-
prior=prior,
|
|
5815
|
+
prior=prior, mechanism="replace",
|
|
5073
5816
|
)
|
|
5074
5817
|
|
|
5075
5818
|
_stats_rebuild_test_pause("rebuild_before_cutover")
|
|
@@ -5101,6 +5844,7 @@ def _publish_rebuilt_stats_index(
|
|
|
5101
5844
|
_write_publication_marker(
|
|
5102
5845
|
destination, record_path, started_at=started_at,
|
|
5103
5846
|
scratch_path=scratch, status="failed", error=post_error,
|
|
5847
|
+
mechanism="replace",
|
|
5104
5848
|
)
|
|
5105
5849
|
raise JournalError(
|
|
5106
5850
|
"published stats index failed post-publication validation: "
|
|
@@ -5110,9 +5854,36 @@ def _publish_rebuilt_stats_index(
|
|
|
5110
5854
|
return incident
|
|
5111
5855
|
|
|
5112
5856
|
|
|
5113
|
-
def
|
|
5857
|
+
def _decoded_quota_stream(quota_raw, cutover_claude, counters=None):
|
|
5858
|
+
"""Decode and normalize retained observation bytes ONE AT A TIME.
|
|
5859
|
+
|
|
5860
|
+
Peak heap therefore holds one record rather than the whole population.
|
|
5861
|
+
Normalization runs HERE, on the record decoded from the retained bytes: a
|
|
5862
|
+
dict normalized during the router pass is discarded with the pass, so
|
|
5863
|
+
stamping it there would be lost and every legacy observation would
|
|
5864
|
+
re-materialize with a NULL account_key. A Codex legacy line maps to
|
|
5865
|
+
`unattributed` regardless of the cutover value, so this does not depend on
|
|
5866
|
+
capture ordering (#496 S4 §6.3).
|
|
5867
|
+
"""
|
|
5868
|
+
for raw in quota_raw:
|
|
5869
|
+
if counters is not None:
|
|
5870
|
+
counters["lines"] += 1
|
|
5871
|
+
counters["bytes"] += len(raw) + 1
|
|
5872
|
+
record = _lib_journal.decode_line(raw)
|
|
5873
|
+
if record is None: # pragma: no cover — retained bytes decoded once already
|
|
5874
|
+
continue
|
|
5875
|
+
if counters is not None:
|
|
5876
|
+
counters["decodes"] += 1
|
|
5877
|
+
_normalize_legacy_account_stamp(record, cutover_claude)
|
|
5878
|
+
yield record
|
|
5879
|
+
|
|
5880
|
+
|
|
5881
|
+
def _rebuild_quota_cache_leg_raw(
|
|
5882
|
+
quota_raw, decoded, cutover_claude, counters=None
|
|
5883
|
+
) -> float:
|
|
5114
5884
|
"""Re-materialize cache.db `quota_window_snapshots` AND the #416 Codex
|
|
5115
|
-
attribution map from the journal (spec §5.4 + #416 spec §3.4)
|
|
5885
|
+
attribution map from the journal (spec §5.4 + #416 spec §3.4), fed RAW
|
|
5886
|
+
ENCODED LINES for the observations instead of decoded dicts.
|
|
5116
5887
|
|
|
5117
5888
|
The journal records are the DURABLE source (§1 latent data-loss hole — the
|
|
5118
5889
|
rollout JSONL evaporates); this INSERT-OR-IGNOREs the quota obs on their
|
|
@@ -5126,14 +5897,31 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5126
5897
|
followed by the `cache.db.codex.lock` provider flock (lock-order law).
|
|
5127
5898
|
Best-effort: a missing/busy cache.db is a clean skip (the records stay
|
|
5128
5899
|
durable in the journal; the stats quota projection pass then degrades
|
|
5129
|
-
cleanly).
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5900
|
+
cleanly).
|
|
5901
|
+
|
|
5902
|
+
Taking raw bytes is what makes the rebuild affordable. 1.81M decoded
|
|
5903
|
+
observation dictionaries are roughly six gigabytes against 1.64 GB of raw
|
|
5904
|
+
bytes, so retaining bytes removes about four gigabytes of peak heap while
|
|
5905
|
+
adding NO file input and NO second traversal — only the JSON decode of
|
|
5906
|
+
records already in memory moves inside the flocks (#496 S4 §6.3). That
|
|
5907
|
+
decode is why the measured hold is longer than it was before S4; see the
|
|
5908
|
+
spec's §6.3 for the measured figures.
|
|
5909
|
+
|
|
5910
|
+
Ordering is preserved: file-account decisions are ops, so the router already
|
|
5911
|
+
retains them decoded and they are available before the observation loop
|
|
5912
|
+
begins, exactly as the §3.5 precedence rule requires.
|
|
5913
|
+
|
|
5914
|
+
Returns the measured flock hold in seconds, which acceptance criterion 7
|
|
5915
|
+
caps.
|
|
5916
|
+
"""
|
|
5917
|
+
file_accounts = [
|
|
5918
|
+
r for r in decoded if r is not None and _is_codex_file_account_op(r)
|
|
5919
|
+
]
|
|
5920
|
+
if not quota_raw and not file_accounts:
|
|
5921
|
+
return 0.0
|
|
5134
5922
|
cache_path = _cctally_core.CACHE_DB_PATH
|
|
5135
5923
|
if not cache_path.exists():
|
|
5136
|
-
return
|
|
5924
|
+
return 0.0
|
|
5137
5925
|
from _lib_cache_writer_lock import (
|
|
5138
5926
|
acquire_cache_writer_flocks,
|
|
5139
5927
|
release_cache_writer_flocks,
|
|
@@ -5148,22 +5936,26 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5148
5936
|
)
|
|
5149
5937
|
except OSError as exc:
|
|
5150
5938
|
print(f"[rebuild] quota cache leg lock failed: {exc}", file=sys.stderr)
|
|
5151
|
-
return
|
|
5939
|
+
return 0.0
|
|
5152
5940
|
if held is None:
|
|
5153
5941
|
print("[rebuild] quota cache leg locks busy; skipping", file=sys.stderr)
|
|
5154
|
-
return
|
|
5942
|
+
return 0.0
|
|
5943
|
+
held_from = time.monotonic()
|
|
5155
5944
|
try:
|
|
5156
5945
|
try:
|
|
5157
5946
|
cache = sqlite3.connect(str(cache_path), timeout=15.0)
|
|
5158
5947
|
except sqlite3.Error as exc: # pragma: no cover — cache.db unopenable
|
|
5159
5948
|
print(f"[rebuild] quota cache leg connect failed: {exc}", file=sys.stderr)
|
|
5160
|
-
return
|
|
5949
|
+
return time.monotonic() - held_from
|
|
5161
5950
|
try:
|
|
5162
5951
|
cache.execute("PRAGMA busy_timeout=15000")
|
|
5163
5952
|
cache.execute("BEGIN IMMEDIATE")
|
|
5164
5953
|
# Decisions FIRST — same §3.5 precedence ordering as `_cache_applier`.
|
|
5165
5954
|
_, _file_conflicts = _apply_file_account_records(cache, file_accounts)
|
|
5166
|
-
_apply_quota_records(
|
|
5955
|
+
_apply_quota_records(
|
|
5956
|
+
cache,
|
|
5957
|
+
_decoded_quota_stream(quota_raw, cutover_claude, counters),
|
|
5958
|
+
)
|
|
5167
5959
|
cache.commit()
|
|
5168
5960
|
_report_file_account_conflicts(_file_conflicts)
|
|
5169
5961
|
except sqlite3.Error as exc:
|
|
@@ -5176,6 +5968,66 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
5176
5968
|
cache.close()
|
|
5177
5969
|
finally:
|
|
5178
5970
|
release_cache_writer_flocks(held)
|
|
5971
|
+
return time.monotonic() - held_from
|
|
5972
|
+
|
|
5973
|
+
|
|
5974
|
+
#: `_resolve_cutover_for_rebuild` distinguishes "the streaming pass never saw the
|
|
5975
|
+
#: op" from "it saw the op and the op recorded no account". `find_accounts_cutover_op`
|
|
5976
|
+
#: makes the same distinction by returning at the first matching RECORD id, so a
|
|
5977
|
+
#: plain `None` cannot stand in for both without changing which answer wins.
|
|
5978
|
+
_CUTOVER_UNSEEN = object()
|
|
5979
|
+
|
|
5980
|
+
|
|
5981
|
+
def _cutover_value_of(record) -> "str | None":
|
|
5982
|
+
"""The cutover op's recorded `claude_legacy_account`, or None when this
|
|
5983
|
+
record is not the canonical cutover op. Shared by the rebuild's inline
|
|
5984
|
+
capture and `find_accounts_cutover_op` so the two cannot disagree."""
|
|
5985
|
+
if record is None or record.get("id") != CUTOVER_OP_ID:
|
|
5986
|
+
return None
|
|
5987
|
+
payload = record.get("payload")
|
|
5988
|
+
if not isinstance(payload, dict):
|
|
5989
|
+
return None
|
|
5990
|
+
return payload.get("claude_legacy_account")
|
|
5991
|
+
|
|
5992
|
+
|
|
5993
|
+
def _resolve_cutover_for_rebuild(captured, hw, segments, counters=None) -> str:
|
|
5994
|
+
"""The cutover account for one rebuild, reading each byte at most once.
|
|
5995
|
+
|
|
5996
|
+
`captured` is what the streaming pass saw inside the pinned prefix, or
|
|
5997
|
+
`_CUTOVER_UNSEEN`. When the prefix did not contain the op — reachable,
|
|
5998
|
+
because correction recovery, journal repair and rederive all pin
|
|
5999
|
+
high-waters, and the op sits at 92.9% of a production journal — scan ONLY
|
|
6000
|
+
the unvisited suffix, from the pinned high-water to the current one,
|
|
6001
|
+
stopping at the first match. Resolving from the prefix alone would flip
|
|
6002
|
+
those rebuilds to `unattributed` and restamp every legacy Claude
|
|
6003
|
+
observation, moving `accounts.last_seen_utc` with it (#496 S4 §5.1).
|
|
6004
|
+
"""
|
|
6005
|
+
if captured is not _CUTOVER_UNSEEN:
|
|
6006
|
+
return captured if captured is not None else _lib_accounts.UNATTRIBUTED
|
|
6007
|
+
if hw is None or not segments:
|
|
6008
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6009
|
+
current = journal_high_water()
|
|
6010
|
+
if current is None or current == hw:
|
|
6011
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6012
|
+
if current[0] not in segments:
|
|
6013
|
+
# A segment appended after this rebuild's snapshot. Every pass of one
|
|
6014
|
+
# rebuild reads the same snapshot (§4), so the suffix stops at its end
|
|
6015
|
+
# rather than silently adopting a different journal shape.
|
|
6016
|
+
last = segments[-1]
|
|
6017
|
+
current = (last, os.path.getsize(_cctally_core.JOURNAL_DIR / last))
|
|
6018
|
+
if current == hw:
|
|
6019
|
+
return _lib_accounts.UNATTRIBUTED
|
|
6020
|
+
for _segment, _offset, raw in _iter_range_with_segments(hw, current, segments):
|
|
6021
|
+
if counters is not None:
|
|
6022
|
+
counters["lines"] += 1
|
|
6023
|
+
counters["bytes"] += len(raw) + 1
|
|
6024
|
+
record = _lib_journal.decode_line(raw)
|
|
6025
|
+
if counters is not None and record is not None:
|
|
6026
|
+
counters["decodes"] += 1
|
|
6027
|
+
if record is not None and record.get("id") == CUTOVER_OP_ID:
|
|
6028
|
+
value = _cutover_value_of(record)
|
|
6029
|
+
return value if value is not None else _lib_accounts.UNATTRIBUTED
|
|
6030
|
+
return _lib_accounts.UNATTRIBUTED
|
|
5179
6031
|
|
|
5180
6032
|
|
|
5181
6033
|
def rebuild_stats_index(
|
|
@@ -5208,6 +6060,11 @@ def rebuild_stats_index(
|
|
|
5208
6060
|
replaces the main file. A `target_path` build uses the same atomic
|
|
5209
6061
|
publication but does not create a live-family quarantine incident.
|
|
5210
6062
|
"""
|
|
6063
|
+
# Imported HERE, not at module scope: `_cctally_journal` is on the ingest
|
|
6064
|
+
# path every status-line tick reaches, and `import tracemalloc` measured
|
|
6065
|
+
# 2.9 ms. Only a rebuild reads the peak, so only a rebuild pays for it.
|
|
6066
|
+
import tracemalloc
|
|
6067
|
+
|
|
5211
6068
|
start = time.monotonic()
|
|
5212
6069
|
context = context.validate()
|
|
5213
6070
|
# Resolve the rebuild record's path ONCE, here, because preservation runs
|
|
@@ -5227,7 +6084,13 @@ def rebuild_stats_index(
|
|
|
5227
6084
|
# and belong to the next ingest cycle (they replay idempotently); mirrors the
|
|
5228
6085
|
# live cycle's §5.2.1 HW-prefix rule.
|
|
5229
6086
|
hw = high_water if high_water is not None else journal_high_water()
|
|
5230
|
-
|
|
6087
|
+
# ONE segment snapshot for the whole rebuild (#496 S4 §4). `list_segments()`
|
|
6088
|
+
# re-enumerates the directory at call time and orders bootstrap segments
|
|
6089
|
+
# first, so a bootstrap segment appearing mid-rebuild would shift the indices
|
|
6090
|
+
# `iter_range` addresses by; before this, the read pass and the cutover scan
|
|
6091
|
+
# each listed separately and could already disagree about the journal's shape.
|
|
6092
|
+
all_segments = list_segments()
|
|
6093
|
+
segments = all_segments
|
|
5231
6094
|
if hw is not None:
|
|
5232
6095
|
if hw[0] not in segments:
|
|
5233
6096
|
raise JournalError(
|
|
@@ -5250,12 +6113,42 @@ def rebuild_stats_index(
|
|
|
5250
6113
|
conn = _cctally_core.open_db(_target_path=str(scratch))
|
|
5251
6114
|
malformed = 0
|
|
5252
6115
|
lines_folded = 0
|
|
6116
|
+
phase_seconds: dict = {}
|
|
6117
|
+
traversal = {
|
|
6118
|
+
name: {"lines": 0, "bytes": 0, "decodes": 0}
|
|
6119
|
+
for name in ("stats_prefix", "cutover_suffix", "protocol_evidence",
|
|
6120
|
+
"quota_replay")
|
|
6121
|
+
}
|
|
6122
|
+
quota_lock_hold = 0.0
|
|
6123
|
+
tracing = tracemalloc.is_tracing()
|
|
6124
|
+
if tracing:
|
|
6125
|
+
tracemalloc.reset_peak()
|
|
5253
6126
|
try:
|
|
6127
|
+
# ONE streaming pass. Decode each line once, feed the account last-seen
|
|
6128
|
+
# accumulator, capture the cutover inline, and retain only what a
|
|
6129
|
+
# consumer actually needs: the decision records decoded (5.08% of a
|
|
6130
|
+
# production journal) and the Codex quota observations as RAW BYTES
|
|
6131
|
+
# (1.64 GB against roughly six gigabytes of dicts). Everything else is
|
|
6132
|
+
# dropped as soon as it has contributed (#496 S4 §4).
|
|
5254
6133
|
decoded: list = []
|
|
6134
|
+
quota_raw: list = []
|
|
5255
6135
|
protocol_evidence = []
|
|
5256
6136
|
prior_high_water = None
|
|
6137
|
+
cutover_captured = _CUTOVER_UNSEEN
|
|
6138
|
+
last_seen = _lib_journal_router.LastSeenAccumulator()
|
|
6139
|
+
hasher = _lib_journal_router.PrefixHashAccumulator()
|
|
6140
|
+
evidence_seconds = 0.0
|
|
6141
|
+
prefix = traversal["stats_prefix"]
|
|
6142
|
+
read_started = time.monotonic()
|
|
5257
6143
|
if hw is not None:
|
|
5258
|
-
for segment, offset, raw in
|
|
6144
|
+
for segment, offset, raw in _iter_range_with_segments(
|
|
6145
|
+
None, hw, segments,
|
|
6146
|
+
on_segment=lambda name: hasher.begin_segment(
|
|
6147
|
+
name, prior_high_water),
|
|
6148
|
+
on_bytes=hasher.extend,
|
|
6149
|
+
):
|
|
6150
|
+
prefix["lines"] += 1
|
|
6151
|
+
prefix["bytes"] += len(raw) + 1
|
|
5259
6152
|
rec = _lib_journal.decode_line(raw)
|
|
5260
6153
|
if rec is None:
|
|
5261
6154
|
malformed += 1
|
|
@@ -5264,46 +6157,104 @@ def rebuild_stats_index(
|
|
|
5264
6157
|
offset + len(raw) + 1,
|
|
5265
6158
|
)
|
|
5266
6159
|
continue
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
6160
|
+
prefix["decodes"] += 1
|
|
6161
|
+
# `_capture_protocol_prefix_evidence` returns immediately for
|
|
6162
|
+
# anything that is not an op, so this guard changes nothing it
|
|
6163
|
+
# does — it moves the phase attribution's two clock reads from
|
|
6164
|
+
# every record to every op. That is 195 ops against 1,954,007
|
|
6165
|
+
# lines on a production journal, where the phase itself measures
|
|
6166
|
+
# zero because the journal carries no resolution operation.
|
|
6167
|
+
if rec.get("t") == "op":
|
|
6168
|
+
evidence_started = time.monotonic()
|
|
6169
|
+
_capture_protocol_prefix_evidence(
|
|
6170
|
+
rec,
|
|
6171
|
+
prior_high_water,
|
|
6172
|
+
protocol_evidence,
|
|
6173
|
+
hasher=hasher,
|
|
6174
|
+
)
|
|
6175
|
+
evidence_seconds += time.monotonic() - evidence_started
|
|
6176
|
+
# First cutover op wins, exactly as `find_accounts_cutover_op`
|
|
6177
|
+
# scans — captured here so the rebuild reads the journal once.
|
|
6178
|
+
if (cutover_captured is _CUTOVER_UNSEEN
|
|
6179
|
+
and rec.get("id") == CUTOVER_OP_ID):
|
|
6180
|
+
cutover_captured = _cutover_value_of(rec)
|
|
6181
|
+
last_seen.observe(rec, classify_legacy_provider)
|
|
6182
|
+
if rec.get("t") in _lib_journal_router.RETAINED_RECORD_TYPES:
|
|
6183
|
+
decoded.append(rec)
|
|
6184
|
+
else:
|
|
6185
|
+
if update_quota_cache and _is_codex_quota_obs(rec):
|
|
6186
|
+
quota_raw.append(raw)
|
|
6187
|
+
# A PLACEHOLDER, not a dropped element. `resolve_effective_events`
|
|
6188
|
+
# numbers candidates with `enumerate(records)`, and three of the
|
|
6189
|
+
# seven structural violation kinds put that number inside
|
|
6190
|
+
# `ProtocolViolation.evidence` — which the fingerprint hashes.
|
|
6191
|
+
# That fingerprint is durable: it lands in
|
|
6192
|
+
# `journal_protocol_violations` and is referenced by name from a
|
|
6193
|
+
# `journal_protocol_resolution` op, which `_cctally_journal_repair`
|
|
6194
|
+
# mints from the UNFILTERED record list. Renumbering here would
|
|
6195
|
+
# therefore make a previously acknowledged violation unresolvable
|
|
6196
|
+
# and raise on every later rebuild. The selector skips a non-dict
|
|
6197
|
+
# element, so this costs one pointer and keeps every sequence
|
|
6198
|
+
# identical to the pre-change numbering (#496 S4; corrects §4.7).
|
|
6199
|
+
decoded.append(None)
|
|
5273
6200
|
prior_high_water = (
|
|
5274
6201
|
segment,
|
|
5275
6202
|
offset + len(raw) + 1,
|
|
5276
6203
|
)
|
|
6204
|
+
phase_seconds["journal_read_decode"] = round(
|
|
6205
|
+
max(0.0, time.monotonic() - read_started - evidence_seconds), 6)
|
|
6206
|
+
phase_seconds["protocol_evidence"] = round(evidence_seconds, 6)
|
|
6207
|
+
traversal["protocol_evidence"]["bytes"] = hasher.bytes_hashed
|
|
6208
|
+
traversal["protocol_evidence"]["lines"] = hasher.digests_computed
|
|
6209
|
+
hasher = None
|
|
5277
6210
|
|
|
5278
6211
|
# Legacy account normalisation (#341, spec §2 / handoff item 2): a
|
|
5279
6212
|
# pre-#341 real-account line lacks an account stamp — inject the cutover
|
|
5280
6213
|
# mapping BEFORE the fold (Claude legacy -> the cutover op's account;
|
|
5281
6214
|
# Codex legacy -> unattributed). `*`-families + already-stamped lines are
|
|
5282
|
-
# untouched. Resolved
|
|
5283
|
-
#
|
|
5284
|
-
#
|
|
5285
|
-
|
|
6215
|
+
# untouched. Resolved from the journal's own cutover op — inline when the
|
|
6216
|
+
# streamed prefix contained it, otherwise from the unvisited suffix alone
|
|
6217
|
+
# (falls back to `unattributed` when neither has it), so a fresh
|
|
6218
|
+
# single-account rebuild is byte-neutral.
|
|
6219
|
+
cutover_started = time.monotonic()
|
|
6220
|
+
cutover_claude = _resolve_cutover_for_rebuild(
|
|
6221
|
+
cutover_captured, hw, all_segments, traversal["cutover_suffix"])
|
|
6222
|
+
phase_seconds["cutover_suffix"] = round(
|
|
6223
|
+
time.monotonic() - cutover_started, 6)
|
|
5286
6224
|
for rec in decoded:
|
|
5287
|
-
|
|
6225
|
+
if rec is not None:
|
|
6226
|
+
_normalize_legacy_account_stamp(rec, cutover_claude)
|
|
5288
6227
|
|
|
5289
6228
|
# Resolve corrections BEFORE either disposable index is mutated. A
|
|
5290
6229
|
# malformed revision, divergent same-revision candidate, or invalid
|
|
5291
6230
|
# committed manifest leaves the existing destination untouched.
|
|
6231
|
+
selection_started = time.monotonic()
|
|
5292
6232
|
effective = _lib_journal.resolve_effective_events(
|
|
5293
6233
|
decoded,
|
|
5294
6234
|
protocol_prefix_evidence=protocol_evidence,
|
|
5295
6235
|
)
|
|
6236
|
+
phase_seconds["effective_selection"] = round(
|
|
6237
|
+
time.monotonic() - selection_started, 6)
|
|
5296
6238
|
|
|
5297
6239
|
# Cache leg BEFORE any stats txn (provider-flock lock-order): journal
|
|
5298
|
-
# Codex quota obs -> cache.db quota_window_snapshots.
|
|
6240
|
+
# Codex quota obs -> cache.db quota_window_snapshots. The retained bytes
|
|
6241
|
+
# are decoded inside the leg's existing transaction and freed here, so
|
|
6242
|
+
# the SQLite fold never runs on top of them.
|
|
6243
|
+
leg_started = time.monotonic()
|
|
5299
6244
|
if update_quota_cache:
|
|
5300
|
-
|
|
6245
|
+
quota_lock_hold = _rebuild_quota_cache_leg_raw(
|
|
6246
|
+
quota_raw, decoded, cutover_claude, traversal["quota_replay"])
|
|
6247
|
+
quota_raw = []
|
|
6248
|
+
phase_seconds["quota_cache_leg"] = round(
|
|
6249
|
+
time.monotonic() - leg_started, 6)
|
|
5301
6250
|
|
|
5302
6251
|
# One ordered fold stream: op-folds (order 5) + evts, keyed by
|
|
5303
6252
|
# (fold_order, canonical seq) so referenced families resolve before
|
|
5304
6253
|
# referencing ones and crash-replay duplicates fold idempotently.
|
|
5305
6254
|
stream: list = []
|
|
5306
6255
|
for seq, rec in enumerate(decoded):
|
|
6256
|
+
if rec is None:
|
|
6257
|
+
continue
|
|
5307
6258
|
t = rec.get("t")
|
|
5308
6259
|
kind = (rec.get("payload") or {}).get("kind")
|
|
5309
6260
|
if t == "op" and kind in FOLD_APPLIERS:
|
|
@@ -5315,6 +6266,7 @@ def rebuild_stats_index(
|
|
|
5315
6266
|
tail = [s for s in stream if s[0] >= _REBUILD_MILESTONE_ORDER]
|
|
5316
6267
|
|
|
5317
6268
|
_stats_rebuild_test_pause("rebuild_fold_started")
|
|
6269
|
+
fold_started = time.monotonic()
|
|
5318
6270
|
|
|
5319
6271
|
# Phase 1 (txn A) — structural folds: op floors, snapshot_accept, cost
|
|
5320
6272
|
# snapshots, resets+suppression, block_close, arming, credit effects.
|
|
@@ -5362,9 +6314,14 @@ def rebuild_stats_index(
|
|
|
5362
6314
|
_apply_evt(conn, rec)
|
|
5363
6315
|
lines_folded += 1
|
|
5364
6316
|
# Fold-time `last_seen_utc` derivation (#341): re-derive each
|
|
5365
|
-
# account's last-seen from the whole journal
|
|
5366
|
-
#
|
|
5367
|
-
|
|
6317
|
+
# account's last-seen from the whole journal. The map was
|
|
6318
|
+
# accumulated during the single read pass — `decoded` no longer
|
|
6319
|
+
# contains observations, so deriving from it here would silently
|
|
6320
|
+
# drop every observation's contribution (#496 S4 §4.6).
|
|
6321
|
+
_apply_account_last_seen(
|
|
6322
|
+
conn,
|
|
6323
|
+
last_seen.resolve(cutover_claude, _lib_accounts.UNATTRIBUTED),
|
|
6324
|
+
)
|
|
5368
6325
|
if hw is not None:
|
|
5369
6326
|
_write_cursor(conn, hw[0], hw[1])
|
|
5370
6327
|
conn.commit()
|
|
@@ -5374,7 +6331,9 @@ def rebuild_stats_index(
|
|
|
5374
6331
|
except Exception:
|
|
5375
6332
|
pass
|
|
5376
6333
|
raise
|
|
6334
|
+
phase_seconds["stats_fold"] = round(time.monotonic() - fold_started, 6)
|
|
5377
6335
|
|
|
6336
|
+
validate_started = time.monotonic()
|
|
5378
6337
|
rows_by_table = {}
|
|
5379
6338
|
for tbl in _REBUILD_COUNT_TABLES:
|
|
5380
6339
|
try:
|
|
@@ -5388,6 +6347,8 @@ def rebuild_stats_index(
|
|
|
5388
6347
|
raise JournalError("rebuilt stats index WAL could not be drained")
|
|
5389
6348
|
_validate_rebuilt_stats_index(conn, hw)
|
|
5390
6349
|
_stats_rebuild_test_pause("rebuild_scratch_complete")
|
|
6350
|
+
phase_seconds["scratch_validate"] = round(
|
|
6351
|
+
time.monotonic() - validate_started, 6)
|
|
5391
6352
|
finally:
|
|
5392
6353
|
conn.close()
|
|
5393
6354
|
|
|
@@ -5397,6 +6358,22 @@ def rebuild_stats_index(
|
|
|
5397
6358
|
os.fsync(handle.fileno())
|
|
5398
6359
|
_fsync_dir(scratch.parent)
|
|
5399
6360
|
|
|
6361
|
+
# Extract the compact result data and RELEASE the replay structures before
|
|
6362
|
+
# publication begins (#496 S3 §4.2). The in-place attempt adds a
|
|
6363
|
+
# database-sized WAL while the scratch still exists, so the measured
|
|
6364
|
+
# multi-gigabyte replay peak must not still be resident on top of it.
|
|
6365
|
+
segments_read = len(segments)
|
|
6366
|
+
conflicts = effective.conflicts
|
|
6367
|
+
protocol_violations = effective.protocol_violations
|
|
6368
|
+
acknowledged = effective.acknowledged_protocol_violations
|
|
6369
|
+
# The pre-publication window is what F9's memory acceptance is measured
|
|
6370
|
+
# over: everything after this point is publication, whose own WAL cost S3
|
|
6371
|
+
# already accounts for.
|
|
6372
|
+
peak_heap_bytes = (
|
|
6373
|
+
tracemalloc.get_traced_memory()[1] if tracing else 0)
|
|
6374
|
+
decoded = effective = stream = structural = tail = None
|
|
6375
|
+
segments = protocol_evidence = last_seen = None
|
|
6376
|
+
|
|
5400
6377
|
# First fresh-connection validation (#496 S1 F1). A failure here raises
|
|
5401
6378
|
# BEFORE any preservation, so no incident is created and the old family
|
|
5402
6379
|
# stays live — the existing contract is preserved exactly.
|
|
@@ -5410,6 +6387,7 @@ def rebuild_stats_index(
|
|
|
5410
6387
|
# `os.replace` as a stray artifact.
|
|
5411
6388
|
_remove_db_sidecars_strict(scratch)
|
|
5412
6389
|
|
|
6390
|
+
publication_started = time.monotonic()
|
|
5413
6391
|
incident = _publish_rebuilt_stats_index(
|
|
5414
6392
|
scratch=scratch,
|
|
5415
6393
|
destination=dest,
|
|
@@ -5427,24 +6405,39 @@ def rebuild_stats_index(
|
|
|
5427
6405
|
"highWater": [hw[0], hw[1]] if hw is not None else None,
|
|
5428
6406
|
"destination": str(dest),
|
|
5429
6407
|
"targetPath": str(target_path) if target_path is not None else None,
|
|
5430
|
-
"segmentsRead":
|
|
6408
|
+
"segmentsRead": segments_read,
|
|
5431
6409
|
"linesFolded": lines_folded,
|
|
5432
6410
|
"malformed": malformed,
|
|
5433
6411
|
"rowsByTable": rows_by_table,
|
|
5434
6412
|
"buildSeconds": round(time.monotonic() - start, 3),
|
|
5435
6413
|
"prePublicationValidation": {"ok": True, "error": None},
|
|
6414
|
+
# Additive instrumentation (#496 S4 §8.7). Additive keys do not bump
|
|
6415
|
+
# `schemaVersion` and no existing field changes meaning. `publication`
|
|
6416
|
+
# is absent HERE and present on `RebuildResult`: publication copies
|
|
6417
|
+
# this dict before it writes it, so its own duration cannot be known
|
|
6418
|
+
# at the time the record is written.
|
|
6419
|
+
"phaseSeconds": dict(phase_seconds),
|
|
6420
|
+
"traversal": {
|
|
6421
|
+
name: dict(counts) for name, counts in traversal.items()
|
|
6422
|
+
},
|
|
6423
|
+
"peakHeapBytes": peak_heap_bytes,
|
|
6424
|
+
"quotaLockHoldSeconds": round(quota_lock_hold, 6),
|
|
5436
6425
|
},
|
|
5437
6426
|
)
|
|
6427
|
+
phase_seconds["publication"] = round(
|
|
6428
|
+
time.monotonic() - publication_started, 6)
|
|
5438
6429
|
|
|
5439
6430
|
return RebuildResult(
|
|
5440
6431
|
rows_by_table=rows_by_table, malformed=malformed,
|
|
5441
|
-
duration_s=time.monotonic() - start, segments_read=
|
|
5442
|
-
lines_folded=lines_folded, conflicts=
|
|
5443
|
-
protocol_violations=
|
|
5444
|
-
acknowledged_protocol_violations=
|
|
5445
|
-
effective.acknowledged_protocol_violations
|
|
5446
|
-
),
|
|
6432
|
+
duration_s=time.monotonic() - start, segments_read=segments_read,
|
|
6433
|
+
lines_folded=lines_folded, conflicts=conflicts,
|
|
6434
|
+
protocol_violations=protocol_violations,
|
|
6435
|
+
acknowledged_protocol_violations=acknowledged,
|
|
5447
6436
|
quarantine_dir=incident,
|
|
6437
|
+
phase_seconds=phase_seconds,
|
|
6438
|
+
traversal=traversal,
|
|
6439
|
+
peak_heap_bytes=peak_heap_bytes,
|
|
6440
|
+
quota_lock_hold_seconds=round(quota_lock_hold, 6),
|
|
5448
6441
|
)
|
|
5449
6442
|
|
|
5450
6443
|
|
|
@@ -5859,17 +6852,24 @@ def _resolve_claude_cutover_identity(claude_json_path=None) -> str:
|
|
|
5859
6852
|
def find_accounts_cutover_op():
|
|
5860
6853
|
"""Scan the journal for the canonical cutover op; return its recorded
|
|
5861
6854
|
``claude_legacy_account`` (spec §2 payload), or None when it has not been
|
|
5862
|
-
appended yet. Cheap enough for the one-time transition + the retry check.
|
|
6855
|
+
appended yet. Cheap enough for the one-time transition + the retry check.
|
|
6856
|
+
|
|
6857
|
+
Streams rather than materializing (#496 S4): the previous form built each
|
|
6858
|
+
segment's whole line list before its first-match return, so the early exit
|
|
6859
|
+
could not stop reading inside the containing segment. The None-on-absence
|
|
6860
|
+
contract is UNCHANGED — the cache and conversations migrations depend on it
|
|
6861
|
+
to defer their backfill.
|
|
6862
|
+
"""
|
|
5863
6863
|
for seg in list_segments():
|
|
5864
6864
|
seg_path = _cctally_core.JOURNAL_DIR / seg
|
|
5865
6865
|
try:
|
|
5866
6866
|
size = os.path.getsize(seg_path)
|
|
5867
6867
|
except OSError:
|
|
5868
6868
|
continue
|
|
5869
|
-
for _name, _off, raw in
|
|
6869
|
+
for _name, _off, raw in _iter_segment_lines(seg_path, 0, size):
|
|
5870
6870
|
rec = _lib_journal.decode_line(raw)
|
|
5871
6871
|
if rec is not None and rec.get("id") == CUTOVER_OP_ID:
|
|
5872
|
-
return (rec
|
|
6872
|
+
return _cutover_value_of(rec)
|
|
5873
6873
|
return None
|
|
5874
6874
|
|
|
5875
6875
|
|