cctally 1.80.4 → 1.81.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 +24 -0
- package/bin/_cctally_account.py +397 -0
- package/bin/_cctally_alerts.py +58 -2
- package/bin/_cctally_cache.py +696 -166
- package/bin/_cctally_cache_report.py +21 -3
- package/bin/_cctally_config.py +119 -8
- package/bin/_cctally_core.py +343 -48
- package/bin/_cctally_dashboard.py +32 -7
- package/bin/_cctally_dashboard_conversation.py +6 -2
- package/bin/_cctally_dashboard_envelope.py +49 -0
- package/bin/_cctally_dashboard_share.py +78 -1
- package/bin/_cctally_dashboard_sources.py +434 -48
- package/bin/_cctally_db.py +659 -152
- package/bin/_cctally_diff.py +9 -0
- package/bin/_cctally_doctor.py +201 -26
- package/bin/_cctally_five_hour.py +110 -69
- package/bin/_cctally_forecast.py +112 -43
- package/bin/_cctally_journal.py +591 -80
- package/bin/_cctally_milestones.py +180 -67
- package/bin/_cctally_parser.py +87 -0
- package/bin/_cctally_percent_breakdown.py +24 -15
- package/bin/_cctally_project.py +12 -1
- package/bin/_cctally_quota.py +150 -39
- package/bin/_cctally_record.py +491 -141
- package/bin/_cctally_reporting.py +66 -14
- package/bin/_cctally_setup.py +9 -1
- package/bin/_cctally_source_analytics.py +56 -7
- package/bin/_cctally_statusline.py +55 -8
- package/bin/_cctally_store.py +20 -8
- package/bin/_cctally_sync_week.py +30 -10
- package/bin/_cctally_tui.py +99 -18
- package/bin/_cctally_weekrefs.py +38 -15
- package/bin/_lib_accounts.py +325 -0
- package/bin/_lib_alerts_payload.py +25 -4
- package/bin/_lib_cache_writer_lock.py +77 -0
- package/bin/_lib_codex_hooks.py +40 -1
- package/bin/_lib_diff_kernel.py +28 -14
- package/bin/_lib_doctor.py +160 -0
- package/bin/_lib_journal.py +65 -2
- package/bin/_lib_quota.py +81 -4
- package/bin/_lib_render.py +19 -5
- package/bin/_lib_share.py +25 -0
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_subscription_weeks.py +16 -2
- package/bin/_lib_view_models.py +18 -9
- package/bin/cctally +43 -4
- package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
- package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BzWujBS3.js +0 -92
- package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
package/bin/_cctally_db.py
CHANGED
|
@@ -60,6 +60,7 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
60
60
|
from __future__ import annotations
|
|
61
61
|
|
|
62
62
|
import argparse
|
|
63
|
+
import contextvars
|
|
63
64
|
import datetime as dt
|
|
64
65
|
import enum
|
|
65
66
|
import fcntl
|
|
@@ -99,6 +100,18 @@ from _cctally_core import (
|
|
|
99
100
|
# CCTALLY_DEBUG verbosity is decided in one place.
|
|
100
101
|
import _lib_log
|
|
101
102
|
|
|
103
|
+
|
|
104
|
+
# Production cache dispatchers hold maintenance-exclusive + the global cache
|
|
105
|
+
# writer flock across schema apply, every migration handler, and ledger/version
|
|
106
|
+
# stamps. Older destructive handlers still acquire the global flock when
|
|
107
|
+
# invoked directly by their unit/golden tests; this context flag prevents those
|
|
108
|
+
# handlers from trying to re-lock the same flock through a second descriptor
|
|
109
|
+
# during a production dispatcher walk.
|
|
110
|
+
_CACHE_DISPATCH_WRITER_LOCK_HELD = contextvars.ContextVar(
|
|
111
|
+
"_CACHE_DISPATCH_WRITER_LOCK_HELD",
|
|
112
|
+
default=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
102
115
|
# Stats migration 008 needs the same per-entry cost computation used by
|
|
103
116
|
# the live cost-report path. Direct import keeps the kernel single-sourced
|
|
104
117
|
# (no shim drift); _lib_pricing is a stdlib-only leaf module so no cycle
|
|
@@ -732,13 +745,172 @@ def write_corruption_forensics(
|
|
|
732
745
|
return out
|
|
733
746
|
|
|
734
747
|
|
|
735
|
-
def
|
|
748
|
+
def _quarantine_pending_path(db_path: pathlib.Path) -> pathlib.Path:
|
|
749
|
+
return db_path.with_name(f"{db_path.name}.quarantine-pending.json")
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _atomic_write_private_json(path: pathlib.Path, payload: dict) -> None:
|
|
753
|
+
"""Durably replace one private JSON control record."""
|
|
754
|
+
token = f"{os.getpid()}-{time.time_ns()}-{os.urandom(8).hex()}"
|
|
755
|
+
temp = path.with_name(f".{path.name}.{token}.tmp")
|
|
756
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
757
|
+
try:
|
|
758
|
+
encoded = (
|
|
759
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
760
|
+
).encode("utf-8")
|
|
761
|
+
view = memoryview(encoded)
|
|
762
|
+
while view:
|
|
763
|
+
written = os.write(fd, view)
|
|
764
|
+
if written <= 0:
|
|
765
|
+
raise OSError("short write while publishing control record")
|
|
766
|
+
view = view[written:]
|
|
767
|
+
os.fsync(fd)
|
|
768
|
+
finally:
|
|
769
|
+
os.close(fd)
|
|
770
|
+
try:
|
|
771
|
+
os.replace(temp, path)
|
|
772
|
+
_fsync_directory(path.parent)
|
|
773
|
+
finally:
|
|
774
|
+
try:
|
|
775
|
+
temp.unlink()
|
|
776
|
+
except FileNotFoundError:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _load_pending_quarantine(db_path: pathlib.Path) -> "dict[str, Any] | None":
|
|
781
|
+
pending = _quarantine_pending_path(db_path)
|
|
782
|
+
if not pending.exists():
|
|
783
|
+
return None
|
|
784
|
+
try:
|
|
785
|
+
state = json.loads(pending.read_text(encoding="utf-8"))
|
|
786
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
787
|
+
raise OSError(f"invalid pending quarantine record {pending}: {exc}") from exc
|
|
788
|
+
if (
|
|
789
|
+
not isinstance(state, dict)
|
|
790
|
+
or state.get("schemaVersion") != 1
|
|
791
|
+
or state.get("originalPath") != str(db_path)
|
|
792
|
+
or not isinstance(state.get("incidentPath"), str)
|
|
793
|
+
or not isinstance(state.get("members"), list)
|
|
794
|
+
or not all(
|
|
795
|
+
isinstance(name, str)
|
|
796
|
+
and name in {
|
|
797
|
+
db_path.name,
|
|
798
|
+
f"{db_path.name}-wal",
|
|
799
|
+
f"{db_path.name}-shm",
|
|
800
|
+
}
|
|
801
|
+
for name in state["members"]
|
|
802
|
+
)
|
|
803
|
+
):
|
|
804
|
+
raise OSError(f"invalid pending quarantine record {pending}")
|
|
805
|
+
incident = pathlib.Path(state["incidentPath"])
|
|
806
|
+
expected_root = _cctally_core.APP_DIR / "quarantine"
|
|
807
|
+
try:
|
|
808
|
+
if incident.parent.resolve() != expected_root.resolve():
|
|
809
|
+
raise OSError(
|
|
810
|
+
f"pending quarantine incident is outside {expected_root}: {incident}"
|
|
811
|
+
)
|
|
812
|
+
except OSError as exc:
|
|
813
|
+
raise OSError(f"invalid pending quarantine incident {incident}: {exc}") from exc
|
|
814
|
+
return state
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _quarantine_db_family_strict(
|
|
818
|
+
db_path: pathlib.Path, *, ts: "str | None" = None,
|
|
819
|
+
) -> pathlib.Path:
|
|
820
|
+
"""Resumably move every snapshotted family member or fail closed.
|
|
821
|
+
|
|
822
|
+
The durable pending record is published before the first rename. A killed
|
|
823
|
+
owner or individual rename failure therefore leaves enough information for
|
|
824
|
+
the next maintenance-exclusive opener to complete the exact same incident;
|
|
825
|
+
recreation is forbidden until every member is present at its destination.
|
|
826
|
+
"""
|
|
827
|
+
db_path = pathlib.Path(db_path)
|
|
828
|
+
pending = _quarantine_pending_path(db_path)
|
|
829
|
+
state = _load_pending_quarantine(db_path)
|
|
830
|
+
if state is None:
|
|
831
|
+
ts = ts or _db_backup_timestamp()
|
|
832
|
+
root = _cctally_core.APP_DIR / "quarantine"
|
|
833
|
+
incident = root / f"{db_path.name}-{ts}"
|
|
834
|
+
incident.mkdir(parents=True, exist_ok=True)
|
|
835
|
+
for directory in (root, incident):
|
|
836
|
+
try:
|
|
837
|
+
os.chmod(directory, 0o700)
|
|
838
|
+
except OSError:
|
|
839
|
+
pass
|
|
840
|
+
# Move sidecars first and the main file last. The pending record blocks
|
|
841
|
+
# every opener throughout, but retaining the main file longest also
|
|
842
|
+
# leaves the most useful evidence in place after an abrupt first move.
|
|
843
|
+
members = [
|
|
844
|
+
candidate.name
|
|
845
|
+
for candidate in (
|
|
846
|
+
pathlib.Path(f"{db_path}-wal"),
|
|
847
|
+
pathlib.Path(f"{db_path}-shm"),
|
|
848
|
+
db_path,
|
|
849
|
+
)
|
|
850
|
+
if candidate.exists()
|
|
851
|
+
]
|
|
852
|
+
if not members:
|
|
853
|
+
raise OSError(f"no database family exists to quarantine at {db_path}")
|
|
854
|
+
state = {
|
|
855
|
+
"schemaVersion": 1,
|
|
856
|
+
"originalPath": str(db_path),
|
|
857
|
+
"incidentPath": str(incident),
|
|
858
|
+
"members": members,
|
|
859
|
+
"createdAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
|
|
860
|
+
}
|
|
861
|
+
_atomic_write_private_json(pending, state)
|
|
862
|
+
else:
|
|
863
|
+
incident = pathlib.Path(state["incidentPath"])
|
|
864
|
+
incident.mkdir(parents=True, exist_ok=True)
|
|
865
|
+
|
|
866
|
+
moved: list[str] = []
|
|
867
|
+
for name in state["members"]:
|
|
868
|
+
src = db_path.parent / name
|
|
869
|
+
dst = incident / name
|
|
870
|
+
src_exists = src.exists()
|
|
871
|
+
dst_exists = dst.exists()
|
|
872
|
+
if src_exists and dst_exists:
|
|
873
|
+
raise OSError(
|
|
874
|
+
f"ambiguous quarantine member exists at source and destination: {name}"
|
|
875
|
+
)
|
|
876
|
+
if src_exists:
|
|
877
|
+
os.replace(src, dst)
|
|
878
|
+
_fsync_directory(src.parent)
|
|
879
|
+
_fsync_directory(dst.parent)
|
|
880
|
+
elif not dst_exists:
|
|
881
|
+
raise OSError(
|
|
882
|
+
f"pending quarantine member is missing at source and destination: {name}"
|
|
883
|
+
)
|
|
884
|
+
try:
|
|
885
|
+
os.chmod(dst, 0o600)
|
|
886
|
+
except OSError:
|
|
887
|
+
pass
|
|
888
|
+
moved.append(name)
|
|
889
|
+
|
|
890
|
+
manifest = {
|
|
891
|
+
"schemaVersion": 1,
|
|
892
|
+
"quarantinedAtUtc": _forensics_iso(dt.datetime.now(dt.timezone.utc)),
|
|
893
|
+
"originalPath": str(db_path),
|
|
894
|
+
"movedFiles": moved,
|
|
895
|
+
"complete": True,
|
|
896
|
+
}
|
|
897
|
+
_atomic_write_private_json(incident / "manifest.json", manifest)
|
|
898
|
+
pending.unlink()
|
|
899
|
+
_fsync_directory(pending.parent)
|
|
900
|
+
return incident
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def quarantine_db_family(
|
|
904
|
+
db_path, *, ts: "str | None" = None, strict: bool = False,
|
|
905
|
+
) -> pathlib.Path:
|
|
736
906
|
"""Move a damaged DB + its ``-wal``/``-shm`` sidecars into a single
|
|
737
907
|
timestamped incident directory under ``quarantine/`` with a manifest (spec
|
|
738
908
|
§6.3). NEVER deletes evidence — three renames under the caller's exclusion
|
|
739
909
|
locks, not pretending to be one atomic op. ``0o700`` dir / ``0o600`` files.
|
|
740
910
|
Returns the incident directory (which may be empty if nothing was present)."""
|
|
741
911
|
db_path = pathlib.Path(db_path)
|
|
912
|
+
if strict:
|
|
913
|
+
return _quarantine_db_family_strict(db_path, ts=ts)
|
|
742
914
|
ts = ts or _db_backup_timestamp()
|
|
743
915
|
root = _cctally_core.APP_DIR / "quarantine"
|
|
744
916
|
incident = root / f"{db_path.name}-{ts}"
|
|
@@ -2674,8 +2846,9 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2674
2846
|
``add_column_if_missing``). Does NOT run the dispatcher and does NOT include
|
|
2675
2847
|
the Codex ``last_total_tokens`` ALTER, which carries a one-time purge
|
|
2676
2848
|
side-effect that stays in ``open_cache_db``. The eager dispatcher can run
|
|
2677
|
-
024
|
|
2678
|
-
Codex-derived rows; it neither
|
|
2849
|
+
024 under the dispatcher's global writer flock, after which that handler
|
|
2850
|
+
acquires its Codex flock and only deletes Codex-derived rows; it neither
|
|
2851
|
+
reads nor requires ``last_total_tokens``.
|
|
2679
2852
|
Normal ``open_cache_db`` remains responsible for the ALTER and its historic
|
|
2680
2853
|
purge, so leaving it outside this shared schema is safe.
|
|
2681
2854
|
"""
|
|
@@ -3084,6 +3257,20 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
3084
3257
|
# existing last_model/thread facts. A batch that ends after a turn_context
|
|
3085
3258
|
# and resumes with a response_item stamps the correct effective turn.
|
|
3086
3259
|
add_column_if_missing(conn, "codex_session_files", "last_turn_id", "TEXT")
|
|
3260
|
+
# account_key (#341, spec §2 cache.db): observe-and-stamp attribution rides a
|
|
3261
|
+
# NULLABLE column on every stamped-entry family — the read-side rule is
|
|
3262
|
+
# ``NULL ≡ unattributed`` (no NOT NULL, no default), so an unstamped legacy
|
|
3263
|
+
# row and a stably-unattributed ingest render identically. The Claude
|
|
3264
|
+
# families (session_entries/session_files) are backfilled from the cutover op
|
|
3265
|
+
# by cache migration 029; Codex families are stamped forward at ingest time
|
|
3266
|
+
# (per-root auth.json) and pre-feature Codex rows stay NULL per Q2. These
|
|
3267
|
+
# column-adds ride the migration-bumped head like every other cache column
|
|
3268
|
+
# addition (schema apply is version-gated, migrations-gotchas.md).
|
|
3269
|
+
add_column_if_missing(conn, "session_entries", "account_key", "TEXT")
|
|
3270
|
+
add_column_if_missing(conn, "session_files", "account_key", "TEXT")
|
|
3271
|
+
add_column_if_missing(conn, "codex_session_entries", "account_key", "TEXT")
|
|
3272
|
+
add_column_if_missing(conn, "codex_session_files", "account_key", "TEXT")
|
|
3273
|
+
add_column_if_missing(conn, "quota_window_snapshots", "account_key", "TEXT")
|
|
3087
3274
|
conn.execute(
|
|
3088
3275
|
"CREATE INDEX IF NOT EXISTS idx_codex_files_source_root "
|
|
3089
3276
|
"ON codex_session_files(source_root_key)"
|
|
@@ -3846,7 +4033,47 @@ def _codex_conversation_fts_full_clear(conn: sqlite3.Connection) -> None:
|
|
|
3846
4033
|
pass # table not yet created (pre-schema conn); nothing to clear
|
|
3847
4034
|
|
|
3848
4035
|
|
|
4036
|
+
def _run_pending_cache_migrations_under_writer_lock(
|
|
4037
|
+
conn: sqlite3.Connection,
|
|
4038
|
+
) -> None:
|
|
4039
|
+
"""Run the cache dispatcher while marking its global flock as owned."""
|
|
4040
|
+
token = _CACHE_DISPATCH_WRITER_LOCK_HELD.set(True)
|
|
4041
|
+
try:
|
|
4042
|
+
_run_pending_migrations(
|
|
4043
|
+
conn,
|
|
4044
|
+
registry=_CACHE_MIGRATIONS,
|
|
4045
|
+
db_label="cache.db",
|
|
4046
|
+
recover_version_ahead=True,
|
|
4047
|
+
)
|
|
4048
|
+
finally:
|
|
4049
|
+
_CACHE_DISPATCH_WRITER_LOCK_HELD.reset(token)
|
|
4050
|
+
|
|
4051
|
+
|
|
3849
4052
|
def _eagerly_apply_cache_migrations() -> None:
|
|
4053
|
+
"""Serialize the eager cache schema/migration path against all writers."""
|
|
4054
|
+
from _lib_cache_writer_lock import (
|
|
4055
|
+
acquire_ordered_flocks,
|
|
4056
|
+
release_cache_writer_flocks,
|
|
4057
|
+
)
|
|
4058
|
+
|
|
4059
|
+
_cctally_core.APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
4060
|
+
held = acquire_ordered_flocks(
|
|
4061
|
+
[
|
|
4062
|
+
(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_EX),
|
|
4063
|
+
(_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
|
|
4064
|
+
]
|
|
4065
|
+
)
|
|
4066
|
+
if held is None:
|
|
4067
|
+
raise MigrationGateNotMet(
|
|
4068
|
+
"cache.db writer busy; deferring eager cache migrations"
|
|
4069
|
+
)
|
|
4070
|
+
try:
|
|
4071
|
+
_eagerly_apply_cache_migrations_under_writer_lock()
|
|
4072
|
+
finally:
|
|
4073
|
+
release_cache_writer_flocks(held)
|
|
4074
|
+
|
|
4075
|
+
|
|
4076
|
+
def _eagerly_apply_cache_migrations_under_writer_lock() -> None:
|
|
3850
4077
|
"""Open cache.db so its pending migrations (notably
|
|
3851
4078
|
``001_dedup_highest_wins``) apply BEFORE stats migration 008's gate
|
|
3852
4079
|
check.
|
|
@@ -3953,10 +4180,7 @@ def _eagerly_apply_cache_migrations() -> None:
|
|
|
3953
4180
|
# Dispatcher (cache.db side). Runs every pending cache
|
|
3954
4181
|
# migration, including ``001_dedup_highest_wins``. Idempotent —
|
|
3955
4182
|
# if 001 has already applied, this is a fast-path return.
|
|
3956
|
-
|
|
3957
|
-
conn, registry=_CACHE_MIGRATIONS, db_label="cache.db",
|
|
3958
|
-
recover_version_ahead=True,
|
|
3959
|
-
)
|
|
4183
|
+
_run_pending_cache_migrations_under_writer_lock(conn)
|
|
3960
4184
|
finally:
|
|
3961
4185
|
# Close immediately so the WAL writer lock (if any) is
|
|
3962
4186
|
# released before the stats 008 body opens its read-only
|
|
@@ -4103,6 +4327,43 @@ def _cache_db_codex_lock_path_for_conn(
|
|
|
4103
4327
|
return None
|
|
4104
4328
|
|
|
4105
4329
|
|
|
4330
|
+
def _acquire_cache_db_codex_provider_flock(
|
|
4331
|
+
conn: sqlite3.Connection,
|
|
4332
|
+
*,
|
|
4333
|
+
migration: str,
|
|
4334
|
+
) -> list[int]:
|
|
4335
|
+
"""Take the Codex provider flock inside the dispatcher's global flock.
|
|
4336
|
+
|
|
4337
|
+
Production dispatchers already hold the global cache writer flock. The
|
|
4338
|
+
provider-local lock remains inside handlers 024–027 so direct migration
|
|
4339
|
+
tests and provider-scoped coordination retain their fail-closed behavior.
|
|
4340
|
+
"""
|
|
4341
|
+
provider_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4342
|
+
if provider_path is None:
|
|
4343
|
+
return []
|
|
4344
|
+
|
|
4345
|
+
from _lib_cache_writer_lock import acquire_ordered_flocks
|
|
4346
|
+
|
|
4347
|
+
try:
|
|
4348
|
+
held = acquire_ordered_flocks([(provider_path, fcntl.LOCK_EX)])
|
|
4349
|
+
except OSError as exc:
|
|
4350
|
+
raise MigrationGateNotMet(
|
|
4351
|
+
f"cache.db Codex lock unavailable; deferring cache {migration}"
|
|
4352
|
+
) from exc
|
|
4353
|
+
if held is None:
|
|
4354
|
+
raise MigrationGateNotMet(
|
|
4355
|
+
f"cache.db Codex lock held by a concurrent Codex sync; "
|
|
4356
|
+
f"deferring cache {migration}"
|
|
4357
|
+
)
|
|
4358
|
+
return held
|
|
4359
|
+
|
|
4360
|
+
|
|
4361
|
+
def _release_cache_db_writer_flocks(held: list[int]) -> None:
|
|
4362
|
+
from _lib_cache_writer_lock import release_cache_writer_flocks
|
|
4363
|
+
|
|
4364
|
+
release_cache_writer_flocks(held)
|
|
4365
|
+
|
|
4366
|
+
|
|
4106
4367
|
@cache_migration("001_dedup_highest_wins")
|
|
4107
4368
|
def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
|
|
4108
4369
|
"""One-time re-ingest of session_entries with corrected msg_id+req_id dedup.
|
|
@@ -4151,9 +4412,10 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
|
|
|
4151
4412
|
handler time anyway. Interactive surfaces (``report``,
|
|
4152
4413
|
``weekly``, ``percent-breakdown``, etc.) still see it once.
|
|
4153
4414
|
"""
|
|
4154
|
-
# #105 — mutual exclusion with ``sync_cache``.
|
|
4155
|
-
# ``cache.db.lock`` fcntl flock ``sync_cache`` holds
|
|
4156
|
-
# walk
|
|
4415
|
+
# #105 — mutual exclusion with ``sync_cache``. Production dispatchers
|
|
4416
|
+
# already hold the SAME ``cache.db.lock`` fcntl flock ``sync_cache`` holds
|
|
4417
|
+
# for its entire walk. Direct handler calls (unit/golden tests) acquire it
|
|
4418
|
+
# here before the ``BEGIN IMMEDIATE`` below. Both paths therefore
|
|
4157
4419
|
# acquire fcntl -> SQLite write lock in ONE consistent order, so there
|
|
4158
4420
|
# is no opposite-order deadlock (the hazard that deferred this fix:
|
|
4159
4421
|
# SQLite-then-fcntl in 001 vs fcntl-then-SQLite in sync_cache). With
|
|
@@ -4172,7 +4434,11 @@ def _001_dedup_highest_wins(conn: sqlite3.Connection) -> None:
|
|
|
4172
4434
|
# non-blocking LOCK_NB-and-bail and the framework's "defer is the safe
|
|
4173
4435
|
# side" contract. 008/009/010 already defer while 001 is pending, so the
|
|
4174
4436
|
# system stays safe until a non-contended instant applies it.
|
|
4175
|
-
lock_path =
|
|
4437
|
+
lock_path = (
|
|
4438
|
+
None
|
|
4439
|
+
if _CACHE_DISPATCH_WRITER_LOCK_HELD.get()
|
|
4440
|
+
else _cache_db_lock_path_for_conn(conn)
|
|
4441
|
+
)
|
|
4176
4442
|
lock_fh = None
|
|
4177
4443
|
if lock_path is not None:
|
|
4178
4444
|
lock_fh = open(lock_path, "w")
|
|
@@ -4786,8 +5052,10 @@ def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
|
|
|
4786
5052
|
offset bookkeeping cannot produce them) with no principled winner; MIN(id)
|
|
4787
5053
|
keeps the first-ingested row, matching the pre-bug state. cache.db is
|
|
4788
5054
|
re-derivable — `cache-sync --rebuild` remains the escape hatch. Mirrors
|
|
4789
|
-
cache 001's flock-then-BEGIN-IMMEDIATE pattern (#105): mutual
|
|
4790
|
-
a mid-walk sync_cache
|
|
5055
|
+
cache 001's global-flock-then-BEGIN-IMMEDIATE pattern (#105): mutual
|
|
5056
|
+
exclusion with a mid-walk sync_cache. Production dispatchers already hold
|
|
5057
|
+
that flock; direct handler calls acquire it here and defer via
|
|
5058
|
+
MigrationGateNotMet on contention.
|
|
4791
5059
|
|
|
4792
5060
|
Fresh installs never run this handler: the dispatcher stamps it WITHOUT
|
|
4793
5061
|
running (the fresh-install branch), and `_apply_cache_schema` already created
|
|
@@ -4796,7 +5064,11 @@ def _020_session_entries_physical_unique(conn: sqlite3.Connection) -> None:
|
|
|
4796
5064
|
on a second run) and the index create is `IF NOT EXISTS`; safe on an empty
|
|
4797
5065
|
table.
|
|
4798
5066
|
"""
|
|
4799
|
-
lock_path =
|
|
5067
|
+
lock_path = (
|
|
5068
|
+
None
|
|
5069
|
+
if _CACHE_DISPATCH_WRITER_LOCK_HELD.get()
|
|
5070
|
+
else _cache_db_lock_path_for_conn(conn)
|
|
5071
|
+
)
|
|
4800
5072
|
lock_fh = None
|
|
4801
5073
|
if lock_path is not None:
|
|
4802
5074
|
lock_fh = open(lock_path, "w")
|
|
@@ -4906,27 +5178,18 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
4906
5178
|
sync rederives them from rollout source. Claude tables and Claude quota
|
|
4907
5179
|
snapshots remain untouched.
|
|
4908
5180
|
|
|
4909
|
-
The
|
|
4910
|
-
|
|
5181
|
+
The cache dispatcher holds maintenance + global writer exclusion; this
|
|
5182
|
+
handler then takes ``cache.db.codex.lock`` before ``BEGIN IMMEDIATE`` — the same
|
|
5183
|
+
fcntl-then-SQLite order as ``sync_codex_cache``. Contention raises
|
|
4911
5184
|
``MigrationGateNotMet`` before any DML, so both ordinary and eager cache
|
|
4912
5185
|
dispatch defer without a partial clear. The DELETE-only transition is safe
|
|
4913
5186
|
to retry after a crash between this commit and the dispatcher's separate
|
|
4914
|
-
marker stamp: rerunning against its own empty Codex state is a no-op.
|
|
4915
|
-
|
|
4916
|
-
``user_version``.
|
|
5187
|
+
marker stamp: rerunning against its own empty Codex state is a no-op. Never
|
|
5188
|
+
self-stamp; the dispatcher owns ``schema_migrations`` and ``user_version``.
|
|
4917
5189
|
"""
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
lock_fh = open(lock_path, "w")
|
|
4922
|
-
try:
|
|
4923
|
-
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4924
|
-
except BlockingIOError:
|
|
4925
|
-
lock_fh.close()
|
|
4926
|
-
raise MigrationGateNotMet(
|
|
4927
|
-
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4928
|
-
"deferring cache 024 fused-ingest rebuild (#294 S1)"
|
|
4929
|
-
)
|
|
5190
|
+
held = _acquire_cache_db_codex_provider_flock(
|
|
5191
|
+
conn, migration="024 fused-ingest rebuild (#294 S1)"
|
|
5192
|
+
)
|
|
4930
5193
|
try:
|
|
4931
5194
|
conn.execute("BEGIN IMMEDIATE")
|
|
4932
5195
|
try:
|
|
@@ -4948,12 +5211,7 @@ def _024_codex_fused_ingest_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
4948
5211
|
conn.rollback()
|
|
4949
5212
|
raise
|
|
4950
5213
|
finally:
|
|
4951
|
-
|
|
4952
|
-
try:
|
|
4953
|
-
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4954
|
-
except OSError:
|
|
4955
|
-
pass
|
|
4956
|
-
lock_fh.close()
|
|
5214
|
+
_release_cache_db_writer_flocks(held)
|
|
4957
5215
|
|
|
4958
5216
|
|
|
4959
5217
|
@cache_migration("025_codex_conversation_normalization")
|
|
@@ -4969,27 +5227,19 @@ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
|
|
|
4969
5227
|
turn_context sticky state as it goes. The physical event log and Codex
|
|
4970
5228
|
accounting/thread/quota rows are the replay SOURCE and are never touched.
|
|
4971
5229
|
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
5230
|
+
The cache dispatcher holds maintenance + global writer exclusion; this
|
|
5231
|
+
handler then takes ``cache.db.codex.lock`` with the same
|
|
5232
|
+
fcntl-then-``BEGIN IMMEDIATE`` order as ``sync_codex_cache``, raising
|
|
5233
|
+
``MigrationGateNotMet`` on contention (defer, never partially normalize)
|
|
5234
|
+
from both the ordinary and eager cache dispatch paths. Deterministic replay
|
|
5235
|
+
order + the non-AUTOINCREMENT ``id`` rowid alias + the storm-free full-clear
|
|
5236
|
+
make a re-run (handler success, crash before the central stamp)
|
|
5237
|
+
byte-idempotent INCLUDING the FTS shadow tables. Never self-stamps; the
|
|
5238
|
+
dispatcher owns ``schema_migrations`` and ``user_version``.
|
|
4980
5239
|
"""
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
lock_fh = open(lock_path, "w")
|
|
4985
|
-
try:
|
|
4986
|
-
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4987
|
-
except BlockingIOError:
|
|
4988
|
-
lock_fh.close()
|
|
4989
|
-
raise MigrationGateNotMet(
|
|
4990
|
-
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4991
|
-
"deferring cache 025 conversation normalization (#294 S6)"
|
|
4992
|
-
)
|
|
5240
|
+
held = _acquire_cache_db_codex_provider_flock(
|
|
5241
|
+
conn, migration="025 conversation normalization (#294 S6)"
|
|
5242
|
+
)
|
|
4993
5243
|
try:
|
|
4994
5244
|
conn.execute("BEGIN IMMEDIATE")
|
|
4995
5245
|
try:
|
|
@@ -5003,12 +5253,7 @@ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
|
|
|
5003
5253
|
conn.rollback()
|
|
5004
5254
|
raise
|
|
5005
5255
|
finally:
|
|
5006
|
-
|
|
5007
|
-
try:
|
|
5008
|
-
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5009
|
-
except OSError:
|
|
5010
|
-
pass
|
|
5011
|
-
lock_fh.close()
|
|
5256
|
+
_release_cache_db_writer_flocks(held)
|
|
5012
5257
|
|
|
5013
5258
|
|
|
5014
5259
|
@cache_migration("026_codex_conversation_key_backfill")
|
|
@@ -5022,25 +5267,17 @@ def _026_codex_conversation_key_backfill(conn: sqlite3.Connection) -> None:
|
|
|
5022
5267
|
clear helper with ``sync_codex_cache(rebuild=True)`` so new or future Codex
|
|
5023
5268
|
families cannot drift from the migration's destructive scope.
|
|
5024
5269
|
|
|
5025
|
-
The
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5270
|
+
The cache dispatcher holds maintenance + global writer exclusion; this
|
|
5271
|
+
handler then takes the Codex flock before ``BEGIN IMMEDIATE``. Contention
|
|
5272
|
+
defers through ``MigrationGateNotMet``
|
|
5273
|
+
before any DML; after a handler/data commit but before the dispatcher's
|
|
5274
|
+
central stamp, a rerun against the empty state is a byte-idempotent no-op.
|
|
5275
|
+
The physical mutation sequence advances only when the shared clear actually
|
|
5276
|
+
changed persisted Codex state. The handler never self-stamps.
|
|
5031
5277
|
"""
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
lock_fh = open(lock_path, "w")
|
|
5036
|
-
try:
|
|
5037
|
-
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
5038
|
-
except BlockingIOError:
|
|
5039
|
-
lock_fh.close()
|
|
5040
|
-
raise MigrationGateNotMet(
|
|
5041
|
-
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
5042
|
-
"deferring cache 026 conversation-key backfill (#312)"
|
|
5043
|
-
)
|
|
5278
|
+
held = _acquire_cache_db_codex_provider_flock(
|
|
5279
|
+
conn, migration="026 conversation-key backfill (#312)"
|
|
5280
|
+
)
|
|
5044
5281
|
try:
|
|
5045
5282
|
conn.execute("BEGIN IMMEDIATE")
|
|
5046
5283
|
try:
|
|
@@ -5053,12 +5290,7 @@ def _026_codex_conversation_key_backfill(conn: sqlite3.Connection) -> None:
|
|
|
5053
5290
|
conn.rollback()
|
|
5054
5291
|
raise
|
|
5055
5292
|
finally:
|
|
5056
|
-
|
|
5057
|
-
try:
|
|
5058
|
-
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5059
|
-
except OSError:
|
|
5060
|
-
pass
|
|
5061
|
-
lock_fh.close()
|
|
5293
|
+
_release_cache_db_writer_flocks(held)
|
|
5062
5294
|
|
|
5063
5295
|
|
|
5064
5296
|
@cache_migration("027_codex_fork_preamble_rebuild")
|
|
@@ -5072,24 +5304,17 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
5072
5304
|
remain authoritative, so clear all re-derivable Codex families and let the
|
|
5073
5305
|
corrected fused parser replay them from byte zero.
|
|
5074
5306
|
|
|
5075
|
-
The
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5307
|
+
The cache dispatcher holds maintenance + global writer exclusion; this
|
|
5308
|
+
handler then takes the Codex flock before ``BEGIN IMMEDIATE``. Contention
|
|
5309
|
+
defers before DML; a markerless retry
|
|
5310
|
+
against already-cleared state is a no-op. The shared clear helper keeps this
|
|
5311
|
+
migration aligned with runtime rebuild scope and advances the physical
|
|
5312
|
+
mutation sequence only when state actually changed. The dispatcher owns the
|
|
5313
|
+
migration marker.
|
|
5080
5314
|
"""
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
lock_fh = open(lock_path, "w")
|
|
5085
|
-
try:
|
|
5086
|
-
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
5087
|
-
except BlockingIOError:
|
|
5088
|
-
lock_fh.close()
|
|
5089
|
-
raise MigrationGateNotMet(
|
|
5090
|
-
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
5091
|
-
"deferring cache 027 fork-preamble rebuild"
|
|
5092
|
-
)
|
|
5315
|
+
held = _acquire_cache_db_codex_provider_flock(
|
|
5316
|
+
conn, migration="027 fork-preamble rebuild"
|
|
5317
|
+
)
|
|
5093
5318
|
try:
|
|
5094
5319
|
conn.execute("BEGIN IMMEDIATE")
|
|
5095
5320
|
try:
|
|
@@ -5102,12 +5327,7 @@ def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
|
|
|
5102
5327
|
conn.rollback()
|
|
5103
5328
|
raise
|
|
5104
5329
|
finally:
|
|
5105
|
-
|
|
5106
|
-
try:
|
|
5107
|
-
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
5108
|
-
except OSError:
|
|
5109
|
-
pass
|
|
5110
|
-
lock_fh.close()
|
|
5330
|
+
_release_cache_db_writer_flocks(held)
|
|
5111
5331
|
|
|
5112
5332
|
|
|
5113
5333
|
@cache_migration("028_split_conversation_store")
|
|
@@ -5118,14 +5338,14 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
|
|
|
5118
5338
|
the independent current-schema store, arms provider-local byte-zero replay,
|
|
5119
5339
|
then drops the legacy transcript tables from the hot cache. Compact Codex
|
|
5120
5340
|
thread metadata stays in cache.db because accounting/project attribution
|
|
5121
|
-
joins it directly.
|
|
5341
|
+
joins it directly. The dispatcher already holds cache maintenance exclusive
|
|
5342
|
+
+ the global writer flock; this handler takes the remaining conversation
|
|
5343
|
+
maintenance and provider flocks in total lock order.
|
|
5122
5344
|
"""
|
|
5123
5345
|
core = _cctally_core
|
|
5124
5346
|
lock_paths = (
|
|
5125
|
-
core.CACHE_LOCK_MAINTENANCE_PATH,
|
|
5126
|
-
core.CACHE_LOCK_PATH,
|
|
5127
|
-
core.CACHE_LOCK_CODEX_PATH,
|
|
5128
5347
|
core.CONVERSATIONS_LOCK_MAINTENANCE_PATH,
|
|
5348
|
+
core.CACHE_LOCK_CODEX_PATH,
|
|
5129
5349
|
core.CONVERSATIONS_LOCK_PATH,
|
|
5130
5350
|
core.CONVERSATIONS_LOCK_CODEX_PATH,
|
|
5131
5351
|
)
|
|
@@ -5222,6 +5442,62 @@ def _028_split_conversation_store(conn: sqlite3.Connection) -> None:
|
|
|
5222
5442
|
fh.close()
|
|
5223
5443
|
|
|
5224
5444
|
|
|
5445
|
+
@cache_migration("029_backfill_claude_account")
|
|
5446
|
+
def _029_backfill_claude_account(conn: sqlite3.Connection) -> None:
|
|
5447
|
+
"""Backfill Claude ``session_entries`` / ``session_files`` ``account_key``
|
|
5448
|
+
from the cutover op's recorded account (#341, spec §2 cache.db).
|
|
5449
|
+
|
|
5450
|
+
The ``account_key`` columns themselves are added by ``_apply_cache_schema``
|
|
5451
|
+
(CREATE-time + ``add_column_if_missing`` — the repo's column-addition rule),
|
|
5452
|
+
so they are present in BOTH pre and post; this migration bumps the cache head
|
|
5453
|
+
so an existing install re-runs that schema apply AND performs the one-time
|
|
5454
|
+
Claude row backfill. It reads ONLY the journal cutover op (``never re-reads
|
|
5455
|
+
auth`` — spec §2 epoch transition): Claude legacy rows attribute to the op's
|
|
5456
|
+
recorded account; Codex rows stay NULL (Q2). ``NULL ≡ unattributed`` on the
|
|
5457
|
+
read path, so a single-account / no-identity install is byte-stable.
|
|
5458
|
+
|
|
5459
|
+
Ordering: the cutover op is appended by the stats epoch transition, which may
|
|
5460
|
+
run AFTER this cache open. So when the op is ABSENT but legacy Claude rows
|
|
5461
|
+
exist, DEFER (``MigrationGateNotMet``) — the op appears on a later open and
|
|
5462
|
+
this backfill runs then. An op recording the ``unattributed`` sentinel is a
|
|
5463
|
+
resolved (not pending) outcome → no-op (leave NULL). Idempotent: only touches
|
|
5464
|
+
``account_key IS NULL`` rows. Central stamp via the dispatcher (#140); the
|
|
5465
|
+
handler does NOT self-stamp.
|
|
5466
|
+
"""
|
|
5467
|
+
import _cctally_journal as _jr
|
|
5468
|
+
import _lib_accounts as _acc
|
|
5469
|
+
|
|
5470
|
+
op_value = _jr.find_accounts_cutover_op()
|
|
5471
|
+
if op_value is None:
|
|
5472
|
+
# Cutover hasn't appended its op yet. Defer only if there is legacy Claude
|
|
5473
|
+
# history to attribute; a fresh install (no such rows) proceeds as a no-op.
|
|
5474
|
+
pending = conn.execute(
|
|
5475
|
+
"SELECT 1 FROM session_entries WHERE account_key IS NULL LIMIT 1"
|
|
5476
|
+
).fetchone()
|
|
5477
|
+
if pending is not None:
|
|
5478
|
+
raise MigrationGateNotMet(
|
|
5479
|
+
"accounts cutover op not yet appended; deferring Claude account "
|
|
5480
|
+
"backfill until the epoch transition records it"
|
|
5481
|
+
)
|
|
5482
|
+
return
|
|
5483
|
+
if op_value == _acc.UNATTRIBUTED:
|
|
5484
|
+
return # resolved unattributed — leave NULL (== unattributed)
|
|
5485
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
5486
|
+
try:
|
|
5487
|
+
conn.execute(
|
|
5488
|
+
"UPDATE session_entries SET account_key=? WHERE account_key IS NULL",
|
|
5489
|
+
(op_value,),
|
|
5490
|
+
)
|
|
5491
|
+
conn.execute(
|
|
5492
|
+
"UPDATE session_files SET account_key=? WHERE account_key IS NULL",
|
|
5493
|
+
(op_value,),
|
|
5494
|
+
)
|
|
5495
|
+
conn.commit()
|
|
5496
|
+
except Exception:
|
|
5497
|
+
conn.rollback()
|
|
5498
|
+
raise
|
|
5499
|
+
|
|
5500
|
+
|
|
5225
5501
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
5226
5502
|
|
|
5227
5503
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -6499,6 +6775,33 @@ def _db_path_for_label(db_label: str) -> pathlib.Path:
|
|
|
6499
6775
|
raise ValueError(f"unknown db_label: {db_label}")
|
|
6500
6776
|
|
|
6501
6777
|
|
|
6778
|
+
def _acquire_cache_admin_writer_flocks(
|
|
6779
|
+
db_label: str,
|
|
6780
|
+
*,
|
|
6781
|
+
timeout: float = 15.0,
|
|
6782
|
+
) -> "list[int] | None":
|
|
6783
|
+
"""Serialize raw cache admin mutations without affecting other stores."""
|
|
6784
|
+
if db_label != "cache.db":
|
|
6785
|
+
return []
|
|
6786
|
+
from _lib_cache_writer_lock import acquire_ordered_flocks
|
|
6787
|
+
|
|
6788
|
+
return acquire_ordered_flocks(
|
|
6789
|
+
[
|
|
6790
|
+
(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
|
|
6791
|
+
(_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
|
|
6792
|
+
],
|
|
6793
|
+
timeout=timeout,
|
|
6794
|
+
)
|
|
6795
|
+
|
|
6796
|
+
|
|
6797
|
+
def _release_cache_admin_writer_flocks(held: list[int]) -> None:
|
|
6798
|
+
if not held:
|
|
6799
|
+
return
|
|
6800
|
+
from _lib_cache_writer_lock import release_cache_writer_flocks
|
|
6801
|
+
|
|
6802
|
+
release_cache_writer_flocks(held)
|
|
6803
|
+
|
|
6804
|
+
|
|
6502
6805
|
def cmd_db_skip(args: argparse.Namespace) -> int:
|
|
6503
6806
|
"""Mark a migration as skipped.
|
|
6504
6807
|
|
|
@@ -6524,7 +6827,15 @@ def cmd_db_skip(args: argparse.Namespace) -> int:
|
|
|
6524
6827
|
# ~/.local/share/cctally/ yet, and sqlite3.connect() does NOT
|
|
6525
6828
|
# create parent directories (only the DB file itself).
|
|
6526
6829
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
6527
|
-
|
|
6830
|
+
held = _acquire_cache_admin_writer_flocks(db_label)
|
|
6831
|
+
if held is None:
|
|
6832
|
+
eprint("cctally: cache.db writer busy; could not record migration skip.")
|
|
6833
|
+
return 3
|
|
6834
|
+
try:
|
|
6835
|
+
conn = sqlite3.connect(path)
|
|
6836
|
+
except BaseException:
|
|
6837
|
+
_release_cache_admin_writer_flocks(held)
|
|
6838
|
+
raise
|
|
6528
6839
|
try:
|
|
6529
6840
|
conn.execute(
|
|
6530
6841
|
"""
|
|
@@ -6571,6 +6882,7 @@ def cmd_db_skip(args: argparse.Namespace) -> int:
|
|
|
6571
6882
|
conn.commit()
|
|
6572
6883
|
finally:
|
|
6573
6884
|
conn.close()
|
|
6885
|
+
_release_cache_admin_writer_flocks(held)
|
|
6574
6886
|
print(f"Skipped: {name}")
|
|
6575
6887
|
return 0
|
|
6576
6888
|
|
|
@@ -6609,7 +6921,15 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
|
|
|
6609
6921
|
if not path.exists():
|
|
6610
6922
|
print(f"cctally: {name} is not skipped; nothing to do.")
|
|
6611
6923
|
return 0
|
|
6612
|
-
|
|
6924
|
+
held = _acquire_cache_admin_writer_flocks(db_label)
|
|
6925
|
+
if held is None:
|
|
6926
|
+
eprint("cctally: cache.db writer busy; could not remove migration skip.")
|
|
6927
|
+
return 3
|
|
6928
|
+
try:
|
|
6929
|
+
conn = sqlite3.connect(path)
|
|
6930
|
+
except BaseException:
|
|
6931
|
+
_release_cache_admin_writer_flocks(held)
|
|
6932
|
+
raise
|
|
6613
6933
|
try:
|
|
6614
6934
|
try:
|
|
6615
6935
|
cur = conn.execute(
|
|
@@ -6627,6 +6947,7 @@ def cmd_db_unskip(args: argparse.Namespace) -> int:
|
|
|
6627
6947
|
conn.commit()
|
|
6628
6948
|
finally:
|
|
6629
6949
|
conn.close()
|
|
6950
|
+
_release_cache_admin_writer_flocks(held)
|
|
6630
6951
|
print(f"Unskipped: {name} (will run on next open).")
|
|
6631
6952
|
return 0
|
|
6632
6953
|
|
|
@@ -6665,7 +6986,15 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
|
6665
6986
|
print(f"cctally: {label} not present; nothing to recover.")
|
|
6666
6987
|
return 0
|
|
6667
6988
|
|
|
6668
|
-
|
|
6989
|
+
held = _acquire_cache_admin_writer_flocks(label)
|
|
6990
|
+
if held is None:
|
|
6991
|
+
eprint("cctally: cache.db writer busy; could not recover schema state.")
|
|
6992
|
+
return 3
|
|
6993
|
+
try:
|
|
6994
|
+
conn = sqlite3.connect(path)
|
|
6995
|
+
except BaseException:
|
|
6996
|
+
_release_cache_admin_writer_flocks(held)
|
|
6997
|
+
raise
|
|
6669
6998
|
try:
|
|
6670
6999
|
cur_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
6671
7000
|
head = len(registry)
|
|
@@ -6688,6 +7017,7 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
|
6688
7017
|
return 0
|
|
6689
7018
|
finally:
|
|
6690
7019
|
conn.close()
|
|
7020
|
+
_release_cache_admin_writer_flocks(held)
|
|
6691
7021
|
|
|
6692
7022
|
|
|
6693
7023
|
def _db_backup_timestamp() -> str:
|
|
@@ -6698,6 +7028,16 @@ def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
|
|
|
6698
7028
|
return path.with_name(f"{path.name}.repairing")
|
|
6699
7029
|
|
|
6700
7030
|
|
|
7031
|
+
_REPAIR_OWNER_VERSION = 1
|
|
7032
|
+
_REPAIR_OWNER_UNKNOWN_IDENTITY_LEASE_SECONDS = 30 * 60
|
|
7033
|
+
|
|
7034
|
+
|
|
7035
|
+
@dataclass(frozen=True)
|
|
7036
|
+
class RepairMarkerClaim:
|
|
7037
|
+
marker: pathlib.Path
|
|
7038
|
+
claim_id: str
|
|
7039
|
+
|
|
7040
|
+
|
|
6701
7041
|
def _pid_is_alive(pid: int) -> bool:
|
|
6702
7042
|
if pid <= 0:
|
|
6703
7043
|
return False
|
|
@@ -6710,40 +7050,177 @@ def _pid_is_alive(pid: int) -> bool:
|
|
|
6710
7050
|
return True
|
|
6711
7051
|
|
|
6712
7052
|
|
|
6713
|
-
def
|
|
6714
|
-
"""
|
|
7053
|
+
def _process_start_identity(pid: int) -> "str | None":
|
|
7054
|
+
"""Return a kernel-derived process-start identity for PID-reuse defense."""
|
|
7055
|
+
if pid <= 0:
|
|
7056
|
+
return None
|
|
7057
|
+
proc_stat = pathlib.Path(f"/proc/{pid}/stat")
|
|
7058
|
+
try:
|
|
7059
|
+
raw = proc_stat.read_text()
|
|
7060
|
+
# Field 2 is parenthesized and may contain spaces. Fields after the
|
|
7061
|
+
# final ')' begin at field 3; process starttime is field 22.
|
|
7062
|
+
tail = raw.rsplit(")", 1)[1].strip().split()
|
|
7063
|
+
if len(tail) >= 20:
|
|
7064
|
+
return f"proc:{tail[19]}"
|
|
7065
|
+
except (OSError, IndexError):
|
|
7066
|
+
pass
|
|
7067
|
+
try:
|
|
7068
|
+
result = subprocess.run(
|
|
7069
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
7070
|
+
stdout=subprocess.PIPE,
|
|
7071
|
+
stderr=subprocess.DEVNULL,
|
|
7072
|
+
text=True,
|
|
7073
|
+
check=False,
|
|
7074
|
+
env={**os.environ, "LC_ALL": "C"},
|
|
7075
|
+
)
|
|
7076
|
+
except OSError:
|
|
7077
|
+
return None
|
|
7078
|
+
started = " ".join(result.stdout.split())
|
|
7079
|
+
return f"ps:{started}" if result.returncode == 0 and started else None
|
|
7080
|
+
|
|
7081
|
+
|
|
7082
|
+
def _encode_repair_owner(
|
|
7083
|
+
*,
|
|
7084
|
+
pid: int,
|
|
7085
|
+
process_start: str,
|
|
7086
|
+
claim_id: str,
|
|
7087
|
+
created_ns: "int | None" = None,
|
|
7088
|
+
) -> str:
|
|
7089
|
+
return json.dumps(
|
|
7090
|
+
{
|
|
7091
|
+
"version": _REPAIR_OWNER_VERSION,
|
|
7092
|
+
"pid": pid,
|
|
7093
|
+
"process_start": process_start,
|
|
7094
|
+
"claim_id": claim_id,
|
|
7095
|
+
"created_ns": time.time_ns() if created_ns is None else created_ns,
|
|
7096
|
+
},
|
|
7097
|
+
sort_keys=True,
|
|
7098
|
+
separators=(",", ":"),
|
|
7099
|
+
) + "\n"
|
|
7100
|
+
|
|
7101
|
+
|
|
7102
|
+
def _read_repair_owner(marker: pathlib.Path) -> "dict[str, Any] | None":
|
|
7103
|
+
try:
|
|
7104
|
+
raw = marker.read_text().strip()
|
|
7105
|
+
except OSError:
|
|
7106
|
+
return None
|
|
7107
|
+
def legacy_owner() -> "dict[str, Any] | None":
|
|
7108
|
+
# Legacy markers contained only a PID. They remain live for a bounded
|
|
7109
|
+
# lease when that PID exists, preventing a reused PID from wedging the
|
|
7110
|
+
# database forever.
|
|
7111
|
+
try:
|
|
7112
|
+
return {
|
|
7113
|
+
"version": 0,
|
|
7114
|
+
"pid": int(raw),
|
|
7115
|
+
"process_start": None,
|
|
7116
|
+
"claim_id": None,
|
|
7117
|
+
}
|
|
7118
|
+
except ValueError:
|
|
7119
|
+
return None
|
|
7120
|
+
try:
|
|
7121
|
+
decoded = json.loads(raw)
|
|
7122
|
+
except (json.JSONDecodeError, TypeError):
|
|
7123
|
+
return legacy_owner()
|
|
7124
|
+
if not isinstance(decoded, dict):
|
|
7125
|
+
return legacy_owner()
|
|
7126
|
+
if (
|
|
7127
|
+
decoded.get("version") != _REPAIR_OWNER_VERSION
|
|
7128
|
+
or not isinstance(decoded.get("pid"), int)
|
|
7129
|
+
or not isinstance(decoded.get("process_start"), str)
|
|
7130
|
+
or not decoded["process_start"]
|
|
7131
|
+
or not isinstance(decoded.get("claim_id"), str)
|
|
7132
|
+
or not decoded["claim_id"]
|
|
7133
|
+
):
|
|
7134
|
+
return None
|
|
7135
|
+
return decoded
|
|
7136
|
+
|
|
7137
|
+
|
|
7138
|
+
def _repair_marker_is_live(marker: pathlib.Path) -> "tuple[bool, str]":
|
|
7139
|
+
owner = _read_repair_owner(marker)
|
|
7140
|
+
if owner is None:
|
|
7141
|
+
return False, "malformed owner record"
|
|
7142
|
+
pid = int(owner["pid"])
|
|
7143
|
+
if not _pid_is_alive(pid):
|
|
7144
|
+
return False, f"owner pid {pid} is not running"
|
|
7145
|
+
expected_start = owner.get("process_start")
|
|
7146
|
+
if expected_start:
|
|
7147
|
+
actual_start = _process_start_identity(pid)
|
|
7148
|
+
if actual_start is not None:
|
|
7149
|
+
if actual_start == expected_start:
|
|
7150
|
+
return True, f"pid {pid}"
|
|
7151
|
+
return False, f"pid {pid} was reused"
|
|
7152
|
+
try:
|
|
7153
|
+
age_seconds = max(0.0, time.time() - marker.stat().st_mtime)
|
|
7154
|
+
except OSError:
|
|
7155
|
+
return False, "owner record disappeared"
|
|
7156
|
+
if age_seconds <= _REPAIR_OWNER_UNKNOWN_IDENTITY_LEASE_SECONDS:
|
|
7157
|
+
return True, f"pid {pid} (identity unavailable; bounded lease)"
|
|
7158
|
+
return False, f"pid {pid} owner identity lease expired"
|
|
7159
|
+
|
|
7160
|
+
|
|
7161
|
+
def _remove_stale_repair_marker(path: pathlib.Path) -> "tuple[bool, str]":
|
|
7162
|
+
"""Remove a provably stale marker; the caller owns maintenance-exclusive."""
|
|
7163
|
+
marker = _repair_marker_path(path)
|
|
7164
|
+
if not marker.exists():
|
|
7165
|
+
return True, ""
|
|
7166
|
+
live, reason = _repair_marker_is_live(marker)
|
|
7167
|
+
if live:
|
|
7168
|
+
return False, f"another {path.name} repair owns {marker} ({reason})"
|
|
7169
|
+
try:
|
|
7170
|
+
marker.unlink()
|
|
7171
|
+
except FileNotFoundError:
|
|
7172
|
+
return True, ""
|
|
7173
|
+
except OSError as exc:
|
|
7174
|
+
return False, f"could not remove stale repair marker {marker}: {exc}"
|
|
7175
|
+
_fsync_directory(marker.parent)
|
|
7176
|
+
return True, reason
|
|
7177
|
+
|
|
7178
|
+
|
|
7179
|
+
def _claim_repair_marker(
|
|
7180
|
+
path: pathlib.Path,
|
|
7181
|
+
) -> "tuple[RepairMarkerClaim | None, str]":
|
|
7182
|
+
"""Atomically publish a complete owner record; reclaim stale ownership."""
|
|
6715
7183
|
marker = _repair_marker_path(path)
|
|
6716
|
-
|
|
7184
|
+
process_start = _process_start_identity(os.getpid())
|
|
7185
|
+
if process_start is None:
|
|
7186
|
+
raise OSError("could not establish repair-owner process identity")
|
|
7187
|
+
for _attempt in range(3):
|
|
7188
|
+
claim_id = f"{os.getpid()}-{time.time_ns()}-{os.urandom(8).hex()}"
|
|
7189
|
+
temp = marker.with_name(f".{marker.name}.{claim_id}.tmp")
|
|
7190
|
+
payload = _encode_repair_owner(
|
|
7191
|
+
pid=os.getpid(),
|
|
7192
|
+
process_start=process_start,
|
|
7193
|
+
claim_id=claim_id,
|
|
7194
|
+
)
|
|
7195
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
7196
|
+
try:
|
|
7197
|
+
os.write(fd, payload.encode("utf-8"))
|
|
7198
|
+
os.fsync(fd)
|
|
7199
|
+
finally:
|
|
7200
|
+
os.close(fd)
|
|
6717
7201
|
try:
|
|
6718
|
-
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
6719
|
-
except FileExistsError:
|
|
6720
7202
|
try:
|
|
6721
|
-
|
|
6722
|
-
except
|
|
6723
|
-
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
7203
|
+
os.link(temp, marker)
|
|
7204
|
+
except FileExistsError:
|
|
7205
|
+
removed, reason = _remove_stale_repair_marker(path)
|
|
7206
|
+
if not removed:
|
|
7207
|
+
return None, reason
|
|
7208
|
+
continue
|
|
7209
|
+
_fsync_directory(marker.parent)
|
|
7210
|
+
return RepairMarkerClaim(marker=marker, claim_id=claim_id), ""
|
|
7211
|
+
finally:
|
|
6728
7212
|
try:
|
|
6729
|
-
|
|
7213
|
+
temp.unlink()
|
|
6730
7214
|
except FileNotFoundError:
|
|
6731
7215
|
pass
|
|
6732
|
-
|
|
6733
|
-
return False, f"could not remove stale repair marker {marker}: {exc}"
|
|
6734
|
-
continue
|
|
6735
|
-
try:
|
|
6736
|
-
os.write(fd, f"{os.getpid()}\n".encode("ascii"))
|
|
6737
|
-
os.fsync(fd)
|
|
6738
|
-
finally:
|
|
6739
|
-
os.close(fd)
|
|
6740
|
-
_fsync_directory(marker.parent)
|
|
6741
|
-
return True, ""
|
|
6742
|
-
return False, f"could not claim repair marker {marker}"
|
|
7216
|
+
return None, f"could not claim repair marker {marker}"
|
|
6743
7217
|
|
|
6744
7218
|
|
|
6745
|
-
def _release_repair_marker(path: pathlib.Path) -> None:
|
|
7219
|
+
def _release_repair_marker(path: pathlib.Path, claim: RepairMarkerClaim) -> None:
|
|
6746
7220
|
marker = _repair_marker_path(path)
|
|
7221
|
+
owner = _read_repair_owner(marker)
|
|
7222
|
+
if owner is not None and owner.get("claim_id") != claim.claim_id:
|
|
7223
|
+
raise OSError(f"repair marker ownership changed before cleanup: {marker}")
|
|
6747
7224
|
try:
|
|
6748
7225
|
marker.unlink()
|
|
6749
7226
|
except FileNotFoundError:
|
|
@@ -7031,11 +7508,11 @@ def cmd_db_repair(args: argparse.Namespace) -> int:
|
|
|
7031
7508
|
return 2
|
|
7032
7509
|
|
|
7033
7510
|
try:
|
|
7034
|
-
|
|
7511
|
+
claim, reason = _claim_repair_marker(path)
|
|
7035
7512
|
except OSError as exc:
|
|
7036
7513
|
eprint(f"cctally: could not create stats.db repair marker: {exc}")
|
|
7037
7514
|
return 3
|
|
7038
|
-
if
|
|
7515
|
+
if claim is None:
|
|
7039
7516
|
eprint(f"cctally: {reason}")
|
|
7040
7517
|
return 3
|
|
7041
7518
|
try:
|
|
@@ -7047,7 +7524,7 @@ def cmd_db_repair(args: argparse.Namespace) -> int:
|
|
|
7047
7524
|
eprint(f"cctally: unexpected stats.db repair failure: {exc}")
|
|
7048
7525
|
rc = 3
|
|
7049
7526
|
try:
|
|
7050
|
-
_release_repair_marker(path)
|
|
7527
|
+
_release_repair_marker(path, claim)
|
|
7051
7528
|
except OSError as exc:
|
|
7052
7529
|
eprint(
|
|
7053
7530
|
f"cctally: stats.db repair marker cleanup failed ({exc}); "
|
|
@@ -7381,9 +7858,12 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
|
|
|
7381
7858
|
the main DB and truncates the -wal file — it changes no data, no schema, no
|
|
7382
7859
|
user_version — so there is no prod guard and no --yes.
|
|
7383
7860
|
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7861
|
+
A cache checkpoint takes the same global writer flock as both provider
|
|
7862
|
+
syncs, using the command's timeout as one bounded wait, so it cannot overlap
|
|
7863
|
+
a cache write or another cache checkpoint. Exit 0 when drained /
|
|
7864
|
+
already-small / the DB is absent; 3 (staged) if the target stayed busy or
|
|
7865
|
+
was not fully truncated through the timeout — an actionable "something is
|
|
7866
|
+
still holding it" signal.
|
|
7387
7867
|
"""
|
|
7388
7868
|
import _cctally_cache
|
|
7389
7869
|
from _lib_json_envelope import stamp_schema_version
|
|
@@ -7410,12 +7890,39 @@ def cmd_db_checkpoint(args: argparse.Namespace) -> int:
|
|
|
7410
7890
|
print(f"cctally: no {label} database file present; nothing to drain.")
|
|
7411
7891
|
return 0
|
|
7412
7892
|
|
|
7413
|
-
|
|
7893
|
+
held: list[int] = []
|
|
7414
7894
|
try:
|
|
7415
|
-
|
|
7416
|
-
|
|
7895
|
+
if which == "cache":
|
|
7896
|
+
from _lib_cache_writer_lock import acquire_ordered_flocks
|
|
7897
|
+
|
|
7898
|
+
acquired = acquire_ordered_flocks(
|
|
7899
|
+
[
|
|
7900
|
+
(_cctally_core.CACHE_LOCK_MAINTENANCE_PATH, fcntl.LOCK_SH),
|
|
7901
|
+
(_cctally_core.CACHE_LOCK_PATH, fcntl.LOCK_EX),
|
|
7902
|
+
],
|
|
7903
|
+
timeout=max(timeout, 0) / 1000,
|
|
7904
|
+
)
|
|
7905
|
+
if acquired is None:
|
|
7906
|
+
wal_bytes = _cctally_cache._wal_file_size(path)
|
|
7907
|
+
result = _cctally_cache.CheckpointResult(
|
|
7908
|
+
label, wal_bytes, wal_bytes, 0, True, False
|
|
7909
|
+
)
|
|
7910
|
+
else:
|
|
7911
|
+
held = acquired
|
|
7912
|
+
if which != "cache" or held:
|
|
7913
|
+
conn = sqlite3.connect(f"file:{path}?mode=rw", uri=True)
|
|
7914
|
+
try:
|
|
7915
|
+
conn.execute(f"PRAGMA busy_timeout={timeout}")
|
|
7916
|
+
result = _cctally_cache._run_wal_truncate(
|
|
7917
|
+
conn, path, db_label=label
|
|
7918
|
+
)
|
|
7919
|
+
finally:
|
|
7920
|
+
conn.close()
|
|
7417
7921
|
finally:
|
|
7418
|
-
|
|
7922
|
+
if held:
|
|
7923
|
+
from _lib_cache_writer_lock import release_cache_writer_flocks
|
|
7924
|
+
|
|
7925
|
+
release_cache_writer_flocks(held)
|
|
7419
7926
|
|
|
7420
7927
|
if as_json:
|
|
7421
7928
|
payload = {"db": result.db, "walBytesBefore": result.wal_bytes_before,
|