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.
@@ -40,6 +40,7 @@ from dataclasses import dataclass, field, replace as _dc_replace
40
40
  import _cctally_core
41
41
  import _lib_accounts
42
42
  import _lib_journal
43
+ import _lib_journal_router
43
44
  import _lib_record
44
45
 
45
46
 
@@ -178,12 +179,10 @@ def _repair_torn_tail(fd: int) -> None:
178
179
  # public append surface
179
180
  # --------------------------------------------------------------------------
180
181
 
181
- def _is_codex_quota_obs(record: dict) -> bool:
182
- return (
183
- record.get("t") == "obs"
184
- and record.get("provider") == "codex"
185
- and (record.get("payload") or {}).get("kind") == "quota_window_snapshot"
186
- )
182
+ # NOTE: `_is_codex_quota_obs` is defined ONCE, further down beside
183
+ # `_QUOTA_OBS_KIND`. A duplicate definition used to sit here and was shadowed by
184
+ # that one at import time, so it was dead code an edit here would silently not
185
+ # reach (#496 S4). Do not reintroduce a second definition.
187
186
 
188
187
 
189
188
  def _codex_quota_natural_key(record: dict) -> str | None:
@@ -952,11 +951,18 @@ def _write_cursor(conn: sqlite3.Connection, segment: str, offset: int) -> None:
952
951
  _SEGMENT_READ_CHUNK = 256 * 1024
953
952
 
954
953
 
955
- def _iter_segment_lines(seg_path, lo: int, hi: int):
954
+ def _iter_segment_lines(seg_path, lo: int, hi: int, *, on_bytes=None):
956
955
  """Stream `(basename, absolute-offset, raw-line-without-newline)` for every
957
956
  complete line in `[lo, hi)`, holding at most one chunk plus one partial line
958
957
  in memory. `hi` is a line boundary (a HW snapshot size or an immutable prior
959
- segment's full size), so no partial trailing line appears."""
958
+ segment's full size), so no partial trailing line appears.
959
+
960
+ `on_bytes` receives each chunk exactly as it is read, before any line
961
+ splitting. It exists so a caller can reproduce `journal_prefix_hash` from
962
+ the bytes this pass is already reading (#496 S4 §5.2) rather than re-reading
963
+ the segment; it must therefore see the raw `[lo, hi)` range verbatim,
964
+ including a torn trailing partial line that is never yielded.
965
+ """
960
966
  name = seg_path.name
961
967
  with open(seg_path, "rb") as fh:
962
968
  fh.seek(lo)
@@ -968,6 +974,8 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
968
974
  if not data:
969
975
  break
970
976
  pos += len(data)
977
+ if on_bytes is not None:
978
+ on_bytes(data)
971
979
  buf = buf + data if buf else data
972
980
  start = 0
973
981
  while True:
@@ -981,14 +989,6 @@ def _iter_segment_lines(seg_path, lo: int, hi: int):
981
989
  buf_at += start
982
990
 
983
991
 
984
- def _read_segment_lines(seg_path, lo: int, hi: int) -> list[tuple[str, int, bytes]]:
985
- """Materialized form of :func:`_iter_segment_lines` (see it for the
986
- contract). Callers that walk a whole range at once should prefer
987
- :func:`iter_range`; this list form is retained for the ingest cycle, which
988
- needs the batch as an indexable sequence."""
989
- return list(_iter_segment_lines(seg_path, lo, hi))
990
-
991
-
992
992
  def iter_range(cursor, hw):
993
993
  """Stream `cursor -> HW` across segments in canonical order (spec §5.2.2).
994
994
 
@@ -1002,8 +1002,26 @@ def iter_range(cursor, hw):
1002
1002
  materialized form for the ingest cycle, which genuinely needs the batch as
1003
1003
  an indexable sequence (prefix-stop indices address into it).
1004
1004
  """
1005
+ yield from _iter_range_with_segments(cursor, hw, list_segments())
1006
+
1007
+
1008
+ def _iter_range_with_segments(cursor, hw, segments, *, on_segment=None,
1009
+ on_bytes=None):
1010
+ """`iter_range` over a segment list the CALLER snapshotted (#496 S4 §4).
1011
+
1012
+ `list_segments()` enumerates the journal directory at call time and orders
1013
+ bootstrap segments before observation segments, so a bootstrap segment
1014
+ appearing mid-rebuild would insert ahead of the high-water segment and shift
1015
+ the indices this function addresses by. A rebuild takes ONE snapshot at its
1016
+ pinned high-water and drives every pass from it, so two passes of the same
1017
+ rebuild cannot disagree about the journal's shape.
1018
+
1019
+ `on_segment` is called for EVERY segment in the range, including one this
1020
+ function then skips because it holds no bytes in range: `journal_prefix_hash`
1021
+ frames a zero-byte segment, so a hash accumulator has to be told it exists.
1022
+ `on_bytes` is forwarded to `_iter_segment_lines`.
1023
+ """
1005
1024
  hw_seg, hw_size = hw
1006
- segments = list_segments()
1007
1025
  if hw_seg not in segments:
1008
1026
  return
1009
1027
  hw_idx = segments.index(hw_seg)
@@ -1020,9 +1038,11 @@ def iter_range(cursor, hw):
1020
1038
  seg_path = _cctally_core.JOURNAL_DIR / seg
1021
1039
  lo = start_off if idx == start_idx else 0
1022
1040
  hi = hw_size if idx == hw_idx else os.path.getsize(seg_path)
1041
+ if on_segment is not None:
1042
+ on_segment(seg)
1023
1043
  if lo >= hi:
1024
1044
  continue
1025
- yield from _iter_segment_lines(seg_path, lo, hi)
1045
+ yield from _iter_segment_lines(seg_path, lo, hi, on_bytes=on_bytes)
1026
1046
 
1027
1047
 
1028
1048
  def _read_range(cursor, hw) -> list[tuple[str, int, bytes]]:
@@ -1057,20 +1077,28 @@ def journal_prefix_hash(high_water) -> "str | None":
1057
1077
  return "sha256:" + digest.hexdigest()
1058
1078
 
1059
1079
 
1060
- def _capture_protocol_prefix_evidence(record, prior_high_water, evidence) -> None:
1061
- """Capture the actual raw prefix immediately preceding one audit record."""
1080
+ def _capture_protocol_prefix_evidence(
1081
+ record, prior_high_water, evidence, hasher=None
1082
+ ) -> None:
1083
+ """Capture the actual raw prefix immediately preceding one audit record.
1084
+
1085
+ `hasher` is a `_lib_journal_router.PrefixHashAccumulator` fed by the caller's
1086
+ streaming pass. When supplied, the digest comes from bytes that pass has
1087
+ already read; otherwise `journal_prefix_hash` re-reads the whole prefix from
1088
+ disk, which is what the streaming callers exist to avoid (#496 S4 §5.2). The
1089
+ two produce the identical durable digest.
1090
+ """
1062
1091
  if (
1063
1092
  record.get("t") == "op"
1064
1093
  and isinstance(record.get("payload"), dict)
1065
1094
  and record["payload"].get("kind")
1066
1095
  == _lib_journal._PROTOCOL_RESOLUTION_KIND
1067
1096
  ):
1068
- evidence.append(
1069
- (
1070
- prior_high_water,
1071
- journal_prefix_hash(prior_high_water),
1072
- )
1097
+ digest = (
1098
+ hasher.digest_at(prior_high_water) if hasher is not None
1099
+ else journal_prefix_hash(prior_high_water)
1073
1100
  )
1101
+ evidence.append((prior_high_water, digest))
1074
1102
 
1075
1103
 
1076
1104
  # --------------------------------------------------------------------------
@@ -2017,6 +2045,8 @@ def _derive_account_last_seen(conn, records) -> None:
2017
2045
  prior observe already created (never invents an account row)."""
2018
2046
  latest: dict = {}
2019
2047
  for rec in records:
2048
+ if rec is None:
2049
+ continue
2020
2050
  key = _account_of(rec)
2021
2051
  at = rec.get("at")
2022
2052
  if not key or not at:
@@ -2024,6 +2054,17 @@ def _derive_account_last_seen(conn, records) -> None:
2024
2054
  prev = latest.get(key)
2025
2055
  if prev is None or at > prev:
2026
2056
  latest[key] = at
2057
+ _apply_account_last_seen(conn, latest)
2058
+
2059
+
2060
+ def _apply_account_last_seen(conn, latest) -> None:
2061
+ """Apply a precomputed `{account_key: max_at}` map.
2062
+
2063
+ Split out so the rebuild can accumulate the map during its single streaming
2064
+ pass (#496 S4 §4.2) instead of walking every record again inside the
2065
+ publication transaction. The rebuild's retained list no longer contains
2066
+ observations at all, so calling `_derive_account_last_seen` over it would
2067
+ silently drop every observation's contribution."""
2027
2068
  for key, at in latest.items():
2028
2069
  conn.execute(
2029
2070
  "UPDATE accounts SET last_seen_utc = ? WHERE account_key = ? "
@@ -3428,6 +3469,10 @@ def _correction_commit_high_water(batch_id, hw=None):
3428
3469
  selector or by the live metadata row that names it. The earliest matching
3429
3470
  commit is the narrowest complete prefix and remains stable even when later
3430
3471
  journal bytes or crash-replayed duplicate markers exist.
3472
+
3473
+ Streams rather than materializing (#496 S4): the previous form built the
3474
+ whole prefix through `_read_range` before its first-match return, so a
3475
+ marker in the first segment still paid for every later one.
3431
3476
  """
3432
3477
  if not batch_id:
3433
3478
  return None
@@ -3435,7 +3480,7 @@ def _correction_commit_high_water(batch_id, hw=None):
3435
3480
  hw = journal_high_water()
3436
3481
  if hw is None:
3437
3482
  return None
3438
- for segment, offset, raw in _read_range(None, hw):
3483
+ for segment, offset, raw in iter_range(None, hw):
3439
3484
  record = _lib_journal.decode_line(raw)
3440
3485
  if (
3441
3486
  record is not None
@@ -4259,6 +4304,25 @@ class RebuildResult:
4259
4304
  # batches remain tainted; this is diagnostic/audit state, never validity.
4260
4305
  acknowledged_protocol_violations: tuple = ()
4261
4306
  quarantine_dir: "pathlib.Path | None" = None
4307
+ # #496 S4 §8.7 — ADDITIVE instrumentation. Names, units and pass boundaries
4308
+ # are fixed by the spec so the gate's assertions are unambiguous; adding
4309
+ # them does not bump the rebuild record's `schemaVersion` and no existing
4310
+ # field changes meaning.
4311
+ #: float seconds per phase. Keys: journal_read_decode, cutover_suffix,
4312
+ #: protocol_evidence, effective_selection, quota_cache_leg, stats_fold,
4313
+ #: scratch_validate, publication. The phases are DISJOINT — evidence hashing
4314
+ #: happens inside the read loop and is subtracted from journal_read_decode.
4315
+ phase_seconds: dict = field(default_factory=dict)
4316
+ #: per named pass, `{lines, bytes, decodes}`. Passes: stats_prefix (the
4317
+ #: router), cutover_suffix (zero unless the §5.1 fallback ran),
4318
+ #: protocol_evidence (`bytes` hashed and `lines` digests computed, both zero
4319
+ #: on a journal with no resolution op), quota_replay (the in-leg decode,
4320
+ #: where `bytes` is the retained byte total and `lines` equals `decodes`).
4321
+ traversal: dict = field(default_factory=dict)
4322
+ #: `tracemalloc` peak over the pre-publication window; 0 when not tracing.
4323
+ peak_heap_bytes: int = 0
4324
+ #: cache writer flock acquisition to release, in seconds.
4325
+ quota_lock_hold_seconds: float = 0.0
4262
4326
 
4263
4327
 
4264
4328
  def _remove_db_sidecars_strict(path) -> None:
@@ -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
- "47bbbfde25fe4e4d40cb39671cf0c6c8fe2d8e9a7a5ce276927b410b916afd54"
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
- decoded: list[dict] = []
4549
+ # Same streaming router as the rebuild (#496 S4 §7). This function's
4550
+ # only output is a selection compared against
4551
+ # `journal_effective_events`, so it needs the decision records and
4552
+ # nothing else: no observation retention, no quota bytes, no second
4553
+ # from-byte-zero cutover scan. The `None` placeholders keep every
4554
+ # `enumerate` sequence — and therefore every violation fingerprint —
4555
+ # identical to what the rebuild wrote.
4556
+ decoded: list = []
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
- for segment, offset, raw in _read_range(None, high_water):
4564
+ if high_water[0] in segments:
4565
+ segments = segments[:segments.index(high_water[0]) + 1]
4566
+ for segment, offset, raw in _iter_range_with_segments(
4567
+ None, high_water, segments,
4568
+ on_segment=lambda name: hasher.begin_segment(
4569
+ name, prior_high_water),
4570
+ on_bytes=hasher.extend,
4571
+ ):
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
- cutover_claude = resolve_cutover_claude_account()
4593
+ hasher = None
4594
+ cutover_claude = _resolve_cutover_for_rebuild(
4595
+ cutover_captured, high_water, all_segments)
4502
4596
  for record in decoded:
4503
- _normalize_legacy_account_stamp(record, cutover_claude)
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 scratch is still on disk never replaced
4742
- anything, so it owes nothing about the live bytes of its own — but it may
4743
- still be CARRYING an older run's verdict, which is passed through so a
4744
- third consecutive crashed run cannot drop it;
4745
- - a `pending` marker whose scratch is gone is settled HERE, by validating
4746
- the destination against its record's pinned high-water — the same check
4747
- the opener would have run. Success clears it; failure is written to both
4748
- the marker and the record before this run touches the destination, and is
4749
- then carried forward.
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
- scratch = state.get("scratchPath")
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
- print(
4806
- "[stats] an earlier stats.db publication was interrupted before it "
4807
- f"could validate what it published, and it FAILED that check: {error}. "
4808
- f"Rebuild record: {record_path}.",
4809
- file=sys.stderr,
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 atomically.
4983
-
4984
- Publication is a two-phase durable transaction (#496 S1 F1). After
4985
- `os.replace` no scratch pathname remains, so interrupted-rebuild recovery
4986
- cannot activate, and the published file carries the current epoch so
4987
- `open_db`'s zero-DDL fast path returns it with no validation. A
4988
- post-publication failure that only RAISED would therefore leave a known-bad
4989
- index accepted by every later command. The record and the marker are what
4990
- make the verdict outlive this process.
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
- if before_swap is not None:
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 _rebuild_quota_cache_leg(records) -> None:
5857
+ def _decoded_quota_stream(quota_raw, cutover_claude, counters=None):
5858
+ """Decode and normalize retained observation bytes ONE AT A TIME.
5859
+
5860
+ Peak heap therefore holds one record rather than the whole population.
5861
+ Normalization runs HERE, on the record decoded from the retained bytes: a
5862
+ dict normalized during the router pass is discarded with the pass, so
5863
+ stamping it there would be lost and every legacy observation would
5864
+ re-materialize with a NULL account_key. A Codex legacy line maps to
5865
+ `unattributed` regardless of the cutover value, so this does not depend on
5866
+ capture ordering (#496 S4 §6.3).
5867
+ """
5868
+ for raw in quota_raw:
5869
+ if counters is not None:
5870
+ counters["lines"] += 1
5871
+ counters["bytes"] += len(raw) + 1
5872
+ record = _lib_journal.decode_line(raw)
5873
+ if record is None: # pragma: no cover — retained bytes decoded once already
5874
+ continue
5875
+ if counters is not None:
5876
+ counters["decodes"] += 1
5877
+ _normalize_legacy_account_stamp(record, cutover_claude)
5878
+ yield record
5879
+
5880
+
5881
+ def _rebuild_quota_cache_leg_raw(
5882
+ quota_raw, decoded, cutover_claude, counters=None
5883
+ ) -> float:
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
- quota_obs = [r for r in records if _is_codex_quota_obs(r)]
5131
- file_accounts = [r for r in records if _is_codex_file_account_op(r)]
5132
- if not quota_obs and not file_accounts:
5133
- return
5900
+ cleanly).
5901
+
5902
+ Taking raw bytes is what makes the rebuild affordable. 1.81M decoded
5903
+ observation dictionaries are roughly six gigabytes against 1.64 GB of raw
5904
+ bytes, so retaining bytes removes about four gigabytes of peak heap while
5905
+ adding NO file input and NO second traversal — only the JSON decode of
5906
+ records already in memory moves inside the flocks (#496 S4 §6.3). That
5907
+ decode is why the measured hold is longer than it was before S4; see the
5908
+ spec's §6.3 for the measured figures.
5909
+
5910
+ Ordering is preserved: file-account decisions are ops, so the router already
5911
+ retains them decoded and they are available before the observation loop
5912
+ begins, exactly as the §3.5 precedence rule requires.
5913
+
5914
+ Returns the measured flock hold in seconds, which acceptance criterion 7
5915
+ caps.
5916
+ """
5917
+ file_accounts = [
5918
+ r for r in decoded if r is not None and _is_codex_file_account_op(r)
5919
+ ]
5920
+ if not quota_raw and not file_accounts:
5921
+ return 0.0
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(cache, quota_obs)
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
- segments = list_segments()
6087
+ # ONE segment snapshot for the whole rebuild (#496 S4 §4). `list_segments()`
6088
+ # re-enumerates the directory at call time and orders bootstrap segments
6089
+ # first, so a bootstrap segment appearing mid-rebuild would shift the indices
6090
+ # `iter_range` addresses by; before this, the read pass and the cutover scan
6091
+ # each listed separately and could already disagree about the journal's shape.
6092
+ all_segments = list_segments()
6093
+ segments = all_segments
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 _read_range(None, hw):
6144
+ for segment, offset, raw in _iter_range_with_segments(
6145
+ None, hw, segments,
6146
+ on_segment=lambda name: hasher.begin_segment(
6147
+ name, prior_high_water),
6148
+ on_bytes=hasher.extend,
6149
+ ):
6150
+ prefix["lines"] += 1
6151
+ prefix["bytes"] += len(raw) + 1
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
- _capture_protocol_prefix_evidence(
5268
- rec,
5269
- prior_high_water,
5270
- protocol_evidence,
5271
- )
5272
- decoded.append(rec)
6160
+ prefix["decodes"] += 1
6161
+ # `_capture_protocol_prefix_evidence` returns immediately for
6162
+ # anything that is not an op, so this guard changes nothing it
6163
+ # does — it moves the phase attribution's two clock reads from
6164
+ # every record to every op. That is 195 ops against 1,954,007
6165
+ # lines on a production journal, where the phase itself measures
6166
+ # zero because the journal carries no resolution operation.
6167
+ if rec.get("t") == "op":
6168
+ evidence_started = time.monotonic()
6169
+ _capture_protocol_prefix_evidence(
6170
+ rec,
6171
+ prior_high_water,
6172
+ protocol_evidence,
6173
+ hasher=hasher,
6174
+ )
6175
+ evidence_seconds += time.monotonic() - evidence_started
6176
+ # First cutover op wins, exactly as `find_accounts_cutover_op`
6177
+ # scans — captured here so the rebuild reads the journal once.
6178
+ if (cutover_captured is _CUTOVER_UNSEEN
6179
+ and rec.get("id") == CUTOVER_OP_ID):
6180
+ cutover_captured = _cutover_value_of(rec)
6181
+ last_seen.observe(rec, classify_legacy_provider)
6182
+ if rec.get("t") in _lib_journal_router.RETAINED_RECORD_TYPES:
6183
+ decoded.append(rec)
6184
+ else:
6185
+ if update_quota_cache and _is_codex_quota_obs(rec):
6186
+ quota_raw.append(raw)
6187
+ # A PLACEHOLDER, not a dropped element. `resolve_effective_events`
6188
+ # numbers candidates with `enumerate(records)`, and three of the
6189
+ # seven structural violation kinds put that number inside
6190
+ # `ProtocolViolation.evidence` — which the fingerprint hashes.
6191
+ # That fingerprint is durable: it lands in
6192
+ # `journal_protocol_violations` and is referenced by name from a
6193
+ # `journal_protocol_resolution` op, which `_cctally_journal_repair`
6194
+ # mints from the UNFILTERED record list. Renumbering here would
6195
+ # therefore make a previously acknowledged violation unresolvable
6196
+ # and raise on every later rebuild. The selector skips a non-dict
6197
+ # element, so this costs one pointer and keeps every sequence
6198
+ # identical to the pre-change numbering (#496 S4; corrects §4.7).
6199
+ decoded.append(None)
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 once from the journal's own cutover op (falls back
5283
- # to `unattributed` when none is present), so a fresh single-account
5284
- # rebuild is byte-neutral (everything is already `unattributed`).
5285
- cutover_claude = resolve_cutover_claude_account()
6215
+ # untouched. Resolved from the journal's own cutover op inline when the
6216
+ # streamed prefix contained it, otherwise from the unvisited suffix alone
6217
+ # (falls back to `unattributed` when neither has it), so a fresh
6218
+ # single-account rebuild is byte-neutral.
6219
+ cutover_started = time.monotonic()
6220
+ cutover_claude = _resolve_cutover_for_rebuild(
6221
+ cutover_captured, hw, all_segments, traversal["cutover_suffix"])
6222
+ phase_seconds["cutover_suffix"] = round(
6223
+ time.monotonic() - cutover_started, 6)
5286
6224
  for rec in decoded:
5287
- _normalize_legacy_account_stamp(rec, cutover_claude)
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
- _rebuild_quota_cache_leg(decoded)
6245
+ quota_lock_hold = _rebuild_quota_cache_leg_raw(
6246
+ quota_raw, decoded, cutover_claude, traversal["quota_replay"])
6247
+ quota_raw = []
6248
+ phase_seconds["quota_cache_leg"] = round(
6249
+ time.monotonic() - leg_started, 6)
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 (the observe ops folded
5366
- # in the structural phase already created the rows).
5367
- _derive_account_last_seen(conn, decoded)
6317
+ # account's last-seen from the whole journal. The map was
6318
+ # accumulated during the single read pass `decoded` no longer
6319
+ # contains observations, so deriving from it here would silently
6320
+ # drop every observation's contribution (#496 S4 §4.6).
6321
+ _apply_account_last_seen(
6322
+ conn,
6323
+ last_seen.resolve(cutover_claude, _lib_accounts.UNATTRIBUTED),
6324
+ )
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": len(segments),
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=len(segments),
5442
- lines_folded=lines_folded, conflicts=effective.conflicts,
5443
- protocol_violations=effective.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 _read_segment_lines(seg_path, 0, size):
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.get("payload") or {}).get("claude_legacy_account")
6872
+ return _cutover_value_of(rec)
5873
6873
  return None
5874
6874
 
5875
6875