cctally 1.91.0 → 1.92.1
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 +51 -0
- package/README.md +4 -2
- package/bin/_cctally_cache.py +903 -74
- package/bin/_cctally_config.py +57 -0
- package/bin/_cctally_core.py +94 -14
- package/bin/_cctally_dashboard.py +217 -19
- package/bin/_cctally_dashboard_conversation.py +170 -20
- package/bin/_cctally_dashboard_envelope.py +2 -0
- package/bin/_cctally_db.py +481 -19
- package/bin/_cctally_doctor.py +18 -1
- package/bin/_cctally_journal.py +1156 -21
- package/bin/_cctally_journal_repair.py +6 -0
- package/bin/_cctally_parser.py +26 -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_statusline.py +6 -6
- package/bin/_cctally_store.py +1061 -40
- package/bin/_cctally_transcript.py +32 -2
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_cache_report.py +8 -3
- package/bin/_lib_codex_conversation.py +851 -81
- package/bin/_lib_codex_conversation_query.py +2031 -96
- package/bin/_lib_codex_find_projection.py +517 -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 +168 -22
- package/bin/_lib_conversation_query.py +62 -2
- 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/_lib_stats_publish.py +243 -0
- package/bin/cctally +17 -3
- package/dashboard/static/assets/index-Dat-mza6.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 +8 -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)."""
|
|
@@ -4251,6 +4315,7 @@ _REBUILD_REQUIRED_TABLES = frozenset(
|
|
|
4251
4315
|
"schema_migrations",
|
|
4252
4316
|
"schema_migrations_skipped",
|
|
4253
4317
|
"stats_open_fixups",
|
|
4318
|
+
"stats_publication_stamp",
|
|
4254
4319
|
"week_reset_events",
|
|
4255
4320
|
"weekly_cost_snapshots",
|
|
4256
4321
|
"weekly_credit_floors",
|
|
@@ -4299,7 +4364,7 @@ _REBUILD_REQUIRED_INDEXES = frozenset(
|
|
|
4299
4364
|
# omitted column, constraint, partial predicate, or index definition. An epoch
|
|
4300
4365
|
# schema change must update this contract alongside STATS_INDEX_EPOCH.
|
|
4301
4366
|
_REBUILD_SCHEMA_FINGERPRINT = (
|
|
4302
|
-
"
|
|
4367
|
+
"7dde5a7995f441558d08b0204136824d6ff7208b221e576c79a76854b76aa178"
|
|
4303
4368
|
)
|
|
4304
4369
|
|
|
4305
4370
|
|
|
@@ -4532,8 +4597,15 @@ def stats_index_matches_journal_prefix(
|
|
|
4532
4597
|
return False
|
|
4533
4598
|
|
|
4534
4599
|
|
|
4535
|
-
def _prepare_existing_stats_for_cutover(path: pathlib.Path) ->
|
|
4536
|
-
"""Checkpoint a readable old index so removing its sidecars is kill-safe.
|
|
4600
|
+
def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> str:
|
|
4601
|
+
"""Checkpoint a readable old index so removing its sidecars is kill-safe.
|
|
4602
|
+
|
|
4603
|
+
Returns what it actually did, so the incident manifest can say whether the
|
|
4604
|
+
explicit checkpoint ran (#496 S1 F8). Failure still RAISES rather than
|
|
4605
|
+
returning an outcome — the caller records `failed` and re-raises, because
|
|
4606
|
+
proceeding past an undrained WAL would pair stale sidecars with the
|
|
4607
|
+
replacement main file.
|
|
4608
|
+
"""
|
|
4537
4609
|
import _cctally_db
|
|
4538
4610
|
|
|
4539
4611
|
try:
|
|
@@ -4551,11 +4623,424 @@ def _prepare_existing_stats_for_cutover(path: pathlib.Path) -> None:
|
|
|
4551
4623
|
# Auto-heal necessarily starts from an unreadable family. Preserve its
|
|
4552
4624
|
# exact bytes below, then publish the already-validated replacement.
|
|
4553
4625
|
if _cctally_db._is_sqlite_corruption_error(exc):
|
|
4554
|
-
return
|
|
4626
|
+
return "skipped_corrupt"
|
|
4555
4627
|
raise
|
|
4628
|
+
return "checkpointed"
|
|
4629
|
+
|
|
4630
|
+
|
|
4631
|
+
def _utc_iso_now() -> str:
|
|
4632
|
+
return dt.datetime.now(dt.timezone.utc).isoformat(
|
|
4633
|
+
timespec="seconds"
|
|
4634
|
+
).replace("+00:00", "Z")
|
|
4635
|
+
|
|
4636
|
+
|
|
4637
|
+
def read_publication_stamp(path):
|
|
4638
|
+
"""Read `stats_publication_stamp` from ``path`` on a fresh read-only conn.
|
|
4639
|
+
|
|
4640
|
+
Never raises. Returns the input `_lib_stats_publish.resolve_stamp` expects:
|
|
4641
|
+
|
|
4642
|
+
- the exception that prevented the read, which resolves INDETERMINATE;
|
|
4643
|
+
- `None` when the read succeeded and named no publication;
|
|
4644
|
+
- the list of row mappings the table held.
|
|
4645
|
+
|
|
4646
|
+
**A destination whose `user_version` is not this binary's
|
|
4647
|
+
`STATS_INDEX_EPOCH` returns `None`, and that is a proof rather than a
|
|
4648
|
+
convenience.** Every scratch eligible for publication has already been
|
|
4649
|
+
validated at `STATS_INDEX_EPOCH`, and the publication transaction stamps
|
|
4650
|
+
that epoch onto the destination in the same commit as the stamp row, so a
|
|
4651
|
+
committed publication always leaves the destination at this epoch. A
|
|
4652
|
+
destination at any other epoch therefore proves this publication did not
|
|
4653
|
+
commit — which is exactly what makes an interrupted upgrade rebuild
|
|
4654
|
+
recoverable: the epoch-1007 index it was publishing into has no stamp
|
|
4655
|
+
table at all, and reading that absence as INDETERMINATE would condemn a
|
|
4656
|
+
perfectly healthy index instead of discarding a marker that never became
|
|
4657
|
+
live. When the epochs differ in the other direction, a newer binary reading
|
|
4658
|
+
an older destination, the same conclusion holds and the epoch gate refuses
|
|
4659
|
+
the destination anyway.
|
|
4660
|
+
"""
|
|
4661
|
+
try:
|
|
4662
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4663
|
+
try:
|
|
4664
|
+
epoch = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
4665
|
+
if epoch != _cctally_core.STATS_INDEX_EPOCH:
|
|
4666
|
+
return None
|
|
4667
|
+
rows = conn.execute(
|
|
4668
|
+
"SELECT record_path FROM stats_publication_stamp"
|
|
4669
|
+
).fetchall()
|
|
4670
|
+
finally:
|
|
4671
|
+
conn.close()
|
|
4672
|
+
except BaseException as exc:
|
|
4673
|
+
return exc
|
|
4674
|
+
if not rows:
|
|
4675
|
+
return None
|
|
4676
|
+
return [{"record_path": row[0]} for row in rows]
|
|
4677
|
+
|
|
4678
|
+
|
|
4679
|
+
def in_place_publication_proven_predecessor(destination, state) -> bool:
|
|
4680
|
+
"""Whether a PENDING in-place marker's publication provably never committed.
|
|
4681
|
+
|
|
4682
|
+
True only on `PROVEN_PREDECESSOR`: the stamp was read and does not name
|
|
4683
|
+
this marker's record, so the live bytes are the untouched predecessor and
|
|
4684
|
+
the marker may be discarded. `MATCH` means the verdict is still owed, and
|
|
4685
|
+
`INDETERMINATE` fails closed — it must never collapse into either of the
|
|
4686
|
+
other two states, because discarding on an unreadable stamp is exactly the
|
|
4687
|
+
silent-acceptance class the publication transaction exists to close.
|
|
4688
|
+
|
|
4689
|
+
Public because both discriminator sites consume it: the opener's
|
|
4690
|
+
`_cctally_store._pending_stats_publication_never_replaced` and this
|
|
4691
|
+
module's `_settle_prior_publication_verdict`.
|
|
4692
|
+
"""
|
|
4693
|
+
import _lib_stats_publish as sp
|
|
4694
|
+
|
|
4695
|
+
record_path = state.get("recordPath")
|
|
4696
|
+
verdict = sp.resolve_stamp(
|
|
4697
|
+
read_publication_stamp(destination),
|
|
4698
|
+
record_path if isinstance(record_path, str) else None,
|
|
4699
|
+
)
|
|
4700
|
+
return verdict == sp.STAMP_PROVEN_PREDECESSOR
|
|
4701
|
+
|
|
4702
|
+
|
|
4703
|
+
def _stamp_identity_error(path, expected_record_path: str) -> "str | None":
|
|
4704
|
+
import _lib_stats_publish as sp
|
|
4705
|
+
|
|
4706
|
+
verdict = sp.resolve_stamp(
|
|
4707
|
+
read_publication_stamp(path), expected_record_path
|
|
4708
|
+
)
|
|
4709
|
+
if verdict == sp.STAMP_MATCH:
|
|
4710
|
+
return None
|
|
4711
|
+
return (
|
|
4712
|
+
"published stats index does not carry this publication's stamp "
|
|
4713
|
+
f"({verdict}); expected {expected_record_path}"
|
|
4714
|
+
)
|
|
4715
|
+
|
|
4716
|
+
|
|
4717
|
+
def validate_published_stats_index(
|
|
4718
|
+
path, high_water: "tuple[str, int] | None", *,
|
|
4719
|
+
expected_record_path: "str | None" = None,
|
|
4720
|
+
) -> "str | None":
|
|
4721
|
+
"""Validate an index on a FRESH read-only connection (#496 S1 F1).
|
|
4722
|
+
|
|
4723
|
+
Returns None on success, or a short failure reason. The building
|
|
4724
|
+
connection wrote the pages it then validated, so it is not an independent
|
|
4725
|
+
witness to what reached the disk; this reopens the file instead. The
|
|
4726
|
+
mechanism is already proven by `stats_index_matches_journal_prefix`, which
|
|
4727
|
+
runs the same check on the same kind of connection.
|
|
4728
|
+
|
|
4729
|
+
``expected_record_path`` names the publication whose bytes these are meant
|
|
4730
|
+
to be, and the stamp row is verified against it (#496 S3 §5): the
|
|
4731
|
+
high-water alone answers "is this index a correct materialization of the
|
|
4732
|
+
journal prefix", not "is this index the generation THIS publication just
|
|
4733
|
+
installed". The in-place publisher passes it because it has just written
|
|
4734
|
+
that identity inside the publication transaction. The opener deliberately
|
|
4735
|
+
does NOT: it has already resolved the stamp as a three-state question, and
|
|
4736
|
+
folding that into a boolean validation error would turn an INDETERMINATE
|
|
4737
|
+
read into a settled `failed` verdict.
|
|
4738
|
+
|
|
4739
|
+
Public because `stats_open_guarded` runs exactly this check when it
|
|
4740
|
+
resolves a pending publication marker.
|
|
4741
|
+
"""
|
|
4742
|
+
try:
|
|
4743
|
+
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
4744
|
+
try:
|
|
4745
|
+
_validate_rebuilt_stats_index(conn, high_water)
|
|
4746
|
+
finally:
|
|
4747
|
+
conn.close()
|
|
4748
|
+
except BaseException as exc:
|
|
4749
|
+
return f"{type(exc).__name__}: {exc}"[:500]
|
|
4750
|
+
if expected_record_path is not None:
|
|
4751
|
+
return _stamp_identity_error(path, expected_record_path)
|
|
4752
|
+
return None
|
|
4753
|
+
|
|
4754
|
+
|
|
4755
|
+
def _publication_marker_path(destination) -> pathlib.Path:
|
|
4756
|
+
return pathlib.Path(str(destination) + ".publication")
|
|
4757
|
+
|
|
4758
|
+
|
|
4759
|
+
def _write_publication_marker(
|
|
4760
|
+
destination, record_path, *, started_at: str, scratch_path,
|
|
4761
|
+
status: str = "pending", error: "str | None" = None,
|
|
4762
|
+
prior: "dict | None" = None, mechanism: str = "replace",
|
|
4763
|
+
) -> None:
|
|
4764
|
+
"""Publish the durable marker a later opener honours (#496 S1 F1).
|
|
4765
|
+
|
|
4766
|
+
Mirrors the existing `cache.db.repairing` marker idiom.
|
|
4767
|
+
`_atomic_write_private_json` writes at mode 0600 and fsyncs both the file
|
|
4768
|
+
and its parent directory.
|
|
4769
|
+
|
|
4770
|
+
`scratchPath` records the exact index this publication was about to install.
|
|
4771
|
+
Across processes `os.replace` is the only thing that consumes a scratch, so
|
|
4772
|
+
its presence or absence on disk is an exact answer to "did this run replace
|
|
4773
|
+
the destination?" — which is what interrupted-rebuild recovery needs in
|
|
4774
|
+
order to tell a marker it supersedes from one whose verdict is still owed.
|
|
4775
|
+
(Within one process the claim is weaker; see
|
|
4776
|
+
`_cctally_store._pending_stats_publication_never_replaced`.)
|
|
4777
|
+
|
|
4778
|
+
`mechanism` states which publication protocol this marker belongs to, so
|
|
4779
|
+
the opener SELECTS its discriminator instead of inferring one (#496 S3 §5).
|
|
4780
|
+
`replace` keeps the `scratchPath` proxy above, which remains exactly
|
|
4781
|
+
correct there. `in_place` attaches the scratch read-only and leaves it on
|
|
4782
|
+
disk whether the transaction committed or rolled back, so the proxy
|
|
4783
|
+
inverts and the publication's own `stats_publication_stamp` row answers
|
|
4784
|
+
instead.
|
|
4785
|
+
|
|
4786
|
+
`priorFailure` carries a settled verdict this publication is about to
|
|
4787
|
+
overwrite, so a crash before `os.replace` cannot discard it — see
|
|
4788
|
+
`_settle_prior_publication_verdict`.
|
|
4789
|
+
"""
|
|
4790
|
+
import _cctally_db
|
|
4791
|
+
|
|
4792
|
+
payload = {
|
|
4793
|
+
"schemaVersion": 1,
|
|
4794
|
+
"status": status,
|
|
4795
|
+
"recordPath": str(record_path),
|
|
4796
|
+
"startedAtUtc": started_at,
|
|
4797
|
+
"scratchPath": str(scratch_path),
|
|
4798
|
+
"mechanism": mechanism,
|
|
4799
|
+
}
|
|
4800
|
+
if error is not None:
|
|
4801
|
+
payload["error"] = error
|
|
4802
|
+
if prior:
|
|
4803
|
+
payload["priorFailure"] = prior
|
|
4804
|
+
_cctally_db._atomic_write_private_json(
|
|
4805
|
+
_publication_marker_path(destination), payload
|
|
4806
|
+
)
|
|
4807
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
def _read_publication_marker(destination) -> "dict | None":
|
|
4811
|
+
"""The marker's state as a MAPPING, or None when no marker exists.
|
|
4812
|
+
|
|
4813
|
+
Present-but-unusable bytes read as an empty mapping, for the reason given
|
|
4814
|
+
in `_cctally_store._read_stats_publication_marker`.
|
|
4815
|
+
"""
|
|
4816
|
+
try:
|
|
4817
|
+
state = json.loads(_publication_marker_path(destination).read_text())
|
|
4818
|
+
except FileNotFoundError:
|
|
4819
|
+
return None
|
|
4820
|
+
except (OSError, ValueError):
|
|
4821
|
+
return {}
|
|
4822
|
+
return state if isinstance(state, dict) else {}
|
|
4823
|
+
|
|
4824
|
+
|
|
4825
|
+
def _pending_publication_owes_nothing(destination, state) -> bool:
|
|
4826
|
+
"""Whether a PENDING marker's own publication never reached the live bytes.
|
|
4827
|
+
|
|
4828
|
+
The marker STATES its mechanism, so the discriminator is selected rather
|
|
4829
|
+
than inferred (#496 S3 §5). `replace` keeps the `scratchPath` proxy, which
|
|
4830
|
+
is exactly correct there because across processes `os.replace` is the only
|
|
4831
|
+
thing that consumes a scratch. `in_place` attaches its scratch read-only
|
|
4832
|
+
and leaves it on disk whether the transaction committed or rolled back, so
|
|
4833
|
+
that proxy INVERTS and the publication's own stamp answers instead. Neither
|
|
4834
|
+
is generalized over the other.
|
|
4835
|
+
|
|
4836
|
+
A marker written before the mechanism field existed reads as `replace`,
|
|
4837
|
+
which is what those binaries did.
|
|
4838
|
+
"""
|
|
4839
|
+
if str(state.get("mechanism") or "replace") == "in_place":
|
|
4840
|
+
return in_place_publication_proven_predecessor(destination, state)
|
|
4841
|
+
scratch = state.get("scratchPath")
|
|
4842
|
+
return (
|
|
4843
|
+
isinstance(scratch, str)
|
|
4844
|
+
and bool(scratch)
|
|
4845
|
+
and pathlib.Path(scratch).exists()
|
|
4846
|
+
)
|
|
4847
|
+
|
|
4848
|
+
|
|
4849
|
+
def _settle_prior_publication_verdict(destination) -> "dict | None":
|
|
4850
|
+
"""Settle the verdict a PREVIOUS publication still owes, before this one
|
|
4851
|
+
overwrites the single marker slot (#496 S1 F1).
|
|
4852
|
+
|
|
4853
|
+
`<db>.publication` is one file, so Phase 1 of a new publication destroys
|
|
4854
|
+
whatever the last one left there. `cmd_db_rebuild` takes maintenance
|
|
4855
|
+
EXCLUSIVE without opening the live database through `stats_open_guarded`,
|
|
4856
|
+
so a rebuild can legitimately begin while a pending marker still owes a
|
|
4857
|
+
verdict on an already-published, never-validated index. If this run then
|
|
4858
|
+
dies between its own marker write and its `os.replace`, the next opener
|
|
4859
|
+
sees a pending marker beside this run's own scratch, discards it as
|
|
4860
|
+
never-replaced, and accepts the earlier index having never validated it.
|
|
4861
|
+
|
|
4862
|
+
Resolving is preferred over refusing to overwrite, because a refusal would
|
|
4863
|
+
wedge the one operation that repairs a bad index behind the marker that
|
|
4864
|
+
reports it. Returns the verdict to CARRY into this run's marker, or None
|
|
4865
|
+
when nothing is owed:
|
|
4866
|
+
|
|
4867
|
+
- a `failed` marker is already settled and is carried verbatim;
|
|
4868
|
+
- a `pending` marker whose own publication provably never became live owes
|
|
4869
|
+
nothing about the live bytes of its own — but it may still be CARRYING an
|
|
4870
|
+
older run's verdict, which is passed through so a third consecutive
|
|
4871
|
+
crashed run cannot drop it;
|
|
4872
|
+
- any other `pending` marker is settled HERE, by validating the destination
|
|
4873
|
+
against its record's pinned high-water — the same check the opener would
|
|
4874
|
+
have run. Success clears it; failure is written to both the marker and
|
|
4875
|
+
the record before this run touches the destination, and is then carried
|
|
4876
|
+
forward.
|
|
4877
|
+
|
|
4878
|
+
A marker that cannot be judged (no record, or no pinned high-water) is left
|
|
4879
|
+
to the opener's existing discard policy rather than wedging the rebuild.
|
|
4880
|
+
"""
|
|
4881
|
+
import _cctally_db
|
|
4882
|
+
|
|
4883
|
+
state = _read_publication_marker(destination)
|
|
4884
|
+
if not state:
|
|
4885
|
+
return None
|
|
4886
|
+
status = str(state.get("status") or "")
|
|
4887
|
+
if status == "failed":
|
|
4888
|
+
return state
|
|
4889
|
+
if status != "pending":
|
|
4890
|
+
return None
|
|
4891
|
+
if _pending_publication_owes_nothing(destination, state):
|
|
4892
|
+
# This marker owes nothing itself, but dropping what it carries would
|
|
4893
|
+
# lose an older run's verdict once a third run crashes the same way.
|
|
4894
|
+
carried = state.get("priorFailure")
|
|
4895
|
+
return carried if isinstance(carried, dict) and carried else None
|
|
4896
|
+
record_path = state.get("recordPath")
|
|
4897
|
+
record = None
|
|
4898
|
+
if isinstance(record_path, str):
|
|
4899
|
+
try:
|
|
4900
|
+
record = json.loads(pathlib.Path(record_path).read_text())
|
|
4901
|
+
except (OSError, ValueError):
|
|
4902
|
+
record = None
|
|
4903
|
+
if not isinstance(record, dict) or "highWater" not in record:
|
|
4904
|
+
return None
|
|
4905
|
+
raw = record.get("highWater")
|
|
4906
|
+
high_water = (
|
|
4907
|
+
(str(raw[0]), int(raw[1]))
|
|
4908
|
+
if isinstance(raw, (list, tuple)) and len(raw) == 2
|
|
4909
|
+
else None
|
|
4910
|
+
)
|
|
4911
|
+
error = validate_published_stats_index(destination, high_water)
|
|
4912
|
+
if error is None:
|
|
4913
|
+
_remove_publication_marker(destination)
|
|
4914
|
+
return None
|
|
4915
|
+
settled = dict(state)
|
|
4916
|
+
settled.update({"status": "failed", "error": error})
|
|
4917
|
+
# Durable BEFORE this run touches the destination: a crash between here and
|
|
4918
|
+
# the marker this run is about to write must still leave the verdict.
|
|
4919
|
+
_cctally_db._atomic_write_private_json(
|
|
4920
|
+
_publication_marker_path(destination), settled
|
|
4921
|
+
)
|
|
4922
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
4923
|
+
record.update({
|
|
4924
|
+
"status": "failed",
|
|
4925
|
+
"postPublicationValidation": {"ok": False, "error": error},
|
|
4926
|
+
})
|
|
4927
|
+
try:
|
|
4928
|
+
_write_rebuild_record(record_path, record)
|
|
4929
|
+
except OSError:
|
|
4930
|
+
pass
|
|
4931
|
+
# #496 exists partly because corruption gets MISATTRIBUTED, so the wording
|
|
4932
|
+
# must not accuse an earlier publication of failing a check that never ran.
|
|
4933
|
+
# A destination that cannot be read fails this validation for a reason that
|
|
4934
|
+
# is not evidence about the publication: the stamp read is INDETERMINATE
|
|
4935
|
+
# and the high-water check then trips on the unrelated damage.
|
|
4936
|
+
unreadable = _cctally_db._is_sqlite_corruption_error(error) or error.startswith(
|
|
4937
|
+
("DatabaseError:", "OperationalError:", "InterfaceError:", "sqlite3.")
|
|
4938
|
+
)
|
|
4939
|
+
if unreadable:
|
|
4940
|
+
print(
|
|
4941
|
+
"[stats] an earlier stats.db publication was interrupted before it "
|
|
4942
|
+
"could validate what it published, and that check could not be "
|
|
4943
|
+
f"completed because the index could not be read: {error}. The read "
|
|
4944
|
+
"failure is not evidence about that publication. Rebuild record: "
|
|
4945
|
+
f"{record_path}.",
|
|
4946
|
+
file=sys.stderr,
|
|
4947
|
+
)
|
|
4948
|
+
else:
|
|
4949
|
+
print(
|
|
4950
|
+
"[stats] an earlier stats.db publication was interrupted before it "
|
|
4951
|
+
f"could validate what it published, and it FAILED that check: "
|
|
4952
|
+
f"{error}. Rebuild record: {record_path}.",
|
|
4953
|
+
file=sys.stderr,
|
|
4954
|
+
)
|
|
4955
|
+
return settled
|
|
4956
|
+
|
|
4957
|
+
|
|
4958
|
+
def _remove_publication_marker(destination) -> None:
|
|
4959
|
+
try:
|
|
4960
|
+
_publication_marker_path(destination).unlink()
|
|
4961
|
+
except FileNotFoundError:
|
|
4962
|
+
pass
|
|
4963
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
4964
|
+
|
|
4965
|
+
|
|
4966
|
+
def _write_rebuild_record(path, payload: dict) -> None:
|
|
4967
|
+
import _cctally_db
|
|
4968
|
+
|
|
4969
|
+
path = pathlib.Path(path)
|
|
4970
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
4971
|
+
_cctally_db._atomic_write_private_json(path, payload)
|
|
4972
|
+
|
|
4973
|
+
|
|
4974
|
+
def _forensics_shape_token(forensics_path) -> "str | None":
|
|
4975
|
+
if not forensics_path:
|
|
4976
|
+
return None
|
|
4977
|
+
try:
|
|
4978
|
+
bundle = json.loads(pathlib.Path(forensics_path).read_text())
|
|
4979
|
+
except (OSError, ValueError):
|
|
4980
|
+
return None
|
|
4981
|
+
damage = bundle.get("damage")
|
|
4982
|
+
return damage.get("shapeToken") if isinstance(damage, dict) else None
|
|
4983
|
+
|
|
4984
|
+
|
|
4985
|
+
def _scan_stats_damage(path) -> dict:
|
|
4986
|
+
"""Describe one stats family member by reading its bytes. Never raises."""
|
|
4987
|
+
try:
|
|
4988
|
+
import _lib_stats_damage
|
|
4989
|
+
|
|
4990
|
+
return _lib_stats_damage.describe_damage(integrity_rows=None, path=path)
|
|
4991
|
+
except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
|
|
4992
|
+
return {
|
|
4993
|
+
"schemaVersion": 1,
|
|
4994
|
+
"method": "unavailable",
|
|
4995
|
+
"findings": [],
|
|
4996
|
+
"shapeToken": "none",
|
|
4997
|
+
"reason": f"{type(exc).__name__}: {exc}"[:200],
|
|
4998
|
+
}
|
|
4999
|
+
|
|
4556
5000
|
|
|
5001
|
+
def _record_post_checkpoint_damage(
|
|
5002
|
+
incident: pathlib.Path, destination: pathlib.Path, outcome: str,
|
|
5003
|
+
) -> "dict | None":
|
|
5004
|
+
"""Add the post-checkpoint scan to an already-written incident manifest.
|
|
4557
5005
|
|
|
4558
|
-
|
|
5006
|
+
A second `_atomic_write_private_json` to the same path is safe: the write
|
|
5007
|
+
is atomic and nothing references the incident yet. It has to be a second
|
|
5008
|
+
write because preservation runs BEFORE the explicit checkpoint, so the
|
|
5009
|
+
outcome this records does not exist when the manifest is first written.
|
|
5010
|
+
"""
|
|
5011
|
+
import _cctally_db
|
|
5012
|
+
|
|
5013
|
+
try:
|
|
5014
|
+
manifest_path = incident / "manifest.json"
|
|
5015
|
+
manifest = json.loads(manifest_path.read_text())
|
|
5016
|
+
damage = manifest.get("damage") or {}
|
|
5017
|
+
damage["postCheckpoint"] = _scan_stats_damage(destination)
|
|
5018
|
+
damage["checkpointOutcome"] = outcome
|
|
5019
|
+
manifest["damage"] = damage
|
|
5020
|
+
_cctally_db._atomic_write_private_json(manifest_path, manifest)
|
|
5021
|
+
return damage
|
|
5022
|
+
except Exception as exc: # noqa: BLE001 — enrichment never breaks a rebuild
|
|
5023
|
+
print(
|
|
5024
|
+
f"[rebuild] post-checkpoint damage scan failed: {exc}",
|
|
5025
|
+
file=sys.stderr,
|
|
5026
|
+
)
|
|
5027
|
+
return None
|
|
5028
|
+
|
|
5029
|
+
|
|
5030
|
+
def _binary_version() -> "str | None":
|
|
5031
|
+
"""The running binary's released version, or None when it cannot be read."""
|
|
5032
|
+
try:
|
|
5033
|
+
import _lib_changelog
|
|
5034
|
+
|
|
5035
|
+
value = _lib_changelog._read_latest_changelog_version()
|
|
5036
|
+
except Exception: # pragma: no cover — a missing CHANGELOG is not fatal
|
|
5037
|
+
return None
|
|
5038
|
+
return value[0] if value else None
|
|
5039
|
+
|
|
5040
|
+
|
|
5041
|
+
def _preserve_stats_family_for_cutover(
|
|
5042
|
+
path: pathlib.Path, *, context: RebuildContext,
|
|
5043
|
+
) -> pathlib.Path:
|
|
4559
5044
|
"""Durably copy the old family into quarantine without removing the main."""
|
|
4560
5045
|
import _cctally_db
|
|
4561
5046
|
|
|
@@ -4580,9 +5065,21 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
|
|
|
4580
5065
|
]
|
|
4581
5066
|
if not members:
|
|
4582
5067
|
raise OSError(f"no database family exists to preserve at {path}")
|
|
5068
|
+
# Observed sizes are read BEFORE the copy, so the empty-WAL case in the
|
|
5069
|
+
# routine corruption heal is evidenced rather than assumed (#496 S1 F2).
|
|
5070
|
+
family_sizes = {}
|
|
5071
|
+
for name in members:
|
|
5072
|
+
try:
|
|
5073
|
+
family_sizes[name] = path.with_name(name).stat().st_size
|
|
5074
|
+
except OSError:
|
|
5075
|
+
family_sizes[name] = None
|
|
5076
|
+
# Read from the raw header rather than by opening the file: the file this
|
|
5077
|
+
# is asked about is typically one SQLite refuses to open, which is exactly
|
|
5078
|
+
# when the epoch it carried is worth recording (#496 S1).
|
|
5079
|
+
preserved_user_version = _cctally_db._read_user_version_header(path)
|
|
4583
5080
|
_cctally_db._copy_db_family(path, destination)
|
|
4584
5081
|
manifest = {
|
|
4585
|
-
"schemaVersion":
|
|
5082
|
+
"schemaVersion": 2,
|
|
4586
5083
|
"quarantinedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat(
|
|
4587
5084
|
timespec="seconds"
|
|
4588
5085
|
).replace("+00:00", "Z"),
|
|
@@ -4590,6 +5087,25 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
|
|
|
4590
5087
|
"movedFiles": members,
|
|
4591
5088
|
"complete": True,
|
|
4592
5089
|
"cutoverProtocol": "preserve-then-atomic-replace-v1",
|
|
5090
|
+
# #496 S1 additive fields. Every key above keeps its v1 name and
|
|
5091
|
+
# meaning, so a v1 reader is unaffected by the bump.
|
|
5092
|
+
"trigger": context.trigger,
|
|
5093
|
+
"triggerError": context.trigger_error,
|
|
5094
|
+
"forensicsPath": context.forensics_path,
|
|
5095
|
+
"rebuildRecordPath": context.record_path,
|
|
5096
|
+
"binaryVersion": _binary_version(),
|
|
5097
|
+
"binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
|
|
5098
|
+
"preservedUserVersion": preserved_user_version,
|
|
5099
|
+
"familySizes": family_sizes,
|
|
5100
|
+
# The retained COPY is described, not the live file, because the copy
|
|
5101
|
+
# is the artifact that actually survives. `postCheckpoint` and
|
|
5102
|
+
# `checkpointOutcome` are filled in by the caller once the explicit
|
|
5103
|
+
# checkpoint has run (#496 S1 F8 section 6.3).
|
|
5104
|
+
"damage": {
|
|
5105
|
+
"preserved": _scan_stats_damage(destination),
|
|
5106
|
+
"postCheckpoint": None,
|
|
5107
|
+
"checkpointOutcome": None,
|
|
5108
|
+
},
|
|
4593
5109
|
}
|
|
4594
5110
|
_cctally_db._atomic_write_private_json(incident / "manifest.json", manifest)
|
|
4595
5111
|
_fsync_dir(incident)
|
|
@@ -4597,21 +5113,499 @@ def _preserve_stats_family_for_cutover(path: pathlib.Path) -> pathlib.Path:
|
|
|
4597
5113
|
return incident
|
|
4598
5114
|
|
|
4599
5115
|
|
|
5116
|
+
#: Set on the exception a failed in-place publication raises, so the caller can
|
|
5117
|
+
#: read the phase it had reached. "The transaction raised" is not a safe
|
|
5118
|
+
#: discriminator: a failure before the commit can roll back while a failure
|
|
5119
|
+
#: after it cannot, and a commit-time I/O error leaves the outcome unknown.
|
|
5120
|
+
_PUBLICATION_PHASE_ATTR = "_cctally_publication_phase"
|
|
5121
|
+
|
|
5122
|
+
|
|
5123
|
+
def publication_phase_of(exc) -> "str | None":
|
|
5124
|
+
"""The publication phase ``exc`` was raised in, or None when unrecorded."""
|
|
5125
|
+
return getattr(exc, _PUBLICATION_PHASE_ATTR, None)
|
|
5126
|
+
|
|
5127
|
+
|
|
5128
|
+
def _open_publication_connection(destination) -> sqlite3.Connection:
|
|
5129
|
+
"""Open the LIVE destination for an in-place publish.
|
|
5130
|
+
|
|
5131
|
+
`stats_open_guarded` skips its own flock when the caller already holds
|
|
5132
|
+
stats maintenance, which every production trigger does. Interrupted-rebuild
|
|
5133
|
+
recovery is suppressed because this run's own `.rebuilding-*` scratch is on
|
|
5134
|
+
disk right now and is not an interruption to recover from.
|
|
5135
|
+
|
|
5136
|
+
`stats_open_guarded` does NOT apply connection policy — `open_db` does that
|
|
5137
|
+
separately — so the busy timeout, journal mode and WAL size limit are
|
|
5138
|
+
applied here rather than assumed.
|
|
5139
|
+
|
|
5140
|
+
The connection is opened with `uri=True` because the publisher ATTACHes the
|
|
5141
|
+
scratch through a `file:...?mode=ro` URI. SQLite honours a URI filename in
|
|
5142
|
+
`ATTACH` only when the main connection carries `SQLITE_OPEN_URI`, or when
|
|
5143
|
+
the library happens to be built with `SQLITE_USE_URI`. Relying on the
|
|
5144
|
+
latter would make the read-only attach ambient rather than guaranteed.
|
|
5145
|
+
"""
|
|
5146
|
+
import _cctally_store
|
|
5147
|
+
|
|
5148
|
+
conn = _cctally_store.stats_open_guarded(
|
|
5149
|
+
pathlib.Path(destination),
|
|
5150
|
+
connect=lambda path: sqlite3.connect(
|
|
5151
|
+
pathlib.Path(path).resolve().as_uri(), uri=True
|
|
5152
|
+
),
|
|
5153
|
+
recover_interruptions=False,
|
|
5154
|
+
)
|
|
5155
|
+
try:
|
|
5156
|
+
_cctally_store.apply_policy(conn, "stats")
|
|
5157
|
+
except BaseException:
|
|
5158
|
+
try:
|
|
5159
|
+
conn.close()
|
|
5160
|
+
except Exception:
|
|
5161
|
+
pass
|
|
5162
|
+
raise
|
|
5163
|
+
return conn
|
|
5164
|
+
|
|
5165
|
+
|
|
5166
|
+
def _carry_sqlite_sequence(conn: sqlite3.Connection) -> None:
|
|
5167
|
+
"""Install the scratch's AUTOINCREMENT watermarks, not the copy's.
|
|
5168
|
+
|
|
5169
|
+
A table-by-table row copy sets each counter to `max(rowid)`, whereas
|
|
5170
|
+
`os.replace` publishes the scratch's `sqlite_sequence` verbatim. Measured on
|
|
5171
|
+
SQLite 3.53.4: a scratch with `max(id)=6` and a counter of 10 — the shape
|
|
5172
|
+
produced whenever the fold inserts rows and later deletes them, as the
|
|
5173
|
+
`five_hour_block_close` fold's exact-child DELETE/INSERT does — published a
|
|
5174
|
+
counter of 6, and the next insert took id 7, an id a deleted row had
|
|
5175
|
+
already used.
|
|
5176
|
+
|
|
5177
|
+
Delete-then-insert rather than update, because `DROP TABLE` removes the
|
|
5178
|
+
table's `sqlite_sequence` row and `CREATE TABLE` does not put one back, so
|
|
5179
|
+
an UPDATE has nothing of its own to act on. Measured on SQLite 3.53.4, a
|
|
5180
|
+
zero-row `INSERT ... SELECT` does create the row with seq 0 — so on that
|
|
5181
|
+
version an UPDATE would in fact land — but that is an undocumented
|
|
5182
|
+
implementation detail rather than a contract, and silently losing an
|
|
5183
|
+
AUTOINCREMENT watermark hands out an id a deleted row already used.
|
|
5184
|
+
"""
|
|
5185
|
+
present = conn.execute(
|
|
5186
|
+
"SELECT (SELECT 1 FROM src.sqlite_schema WHERE type = 'table' "
|
|
5187
|
+
"AND name = 'sqlite_sequence'), "
|
|
5188
|
+
"(SELECT 1 FROM main.sqlite_schema WHERE type = 'table' "
|
|
5189
|
+
"AND name = 'sqlite_sequence')"
|
|
5190
|
+
).fetchone()
|
|
5191
|
+
if present is None or present[0] is None or present[1] is None:
|
|
5192
|
+
return
|
|
5193
|
+
rows = conn.execute("SELECT name, seq FROM src.sqlite_sequence").fetchall()
|
|
5194
|
+
for name, seq in rows:
|
|
5195
|
+
conn.execute("DELETE FROM main.sqlite_sequence WHERE name = ?", (name,))
|
|
5196
|
+
conn.execute(
|
|
5197
|
+
"INSERT INTO main.sqlite_sequence (name, seq) VALUES (?, ?)",
|
|
5198
|
+
(name, seq),
|
|
5199
|
+
)
|
|
5200
|
+
|
|
5201
|
+
|
|
5202
|
+
def _publish_generation_in_place(
|
|
5203
|
+
conn: sqlite3.Connection, scratch, *, record_path, started_at: str
|
|
5204
|
+
) -> str:
|
|
5205
|
+
"""Install the validated scratch's generation into the LIVE database.
|
|
5206
|
+
|
|
5207
|
+
Returns the terminating phase. The whole swap is ONE `BEGIN IMMEDIATE`, so
|
|
5208
|
+
a reader inside a transaction keeps the generation it opened on, an
|
|
5209
|
+
abandoned attempt leaves the prior generation live and sound, and
|
|
5210
|
+
`PRAGMA user_version` flips atomically at the commit.
|
|
5211
|
+
"""
|
|
5212
|
+
import _lib_stats_publish as sp
|
|
5213
|
+
|
|
5214
|
+
if int(conn.execute("PRAGMA foreign_keys").fetchone()[0]) != 0:
|
|
5215
|
+
# `apply_policy` never enables foreign keys and the schema documents
|
|
5216
|
+
# them as enforcement-off, so the derived-FK seam for
|
|
5217
|
+
# `five_hour_milestones.block_id` is a fold ordering contract rather
|
|
5218
|
+
# than an enforced constraint. Assert that rather than depend on it
|
|
5219
|
+
# silently, so a future change that turns them on fails loudly here.
|
|
5220
|
+
raise JournalError(
|
|
5221
|
+
"stats publication requires foreign_keys=0; the schema's derived-FK "
|
|
5222
|
+
"seam is a fold ordering contract, not an enforced constraint"
|
|
5223
|
+
)
|
|
5224
|
+
resolved = pathlib.Path(scratch).resolve()
|
|
5225
|
+
conn.execute("ATTACH DATABASE ? AS src", (resolved.as_uri() + "?mode=ro",))
|
|
5226
|
+
attached = conn.execute(
|
|
5227
|
+
"SELECT file FROM pragma_database_list WHERE name = 'src'"
|
|
5228
|
+
).fetchone()
|
|
5229
|
+
if attached is None or pathlib.Path(str(attached[0])) != resolved:
|
|
5230
|
+
# A connection without SQLITE_OPEN_URI treats the URI as a literal
|
|
5231
|
+
# filename and silently attaches a new, EMPTY database under that name.
|
|
5232
|
+
# Publishing from it would install an empty generation, so the identity
|
|
5233
|
+
# of what was attached is checked rather than assumed.
|
|
5234
|
+
try:
|
|
5235
|
+
conn.execute("DETACH DATABASE src")
|
|
5236
|
+
except Exception:
|
|
5237
|
+
pass
|
|
5238
|
+
raise JournalError(
|
|
5239
|
+
"stats publication attached the wrong file as its scratch: "
|
|
5240
|
+
f"expected {resolved}, got {attached[0] if attached else '<none>'}"
|
|
5241
|
+
)
|
|
5242
|
+
phase = sp.PRE_COMMIT
|
|
5243
|
+
try:
|
|
5244
|
+
try:
|
|
5245
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
5246
|
+
# Both schemas are read INSIDE the transaction, so the drop list
|
|
5247
|
+
# describes the generation actually being retired.
|
|
5248
|
+
src_objects = conn.execute(
|
|
5249
|
+
"SELECT type, name, sql FROM src.sqlite_schema"
|
|
5250
|
+
).fetchall()
|
|
5251
|
+
dest_objects = conn.execute(
|
|
5252
|
+
"SELECT type, name, sql FROM main.sqlite_schema"
|
|
5253
|
+
).fetchall()
|
|
5254
|
+
plan = sp.plan_generation_swap(dest_objects, src_objects)
|
|
5255
|
+
if plan.rejected:
|
|
5256
|
+
raise JournalError(
|
|
5257
|
+
"stats publication cannot copy unsupported object(s): "
|
|
5258
|
+
+ ", ".join(plan.rejected)
|
|
5259
|
+
)
|
|
5260
|
+
for statement in plan.drop_statements:
|
|
5261
|
+
conn.execute(statement)
|
|
5262
|
+
for statement in plan.create_table_statements:
|
|
5263
|
+
conn.execute(statement)
|
|
5264
|
+
for name in plan.copy_tables:
|
|
5265
|
+
conn.execute(
|
|
5266
|
+
f'INSERT INTO main."{name}" SELECT * FROM src."{name}"'
|
|
5267
|
+
)
|
|
5268
|
+
for statement in plan.create_index_statements:
|
|
5269
|
+
conn.execute(statement)
|
|
5270
|
+
_carry_sqlite_sequence(conn)
|
|
5271
|
+
epoch = int(conn.execute("PRAGMA src.user_version").fetchone()[0])
|
|
5272
|
+
if epoch != _cctally_core.STATS_INDEX_EPOCH:
|
|
5273
|
+
# `read_publication_stamp`'s entire short-circuit rests on the
|
|
5274
|
+
# claim that a committed publication always leaves the
|
|
5275
|
+
# destination at THIS binary's epoch. Upstream validation
|
|
5276
|
+
# already guarantees the scratch carries it; asserting it here
|
|
5277
|
+
# costs nothing and turns the argument into an invariant.
|
|
5278
|
+
raise JournalError(
|
|
5279
|
+
"stats publication refuses to stamp a scratch at index "
|
|
5280
|
+
f"epoch {epoch}; this binary builds "
|
|
5281
|
+
f"{_cctally_core.STATS_INDEX_EPOCH}"
|
|
5282
|
+
)
|
|
5283
|
+
conn.execute(f"PRAGMA main.user_version={epoch:d}")
|
|
5284
|
+
# The publication's own identity, committed atomically with the
|
|
5285
|
+
# content and the epoch it describes (#496 S3 §5).
|
|
5286
|
+
conn.execute("DELETE FROM main.stats_publication_stamp")
|
|
5287
|
+
conn.execute(
|
|
5288
|
+
"INSERT INTO main.stats_publication_stamp "
|
|
5289
|
+
"(record_path, started_at_utc, stamped_at_utc) VALUES (?, ?, ?)",
|
|
5290
|
+
(str(record_path), started_at, _utc_iso_now()),
|
|
5291
|
+
)
|
|
5292
|
+
phase = sp.COMMIT_UNKNOWN
|
|
5293
|
+
conn.commit()
|
|
5294
|
+
phase = sp.COMMITTED
|
|
5295
|
+
_stats_rebuild_test_pause("publication_after_commit_before_detach")
|
|
5296
|
+
except BaseException as exc:
|
|
5297
|
+
if phase == sp.PRE_COMMIT:
|
|
5298
|
+
try:
|
|
5299
|
+
conn.rollback()
|
|
5300
|
+
except Exception:
|
|
5301
|
+
pass
|
|
5302
|
+
try:
|
|
5303
|
+
setattr(exc, _PUBLICATION_PHASE_ATTR, phase)
|
|
5304
|
+
except Exception: # pragma: no cover — some exceptions are frozen
|
|
5305
|
+
pass
|
|
5306
|
+
raise
|
|
5307
|
+
finally:
|
|
5308
|
+
# DETACH cannot run inside a transaction, so this is best-effort: a
|
|
5309
|
+
# failure that left one open is already being raised.
|
|
5310
|
+
try:
|
|
5311
|
+
conn.execute("DETACH DATABASE src")
|
|
5312
|
+
except Exception:
|
|
5313
|
+
pass
|
|
5314
|
+
return phase
|
|
5315
|
+
|
|
5316
|
+
|
|
5317
|
+
def _checkpoint_after_publication(conn: sqlite3.Connection) -> str:
|
|
5318
|
+
"""Drain the WAL after a committed in-place publish — BEST EFFORT.
|
|
5319
|
+
|
|
5320
|
+
`wal_checkpoint(TRUNCATE)` returns a busy ROW rather than raising, and this
|
|
5321
|
+
repository has measured it taking about 16 seconds against a 15-second
|
|
5322
|
+
`busy_timeout` under a pinned reader. Its result is recorded and never
|
|
5323
|
+
interpreted as a transaction failure, and it is never a reason to fall back
|
|
5324
|
+
after a commit.
|
|
5325
|
+
"""
|
|
5326
|
+
try:
|
|
5327
|
+
row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
5328
|
+
except sqlite3.Error as exc:
|
|
5329
|
+
return f"error:{type(exc).__name__}"
|
|
5330
|
+
if row is None:
|
|
5331
|
+
return "unknown"
|
|
5332
|
+
return "checkpointed" if int(row[0]) == 0 else "busy"
|
|
5333
|
+
|
|
5334
|
+
|
|
5335
|
+
def _remove_empty_db_sidecars(path) -> None:
|
|
5336
|
+
"""Remove the sidecars a read-only validation open leaves behind.
|
|
5337
|
+
|
|
5338
|
+
Unlike the physical path, an in-place publish leaves a LEGITIMATE WAL: it
|
|
5339
|
+
belongs to the live database, and deleting a non-empty one would discard
|
|
5340
|
+
committed frames. Only a zero-length WAL is removed — the state a
|
|
5341
|
+
successful TRUNCATE checkpoint and a clean last close leave — so the
|
|
5342
|
+
documented sidecar-free end state still holds without ever risking data.
|
|
5343
|
+
"""
|
|
5344
|
+
path = pathlib.Path(path)
|
|
5345
|
+
wal = pathlib.Path(str(path) + "-wal")
|
|
5346
|
+
try:
|
|
5347
|
+
if wal.stat().st_size:
|
|
5348
|
+
return
|
|
5349
|
+
except OSError:
|
|
5350
|
+
pass
|
|
5351
|
+
for suffix in ("-wal", "-shm"):
|
|
5352
|
+
try:
|
|
5353
|
+
pathlib.Path(str(path) + suffix).unlink()
|
|
5354
|
+
except FileNotFoundError:
|
|
5355
|
+
pass
|
|
5356
|
+
except OSError:
|
|
5357
|
+
return
|
|
5358
|
+
_fsync_dir(path.parent)
|
|
5359
|
+
|
|
5360
|
+
|
|
5361
|
+
#: Probe 2 measured the live WAL peaking at 1.01x the main file across five
|
|
5362
|
+
#: successive in-place publishes, so the projection is that plus a margin.
|
|
5363
|
+
_PUBLICATION_WAL_PROJECTION = 1.05
|
|
5364
|
+
#: Headroom for the rollback journal and freelist churn of an abandoned attempt.
|
|
5365
|
+
_PUBLICATION_ROLLBACK_MARGIN = 0.25
|
|
5366
|
+
|
|
5367
|
+
|
|
5368
|
+
def _free_disk_bytes(directory) -> int:
|
|
5369
|
+
"""Free bytes on the filesystem holding ``directory`` (mockable in tests)."""
|
|
5370
|
+
import _cctally_db
|
|
5371
|
+
|
|
5372
|
+
return _cctally_db._free_disk_bytes(directory)
|
|
5373
|
+
|
|
5374
|
+
|
|
5375
|
+
def _db_family_bytes(path) -> int:
|
|
5376
|
+
total = 0
|
|
5377
|
+
for suffix in ("", "-wal", "-shm"):
|
|
5378
|
+
try:
|
|
5379
|
+
total += pathlib.Path(str(path) + suffix).stat().st_size
|
|
5380
|
+
except OSError:
|
|
5381
|
+
pass
|
|
5382
|
+
return total
|
|
5383
|
+
|
|
5384
|
+
|
|
5385
|
+
def _publication_required_free_bytes(scratch, destination) -> int:
|
|
5386
|
+
"""Conservative free-space floor for publishing ``scratch``.
|
|
5387
|
+
|
|
5388
|
+
The live attempt adds a database-sized WAL while the scratch still exists,
|
|
5389
|
+
and a physical fallback would then add a full quarantine copy of the old
|
|
5390
|
+
family on top of that. All three are counted, because the run cannot know
|
|
5391
|
+
at this point which mechanism it will end up using.
|
|
5392
|
+
"""
|
|
5393
|
+
scratch_bytes = _db_family_bytes(scratch)
|
|
5394
|
+
projected = _PUBLICATION_WAL_PROJECTION + _PUBLICATION_ROLLBACK_MARGIN
|
|
5395
|
+
return int(scratch_bytes * projected) + _db_family_bytes(destination)
|
|
5396
|
+
|
|
5397
|
+
|
|
5398
|
+
def _preflight_publication_space(scratch, destination) -> None:
|
|
5399
|
+
"""Abort before any live mutation when the disk cannot hold the publish.
|
|
5400
|
+
|
|
5401
|
+
Aborting is deliberately NOT a reason to fall back to replacement: a full
|
|
5402
|
+
disk leaves a perfectly good generation live, and physically replacing it
|
|
5403
|
+
is exactly the outcome §12 refuses.
|
|
5404
|
+
"""
|
|
5405
|
+
parent = pathlib.Path(destination).parent
|
|
5406
|
+
needed = _publication_required_free_bytes(scratch, destination)
|
|
5407
|
+
try:
|
|
5408
|
+
free = _free_disk_bytes(parent)
|
|
5409
|
+
except OSError as exc: # pragma: no cover — statvfs failing is exotic
|
|
5410
|
+
raise JournalError(
|
|
5411
|
+
f"could not determine free space on {parent} before publishing "
|
|
5412
|
+
f"the rebuilt stats index: {exc}"
|
|
5413
|
+
) from exc
|
|
5414
|
+
if free < needed:
|
|
5415
|
+
raise JournalError(
|
|
5416
|
+
"publishing the rebuilt stats index needs about "
|
|
5417
|
+
f"{needed / (1024 * 1024):.1f} MB free on {parent}, but only "
|
|
5418
|
+
f"{free / (1024 * 1024):.1f} MB is available. The existing index "
|
|
5419
|
+
"is untouched; free space and retry."
|
|
5420
|
+
)
|
|
5421
|
+
|
|
5422
|
+
|
|
5423
|
+
#: Returned by the in-place publisher when the destination cannot be operated
|
|
5424
|
+
#: on structurally and physical replacement is the sanctioned fallback.
|
|
5425
|
+
_FALL_BACK = object()
|
|
5426
|
+
|
|
5427
|
+
|
|
5428
|
+
def _publish_stats_index_in_place(
|
|
5429
|
+
*, scratch, destination, context, high_water, record, fire_before_swap,
|
|
5430
|
+
prior,
|
|
5431
|
+
):
|
|
5432
|
+
"""Publish transactionally into the live file (#496 S3 §4).
|
|
5433
|
+
|
|
5434
|
+
Returns `None` on success (an in-place publish never preserves, so there is
|
|
5435
|
+
no incident directory), or `_FALL_BACK` when physical replacement is the
|
|
5436
|
+
sanctioned response.
|
|
5437
|
+
"""
|
|
5438
|
+
import _lib_stats_publish as sp
|
|
5439
|
+
|
|
5440
|
+
try:
|
|
5441
|
+
conn = _open_publication_connection(destination)
|
|
5442
|
+
except BaseException as exc:
|
|
5443
|
+
if sp.may_fall_back_to_replacement(exc):
|
|
5444
|
+
print(
|
|
5445
|
+
"[rebuild] the live stats index cannot be opened "
|
|
5446
|
+
f"({exc}); publishing by replacement instead",
|
|
5447
|
+
file=sys.stderr,
|
|
5448
|
+
)
|
|
5449
|
+
return _FALL_BACK
|
|
5450
|
+
raise
|
|
5451
|
+
|
|
5452
|
+
started_at = _utc_iso_now()
|
|
5453
|
+
record_path = pathlib.Path(context.record_path)
|
|
5454
|
+
live = dict(record)
|
|
5455
|
+
live.update({
|
|
5456
|
+
"status": "pending",
|
|
5457
|
+
"startedAtUtc": started_at,
|
|
5458
|
+
"completedAtUtc": None,
|
|
5459
|
+
# An in-place publish never preserves. Preservation is a consequence of
|
|
5460
|
+
# destroying a file; `db backup --db stats` is the supported snapshot.
|
|
5461
|
+
"incidentPath": None,
|
|
5462
|
+
"damageShapeTokens": None,
|
|
5463
|
+
"postPublicationValidation": None,
|
|
5464
|
+
"publicationMechanism": "in_place",
|
|
5465
|
+
})
|
|
5466
|
+
try:
|
|
5467
|
+
fire_before_swap()
|
|
5468
|
+
# Phase 1 of the publication transaction: the record and then the
|
|
5469
|
+
# marker, each fsynced, BEFORE any live byte changes.
|
|
5470
|
+
_write_rebuild_record(record_path, live)
|
|
5471
|
+
_stats_rebuild_test_pause("publication_before_marker")
|
|
5472
|
+
_write_publication_marker(
|
|
5473
|
+
destination, record_path, started_at=started_at,
|
|
5474
|
+
scratch_path=scratch, prior=prior, mechanism="in_place",
|
|
5475
|
+
)
|
|
5476
|
+
_stats_rebuild_test_pause("rebuild_before_cutover")
|
|
5477
|
+
_publish_generation_in_place(
|
|
5478
|
+
conn, scratch, record_path=record_path, started_at=started_at,
|
|
5479
|
+
)
|
|
5480
|
+
except BaseException as exc:
|
|
5481
|
+
# Rollback, detach and CLOSE are all mandatory before any fallback: the
|
|
5482
|
+
# drain gate is a whole-system handle scan, and this connection would
|
|
5483
|
+
# either fail it or hollow out the invariant it exists to enforce.
|
|
5484
|
+
try:
|
|
5485
|
+
conn.close()
|
|
5486
|
+
except Exception:
|
|
5487
|
+
pass
|
|
5488
|
+
phase = publication_phase_of(exc)
|
|
5489
|
+
if phase == sp.PRE_COMMIT and sp.may_fall_back_to_replacement(exc):
|
|
5490
|
+
print(
|
|
5491
|
+
"[rebuild] the in-place stats publication rolled back "
|
|
5492
|
+
f"({exc}); publishing by replacement instead",
|
|
5493
|
+
file=sys.stderr,
|
|
5494
|
+
)
|
|
5495
|
+
record["inPlaceAttempt"] = {
|
|
5496
|
+
"phase": phase, "error": f"{type(exc).__name__}: {exc}"[:500],
|
|
5497
|
+
}
|
|
5498
|
+
return _FALL_BACK
|
|
5499
|
+
raise
|
|
5500
|
+
|
|
5501
|
+
checkpoint_outcome = _checkpoint_after_publication(conn)
|
|
5502
|
+
try:
|
|
5503
|
+
conn.close()
|
|
5504
|
+
except Exception:
|
|
5505
|
+
pass
|
|
5506
|
+
_stats_rebuild_test_pause("rebuild_after_publication_replace")
|
|
5507
|
+
|
|
5508
|
+
# Phase 2: validate the bytes that are now live, on a connection that never
|
|
5509
|
+
# saw them being written. The expected publication identity goes with it:
|
|
5510
|
+
# the high-water alone cannot distinguish this generation from an equally
|
|
5511
|
+
# journal-consistent one some other run installed.
|
|
5512
|
+
post_error = validate_published_stats_index(
|
|
5513
|
+
destination, high_water, expected_record_path=str(record_path),
|
|
5514
|
+
)
|
|
5515
|
+
_remove_empty_db_sidecars(destination)
|
|
5516
|
+
|
|
5517
|
+
live["publicationCheckpoint"] = checkpoint_outcome
|
|
5518
|
+
live["postPublicationValidation"] = {
|
|
5519
|
+
"ok": post_error is None, "error": post_error,
|
|
5520
|
+
}
|
|
5521
|
+
live["completedAtUtc"] = _utc_iso_now()
|
|
5522
|
+
live["status"] = "ok" if post_error is None else "failed"
|
|
5523
|
+
_write_rebuild_record(record_path, live)
|
|
5524
|
+
if post_error is not None:
|
|
5525
|
+
# The scratch is deliberately NOT removed here: it is the last
|
|
5526
|
+
# independently validated copy of this generation, and the live bytes
|
|
5527
|
+
# just failed.
|
|
5528
|
+
_write_publication_marker(
|
|
5529
|
+
destination, record_path, started_at=started_at,
|
|
5530
|
+
scratch_path=scratch, status="failed", error=post_error,
|
|
5531
|
+
mechanism="in_place",
|
|
5532
|
+
)
|
|
5533
|
+
raise JournalError(
|
|
5534
|
+
"published stats index failed post-publication validation: "
|
|
5535
|
+
f"{post_error}; rebuild record: {record_path}"
|
|
5536
|
+
)
|
|
5537
|
+
_stats_rebuild_test_pause("publication_after_verdict_before_marker_removal")
|
|
5538
|
+
_remove_publication_marker(destination)
|
|
5539
|
+
# Removal follows verdict settlement. A surviving `.rebuilding-*` family is
|
|
5540
|
+
# classified FIRST by the next opener and would route this healthy index
|
|
5541
|
+
# through interrupted-rebuild recovery.
|
|
5542
|
+
_stats_rebuild_test_pause("publication_before_scratch_removal")
|
|
5543
|
+
_remove_db_family(scratch)
|
|
5544
|
+
_fsync_dir(pathlib.Path(destination).parent)
|
|
5545
|
+
return None
|
|
5546
|
+
|
|
5547
|
+
|
|
4600
5548
|
def _publish_rebuilt_stats_index(
|
|
4601
5549
|
*,
|
|
4602
5550
|
scratch: pathlib.Path,
|
|
4603
5551
|
destination: pathlib.Path,
|
|
4604
5552
|
preserve_existing: bool,
|
|
5553
|
+
context: RebuildContext,
|
|
5554
|
+
high_water: "tuple[str, int] | None",
|
|
5555
|
+
record: dict,
|
|
4605
5556
|
before_swap=None,
|
|
4606
5557
|
) -> "pathlib.Path | None":
|
|
4607
|
-
"""Publish one validated, closed, sidecar-free scratch index
|
|
5558
|
+
"""Publish one validated, closed, sidecar-free scratch index.
|
|
5559
|
+
|
|
5560
|
+
In-place transactional publication is the mechanism (#496 S3). Physical
|
|
5561
|
+
replacement is the fallback, taken when the destination cannot be operated
|
|
5562
|
+
on structurally. The mechanism is chosen against the destination in front
|
|
5563
|
+
of this run, not by the trigger that reached it: corruption is not uniform,
|
|
5564
|
+
and a readable-but-damaged destination publishes in place like any other.
|
|
5565
|
+
|
|
5566
|
+
Publication is a two-phase durable transaction (#496 S1 F1) under either
|
|
5567
|
+
mechanism. A published file carries the current epoch, so `open_db`'s
|
|
5568
|
+
zero-DDL fast path returns it with no validation and a post-publication
|
|
5569
|
+
failure that only RAISED would leave a known-bad index accepted by every
|
|
5570
|
+
later command. The record and the marker are what make the verdict outlive
|
|
5571
|
+
this process.
|
|
5572
|
+
"""
|
|
4608
5573
|
import _cctally_store
|
|
4609
5574
|
|
|
5575
|
+
# Read-only, and first: a short disk must abort while the destination is
|
|
5576
|
+
# still untouched, not part-way through a publication.
|
|
5577
|
+
_preflight_publication_space(scratch, destination)
|
|
5578
|
+
|
|
5579
|
+
# A marker already beside the destination may still owe a verdict on bytes
|
|
5580
|
+
# that are live right now. Settle it BEFORE Phase 1 overwrites the only
|
|
5581
|
+
# marker slot, while the destination is still exactly what that publication
|
|
5582
|
+
# left there.
|
|
5583
|
+
prior = _settle_prior_publication_verdict(destination)
|
|
5584
|
+
|
|
5585
|
+
# The `before_swap` seam fires ONCE, for either mechanism: it is the
|
|
5586
|
+
# `db rederive` crash seam, and a fallback must not re-enter it.
|
|
5587
|
+
fired = []
|
|
5588
|
+
|
|
5589
|
+
def fire_before_swap() -> None:
|
|
5590
|
+
if before_swap is not None and not fired:
|
|
5591
|
+
fired.append(True)
|
|
5592
|
+
before_swap()
|
|
5593
|
+
|
|
5594
|
+
if pathlib.Path(destination).exists():
|
|
5595
|
+
published = _publish_stats_index_in_place(
|
|
5596
|
+
scratch=scratch, destination=destination, context=context,
|
|
5597
|
+
high_water=high_water, record=record,
|
|
5598
|
+
fire_before_swap=fire_before_swap, prior=prior,
|
|
5599
|
+
)
|
|
5600
|
+
if published is not _FALL_BACK:
|
|
5601
|
+
return published
|
|
5602
|
+
|
|
4610
5603
|
family_exists = any(
|
|
4611
5604
|
pathlib.Path(str(destination) + suffix).exists()
|
|
4612
5605
|
for suffix in ("", "-wal", "-shm")
|
|
4613
5606
|
)
|
|
4614
5607
|
incident = None
|
|
5608
|
+
damage_tokens = None
|
|
4615
5609
|
if family_exists:
|
|
4616
5610
|
blocked = _cctally_store._stats_family_drained(destination)
|
|
4617
5611
|
if blocked is not None:
|
|
@@ -4620,20 +5614,103 @@ def _publish_rebuilt_stats_index(
|
|
|
4620
5614
|
if preserve_existing:
|
|
4621
5615
|
# Preserve the exact pre-cutover family, including a committed WAL
|
|
4622
5616
|
# and SHM, before checkpointing mutates or removes those sidecars.
|
|
4623
|
-
incident = _preserve_stats_family_for_cutover(
|
|
5617
|
+
incident = _preserve_stats_family_for_cutover(
|
|
5618
|
+
destination, context=context
|
|
5619
|
+
)
|
|
5620
|
+
checkpoint_outcome = "skipped_absent"
|
|
4624
5621
|
if destination.exists():
|
|
4625
|
-
|
|
5622
|
+
try:
|
|
5623
|
+
checkpoint_outcome = _prepare_existing_stats_for_cutover(
|
|
5624
|
+
destination
|
|
5625
|
+
)
|
|
5626
|
+
except BaseException:
|
|
5627
|
+
if incident is not None:
|
|
5628
|
+
_record_post_checkpoint_damage(
|
|
5629
|
+
incident, destination, "failed"
|
|
5630
|
+
)
|
|
5631
|
+
raise
|
|
5632
|
+
if incident is not None:
|
|
5633
|
+
# Scanned BEFORE the sidecars are removed, so this and the
|
|
5634
|
+
# preserved scan bracket the explicit checkpoint.
|
|
5635
|
+
damage = _record_post_checkpoint_damage(
|
|
5636
|
+
incident, destination, checkpoint_outcome
|
|
5637
|
+
)
|
|
5638
|
+
if damage:
|
|
5639
|
+
damage_tokens = {
|
|
5640
|
+
"forensics": _forensics_shape_token(context.forensics_path),
|
|
5641
|
+
"preserved": (damage.get("preserved") or {}).get(
|
|
5642
|
+
"shapeToken"
|
|
5643
|
+
),
|
|
5644
|
+
"postCheckpoint": (damage.get("postCheckpoint") or {}).get(
|
|
5645
|
+
"shapeToken"
|
|
5646
|
+
),
|
|
5647
|
+
"checkpointOutcome": damage.get("checkpointOutcome"),
|
|
5648
|
+
}
|
|
4626
5649
|
# The old main stays present and, when it was readable, fully
|
|
4627
5650
|
# checkpointed. A kill from here until os.replace therefore still
|
|
4628
5651
|
# leaves a usable old destination while preventing stale sidecars from
|
|
4629
5652
|
# being paired with the replacement main.
|
|
4630
5653
|
_remove_db_sidecars_strict(destination)
|
|
5654
|
+
_cctally_store._stats_storm_test_pause("stats_replace_sidecars_removed")
|
|
5655
|
+
|
|
5656
|
+
fire_before_swap()
|
|
5657
|
+
|
|
5658
|
+
# Phase 1 of the publication transaction: the record and then the marker,
|
|
5659
|
+
# each fsynced, BEFORE the replacement becomes visible.
|
|
5660
|
+
started_at = _utc_iso_now()
|
|
5661
|
+
record = dict(record)
|
|
5662
|
+
record.update({
|
|
5663
|
+
"status": "pending",
|
|
5664
|
+
"startedAtUtc": started_at,
|
|
5665
|
+
"completedAtUtc": None,
|
|
5666
|
+
"incidentPath": str(incident) if incident is not None else None,
|
|
5667
|
+
"damageShapeTokens": damage_tokens,
|
|
5668
|
+
"postPublicationValidation": None,
|
|
5669
|
+
"publicationMechanism": "replace",
|
|
5670
|
+
})
|
|
5671
|
+
record_path = pathlib.Path(context.record_path)
|
|
5672
|
+
_write_rebuild_record(record_path, record)
|
|
5673
|
+
_write_publication_marker(
|
|
5674
|
+
destination, record_path, started_at=started_at, scratch_path=scratch,
|
|
5675
|
+
prior=prior, mechanism="replace",
|
|
5676
|
+
)
|
|
4631
5677
|
|
|
4632
|
-
if before_swap is not None:
|
|
4633
|
-
before_swap()
|
|
4634
5678
|
_stats_rebuild_test_pause("rebuild_before_cutover")
|
|
4635
5679
|
os.replace(str(scratch), str(destination))
|
|
4636
5680
|
_fsync_dir(destination.parent)
|
|
5681
|
+
_stats_rebuild_test_pause("rebuild_after_publication_replace")
|
|
5682
|
+
|
|
5683
|
+
# Phase 2: validate the bytes that are now live, on a connection that never
|
|
5684
|
+
# saw them being written.
|
|
5685
|
+
post_error = validate_published_stats_index(destination, high_water)
|
|
5686
|
+
# The read-only open above creates a zero-byte WAL and a 32 KiB SHM.
|
|
5687
|
+
# Remove them so the documented no-post-publication-stale-sidecar end state
|
|
5688
|
+
# still holds; an empty WAL is consistent with the freshly published main,
|
|
5689
|
+
# so a crash between validation and removal is harmless.
|
|
5690
|
+
_remove_db_sidecars_strict(destination)
|
|
5691
|
+
|
|
5692
|
+
record["postPublicationValidation"] = {
|
|
5693
|
+
"ok": post_error is None,
|
|
5694
|
+
"error": post_error,
|
|
5695
|
+
}
|
|
5696
|
+
record["completedAtUtc"] = _utc_iso_now()
|
|
5697
|
+
record["status"] = "ok" if post_error is None else "failed"
|
|
5698
|
+
_write_rebuild_record(record_path, record)
|
|
5699
|
+
if post_error is not None:
|
|
5700
|
+
# No rollback is possible: the old family is already quarantined, and
|
|
5701
|
+
# restoring it would republish a file known to be corrupt. Any carried
|
|
5702
|
+
# prior verdict is dropped here on purpose: `os.replace` succeeded, so
|
|
5703
|
+
# the bytes it judged are gone and THIS failure is the live one.
|
|
5704
|
+
_write_publication_marker(
|
|
5705
|
+
destination, record_path, started_at=started_at,
|
|
5706
|
+
scratch_path=scratch, status="failed", error=post_error,
|
|
5707
|
+
mechanism="replace",
|
|
5708
|
+
)
|
|
5709
|
+
raise JournalError(
|
|
5710
|
+
"published stats index failed post-publication validation: "
|
|
5711
|
+
f"{post_error}; rebuild record: {record_path}"
|
|
5712
|
+
)
|
|
5713
|
+
_remove_publication_marker(destination)
|
|
4637
5714
|
return incident
|
|
4638
5715
|
|
|
4639
5716
|
|
|
@@ -4707,6 +5784,7 @@ def _rebuild_quota_cache_leg(records) -> None:
|
|
|
4707
5784
|
|
|
4708
5785
|
def rebuild_stats_index(
|
|
4709
5786
|
*,
|
|
5787
|
+
context: RebuildContext,
|
|
4710
5788
|
target_path=None,
|
|
4711
5789
|
high_water: "tuple[str, int] | None" = None,
|
|
4712
5790
|
update_quota_cache: bool = True,
|
|
@@ -4721,6 +5799,10 @@ def rebuild_stats_index(
|
|
|
4721
5799
|
no alerts, no `reconcile_config` (see the module note above). Post-rebuild the
|
|
4722
5800
|
cursor equals the journal high-water.
|
|
4723
5801
|
|
|
5802
|
+
`context` states WHY this rebuild ran (#496 S1 F3). It is keyword-only with
|
|
5803
|
+
no default, so a future call site cannot silently produce an unattributed
|
|
5804
|
+
quarantine incident; omitting it raises `TypeError` at the call.
|
|
5805
|
+
|
|
4724
5806
|
`target_path` selects the destination (default `DB_PATH`). `high_water`
|
|
4725
5807
|
optionally pins the exact inclusive journal prefix; later bytes stay beyond
|
|
4726
5808
|
the rebuilt cursor. `update_quota_cache=False` is the Task-C Claude-only
|
|
@@ -4731,6 +5813,17 @@ def rebuild_stats_index(
|
|
|
4731
5813
|
publication but does not create a live-family quarantine incident.
|
|
4732
5814
|
"""
|
|
4733
5815
|
start = time.monotonic()
|
|
5816
|
+
context = context.validate()
|
|
5817
|
+
# Resolve the rebuild record's path ONCE, here, because preservation runs
|
|
5818
|
+
# long before the record is written and both must name the same file
|
|
5819
|
+
# (#496 S1). Callers never supply it.
|
|
5820
|
+
record_stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S_%f")
|
|
5821
|
+
context = _dc_replace(
|
|
5822
|
+
context,
|
|
5823
|
+
record_path=str(
|
|
5824
|
+
_cctally_core.LOG_DIR / f"stats-rebuild-{record_stamp}.json"
|
|
5825
|
+
),
|
|
5826
|
+
)
|
|
4734
5827
|
dest = (pathlib.Path(target_path) if target_path is not None
|
|
4735
5828
|
else pathlib.Path(_cctally_core.DB_PATH))
|
|
4736
5829
|
|
|
@@ -4907,21 +6000,63 @@ def rebuild_stats_index(
|
|
|
4907
6000
|
with scratch.open("rb") as handle:
|
|
4908
6001
|
os.fsync(handle.fileno())
|
|
4909
6002
|
_fsync_dir(scratch.parent)
|
|
6003
|
+
|
|
6004
|
+
# Extract the compact result data and RELEASE the replay structures before
|
|
6005
|
+
# publication begins (#496 S3 §4.2). The in-place attempt adds a
|
|
6006
|
+
# database-sized WAL while the scratch still exists, so the measured
|
|
6007
|
+
# multi-gigabyte replay peak must not still be resident on top of it.
|
|
6008
|
+
segments_read = len(segments)
|
|
6009
|
+
conflicts = effective.conflicts
|
|
6010
|
+
protocol_violations = effective.protocol_violations
|
|
6011
|
+
acknowledged = effective.acknowledged_protocol_violations
|
|
6012
|
+
decoded = effective = stream = structural = tail = None
|
|
6013
|
+
segments = protocol_evidence = None
|
|
6014
|
+
|
|
6015
|
+
# First fresh-connection validation (#496 S1 F1). A failure here raises
|
|
6016
|
+
# BEFORE any preservation, so no incident is created and the old family
|
|
6017
|
+
# stays live — the existing contract is preserved exactly.
|
|
6018
|
+
pre_error = validate_published_stats_index(scratch, hw)
|
|
6019
|
+
if pre_error is not None:
|
|
6020
|
+
raise JournalError(
|
|
6021
|
+
f"rebuilt stats index failed pre-publication validation: {pre_error}"
|
|
6022
|
+
)
|
|
6023
|
+
# That read-only open recreated the scratch sidecars; publication requires
|
|
6024
|
+
# a sidecar-free scratch, and a leftover pair would also survive the
|
|
6025
|
+
# `os.replace` as a stray artifact.
|
|
6026
|
+
_remove_db_sidecars_strict(scratch)
|
|
6027
|
+
|
|
4910
6028
|
incident = _publish_rebuilt_stats_index(
|
|
4911
6029
|
scratch=scratch,
|
|
4912
6030
|
destination=dest,
|
|
4913
6031
|
preserve_existing=target_path is None,
|
|
4914
6032
|
before_swap=before_swap,
|
|
6033
|
+
context=context,
|
|
6034
|
+
high_water=hw,
|
|
6035
|
+
record={
|
|
6036
|
+
"schemaVersion": 1,
|
|
6037
|
+
"trigger": context.trigger,
|
|
6038
|
+
"triggerError": context.trigger_error,
|
|
6039
|
+
"forensicsPath": context.forensics_path,
|
|
6040
|
+
"binaryVersion": _binary_version(),
|
|
6041
|
+
"binaryEpoch": _cctally_core.STATS_INDEX_EPOCH,
|
|
6042
|
+
"highWater": [hw[0], hw[1]] if hw is not None else None,
|
|
6043
|
+
"destination": str(dest),
|
|
6044
|
+
"targetPath": str(target_path) if target_path is not None else None,
|
|
6045
|
+
"segmentsRead": segments_read,
|
|
6046
|
+
"linesFolded": lines_folded,
|
|
6047
|
+
"malformed": malformed,
|
|
6048
|
+
"rowsByTable": rows_by_table,
|
|
6049
|
+
"buildSeconds": round(time.monotonic() - start, 3),
|
|
6050
|
+
"prePublicationValidation": {"ok": True, "error": None},
|
|
6051
|
+
},
|
|
4915
6052
|
)
|
|
4916
6053
|
|
|
4917
6054
|
return RebuildResult(
|
|
4918
6055
|
rows_by_table=rows_by_table, malformed=malformed,
|
|
4919
|
-
duration_s=time.monotonic() - start, segments_read=
|
|
4920
|
-
lines_folded=lines_folded, conflicts=
|
|
4921
|
-
protocol_violations=
|
|
4922
|
-
acknowledged_protocol_violations=
|
|
4923
|
-
effective.acknowledged_protocol_violations
|
|
4924
|
-
),
|
|
6056
|
+
duration_s=time.monotonic() - start, segments_read=segments_read,
|
|
6057
|
+
lines_folded=lines_folded, conflicts=conflicts,
|
|
6058
|
+
protocol_violations=protocol_violations,
|
|
6059
|
+
acknowledged_protocol_violations=acknowledged,
|
|
4925
6060
|
quarantine_dir=incident,
|
|
4926
6061
|
)
|
|
4927
6062
|
|
|
@@ -5386,5 +6521,5 @@ def run_epoch_transition(*, claude_json_path=None) -> str:
|
|
|
5386
6521
|
preserves the old index only after the replacement is validated."""
|
|
5387
6522
|
claude_key = _resolve_claude_cutover_identity(claude_json_path)
|
|
5388
6523
|
recorded = append_accounts_cutover_op(claude_key)
|
|
5389
|
-
rebuild_stats_index()
|
|
6524
|
+
rebuild_stats_index(context=RebuildContext(trigger="epoch-transition"))
|
|
5390
6525
|
return recorded
|