cctally 1.73.0 → 1.75.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_cache.py +35 -5
- package/bin/_cctally_config.py +3 -3
- package/bin/_cctally_core.py +12 -3
- package/bin/_cctally_dashboard.py +57 -5
- package/bin/_cctally_dashboard_conversation.py +2 -2
- package/bin/_cctally_dashboard_share.py +38 -3
- package/bin/_cctally_dashboard_sources.py +1199 -78
- package/bin/_cctally_db.py +798 -2
- package/bin/_cctally_doctor.py +27 -4
- package/bin/_cctally_parser.py +100 -6
- package/bin/_cctally_percent_breakdown.py +59 -34
- package/bin/_cctally_quota.py +311 -45
- package/bin/_cctally_record.py +52 -1
- package/bin/_cctally_refresh.py +26 -0
- package/bin/_cctally_source_analytics.py +288 -3
- package/bin/_cctally_statusline.py +3 -3
- package/bin/_cctally_tui.py +20 -14
- package/bin/_lib_aggregators.py +55 -34
- package/bin/_lib_cache_report.py +25 -9
- package/bin/_lib_conversation_retention.py +22 -2
- package/bin/_lib_dashboard_sources.py +20 -7
- package/bin/_lib_doctor.py +48 -4
- package/bin/_lib_jsonl.py +11 -0
- package/bin/_lib_pricing.py +7 -4
- package/bin/_lib_pricing_check.py +7 -1
- package/bin/_lib_quota.py +12 -11
- package/bin/_lib_view_models.py +50 -14
- package/bin/cctally +27 -1
- package/dashboard/static/assets/index-fZRxsSf5.js +80 -0
- package/dashboard/static/assets/{index-D2nwo_ln.css → index-yftBNnLR.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BWadAHxC.js +0 -80
package/bin/_cctally_db.py
CHANGED
|
@@ -67,8 +67,11 @@ import json
|
|
|
67
67
|
import os
|
|
68
68
|
import pathlib
|
|
69
69
|
import re
|
|
70
|
+
import shutil
|
|
70
71
|
import sqlite3
|
|
72
|
+
import subprocess
|
|
71
73
|
import sys
|
|
74
|
+
import tempfile
|
|
72
75
|
import time
|
|
73
76
|
import traceback
|
|
74
77
|
from dataclasses import dataclass
|
|
@@ -248,13 +251,54 @@ class StatsDbCorruptError(sqlite3.DatabaseError):
|
|
|
248
251
|
Subclasses ``sqlite3.DatabaseError`` so graceful-degrade sites (doctor,
|
|
249
252
|
dashboard/TUI background threads, the 5h-anchor fallback) keep treating it
|
|
250
253
|
as a DB failure exactly as before — but command-level handlers that map DB
|
|
251
|
-
errors to OTHER exit codes must re-raise it so the global
|
|
254
|
+
errors to OTHER exit codes must re-raise it so the global staged diagnosis
|
|
252
255
|
wins (``cmd_record_credit`` does; its documented DB-error exit is 3). NOT
|
|
253
256
|
auto-recreated: stats.db is the non-re-derivable DB (recorded usage
|
|
254
257
|
history), unlike cache.db.
|
|
255
258
|
"""
|
|
256
259
|
|
|
257
260
|
|
|
261
|
+
class StatsDbMaintenanceError(sqlite3.OperationalError):
|
|
262
|
+
"""A guided repair owns stats.db; new cctally opens must stay out."""
|
|
263
|
+
|
|
264
|
+
def __init__(self) -> None:
|
|
265
|
+
super().__init__(
|
|
266
|
+
"stats.db repair is in progress; retry after the repair command exits"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_SQLITE_CORRUPTION_MESSAGES = (
|
|
271
|
+
"database disk image is malformed",
|
|
272
|
+
"file is not a database",
|
|
273
|
+
"malformed database schema",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _is_sqlite_corruption_error(value: object) -> bool:
|
|
278
|
+
"""Recognize SQLITE_CORRUPT / SQLITE_NOTADB, including string-only hops.
|
|
279
|
+
|
|
280
|
+
Some orchestration layers intentionally serialize an inner DB failure into
|
|
281
|
+
a structured ``reason`` string. Prefer SQLite's numeric code when the
|
|
282
|
+
exception still carries it, then use the narrow canonical messages for the
|
|
283
|
+
string-only boundary (#314).
|
|
284
|
+
"""
|
|
285
|
+
code = getattr(value, "sqlite_errorcode", None)
|
|
286
|
+
if isinstance(code, int):
|
|
287
|
+
primary = code & 0xFF
|
|
288
|
+
if primary in {sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB}:
|
|
289
|
+
return True
|
|
290
|
+
text = str(value).casefold()
|
|
291
|
+
return any(token in text for token in _SQLITE_CORRUPTION_MESSAGES)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _stats_corruption_guidance() -> str:
|
|
295
|
+
return (
|
|
296
|
+
"stats.db is corrupt — run `cctally db repair --db stats --yes`. "
|
|
297
|
+
"The repair command preserves the corrupt original before replacing "
|
|
298
|
+
"anything; do not copy or restore the live DB by hand."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
258
302
|
class MigrationGateNotMet(Exception):
|
|
259
303
|
"""Migration cannot run yet because a cross-DB prerequisite is unsatisfied.
|
|
260
304
|
|
|
@@ -876,6 +920,10 @@ def _run_pending_migrations(
|
|
|
876
920
|
# absent must still be treated as non-fresh so a flag-only
|
|
877
921
|
# conversation migration's consumer actually runs (e.g. 011's
|
|
878
922
|
# command-args promotion) instead of being stamped without it.
|
|
923
|
+
# codex_session_entries joins them (#312): an accounting-bearing
|
|
924
|
+
# cache written before conversation-key enrichment but missing
|
|
925
|
+
# schema_migrations must not be stamped fresh, or 026 would never
|
|
926
|
+
# clear it for the required byte-zero Codex replay.
|
|
879
927
|
# codex_conversation_events joins them (#294 S6): a Codex-bearing
|
|
880
928
|
# cache written by S1's fused ingest but missing schema_migrations
|
|
881
929
|
# would otherwise be classified fresh and stamp 025 WITHOUT replaying
|
|
@@ -883,7 +931,7 @@ def _run_pending_migrations(
|
|
|
883
931
|
# normalized corpus. Probing it forces 025's handler to run and derive
|
|
884
932
|
# the normalized rows from the retained events.
|
|
885
933
|
"cache.db": (
|
|
886
|
-
"session_entries", "conversation_messages",
|
|
934
|
+
"session_entries", "conversation_messages", "codex_session_entries",
|
|
887
935
|
"codex_conversation_events",
|
|
888
936
|
),
|
|
889
937
|
}.get(db_label, ())
|
|
@@ -4583,6 +4631,105 @@ def _025_codex_conversation_normalization(conn: sqlite3.Connection) -> None:
|
|
|
4583
4631
|
lock_fh.close()
|
|
4584
4632
|
|
|
4585
4633
|
|
|
4634
|
+
@cache_migration("026_codex_conversation_key_backfill")
|
|
4635
|
+
def _026_codex_conversation_key_backfill(conn: sqlite3.Connection) -> None:
|
|
4636
|
+
"""Arm a byte-zero Codex replay for missing conversation-key metadata (#312).
|
|
4637
|
+
|
|
4638
|
+
Historical accounting rows predate the source-derived conversation-key
|
|
4639
|
+
enrichment. The cache cannot truthfully recover those keys on its own, so
|
|
4640
|
+
clear exactly the existing Codex-derived families and let the next Codex
|
|
4641
|
+
sync replay retained rollout data from byte zero. This shares the runtime
|
|
4642
|
+
clear helper with ``sync_codex_cache(rebuild=True)`` so new or future Codex
|
|
4643
|
+
families cannot drift from the migration's destructive scope.
|
|
4644
|
+
|
|
4645
|
+
The Codex flock is acquired before ``BEGIN IMMEDIATE``. Contention defers
|
|
4646
|
+
through ``MigrationGateNotMet`` before any DML; after a handler/data commit
|
|
4647
|
+
but before the dispatcher's central stamp, a rerun against the empty state
|
|
4648
|
+
is a byte-idempotent no-op. The physical mutation sequence advances only
|
|
4649
|
+
when the shared clear actually changed persisted Codex state. The handler
|
|
4650
|
+
never self-stamps.
|
|
4651
|
+
"""
|
|
4652
|
+
lock_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4653
|
+
lock_fh = None
|
|
4654
|
+
if lock_path is not None:
|
|
4655
|
+
lock_fh = open(lock_path, "w")
|
|
4656
|
+
try:
|
|
4657
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4658
|
+
except BlockingIOError:
|
|
4659
|
+
lock_fh.close()
|
|
4660
|
+
raise MigrationGateNotMet(
|
|
4661
|
+
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4662
|
+
"deferring cache 026 conversation-key backfill (#312)"
|
|
4663
|
+
)
|
|
4664
|
+
try:
|
|
4665
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4666
|
+
try:
|
|
4667
|
+
import _cctally_cache
|
|
4668
|
+
|
|
4669
|
+
if _cctally_cache._clear_codex_derived_rows(conn):
|
|
4670
|
+
_cctally_cache._bump_codex_physical_mutation_seq(conn)
|
|
4671
|
+
conn.commit()
|
|
4672
|
+
except Exception:
|
|
4673
|
+
conn.rollback()
|
|
4674
|
+
raise
|
|
4675
|
+
finally:
|
|
4676
|
+
if lock_fh is not None:
|
|
4677
|
+
try:
|
|
4678
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4679
|
+
except OSError:
|
|
4680
|
+
pass
|
|
4681
|
+
lock_fh.close()
|
|
4682
|
+
|
|
4683
|
+
|
|
4684
|
+
@cache_migration("027_codex_fork_preamble_rebuild")
|
|
4685
|
+
def _027_codex_fork_preamble_rebuild(conn: sqlite3.Connection) -> None:
|
|
4686
|
+
"""Replay Codex rollouts after suppressing copied fork-preamble accounting.
|
|
4687
|
+
|
|
4688
|
+
Forked/subagent JSONL can retain parent ``token_count`` records before the
|
|
4689
|
+
child's first model-bearing ``turn_context``. Older ingest projected those
|
|
4690
|
+
copied records into ``codex_session_entries`` as ``model='unknown'``, double
|
|
4691
|
+
counting usage already present in the parent rollout. The source records
|
|
4692
|
+
remain authoritative, so clear all re-derivable Codex families and let the
|
|
4693
|
+
corrected fused parser replay them from byte zero.
|
|
4694
|
+
|
|
4695
|
+
The Codex flock precedes ``BEGIN IMMEDIATE``. Contention defers before DML;
|
|
4696
|
+
a markerless retry against already-cleared state is a no-op. The shared
|
|
4697
|
+
clear helper keeps this migration aligned with runtime rebuild scope and
|
|
4698
|
+
advances the physical mutation sequence only when state actually changed.
|
|
4699
|
+
The dispatcher owns the migration marker.
|
|
4700
|
+
"""
|
|
4701
|
+
lock_path = _cache_db_codex_lock_path_for_conn(conn)
|
|
4702
|
+
lock_fh = None
|
|
4703
|
+
if lock_path is not None:
|
|
4704
|
+
lock_fh = open(lock_path, "w")
|
|
4705
|
+
try:
|
|
4706
|
+
fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
4707
|
+
except BlockingIOError:
|
|
4708
|
+
lock_fh.close()
|
|
4709
|
+
raise MigrationGateNotMet(
|
|
4710
|
+
"cache.db.codex.lock held by a concurrent sync_codex_cache; "
|
|
4711
|
+
"deferring cache 027 fork-preamble rebuild"
|
|
4712
|
+
)
|
|
4713
|
+
try:
|
|
4714
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
4715
|
+
try:
|
|
4716
|
+
import _cctally_cache
|
|
4717
|
+
|
|
4718
|
+
if _cctally_cache._clear_codex_derived_rows(conn):
|
|
4719
|
+
_cctally_cache._bump_codex_physical_mutation_seq(conn)
|
|
4720
|
+
conn.commit()
|
|
4721
|
+
except Exception:
|
|
4722
|
+
conn.rollback()
|
|
4723
|
+
raise
|
|
4724
|
+
finally:
|
|
4725
|
+
if lock_fh is not None:
|
|
4726
|
+
try:
|
|
4727
|
+
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
4728
|
+
except OSError:
|
|
4729
|
+
pass
|
|
4730
|
+
lock_fh.close()
|
|
4731
|
+
|
|
4732
|
+
|
|
4586
4733
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
4587
4734
|
|
|
4588
4735
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
|
@@ -6033,6 +6180,655 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
|
|
|
6033
6180
|
conn.close()
|
|
6034
6181
|
|
|
6035
6182
|
|
|
6183
|
+
def _db_backup_timestamp() -> str:
|
|
6184
|
+
return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
6185
|
+
|
|
6186
|
+
|
|
6187
|
+
def _repair_marker_path(path: pathlib.Path) -> pathlib.Path:
|
|
6188
|
+
return path.with_name("stats.db.repairing")
|
|
6189
|
+
|
|
6190
|
+
|
|
6191
|
+
def _pid_is_alive(pid: int) -> bool:
|
|
6192
|
+
if pid <= 0:
|
|
6193
|
+
return False
|
|
6194
|
+
try:
|
|
6195
|
+
os.kill(pid, 0)
|
|
6196
|
+
except ProcessLookupError:
|
|
6197
|
+
return False
|
|
6198
|
+
except PermissionError:
|
|
6199
|
+
return True
|
|
6200
|
+
return True
|
|
6201
|
+
|
|
6202
|
+
|
|
6203
|
+
def _claim_repair_marker(path: pathlib.Path) -> "tuple[bool, str]":
|
|
6204
|
+
"""Atomically block new cctally stats opens; reclaim dead-owner markers."""
|
|
6205
|
+
marker = _repair_marker_path(path)
|
|
6206
|
+
for _attempt in range(2):
|
|
6207
|
+
try:
|
|
6208
|
+
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
6209
|
+
except FileExistsError:
|
|
6210
|
+
try:
|
|
6211
|
+
owner = int(marker.read_text().strip())
|
|
6212
|
+
except (OSError, ValueError):
|
|
6213
|
+
owner = -1
|
|
6214
|
+
if _pid_is_alive(owner):
|
|
6215
|
+
return False, f"another stats.db repair owns {marker} (pid {owner})"
|
|
6216
|
+
try:
|
|
6217
|
+
marker.unlink()
|
|
6218
|
+
except FileNotFoundError:
|
|
6219
|
+
pass
|
|
6220
|
+
except OSError as exc:
|
|
6221
|
+
return False, f"could not remove stale repair marker {marker}: {exc}"
|
|
6222
|
+
continue
|
|
6223
|
+
try:
|
|
6224
|
+
os.write(fd, f"{os.getpid()}\n".encode("ascii"))
|
|
6225
|
+
os.fsync(fd)
|
|
6226
|
+
finally:
|
|
6227
|
+
os.close(fd)
|
|
6228
|
+
_fsync_directory(marker.parent)
|
|
6229
|
+
return True, ""
|
|
6230
|
+
return False, f"could not claim repair marker {marker}"
|
|
6231
|
+
|
|
6232
|
+
|
|
6233
|
+
def _release_repair_marker(path: pathlib.Path) -> None:
|
|
6234
|
+
marker = _repair_marker_path(path)
|
|
6235
|
+
try:
|
|
6236
|
+
marker.unlink()
|
|
6237
|
+
except FileNotFoundError:
|
|
6238
|
+
return
|
|
6239
|
+
_fsync_directory(marker.parent)
|
|
6240
|
+
|
|
6241
|
+
|
|
6242
|
+
def _db_family_open_pids(path: pathlib.Path) -> "set[int] | None":
|
|
6243
|
+
"""Return processes with main/WAL/SHM open; None means unverifiable."""
|
|
6244
|
+
family = [
|
|
6245
|
+
pathlib.Path(str(path) + suffix)
|
|
6246
|
+
for suffix in ("", "-wal", "-shm")
|
|
6247
|
+
if pathlib.Path(str(path) + suffix).exists()
|
|
6248
|
+
]
|
|
6249
|
+
lsof = shutil.which("lsof")
|
|
6250
|
+
if lsof:
|
|
6251
|
+
try:
|
|
6252
|
+
result = subprocess.run(
|
|
6253
|
+
[lsof, "-F", "p", "--", *(str(item) for item in family)],
|
|
6254
|
+
stdout=subprocess.PIPE,
|
|
6255
|
+
stderr=subprocess.DEVNULL,
|
|
6256
|
+
text=True,
|
|
6257
|
+
check=False,
|
|
6258
|
+
)
|
|
6259
|
+
except OSError:
|
|
6260
|
+
return None
|
|
6261
|
+
return {
|
|
6262
|
+
int(line[1:])
|
|
6263
|
+
for line in result.stdout.splitlines()
|
|
6264
|
+
if line.startswith("p") and line[1:].isdigit()
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
proc = pathlib.Path("/proc")
|
|
6268
|
+
if not proc.is_dir():
|
|
6269
|
+
return None
|
|
6270
|
+
try:
|
|
6271
|
+
identities = {(item.stat().st_dev, item.stat().st_ino) for item in family}
|
|
6272
|
+
except OSError:
|
|
6273
|
+
return None
|
|
6274
|
+
pids: set[int] = set()
|
|
6275
|
+
for process in proc.iterdir():
|
|
6276
|
+
if not process.name.isdigit():
|
|
6277
|
+
continue
|
|
6278
|
+
try:
|
|
6279
|
+
descriptors = (process / "fd").iterdir()
|
|
6280
|
+
for descriptor in descriptors:
|
|
6281
|
+
try:
|
|
6282
|
+
st = descriptor.stat()
|
|
6283
|
+
except OSError:
|
|
6284
|
+
continue
|
|
6285
|
+
if (st.st_dev, st.st_ino) in identities:
|
|
6286
|
+
pids.add(int(process.name))
|
|
6287
|
+
break
|
|
6288
|
+
except (OSError, PermissionError):
|
|
6289
|
+
continue
|
|
6290
|
+
return pids
|
|
6291
|
+
|
|
6292
|
+
|
|
6293
|
+
def _unique_sibling_path(path: pathlib.Path) -> pathlib.Path:
|
|
6294
|
+
"""Return ``path`` or a numbered sibling without overwriting owner data."""
|
|
6295
|
+
def family_exists(candidate: pathlib.Path) -> bool:
|
|
6296
|
+
return any(
|
|
6297
|
+
pathlib.Path(str(candidate) + suffix).exists()
|
|
6298
|
+
for suffix in ("", "-wal", "-shm")
|
|
6299
|
+
)
|
|
6300
|
+
|
|
6301
|
+
if not family_exists(path):
|
|
6302
|
+
return path
|
|
6303
|
+
for number in range(2, 10_000):
|
|
6304
|
+
candidate = path.with_name(f"{path.name}-{number}")
|
|
6305
|
+
if not family_exists(candidate):
|
|
6306
|
+
return candidate
|
|
6307
|
+
raise OSError(f"could not allocate a unique backup path beside {path}")
|
|
6308
|
+
|
|
6309
|
+
|
|
6310
|
+
def _fsync_file(path: pathlib.Path) -> None:
|
|
6311
|
+
with path.open("rb") as fh:
|
|
6312
|
+
os.fsync(fh.fileno())
|
|
6313
|
+
|
|
6314
|
+
|
|
6315
|
+
def _fsync_directory(path: pathlib.Path) -> None:
|
|
6316
|
+
try:
|
|
6317
|
+
fd = os.open(path, os.O_RDONLY)
|
|
6318
|
+
except OSError:
|
|
6319
|
+
return
|
|
6320
|
+
try:
|
|
6321
|
+
os.fsync(fd)
|
|
6322
|
+
finally:
|
|
6323
|
+
os.close(fd)
|
|
6324
|
+
|
|
6325
|
+
|
|
6326
|
+
def _copy_db_family(
|
|
6327
|
+
source: pathlib.Path,
|
|
6328
|
+
destination: pathlib.Path,
|
|
6329
|
+
*,
|
|
6330
|
+
suffixes: "tuple[str, ...]" = ("", "-wal", "-shm"),
|
|
6331
|
+
) -> None:
|
|
6332
|
+
"""Copy main/WAL/SHM bytes while the caller holds SQLite's writer lock."""
|
|
6333
|
+
for suffix in suffixes:
|
|
6334
|
+
src = pathlib.Path(str(source) + suffix)
|
|
6335
|
+
if not src.exists():
|
|
6336
|
+
continue
|
|
6337
|
+
dst = pathlib.Path(str(destination) + suffix)
|
|
6338
|
+
shutil.copyfile(src, dst)
|
|
6339
|
+
os.chmod(dst, 0o600)
|
|
6340
|
+
_fsync_file(dst)
|
|
6341
|
+
|
|
6342
|
+
|
|
6343
|
+
def _read_user_version_header(path: pathlib.Path) -> "int | None":
|
|
6344
|
+
"""Read SQLite's big-endian user_version field without opening pages."""
|
|
6345
|
+
try:
|
|
6346
|
+
with path.open("rb") as fh:
|
|
6347
|
+
header = fh.read(100)
|
|
6348
|
+
except OSError:
|
|
6349
|
+
return None
|
|
6350
|
+
if len(header) < 64 or header[:16] != b"SQLite format 3\x00":
|
|
6351
|
+
return None
|
|
6352
|
+
return int.from_bytes(header[60:64], "big", signed=False)
|
|
6353
|
+
|
|
6354
|
+
|
|
6355
|
+
def _table_counts_best_effort(conn: sqlite3.Connection) -> dict[str, "int | None"]:
|
|
6356
|
+
try:
|
|
6357
|
+
names = [
|
|
6358
|
+
str(row[0])
|
|
6359
|
+
for row in conn.execute(
|
|
6360
|
+
"SELECT name FROM sqlite_schema "
|
|
6361
|
+
"WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
6362
|
+
).fetchall()
|
|
6363
|
+
]
|
|
6364
|
+
except sqlite3.DatabaseError:
|
|
6365
|
+
return {}
|
|
6366
|
+
counts: dict[str, int | None] = {}
|
|
6367
|
+
for name in names:
|
|
6368
|
+
quoted = '"' + name.replace('"', '""') + '"'
|
|
6369
|
+
try:
|
|
6370
|
+
counts[name] = int(conn.execute(f"SELECT COUNT(*) FROM {quoted}").fetchone()[0])
|
|
6371
|
+
except sqlite3.DatabaseError:
|
|
6372
|
+
counts[name] = None
|
|
6373
|
+
return counts
|
|
6374
|
+
|
|
6375
|
+
|
|
6376
|
+
def _run_sqlite_recover(
|
|
6377
|
+
sqlite_binary: str,
|
|
6378
|
+
source: pathlib.Path,
|
|
6379
|
+
destination: pathlib.Path,
|
|
6380
|
+
scratch: pathlib.Path,
|
|
6381
|
+
) -> "tuple[bool, str]":
|
|
6382
|
+
"""Stream sqlite3 ``.recover`` through a SQL file into a fresh database."""
|
|
6383
|
+
sql_path = scratch / "recover.sql"
|
|
6384
|
+
try:
|
|
6385
|
+
with sql_path.open("wb") as sql_out:
|
|
6386
|
+
recovered = subprocess.run(
|
|
6387
|
+
[sqlite_binary, str(source), ".recover"],
|
|
6388
|
+
stdout=sql_out,
|
|
6389
|
+
stderr=subprocess.PIPE,
|
|
6390
|
+
check=False,
|
|
6391
|
+
)
|
|
6392
|
+
except OSError as exc:
|
|
6393
|
+
return False, str(exc)
|
|
6394
|
+
if recovered.returncode != 0:
|
|
6395
|
+
reason = recovered.stderr.decode("utf-8", "replace").strip()
|
|
6396
|
+
return False, reason or f"sqlite3 .recover exited {recovered.returncode}"
|
|
6397
|
+
try:
|
|
6398
|
+
with sql_path.open("rb") as sql_in:
|
|
6399
|
+
imported = subprocess.run(
|
|
6400
|
+
[sqlite_binary, str(destination)],
|
|
6401
|
+
stdin=sql_in,
|
|
6402
|
+
stdout=subprocess.PIPE,
|
|
6403
|
+
stderr=subprocess.PIPE,
|
|
6404
|
+
check=False,
|
|
6405
|
+
)
|
|
6406
|
+
except OSError as exc:
|
|
6407
|
+
return False, str(exc)
|
|
6408
|
+
if imported.returncode != 0:
|
|
6409
|
+
reason = imported.stderr.decode("utf-8", "replace").strip()
|
|
6410
|
+
return False, reason or f"sqlite3 import exited {imported.returncode}"
|
|
6411
|
+
return True, ""
|
|
6412
|
+
|
|
6413
|
+
|
|
6414
|
+
def _repair_preflight_and_copy(
|
|
6415
|
+
path: pathlib.Path,
|
|
6416
|
+
backup: pathlib.Path,
|
|
6417
|
+
snapshot: pathlib.Path,
|
|
6418
|
+
*,
|
|
6419
|
+
timeout_ms: int,
|
|
6420
|
+
) -> "tuple[int, dict[str, int | None], int | None, sqlite3.Connection | None, str]":
|
|
6421
|
+
"""Preserve forensic bytes, drain WAL, and return a held writer guard."""
|
|
6422
|
+
conn = sqlite3.connect(
|
|
6423
|
+
f"file:{path}?mode=rw", uri=True, timeout=max(timeout_ms, 0) / 1000
|
|
6424
|
+
)
|
|
6425
|
+
try:
|
|
6426
|
+
if _would_block_prod_migration(conn):
|
|
6427
|
+
conn.close()
|
|
6428
|
+
return 2, {}, None, None, "prod guard"
|
|
6429
|
+
conn.execute(f"PRAGMA busy_timeout={max(timeout_ms, 0)}")
|
|
6430
|
+
try:
|
|
6431
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
6432
|
+
except sqlite3.DatabaseError as exc:
|
|
6433
|
+
conn.close()
|
|
6434
|
+
return 3, {}, None, None, str(exc)
|
|
6435
|
+
try:
|
|
6436
|
+
quick = conn.execute("PRAGMA quick_check(1)").fetchone()
|
|
6437
|
+
except sqlite3.DatabaseError:
|
|
6438
|
+
quick = None
|
|
6439
|
+
if quick is not None and quick[0] == "ok":
|
|
6440
|
+
conn.rollback()
|
|
6441
|
+
conn.close()
|
|
6442
|
+
return 4, {}, None, None, "quick_check ok"
|
|
6443
|
+
|
|
6444
|
+
# Preserve the exact corrupt family before checkpointing mutates the
|
|
6445
|
+
# main/WAL representation. No other cctally process can open after the
|
|
6446
|
+
# repair marker, and the caller has already proved no old handle exists.
|
|
6447
|
+
_copy_db_family(path, backup)
|
|
6448
|
+
_fsync_directory(path.parent)
|
|
6449
|
+
conn.rollback()
|
|
6450
|
+
|
|
6451
|
+
try:
|
|
6452
|
+
checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
|
6453
|
+
except sqlite3.DatabaseError as exc:
|
|
6454
|
+
conn.close()
|
|
6455
|
+
return 3, {}, None, None, f"WAL checkpoint failed: {exc}"
|
|
6456
|
+
wal_path = pathlib.Path(str(path) + "-wal")
|
|
6457
|
+
wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0
|
|
6458
|
+
if checkpoint is None or int(checkpoint[0]) != 0 or wal_bytes != 0:
|
|
6459
|
+
conn.close()
|
|
6460
|
+
return 3, {}, None, None, "WAL could not be fully checkpointed"
|
|
6461
|
+
|
|
6462
|
+
# Hold this one write exclusion continuously through .recover and the
|
|
6463
|
+
# main-file replace. Since the WAL is empty, replacement failure leaves
|
|
6464
|
+
# the old main file coherent and no committed frames can be lost.
|
|
6465
|
+
try:
|
|
6466
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
6467
|
+
except sqlite3.DatabaseError as exc:
|
|
6468
|
+
conn.close()
|
|
6469
|
+
return 3, {}, None, None, str(exc)
|
|
6470
|
+
try:
|
|
6471
|
+
source_version = int(
|
|
6472
|
+
conn.execute("PRAGMA user_version").fetchone()[0]
|
|
6473
|
+
)
|
|
6474
|
+
except sqlite3.DatabaseError:
|
|
6475
|
+
source_version = None
|
|
6476
|
+
counts = _table_counts_best_effort(conn)
|
|
6477
|
+
_copy_db_family(path, snapshot, suffixes=("",))
|
|
6478
|
+
return 0, counts, source_version, conn, ""
|
|
6479
|
+
except Exception:
|
|
6480
|
+
try:
|
|
6481
|
+
conn.rollback()
|
|
6482
|
+
except sqlite3.DatabaseError:
|
|
6483
|
+
pass
|
|
6484
|
+
conn.close()
|
|
6485
|
+
raise
|
|
6486
|
+
|
|
6487
|
+
|
|
6488
|
+
def cmd_db_repair(args: argparse.Namespace) -> int:
|
|
6489
|
+
"""Claim exclusive maintenance ownership, then repair stats.db (#314)."""
|
|
6490
|
+
path = _cctally_core.DB_PATH
|
|
6491
|
+
if not path.exists():
|
|
6492
|
+
print("cctally: stats.db not present; nothing to repair.")
|
|
6493
|
+
return 0
|
|
6494
|
+
if not getattr(args, "yes", False):
|
|
6495
|
+
eprint(
|
|
6496
|
+
"cctally: repairing stats.db replaces the live non-re-derivable "
|
|
6497
|
+
"database after preserving the corrupt original. Re-run with "
|
|
6498
|
+
"--yes after stopping the dashboard and other cctally processes."
|
|
6499
|
+
)
|
|
6500
|
+
return 2
|
|
6501
|
+
|
|
6502
|
+
try:
|
|
6503
|
+
claimed, reason = _claim_repair_marker(path)
|
|
6504
|
+
except OSError as exc:
|
|
6505
|
+
eprint(f"cctally: could not create stats.db repair marker: {exc}")
|
|
6506
|
+
return 3
|
|
6507
|
+
if not claimed:
|
|
6508
|
+
eprint(f"cctally: {reason}")
|
|
6509
|
+
return 3
|
|
6510
|
+
try:
|
|
6511
|
+
rc = _cmd_db_repair_exclusive(args, path)
|
|
6512
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
6513
|
+
eprint(f"cctally: stats.db repair failed before completion: {exc}")
|
|
6514
|
+
rc = 3
|
|
6515
|
+
except Exception as exc: # defensive: marker cleanup still must run
|
|
6516
|
+
eprint(f"cctally: unexpected stats.db repair failure: {exc}")
|
|
6517
|
+
rc = 3
|
|
6518
|
+
try:
|
|
6519
|
+
_release_repair_marker(path)
|
|
6520
|
+
except OSError as exc:
|
|
6521
|
+
eprint(
|
|
6522
|
+
f"cctally: stats.db repair marker cleanup failed ({exc}); "
|
|
6523
|
+
f"remove {_repair_marker_path(path)} after confirming no repair runs."
|
|
6524
|
+
)
|
|
6525
|
+
return 3
|
|
6526
|
+
return rc
|
|
6527
|
+
|
|
6528
|
+
|
|
6529
|
+
def _cmd_db_repair_exclusive(args: argparse.Namespace, path: pathlib.Path) -> int:
|
|
6530
|
+
"""Verify no pre-marker handle remains, then enter the repair body."""
|
|
6531
|
+
open_pids = _db_family_open_pids(path)
|
|
6532
|
+
if open_pids is None:
|
|
6533
|
+
eprint(
|
|
6534
|
+
"cctally: cannot verify that stats.db has no open handles on "
|
|
6535
|
+
"this platform; refusing the destructive repair."
|
|
6536
|
+
)
|
|
6537
|
+
return 3
|
|
6538
|
+
if open_pids:
|
|
6539
|
+
eprint(
|
|
6540
|
+
"cctally: stats.db is still open in process(es) "
|
|
6541
|
+
+ ", ".join(str(pid) for pid in sorted(open_pids))
|
|
6542
|
+
+ ". Stop the dashboard and other cctally processes, then retry."
|
|
6543
|
+
)
|
|
6544
|
+
return 3
|
|
6545
|
+
return _cmd_db_repair_claimed(args, path)
|
|
6546
|
+
|
|
6547
|
+
|
|
6548
|
+
def _cmd_db_repair_claimed(args: argparse.Namespace, path: pathlib.Path) -> int:
|
|
6549
|
+
"""Repair body; caller owns the marker and has proved no old handles."""
|
|
6550
|
+
|
|
6551
|
+
timeout_ms = int(getattr(args, "busy_timeout_ms", 250) or 250)
|
|
6552
|
+
stamp = _db_backup_timestamp()
|
|
6553
|
+
backup = _unique_sibling_path(
|
|
6554
|
+
path.with_name(f"{path.name}.bak-corrupt-malformed-{stamp}")
|
|
6555
|
+
)
|
|
6556
|
+
with tempfile.TemporaryDirectory(
|
|
6557
|
+
prefix=".cctally-repair-", dir=path.parent
|
|
6558
|
+
) as scratch_raw:
|
|
6559
|
+
scratch = pathlib.Path(scratch_raw)
|
|
6560
|
+
snapshot = scratch / path.name
|
|
6561
|
+
recovered_path = scratch / "recovered.db"
|
|
6562
|
+
try:
|
|
6563
|
+
(
|
|
6564
|
+
preflight_rc,
|
|
6565
|
+
source_counts,
|
|
6566
|
+
source_version,
|
|
6567
|
+
guard,
|
|
6568
|
+
preflight_reason,
|
|
6569
|
+
) = _repair_preflight_and_copy(
|
|
6570
|
+
path, backup, snapshot, timeout_ms=timeout_ms
|
|
6571
|
+
)
|
|
6572
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
6573
|
+
eprint(f"cctally: could not preserve stats.db before repair: {exc}")
|
|
6574
|
+
return 3
|
|
6575
|
+
if preflight_rc == 2:
|
|
6576
|
+
eprint(
|
|
6577
|
+
"cctally: refusing to repair stats.db in the prod data dir "
|
|
6578
|
+
"(~/.local/share/cctally) from a dev checkout. Run the "
|
|
6579
|
+
"installed binary, or override with "
|
|
6580
|
+
"CCTALLY_ALLOW_PROD_MIGRATION=1."
|
|
6581
|
+
)
|
|
6582
|
+
return 2
|
|
6583
|
+
if preflight_rc == 3:
|
|
6584
|
+
eprint(
|
|
6585
|
+
"cctally: could not establish a quiescent stats.db writer "
|
|
6586
|
+
f"guard ({preflight_reason}). The DB is still in use or too "
|
|
6587
|
+
"damaged to lock safely. "
|
|
6588
|
+
"Stop the dashboard and other cctally processes, then retry; "
|
|
6589
|
+
"nothing was changed."
|
|
6590
|
+
)
|
|
6591
|
+
return 3
|
|
6592
|
+
if preflight_rc == 4:
|
|
6593
|
+
eprint(
|
|
6594
|
+
"cctally: stats.db quick_check is ok; refusing a destructive "
|
|
6595
|
+
"repair. Use `cctally db backup --db stats` for a safe backup."
|
|
6596
|
+
)
|
|
6597
|
+
return 2
|
|
6598
|
+
|
|
6599
|
+
assert guard is not None
|
|
6600
|
+
|
|
6601
|
+
def close_guard() -> None:
|
|
6602
|
+
nonlocal guard
|
|
6603
|
+
if guard is None:
|
|
6604
|
+
return
|
|
6605
|
+
try:
|
|
6606
|
+
guard.rollback()
|
|
6607
|
+
except sqlite3.DatabaseError:
|
|
6608
|
+
pass
|
|
6609
|
+
guard.close()
|
|
6610
|
+
guard = None
|
|
6611
|
+
|
|
6612
|
+
# PRAGMA user_version above is WAL-aware. The WAL was then checkpointed
|
|
6613
|
+
# before the snapshot; use its main header only if the pragma itself was
|
|
6614
|
+
# unreadable rather than inventing a version (#148).
|
|
6615
|
+
if source_version is None:
|
|
6616
|
+
source_version = _read_user_version_header(snapshot)
|
|
6617
|
+
if source_version is None:
|
|
6618
|
+
eprint(
|
|
6619
|
+
"cctally: the SQLite header is too damaged to preserve "
|
|
6620
|
+
"PRAGMA user_version safely; refusing the automated swap. "
|
|
6621
|
+
f"The live DB is untouched and the corrupt backup is {backup}."
|
|
6622
|
+
)
|
|
6623
|
+
close_guard()
|
|
6624
|
+
return 3
|
|
6625
|
+
|
|
6626
|
+
sqlite_binary = (
|
|
6627
|
+
getattr(args, "sqlite3_binary", None) or shutil.which("sqlite3")
|
|
6628
|
+
)
|
|
6629
|
+
if not sqlite_binary:
|
|
6630
|
+
eprint(
|
|
6631
|
+
"cctally: db repair requires the sqlite3 command-line tool for "
|
|
6632
|
+
'its corruption-tolerant ".recover" operation. The live DB is '
|
|
6633
|
+
f"untouched and the corrupt backup is {backup}."
|
|
6634
|
+
)
|
|
6635
|
+
close_guard()
|
|
6636
|
+
return 3
|
|
6637
|
+
|
|
6638
|
+
ok, reason = _run_sqlite_recover(
|
|
6639
|
+
str(sqlite_binary), snapshot, recovered_path, scratch
|
|
6640
|
+
)
|
|
6641
|
+
if not ok:
|
|
6642
|
+
eprint(
|
|
6643
|
+
f"cctally: sqlite3 recovery failed: {reason}. The corrupt "
|
|
6644
|
+
f"original was preserved at {backup}."
|
|
6645
|
+
)
|
|
6646
|
+
close_guard()
|
|
6647
|
+
return 3
|
|
6648
|
+
|
|
6649
|
+
try:
|
|
6650
|
+
recovered_conn = sqlite3.connect(recovered_path)
|
|
6651
|
+
try:
|
|
6652
|
+
if source_version is not None:
|
|
6653
|
+
recovered_conn.execute(
|
|
6654
|
+
f"PRAGMA user_version={int(source_version)}"
|
|
6655
|
+
)
|
|
6656
|
+
recovered_conn.commit()
|
|
6657
|
+
recovered_version = int(
|
|
6658
|
+
recovered_conn.execute("PRAGMA user_version").fetchone()[0]
|
|
6659
|
+
)
|
|
6660
|
+
integrity_rows = recovered_conn.execute(
|
|
6661
|
+
"PRAGMA integrity_check"
|
|
6662
|
+
).fetchall()
|
|
6663
|
+
recovered_counts = _table_counts_best_effort(recovered_conn)
|
|
6664
|
+
finally:
|
|
6665
|
+
recovered_conn.close()
|
|
6666
|
+
except sqlite3.DatabaseError as exc:
|
|
6667
|
+
eprint(
|
|
6668
|
+
f"cctally: recovered stats.db could not be verified ({exc}); "
|
|
6669
|
+
f"the live DB is untouched and the corrupt backup is {backup}."
|
|
6670
|
+
)
|
|
6671
|
+
close_guard()
|
|
6672
|
+
return 3
|
|
6673
|
+
|
|
6674
|
+
if integrity_rows != [("ok",)]:
|
|
6675
|
+
eprint(
|
|
6676
|
+
"cctally: recovered stats.db failed integrity_check; the live "
|
|
6677
|
+
f"DB is untouched and the corrupt backup is {backup}."
|
|
6678
|
+
)
|
|
6679
|
+
close_guard()
|
|
6680
|
+
return 3
|
|
6681
|
+
recovered_usage = recovered_counts.get("weekly_usage_snapshots")
|
|
6682
|
+
if recovered_usage is None:
|
|
6683
|
+
eprint(
|
|
6684
|
+
"cctally: recovered stats.db has no readable "
|
|
6685
|
+
"weekly_usage_snapshots table; refusing to replace the live DB. "
|
|
6686
|
+
f"The corrupt backup is {backup}."
|
|
6687
|
+
)
|
|
6688
|
+
close_guard()
|
|
6689
|
+
return 3
|
|
6690
|
+
source_usage = source_counts.get("weekly_usage_snapshots")
|
|
6691
|
+
if source_usage is None:
|
|
6692
|
+
eprint(
|
|
6693
|
+
"cctally: source weekly_usage_snapshots count is unreadable; "
|
|
6694
|
+
"refusing an automated swap that cannot prove preservation. "
|
|
6695
|
+
f"The live DB is untouched and the corrupt backup is {backup}."
|
|
6696
|
+
)
|
|
6697
|
+
close_guard()
|
|
6698
|
+
return 3
|
|
6699
|
+
if recovered_usage != source_usage:
|
|
6700
|
+
eprint(
|
|
6701
|
+
"cctally: recovered weekly_usage_snapshots count changed "
|
|
6702
|
+
f"({source_usage} -> {recovered_usage}); refusing the swap. "
|
|
6703
|
+
f"The corrupt backup is {backup}."
|
|
6704
|
+
)
|
|
6705
|
+
close_guard()
|
|
6706
|
+
return 3
|
|
6707
|
+
|
|
6708
|
+
try:
|
|
6709
|
+
os.chmod(recovered_path, 0o600)
|
|
6710
|
+
_fsync_file(recovered_path)
|
|
6711
|
+
# WAL is already fully checkpointed and this same guard has blocked
|
|
6712
|
+
# every writer since capture. Replace the coherent main file first;
|
|
6713
|
+
# a failed replace therefore leaves the old coherent main + empty
|
|
6714
|
+
# sidecars intact. New cctally opens remain blocked by the marker.
|
|
6715
|
+
os.replace(recovered_path, path)
|
|
6716
|
+
_fsync_directory(path.parent)
|
|
6717
|
+
close_guard()
|
|
6718
|
+
for suffix in ("-wal", "-shm"):
|
|
6719
|
+
try:
|
|
6720
|
+
pathlib.Path(str(path) + suffix).unlink()
|
|
6721
|
+
except FileNotFoundError:
|
|
6722
|
+
pass
|
|
6723
|
+
_fsync_directory(path.parent)
|
|
6724
|
+
except OSError as exc:
|
|
6725
|
+
close_guard()
|
|
6726
|
+
eprint(
|
|
6727
|
+
f"cctally: final stats.db swap failed ({exc}); the corrupt "
|
|
6728
|
+
f"original remains preserved at {backup}."
|
|
6729
|
+
)
|
|
6730
|
+
return 3
|
|
6731
|
+
|
|
6732
|
+
print(f"cctally: repaired stats.db; integrity_check ok; user_version {recovered_version}.")
|
|
6733
|
+
print(
|
|
6734
|
+
"cctally: weekly_usage_snapshots: "
|
|
6735
|
+
f"{source_usage} -> {recovered_usage}."
|
|
6736
|
+
)
|
|
6737
|
+
differences = []
|
|
6738
|
+
for name in sorted(set(source_counts) | set(recovered_counts)):
|
|
6739
|
+
before = source_counts.get(name)
|
|
6740
|
+
after = recovered_counts.get(name)
|
|
6741
|
+
if name == "weekly_usage_snapshots" or before == after:
|
|
6742
|
+
continue
|
|
6743
|
+
before_label = (
|
|
6744
|
+
"missing" if name not in source_counts
|
|
6745
|
+
else ("source unreadable" if before is None else str(before))
|
|
6746
|
+
)
|
|
6747
|
+
after_label = (
|
|
6748
|
+
"missing" if name not in recovered_counts
|
|
6749
|
+
else ("unreadable" if after is None else str(after))
|
|
6750
|
+
)
|
|
6751
|
+
differences.append(f"{name}: {before_label} -> {after_label}")
|
|
6752
|
+
if differences:
|
|
6753
|
+
print("cctally: recovered row differences: " + "; ".join(differences))
|
|
6754
|
+
print(f"cctally: corrupt original preserved at {backup}")
|
|
6755
|
+
return 0
|
|
6756
|
+
|
|
6757
|
+
|
|
6758
|
+
def cmd_db_backup(args: argparse.Namespace) -> int:
|
|
6759
|
+
"""Create one consistent SQLite online-backup snapshot (#314)."""
|
|
6760
|
+
which = args.db
|
|
6761
|
+
if which == "cache":
|
|
6762
|
+
path, label = _cctally_core.CACHE_DB_PATH, "cache.db"
|
|
6763
|
+
else:
|
|
6764
|
+
path, label = _cctally_core.DB_PATH, "stats.db"
|
|
6765
|
+
if not path.exists():
|
|
6766
|
+
print(f"cctally: {label} not present; nothing to back up.")
|
|
6767
|
+
return 0
|
|
6768
|
+
|
|
6769
|
+
raw_output = (
|
|
6770
|
+
getattr(args, "backup_output", None)
|
|
6771
|
+
or getattr(args, "output", None) # direct-call compatibility
|
|
6772
|
+
)
|
|
6773
|
+
if raw_output:
|
|
6774
|
+
output = pathlib.Path(raw_output).expanduser()
|
|
6775
|
+
else:
|
|
6776
|
+
output = _unique_sibling_path(
|
|
6777
|
+
path.with_name(f"{path.name}.bak-{_db_backup_timestamp()}")
|
|
6778
|
+
)
|
|
6779
|
+
if output.exists():
|
|
6780
|
+
eprint(f"cctally: backup destination already exists: {output}")
|
|
6781
|
+
return 2
|
|
6782
|
+
if not output.parent.exists():
|
|
6783
|
+
eprint(f"cctally: backup destination directory does not exist: {output.parent}")
|
|
6784
|
+
return 2
|
|
6785
|
+
|
|
6786
|
+
timeout_ms = int(getattr(args, "busy_timeout_ms", 15_000) or 15_000)
|
|
6787
|
+
try:
|
|
6788
|
+
with tempfile.TemporaryDirectory(
|
|
6789
|
+
prefix=f".{output.name}.tmp-", dir=output.parent
|
|
6790
|
+
) as scratch_raw:
|
|
6791
|
+
temp_path = pathlib.Path(scratch_raw) / output.name
|
|
6792
|
+
source = sqlite3.connect(
|
|
6793
|
+
f"file:{path}?mode=ro", uri=True, timeout=max(timeout_ms, 0) / 1000
|
|
6794
|
+
)
|
|
6795
|
+
destination = sqlite3.connect(temp_path)
|
|
6796
|
+
try:
|
|
6797
|
+
source.execute(f"PRAGMA busy_timeout={max(timeout_ms, 0)}")
|
|
6798
|
+
source.backup(destination)
|
|
6799
|
+
destination.commit()
|
|
6800
|
+
rows = destination.execute("PRAGMA integrity_check").fetchall()
|
|
6801
|
+
finally:
|
|
6802
|
+
destination.close()
|
|
6803
|
+
source.close()
|
|
6804
|
+
if rows != [("ok",)]:
|
|
6805
|
+
eprint(f"cctally: backup integrity_check failed for {label}")
|
|
6806
|
+
return 3
|
|
6807
|
+
os.chmod(temp_path, 0o600)
|
|
6808
|
+
_fsync_file(temp_path)
|
|
6809
|
+
try:
|
|
6810
|
+
# Hard-link publication is same-filesystem and fails atomically
|
|
6811
|
+
# if another process created the destination after validation.
|
|
6812
|
+
# TemporaryDirectory then removes only its original link.
|
|
6813
|
+
os.link(temp_path, output)
|
|
6814
|
+
except FileExistsError:
|
|
6815
|
+
eprint(f"cctally: backup destination already exists: {output}")
|
|
6816
|
+
return 2
|
|
6817
|
+
_fsync_directory(output.parent)
|
|
6818
|
+
except sqlite3.DatabaseError as exc:
|
|
6819
|
+
if which == "stats" and _is_sqlite_corruption_error(exc):
|
|
6820
|
+
eprint(f"cctally: {_stats_corruption_guidance()}")
|
|
6821
|
+
else:
|
|
6822
|
+
eprint(f"cctally: could not back up {label}: {exc}")
|
|
6823
|
+
return 3
|
|
6824
|
+
except OSError as exc:
|
|
6825
|
+
eprint(f"cctally: could not back up {label}: {exc}")
|
|
6826
|
+
return 3
|
|
6827
|
+
|
|
6828
|
+
print(f"cctally: backed up {label} to {output} (integrity_check ok).")
|
|
6829
|
+
return 0
|
|
6830
|
+
|
|
6831
|
+
|
|
6036
6832
|
def cmd_db_checkpoint(args: argparse.Namespace) -> int:
|
|
6037
6833
|
"""Fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db /
|
|
6038
6834
|
stats.db (#297).
|