cctally 1.91.0 → 1.92.0
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 +39 -0
- package/bin/_cctally_cache.py +863 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +39 -8
- package/bin/_cctally_dashboard.py +146 -5
- package/bin/_cctally_dashboard_conversation.py +164 -18
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +372 -10
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +535 -13
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +6 -0
- package/bin/_cctally_quota.py +171 -55
- package/bin/_cctally_record.py +13 -1
- package/bin/_cctally_rederive.py +4 -0
- package/bin/_cctally_store.py +311 -6
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2005 -95
- package/bin/_lib_codex_find_projection.py +370 -0
- package/bin/_lib_codex_harness_preamble.py +176 -0
- package/bin/_lib_codex_hooks.py +5 -3
- package/bin/_lib_codex_js_scan.py +254 -0
- package/bin/_lib_codex_landmarks.py +309 -0
- package/bin/_lib_codex_title_clean.py +116 -0
- package/bin/_lib_conversation_dispatch.py +153 -21
- package/bin/_lib_conversation_watch.py +4 -2
- package/bin/_lib_doctor.py +64 -0
- package/bin/_lib_quota_alert_axes.py +31 -34
- package/bin/_lib_stats_damage.py +523 -0
- package/bin/cctally +5 -0
- package/dashboard/static/assets/index-BEzzJtUd.js +97 -0
- package/dashboard/static/assets/{index-Dwirao3Y.css → index-DnWdv8um.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-CILAoEja.js +0 -90
package/bin/_cctally_journal.py
CHANGED
|
@@ -35,7 +35,7 @@ import signal
|
|
|
35
35
|
import sqlite3
|
|
36
36
|
import sys
|
|
37
37
|
import time
|
|
38
|
-
from dataclasses import dataclass, field
|
|
38
|
+
from dataclasses import dataclass, field, replace as _dc_replace
|
|
39
39
|
|
|
40
40
|
import _cctally_core
|
|
41
41
|
import _lib_accounts
|
|
@@ -1345,7 +1345,8 @@ def _report_file_account_conflicts(conflicts: int) -> None:
|
|
|
1345
1345
|
# forever, for every provider, not just Codex.
|
|
1346
1346
|
_QUOTA_SNAPSHOT_UPSERT_CLAUSE = (
|
|
1347
1347
|
" ON CONFLICT(source, source_path, line_offset, logical_limit_key) "
|
|
1348
|
-
"DO UPDATE SET account_key = excluded.account_key"
|
|
1348
|
+
"DO UPDATE SET account_key = excluded.account_key "
|
|
1349
|
+
"WHERE quota_window_snapshots.account_key IS NOT excluded.account_key"
|
|
1349
1350
|
)
|
|
1350
1351
|
|
|
1351
1352
|
_QUOTA_SNAPSHOT_UPSERT = _QUOTA_SNAPSHOT_INSERT + _QUOTA_SNAPSHOT_UPSERT_CLAUSE
|
|
@@ -1667,6 +1668,8 @@ def _cache_applier(decoded) -> int | None:
|
|
|
1667
1668
|
`decoded[stop]`'s offset, retrying the remainder next cycle (the scalar
|
|
1668
1669
|
cursor never advances past an unmaterialized record — spec §5.2 step 3).
|
|
1669
1670
|
- Flock acquired + everything upserted → return None (full consumption).
|
|
1671
|
+
A quota-row change advances ``codex_physical_mutation_seq`` in the same
|
|
1672
|
+
transaction; an idempotent replay leaves the sequence unchanged.
|
|
1670
1673
|
"""
|
|
1671
1674
|
quota_idx = [i for i, (rec, _s, _o) in enumerate(decoded)
|
|
1672
1675
|
if _is_codex_quota_obs(rec)]
|
|
@@ -1706,7 +1709,14 @@ def _cache_applier(decoded) -> int | None:
|
|
|
1706
1709
|
# must already govern the observations it covers.
|
|
1707
1710
|
_, _file_conflicts = _apply_file_account_records(
|
|
1708
1711
|
cache, [decoded[i][0] for i in file_idx])
|
|
1712
|
+
quota_changes_before = cache.total_changes
|
|
1709
1713
|
_apply_quota_records(cache, [decoded[i][0] for i in quota_idx])
|
|
1714
|
+
if cache.total_changes != quota_changes_before:
|
|
1715
|
+
# #457: this path is independent of the fused rollout writer,
|
|
1716
|
+
# but its quota rows feed the same certificate and dashboard
|
|
1717
|
+
# signatures. Keep the token atomic with the materialization.
|
|
1718
|
+
import _cctally_cache
|
|
1719
|
+
_cctally_cache._bump_codex_physical_mutation_seq(cache)
|
|
1710
1720
|
cache.commit()
|
|
1711
1721
|
_report_file_account_conflicts(_file_conflicts)
|
|
1712
1722
|
except sqlite3.Error as exc:
|
|
@@ -4027,7 +4037,12 @@ def _recover_completed_correction(
|
|
|
4027
4037
|
"maintenance-correction-rebuild",
|
|
4028
4038
|
ingest_lock=True,
|
|
4029
4039
|
):
|
|
4030
|
-
rebuild_stats_index(
|
|
4040
|
+
rebuild_stats_index(
|
|
4041
|
+
context=RebuildContext(
|
|
4042
|
+
trigger="correction-recovery-in-band"
|
|
4043
|
+
),
|
|
4044
|
+
high_water=signal.high_water,
|
|
4045
|
+
)
|
|
4031
4046
|
except BaseException as exc:
|
|
4032
4047
|
_cleanup_new_correction_scratches(scratches_before)
|
|
4033
4048
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
@@ -4174,6 +4189,55 @@ _REBUILD_COUNT_TABLES = (
|
|
|
4174
4189
|
)
|
|
4175
4190
|
|
|
4176
4191
|
|
|
4192
|
+
#: Every production path that reaches `rebuild_stats_index` against the live
|
|
4193
|
+
#: destination, plus one test-only identity. Closed by construction: a value
|
|
4194
|
+
#: outside this set is rejected by `RebuildContext.validate` (#496 S1 F3).
|
|
4195
|
+
#: `test-fixture` is for harnesses only, and
|
|
4196
|
+
#: `tests/test_stats_incident_identity.py` asserts no shipped call site emits it.
|
|
4197
|
+
REBUILD_TRIGGERS = frozenset({
|
|
4198
|
+
"corruption-heal",
|
|
4199
|
+
"interrupted-rebuild-recovery",
|
|
4200
|
+
"db-rebuild",
|
|
4201
|
+
"journal-repair-acknowledge",
|
|
4202
|
+
"journal-repair-recovery",
|
|
4203
|
+
"rederive-apply",
|
|
4204
|
+
"rederive-recovery",
|
|
4205
|
+
"correction-recovery-in-band",
|
|
4206
|
+
"epoch-transition",
|
|
4207
|
+
"test-fixture",
|
|
4208
|
+
})
|
|
4209
|
+
|
|
4210
|
+
|
|
4211
|
+
@dataclass(frozen=True)
|
|
4212
|
+
class RebuildContext:
|
|
4213
|
+
"""Why this rebuild ran, and what evidence preceded it (#496 S1 F3).
|
|
4214
|
+
|
|
4215
|
+
A bare identifier would not be enough: `trigger_error` and `forensics_path`
|
|
4216
|
+
cannot be derived from it, and both are what tie a quarantine incident to
|
|
4217
|
+
the forensics bundle written moments earlier.
|
|
4218
|
+
|
|
4219
|
+
`record_path` is resolved by `rebuild_stats_index` itself, never by a
|
|
4220
|
+
caller, so preservation and the rebuild record name the same file.
|
|
4221
|
+
"""
|
|
4222
|
+
|
|
4223
|
+
trigger: str
|
|
4224
|
+
trigger_error: "str | None" = None
|
|
4225
|
+
forensics_path: "str | None" = None
|
|
4226
|
+
record_path: "str | None" = None
|
|
4227
|
+
|
|
4228
|
+
def validate(self) -> "RebuildContext":
|
|
4229
|
+
if self.trigger not in REBUILD_TRIGGERS:
|
|
4230
|
+
raise ValueError(f"unknown rebuild trigger: {self.trigger!r}")
|
|
4231
|
+
if self.record_path is not None:
|
|
4232
|
+
# `rebuild_stats_index` overwrites this field unconditionally, so a
|
|
4233
|
+
# caller-supplied value would be silently discarded.
|
|
4234
|
+
raise ValueError(
|
|
4235
|
+
"record_path is resolved by rebuild_stats_index; callers must "
|
|
4236
|
+
"leave it unset"
|
|
4237
|
+
)
|
|
4238
|
+
return self
|
|
4239
|
+
|
|
4240
|
+
|
|
4177
4241
|
@dataclass
|
|
4178
4242
|
class RebuildResult:
|
|
4179
4243
|
"""Outcome of a `rebuild_stats_index` call (spec §5.4)."""
|
|
@@ -4299,7 +4363,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
|
|
|
4299
4363
|
# omitted column, constraint, partial predicate, or index definition. An epoch
|
|
4300
4364
|
# schema change must update this contract alongside STATS_INDEX_EPOCH.
|
|
4301
4365
|
_REBUILD_SCHEMA_FINGERPRINT = (
|
|
4302
|
-
"
|
|
4366
|
+
"47bbbfde25fe4e4d40cb39671cf0c6c8fe2d8e9a7a5ce276927b410b916afd54"
|
|
4303
4367
|
)
|
|
4304
4368
|
|
|
4305
4369
|
|
|
@@ -4532,8 +4596,15 @@ def stats_index_matches_journal_prefix(
|
|
|
4532
4596
|
return False
|
|
4533
4597
|
|
|
4534
4598
|
|
|
4535
|
-
def _prepare_existing_stats_for_cutover(path: pathlib.Path) ->
|
|
4536
|
-
"""Checkpoint a readable old index so removing its sidecars is kill-safe.
|
|
4599
|
+
def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> str:
|
|
4600
|
+
"""Checkpoint a readable old index so removing its sidecars is kill-safe.
|
|
4601
|
+
|
|
4602
|
+
Returns what it actually did, so the incident manifest can say whether the
|
|
4603
|
+
explicit checkpoint ran (#496 S1 F8). Failure still RAISES rather than
|
|
4604
|
+
returning an outcome — the caller records `failed` and re-raises, because
|
|
4605
|
+
proceeding past an undrained WAL would pair stale sidecars with the
|
|
4606
|
+
replacement main file.
|
|
4607
|
+
"""
|
|
4537
4608
|
import _cctally_db
|
|
4538
4609
|
|
|
4539
4610
|
try:
|
|
@@ -4551,11 +4622,281 @@ def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> None:
|
|
|
4551
4622
|
# Auto-heal necessarily starts from an unreadable family. Preserve its
|
|
4552
4623
|
# exact bytes below, then publish the already-validated replacement.
|
|
4553
4624
|
if _cctally_db._is_sqlite_corruption_error(exc):
|
|
4554
|
-
return
|
|
4625
|
+
return "skipped_corrupt"
|
|
4555
4626
|
raise
|
|
4627
|
+
return "checkpointed"
|
|
4628
|
+
|
|
4629
|
+
|
|
4630
|
+
def _utc_iso_now() -> str:
|
|
4631
|
+
return dt.datetime.now(dt.timezone.utc).isoformat(
|
|
4632
|
+
timespec="seconds"
|
|
4633
|
+
).replace("+00:00", "Z")
|
|
4634
|
+
|
|
4635
|
+
|
|
4636
|
+
def validate_published_stats_index(
|
|
4637
|
+
path, high_water: "tuple[str, int] | None"
|
|
4638
|
+
) -> "str | None":
|
|
4639
|
+
"""Validate an index on a FRESH read-only connection (#496 S1 F1).
|
|
4640
|
+
|
|
4641
|
+
Returns None on success, or a short failure reason. The building
|
|
4642
|
+
connection wrote the pages it then validated, so it is not an independent
|
|
4643
|
+
witness to what reached the disk; this reopens the file instead. The
|
|
4644
|
+
mechanism is already proven by `stats_index_matches_journal_prefix`, which
|
|
4645
|
+
runs the same check on the same kind of connection.
|
|
4646
|
+
|
|
4647
|
+
Public because `stats_open_guarded` runs exactly this check when it
|
|
4648
|
+
resolves a pending publication marker.
|
|
4649
|
+
"""
|
|
4650
|
+
try:
|
|
4651
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4652
|
+
try:
|
|
4653
|
+
_validate_rebuilt_stats_index(conn, high_water)
|
|
4654
|
+
finally:
|
|
4655
|
+
conn.close()
|
|
4656
|
+
except BaseException as exc:
|
|
4657
|
+
return f"{type(exc).__name__}: {exc}"[:500]
|
|
4658
|
+
return None
|
|
4659
|
+
|
|
4660
|
+
|
|
4661
|
+
def _publication_marker_path(destination) -> pathlib.Path:
|
|
4662
|
+
return pathlib.Path(str(destination) + ".publication")
|
|
4663
|
+
|
|
4664
|
+
|
|
4665
|
+
def _write_publication_marker(
|
|
4666
|
+
destination, record_path, *, started_at: str, scratch_path,
|
|
4667
|
+
status: str = "pending", error: "str | None" = None,
|
|
4668
|
+
prior: "dict | None" = None,
|
|
4669
|
+
) -> None:
|
|
4670
|
+
"""Publish the durable marker a later opener honours (#496 S1 F1).
|
|
4671
|
+
|
|
4672
|
+
Mirrors the existing `cache.db.repairing` marker idiom.
|
|
4673
|
+
`_atomic_write_private_json` writes at mode 0600 and fsyncs both the file
|
|
4674
|
+
and its parent directory.
|
|
4675
|
+
|
|
4676
|
+
`scratchPath` records the exact index this publication was about to install.
|
|
4677
|
+
Across processes `os.replace` is the only thing that consumes a scratch, so
|
|
4678
|
+
its presence or absence on disk is an exact answer to "did this run replace
|
|
4679
|
+
the destination?" — which is what interrupted-rebuild recovery needs in
|
|
4680
|
+
order to tell a marker it supersedes from one whose verdict is still owed.
|
|
4681
|
+
(Within one process the claim is weaker; see
|
|
4682
|
+
`_cctally_store._pending_stats_publication_never_replaced`.)
|
|
4683
|
+
|
|
4684
|
+
`priorFailure` carries a settled verdict this publication is about to
|
|
4685
|
+
overwrite, so a crash before `os.replace` cannot discard it — see
|
|
4686
|
+
`_settle_prior_publication_verdict`.
|
|
4687
|
+
"""
|
|
4688
|
+
import _cctally_db
|
|
4689
|
+
|
|
4690
|
+
payload = {
|
|
4691
|
+
"schemaVersion": 1,
|
|
4692
|
+
"status": status,
|
|
4693
|
+
"recordPath": str(record_path),
|
|
4694
|
+
"startedAtUtc": started_at,
|
|
4695
|
+
"scratchPath": str(scratch_path),
|
|
4696
|
+
}
|
|
4697
|
+
if error is not None:
|
|
4698
|
+
payload["error"] = error
|
|
4699
|
+
if prior:
|
|
4700
|
+
payload["priorFailure"] = prior
|
|
4701
|
+
_cctally_db._atomic_write_private_json(
|
|
4702
|
+
_publication_marker_path(destination), payload
|
|
4703
|
+
)
|
|
4704
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
4705
|
+
|
|
4706
|
+
|
|
4707
|
+
def _read_publication_marker(destination) -> "dict | None":
|
|
4708
|
+
"""The marker's state as a MAPPING, or None when no marker exists.
|
|
4709
|
+
|
|
4710
|
+
Present-but-unusable bytes read as an empty mapping, for the reason given
|
|
4711
|
+
in `_cctally_store._read_stats_publication_marker`.
|
|
4712
|
+
"""
|
|
4713
|
+
try:
|
|
4714
|
+
state = json.loads(_publication_marker_path(destination).read_text())
|
|
4715
|
+
except FileNotFoundError:
|
|
4716
|
+
return None
|
|
4717
|
+
except (OSError, ValueError):
|
|
4718
|
+
return {}
|
|
4719
|
+
return state if isinstance(state, dict) else {}
|
|
4720
|
+
|
|
4721
|
+
|
|
4722
|
+
def _settle_prior_publication_verdict(destination) -> "dict | None":
|
|
4723
|
+
"""Settle the verdict a PREVIOUS publication still owes, before this one
|
|
4724
|
+
overwrites the single marker slot (#496 S1 F1).
|
|
4725
|
+
|
|
4726
|
+
`<db>.publication` is one file, so Phase 1 of a new publication destroys
|
|
4727
|
+
whatever the last one left there. `cmd_db_rebuild` takes maintenance
|
|
4728
|
+
EXCLUSIVE without opening the live database through `stats_open_guarded`,
|
|
4729
|
+
so a rebuild can legitimately begin while a pending marker still owes a
|
|
4730
|
+
verdict on an already-published, never-validated index. If this run then
|
|
4731
|
+
dies between its own marker write and its `os.replace`, the next opener
|
|
4732
|
+
sees a pending marker beside this run's own scratch, discards it as
|
|
4733
|
+
never-replaced, and accepts the earlier index having never validated it.
|
|
4734
|
+
|
|
4735
|
+
Resolving is preferred over refusing to overwrite, because a refusal would
|
|
4736
|
+
wedge the one operation that repairs a bad index behind the marker that
|
|
4737
|
+
reports it. Returns the verdict to CARRY into this run's marker, or None
|
|
4738
|
+
when nothing is owed:
|
|
4739
|
+
|
|
4740
|
+
- 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.
|
|
4750
|
+
|
|
4751
|
+
A marker that cannot be judged (no record, or no pinned high-water) is left
|
|
4752
|
+
to the opener's existing discard policy rather than wedging the rebuild.
|
|
4753
|
+
"""
|
|
4754
|
+
import _cctally_db
|
|
4755
|
+
|
|
4756
|
+
state = _read_publication_marker(destination)
|
|
4757
|
+
if not state:
|
|
4758
|
+
return None
|
|
4759
|
+
status = str(state.get("status") or "")
|
|
4760
|
+
if status == "failed":
|
|
4761
|
+
return state
|
|
4762
|
+
if status != "pending":
|
|
4763
|
+
return None
|
|
4764
|
+
scratch = state.get("scratchPath")
|
|
4765
|
+
if isinstance(scratch, str) and scratch and pathlib.Path(scratch).exists():
|
|
4766
|
+
# This marker owes nothing itself, but dropping what it carries would
|
|
4767
|
+
# lose an older run's verdict once a third run crashes the same way.
|
|
4768
|
+
carried = state.get("priorFailure")
|
|
4769
|
+
return carried if isinstance(carried, dict) and carried else None
|
|
4770
|
+
record_path = state.get("recordPath")
|
|
4771
|
+
record = None
|
|
4772
|
+
if isinstance(record_path, str):
|
|
4773
|
+
try:
|
|
4774
|
+
record = json.loads(pathlib.Path(record_path).read_text())
|
|
4775
|
+
except (OSError, ValueError):
|
|
4776
|
+
record = None
|
|
4777
|
+
if not isinstance(record, dict) or "highWater" not in record:
|
|
4778
|
+
return None
|
|
4779
|
+
raw = record.get("highWater")
|
|
4780
|
+
high_water = (
|
|
4781
|
+
(str(raw[0]), int(raw[1]))
|
|
4782
|
+
if isinstance(raw, (list, tuple)) and len(raw) == 2
|
|
4783
|
+
else None
|
|
4784
|
+
)
|
|
4785
|
+
error = validate_published_stats_index(destination, high_water)
|
|
4786
|
+
if error is None:
|
|
4787
|
+
_remove_publication_marker(destination)
|
|
4788
|
+
return None
|
|
4789
|
+
settled = dict(state)
|
|
4790
|
+
settled.update({"status": "failed", "error": error})
|
|
4791
|
+
# Durable BEFORE this run touches the destination: a crash between here and
|
|
4792
|
+
# the marker this run is about to write must still leave the verdict.
|
|
4793
|
+
_cctally_db._atomic_write_private_json(
|
|
4794
|
+
_publication_marker_path(destination), settled
|
|
4795
|
+
)
|
|
4796
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
4797
|
+
record.update({
|
|
4798
|
+
"status": "failed",
|
|
4799
|
+
"postPublicationValidation": {"ok": False, "error": error},
|
|
4800
|
+
})
|
|
4801
|
+
try:
|
|
4802
|
+
_write_rebuild_record(record_path, record)
|
|
4803
|
+
except OSError:
|
|
4804
|
+
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,
|
|
4810
|
+
)
|
|
4811
|
+
return settled
|
|
4556
4812
|
|
|
4557
4813
|
|
|
4558
|
-
def
|
|
4814
|
+
def _remove_publication_marker(destination) -> None:
|
|
4815
|
+
try:
|
|
4816
|
+
_publication_marker_path(destination).unlink()
|
|
4817
|
+
except FileNotFoundError:
|
|
4818
|
+
pass
|
|
4819
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
4820
|
+
|
|
4821
|
+
|
|
4822
|
+
def _write_rebuild_record(path, payload: dict) -> None:
|
|
4823
|
+
import _cctally_db
|
|
4824
|
+
|
|
4825
|
+
path = pathlib.Path(path)
|
|
4826
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
4827
|
+
_cctally_db._atomic_write_private_json(path, payload)
|
|
4828
|
+
|
|
4829
|
+
|
|
4830
|
+
def _forensics_shape_token(forensics_path) -> "str | None":
|
|
4831
|
+
if not forensics_path:
|
|
4832
|
+
return None
|
|
4833
|
+
try:
|
|
4834
|
+
bundle = json.loads(pathlib.Path(forensics_path).read_text())
|
|
4835
|
+
except (OSError, ValueError):
|
|
4836
|
+
return None
|
|
4837
|
+
damage = bundle.get("damage")
|
|
4838
|
+
return damage.get("shapeToken") if isinstance(damage, dict) else None
|
|
4839
|
+
|
|
4840
|
+
|
|
4841
|
+
def _scan_stats_damage(path) -> dict:
|
|
4842
|
+
"""Describe one stats family member by reading its bytes. Never raises."""
|
|
4843
|
+
try:
|
|
4844
|
+
import _lib_stats_damage
|
|
4845
|
+
|
|
4846
|
+
return _lib_stats_damage.describe_damage(integrity_rows=None, path=path)
|
|
4847
|
+
except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
|
|
4848
|
+
return {
|
|
4849
|
+
"schemaVersion": 1,
|
|
4850
|
+
"method": "unavailable",
|
|
4851
|
+
"findings": [],
|
|
4852
|
+
"shapeToken": "none",
|
|
4853
|
+
"reason": f"{type(exc).__name__}: {exc}"[:200],
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
|
|
4857
|
+
def _record_post_checkpoint_damage(
|
|
4858
|
+
incident: pathlib.Path, destination: pathlib.Path, outcome: str,
|
|
4859
|
+
) -> "dict | None":
|
|
4860
|
+
"""Add the post-checkpoint scan to an already-written incident manifest.
|
|
4861
|
+
|
|
4862
|
+
A second `_atomic_write_private_json` to the same path is safe: the write
|
|
4863
|
+
is atomic and nothing references the incident yet. It has to be a second
|
|
4864
|
+
write because preservation runs BEFORE the explicit checkpoint, so the
|
|
4865
|
+
outcome this records does not exist when the manifest is first written.
|
|
4866
|
+
"""
|
|
4867
|
+
import _cctally_db
|
|
4868
|
+
|
|
4869
|
+
try:
|
|
4870
|
+
manifest_path = incident / "manifest.json"
|
|
4871
|
+
manifest = json.loads(manifest_path.read_text())
|
|
4872
|
+
damage = manifest.get("damage") or {}
|
|
4873
|
+
damage["postCheckpoint"] = _scan_stats_damage(destination)
|
|
4874
|
+
damage["checkpointOutcome"] = outcome
|
|
4875
|
+
manifest["damage"] = damage
|
|
4876
|
+
_cctally_db._atomic_write_private_json(manifest_path, manifest)
|
|
4877
|
+
return damage
|
|
4878
|
+
except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
|
|
4879
|
+
print(
|
|
4880
|
+
f"[rebuild] post-checkpoint damage scan failed: {exc}",
|
|
4881
|
+
file=sys.stderr,
|
|
4882
|
+
)
|
|
4883
|
+
return None
|
|
4884
|
+
|
|
4885
|
+
|
|
4886
|
+
def _binary_version() -> "str | None":
|
|
4887
|
+
"""The running binary's released version, or None when it cannot be read."""
|
|
4888
|
+
try:
|
|
4889
|
+
import _lib_changelog
|
|
4890
|
+
|
|
4891
|
+
value = _lib_changelog._read_latest_changelog_version()
|
|
4892
|
+
except Exception: # pragma: no cover — a missing CHANGELOG is not fatal
|
|
4893
|
+
return None
|
|
4894
|
+
return value[0] if value else None
|
|
4895
|
+
|
|
4896
|
+
|
|
4897
|
+
def _preserve_stats_family_for_cutover(
|
|
4898
|
+
path: pathlib.Path, *, context: RebuildContext,
|
|
4899
|
+
) -> pathlib.Path:
|
|
4559
4900
|
"""Durably copy the old family into quarantine without removing the main."""
|
|
4560
4901
|
import _cctally_db
|
|
4561
4902
|
|
|
@@ -4580,9 +4921,21 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
|
|
|
4580
4921
|
]
|
|
4581
4922
|
if not members:
|
|
4582
4923
|
raise OSError(f"no database family exists to preserve at {path}")
|
|
4924
|
+
# Observed sizes are read BEFORE the copy, so the empty-WAL case in the
|
|
4925
|
+
# routine corruption heal is evidenced rather than assumed (#496 S1 F2).
|
|
4926
|
+
family_sizes = {}
|
|
4927
|
+
for name in members:
|
|
4928
|
+
try:
|
|
4929
|
+
family_sizes[name] = path.with_name(name).stat().st_size
|
|
4930
|
+
except OSError:
|
|
4931
|
+
family_sizes[name] = None
|
|
4932
|
+
# Read from the raw header rather than by opening the file: the file this
|
|
4933
|
+
# is asked about is typically one SQLite refuses to open, which is exactly
|
|
4934
|
+
# when the epoch it carried is worth recording (#496 S1).
|
|
4935
|
+
preserved_user_version = _cctally_db._read_user_version_header(path)
|
|
4583
4936
|
_cctally_db._copy_db_family(path, destination)
|
|
4584
4937
|
manifest = {
|
|
4585
|
-
"schemaVersion":
|
|
4938
|
+
"schemaVersion": 2,
|
|
4586
4939
|
"quarantinedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat(
|
|
4587
4940
|
timespec="seconds"
|
|
4588
4941
|
).replace("+00:00", "Z"),
|
|
@@ -4590,6 +4943,25 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
|
|
|
4590
4943
|
"movedFiles": members,
|
|
4591
4944
|
"complete": True,
|
|
4592
4945
|
"cutoverProtocol": "preserve-then-atomic-replace-v1",
|
|
4946
|
+
# #496 S1 additive fields. Every key above keeps its v1 name and
|
|
4947
|
+
# meaning, so a v1 reader is unaffected by the bump.
|
|
4948
|
+
"trigger": context.trigger,
|
|
4949
|
+
"triggerError": context.trigger_error,
|
|
4950
|
+
"forensicsPath": context.forensics_path,
|
|
4951
|
+
"rebuildRecordPath": context.record_path,
|
|
4952
|
+
"binaryVersion": _binary_version(),
|
|
4953
|
+
"binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
|
|
4954
|
+
"preservedUserVersion": preserved_user_version,
|
|
4955
|
+
"familySizes": family_sizes,
|
|
4956
|
+
# The retained COPY is described, not the live file, because the copy
|
|
4957
|
+
# is the artifact that actually survives. `postCheckpoint` and
|
|
4958
|
+
# `checkpointOutcome` are filled in by the caller once the explicit
|
|
4959
|
+
# checkpoint has run (#496 S1 F8 section 6.3).
|
|
4960
|
+
"damage": {
|
|
4961
|
+
"preserved": _scan_stats_damage(destination),
|
|
4962
|
+
"postCheckpoint": None,
|
|
4963
|
+
"checkpointOutcome": None,
|
|
4964
|
+
},
|
|
4593
4965
|
}
|
|
4594
4966
|
_cctally_db._atomic_write_private_json(incident / "manifest.json", manifest)
|
|
4595
4967
|
_fsync_dir(incident)
|
|
@@ -4602,16 +4974,35 @@ def _publish_rebuilt_stats_index(
|
|
|
4602
4974
|
scratch: pathlib.Path,
|
|
4603
4975
|
destination: pathlib.Path,
|
|
4604
4976
|
preserve_existing: bool,
|
|
4977
|
+
context: RebuildContext,
|
|
4978
|
+
high_water: "tuple[str, int] | None",
|
|
4979
|
+
record: dict,
|
|
4605
4980
|
before_swap=None,
|
|
4606
4981
|
) -> "pathlib.Path | None":
|
|
4607
|
-
"""Publish one validated, closed, sidecar-free scratch index atomically.
|
|
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.
|
|
4991
|
+
"""
|
|
4608
4992
|
import _cctally_store
|
|
4609
4993
|
|
|
4994
|
+
# A marker already beside the destination may still owe a verdict on bytes
|
|
4995
|
+
# that are live right now. Settle it BEFORE Phase 1 overwrites the only
|
|
4996
|
+
# marker slot, while the destination is still exactly what that publication
|
|
4997
|
+
# left there.
|
|
4998
|
+
prior = _settle_prior_publication_verdict(destination)
|
|
4999
|
+
|
|
4610
5000
|
family_exists = any(
|
|
4611
5001
|
pathlib.Path(str(destination) + suffix).exists()
|
|
4612
5002
|
for suffix in ("", "-wal", "-shm")
|
|
4613
5003
|
)
|
|
4614
5004
|
incident = None
|
|
5005
|
+
damage_tokens = None
|
|
4615
5006
|
if family_exists:
|
|
4616
5007
|
blocked = _cctally_store._stats_family_drained(destination)
|
|
4617
5008
|
if blocked is not None:
|
|
@@ -4620,20 +5011,102 @@ def _publish_rebuilt_stats_index(
|
|
|
4620
5011
|
if preserve_existing:
|
|
4621
5012
|
# Preserve the exact pre-cutover family, including a committed WAL
|
|
4622
5013
|
# and SHM, before checkpointing mutates or removes those sidecars.
|
|
4623
|
-
incident = _preserve_stats_family_for_cutover(
|
|
5014
|
+
incident = _preserve_stats_family_for_cutover(
|
|
5015
|
+
destination, context=context
|
|
5016
|
+
)
|
|
5017
|
+
checkpoint_outcome = "skipped_absent"
|
|
4624
5018
|
if destination.exists():
|
|
4625
|
-
|
|
5019
|
+
try:
|
|
5020
|
+
checkpoint_outcome = _prepare_existing_stats_for_cutover(
|
|
5021
|
+
destination
|
|
5022
|
+
)
|
|
5023
|
+
except BaseException:
|
|
5024
|
+
if incident is not None:
|
|
5025
|
+
_record_post_checkpoint_damage(
|
|
5026
|
+
incident, destination, "failed"
|
|
5027
|
+
)
|
|
5028
|
+
raise
|
|
5029
|
+
if incident is not None:
|
|
5030
|
+
# Scanned BEFORE the sidecars are removed, so this and the
|
|
5031
|
+
# preserved scan bracket the explicit checkpoint.
|
|
5032
|
+
damage = _record_post_checkpoint_damage(
|
|
5033
|
+
incident, destination, checkpoint_outcome
|
|
5034
|
+
)
|
|
5035
|
+
if damage:
|
|
5036
|
+
damage_tokens = {
|
|
5037
|
+
"forensics": _forensics_shape_token(context.forensics_path),
|
|
5038
|
+
"preserved": (damage.get("preserved") or {}).get(
|
|
5039
|
+
"shapeToken"
|
|
5040
|
+
),
|
|
5041
|
+
"postCheckpoint": (damage.get("postCheckpoint") or {}).get(
|
|
5042
|
+
"shapeToken"
|
|
5043
|
+
),
|
|
5044
|
+
"checkpointOutcome": damage.get("checkpointOutcome"),
|
|
5045
|
+
}
|
|
4626
5046
|
# The old main stays present and, when it was readable, fully
|
|
4627
5047
|
# checkpointed. A kill from here until os.replace therefore still
|
|
4628
5048
|
# leaves a usable old destination while preventing stale sidecars from
|
|
4629
5049
|
# being paired with the replacement main.
|
|
4630
5050
|
_remove_db_sidecars_strict(destination)
|
|
5051
|
+
_cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
|
|
4631
5052
|
|
|
4632
5053
|
if before_swap is not None:
|
|
4633
5054
|
before_swap()
|
|
5055
|
+
|
|
5056
|
+
# Phase 1 of the publication transaction: the record and then the marker,
|
|
5057
|
+
# each fsynced, BEFORE the replacement becomes visible.
|
|
5058
|
+
started_at = _utc_iso_now()
|
|
5059
|
+
record = dict(record)
|
|
5060
|
+
record.update({
|
|
5061
|
+
"status": "pending",
|
|
5062
|
+
"startedAtUtc": started_at,
|
|
5063
|
+
"completedAtUtc": None,
|
|
5064
|
+
"incidentPath": str(incident) if incident is not None else None,
|
|
5065
|
+
"damageShapeTokens": damage_tokens,
|
|
5066
|
+
"postPublicationValidation": None,
|
|
5067
|
+
})
|
|
5068
|
+
record_path = pathlib.Path(context.record_path)
|
|
5069
|
+
_write_rebuild_record(record_path, record)
|
|
5070
|
+
_write_publication_marker(
|
|
5071
|
+
destination, record_path, started_at=started_at, scratch_path=scratch,
|
|
5072
|
+
prior=prior,
|
|
5073
|
+
)
|
|
5074
|
+
|
|
4634
5075
|
_stats_rebuild_test_pause("rebuild_before_cutover")
|
|
4635
5076
|
os.replace(str(scratch), str(destination))
|
|
4636
5077
|
_fsync_dir(destination.parent)
|
|
5078
|
+
_stats_rebuild_test_pause("rebuild_after_publication_replace")
|
|
5079
|
+
|
|
5080
|
+
# Phase 2: validate the bytes that are now live, on a connection that never
|
|
5081
|
+
# saw them being written.
|
|
5082
|
+
post_error = validate_published_stats_index(destination, high_water)
|
|
5083
|
+
# The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
|
|
5084
|
+
# Remove them so the documented no-post-publication-stale-sidecar end state
|
|
5085
|
+
# still holds; an empty WAL is consistent with the freshly published main,
|
|
5086
|
+
# so a crash between validation and removal is harmless.
|
|
5087
|
+
_remove_db_sidecars_strict(destination)
|
|
5088
|
+
|
|
5089
|
+
record["postPublicationValidation"] = {
|
|
5090
|
+
"ok": post_error is None,
|
|
5091
|
+
"error": post_error,
|
|
5092
|
+
}
|
|
5093
|
+
record["completedAtUtc"] = _utc_iso_now()
|
|
5094
|
+
record["status"] = "ok" if post_error is None else "failed"
|
|
5095
|
+
_write_rebuild_record(record_path, record)
|
|
5096
|
+
if post_error is not None:
|
|
5097
|
+
# No rollback is possible: the old family is already quarantined, and
|
|
5098
|
+
# restoring it would republish a file known to be corrupt. Any carried
|
|
5099
|
+
# prior verdict is dropped here on purpose: `os.replace` succeeded, so
|
|
5100
|
+
# the bytes it judged are gone and THIS failure is the live one.
|
|
5101
|
+
_write_publication_marker(
|
|
5102
|
+
destination, record_path, started_at=started_at,
|
|
5103
|
+
scratch_path=scratch, status="failed", error=post_error,
|
|
5104
|
+
)
|
|
5105
|
+
raise JournalError(
|
|
5106
|
+
"published stats index failed post-publication validation: "
|
|
5107
|
+
f"{post_error}; rebuild record: {record_path}"
|
|
5108
|
+
)
|
|
5109
|
+
_remove_publication_marker(destination)
|
|
4637
5110
|
return incident
|
|
4638
5111
|
|
|
4639
5112
|
|
|
@@ -4707,6 +5180,7 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
4707
5180
|
|
|
4708
5181
|
def rebuild_stats_index(
|
|
4709
5182
|
*,
|
|
5183
|
+
context: RebuildContext,
|
|
4710
5184
|
target_path=None,
|
|
4711
5185
|
high_water: "tuple[str, int] | None" = None,
|
|
4712
5186
|
update_quota_cache: bool = True,
|
|
@@ -4721,6 +5195,10 @@ def rebuild_stats_index(
|
|
|
4721
5195
|
no alerts, no `reconcile_config` (see the module note above). Post-rebuild the
|
|
4722
5196
|
cursor equals the journal high-water.
|
|
4723
5197
|
|
|
5198
|
+
`context` states WHY this rebuild ran (#496 S1 F3). It is keyword-only with
|
|
5199
|
+
no default, so a future call site cannot silently produce an unattributed
|
|
5200
|
+
quarantine incident; omitting it raises `TypeError` at the call.
|
|
5201
|
+
|
|
4724
5202
|
`target_path` selects the destination (default `DB_PATH`). `high_water`
|
|
4725
5203
|
optionally pins the exact inclusive journal prefix; later bytes stay beyond
|
|
4726
5204
|
the rebuilt cursor. `update_quota_cache=False` is the Task-C Claude-only
|
|
@@ -4731,6 +5209,17 @@ def rebuild_stats_index(
|
|
|
4731
5209
|
publication but does not create a live-family quarantine incident.
|
|
4732
5210
|
"""
|
|
4733
5211
|
start = time.monotonic()
|
|
5212
|
+
context = context.validate()
|
|
5213
|
+
# Resolve the rebuild record's path ONCE, here, because preservation runs
|
|
5214
|
+
# long before the record is written and both must name the same file
|
|
5215
|
+
# (#496 S1). Callers never supply it.
|
|
5216
|
+
record_stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S_%f")
|
|
5217
|
+
context = _dc_replace(
|
|
5218
|
+
context,
|
|
5219
|
+
record_path=str(
|
|
5220
|
+
_cctally_core.LOG_DIR / f"stats-rebuild-{record_stamp}.json"
|
|
5221
|
+
),
|
|
5222
|
+
)
|
|
4734
5223
|
dest = (pathlib.Path(target_path) if target_path is not None
|
|
4735
5224
|
else pathlib.Path(_cctally_core.DB_PATH))
|
|
4736
5225
|
|
|
@@ -4907,11 +5396,44 @@ def rebuild_stats_index(
|
|
|
4907
5396
|
with scratch.open("rb") as handle:
|
|
4908
5397
|
os.fsync(handle.fileno())
|
|
4909
5398
|
_fsync_dir(scratch.parent)
|
|
5399
|
+
|
|
5400
|
+
# First fresh-connection validation (#496 S1 F1). A failure here raises
|
|
5401
|
+
# BEFORE any preservation, so no incident is created and the old family
|
|
5402
|
+
# stays live — the existing contract is preserved exactly.
|
|
5403
|
+
pre_error = validate_published_stats_index(scratch, hw)
|
|
5404
|
+
if pre_error is not None:
|
|
5405
|
+
raise JournalError(
|
|
5406
|
+
f"rebuilt stats index failed pre-publication validation: {pre_error}"
|
|
5407
|
+
)
|
|
5408
|
+
# That read-only open recreated the scratch sidecars; publication requires
|
|
5409
|
+
# a sidecar-free scratch, and a leftover pair would also survive the
|
|
5410
|
+
# `os.replace` as a stray artifact.
|
|
5411
|
+
_remove_db_sidecars_strict(scratch)
|
|
5412
|
+
|
|
4910
5413
|
incident = _publish_rebuilt_stats_index(
|
|
4911
5414
|
scratch=scratch,
|
|
4912
5415
|
destination=dest,
|
|
4913
5416
|
preserve_existing=target_path is None,
|
|
4914
5417
|
before_swap=before_swap,
|
|
5418
|
+
context=context,
|
|
5419
|
+
high_water=hw,
|
|
5420
|
+
record={
|
|
5421
|
+
"schemaVersion": 1,
|
|
5422
|
+
"trigger": context.trigger,
|
|
5423
|
+
"triggerError": context.trigger_error,
|
|
5424
|
+
"forensicsPath": context.forensics_path,
|
|
5425
|
+
"binaryVersion": _binary_version(),
|
|
5426
|
+
"binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
|
|
5427
|
+
"highWater": [hw[0], hw[1]] if hw is not None else None,
|
|
5428
|
+
"destination": str(dest),
|
|
5429
|
+
"targetPath": str(target_path) if target_path is not None else None,
|
|
5430
|
+
"segmentsRead": len(segments),
|
|
5431
|
+
"linesFolded": lines_folded,
|
|
5432
|
+
"malformed": malformed,
|
|
5433
|
+
"rowsByTable": rows_by_table,
|
|
5434
|
+
"buildSeconds": round(time.monotonic() - start, 3),
|
|
5435
|
+
"prePublicationValidation": {"ok": True, "error": None},
|
|
5436
|
+
},
|
|
4915
5437
|
)
|
|
4916
5438
|
|
|
4917
5439
|
return RebuildResult(
|
|
@@ -5386,5 +5908,5 @@ def run_epoch_transition(*, claude_json_path=None) -> str:
|
|
|
5386
5908
|
preserves the old index only after the replacement is validated."""
|
|
5387
5909
|
claude_key = _resolve_claude_cutover_identity(claude_json_path)
|
|
5388
5910
|
recorded = append_accounts_cutover_op(claude_key)
|
|
5389
|
-
rebuild_stats_index()
|
|
5911
|
+
rebuild_stats_index(context=RebuildContext(trigger="epoch-transition"))
|
|
5390
5912
|
return recorded
|