cctally 1.74.0 → 1.75.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.
@@ -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 exit-2 diagnosis
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
 
@@ -6136,6 +6180,684 @@ def cmd_db_recover(args: argparse.Namespace) -> int:
6136
6180
  conn.close()
6137
6181
 
6138
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 _probe_sqlite_recover(sqlite_binary: str) -> "tuple[bool, str]":
6415
+ """Prove the selected shell has a functional ``.recover`` command."""
6416
+ try:
6417
+ probe = subprocess.run(
6418
+ [sqlite_binary, ":memory:", ".recover"],
6419
+ stdout=subprocess.PIPE,
6420
+ stderr=subprocess.PIPE,
6421
+ check=False,
6422
+ )
6423
+ except OSError as exc:
6424
+ return False, str(exc)
6425
+ stderr = probe.stderr.decode("utf-8", "replace").strip()
6426
+ if probe.returncode != 0:
6427
+ return False, stderr or f"sqlite3 .recover probe exited {probe.returncode}"
6428
+ if b"BEGIN;" not in probe.stdout or b"COMMIT;" not in probe.stdout:
6429
+ return False, stderr or "sqlite3 .recover probe produced incomplete SQL"
6430
+ return True, ""
6431
+
6432
+
6433
+ def _repair_preflight_and_copy(
6434
+ path: pathlib.Path,
6435
+ backup: pathlib.Path,
6436
+ snapshot: pathlib.Path,
6437
+ *,
6438
+ timeout_ms: int,
6439
+ ) -> "tuple[int, dict[str, int | None], int | None, sqlite3.Connection | None, str]":
6440
+ """Preserve forensic bytes, drain WAL, and return a held writer guard."""
6441
+ conn = sqlite3.connect(
6442
+ f"file:{path}?mode=rw", uri=True, timeout=max(timeout_ms, 0) / 1000
6443
+ )
6444
+ try:
6445
+ if _would_block_prod_migration(conn):
6446
+ conn.close()
6447
+ return 2, {}, None, None, "prod guard"
6448
+ conn.execute(f"PRAGMA busy_timeout={max(timeout_ms, 0)}")
6449
+ try:
6450
+ conn.execute("BEGIN IMMEDIATE")
6451
+ except sqlite3.DatabaseError as exc:
6452
+ conn.close()
6453
+ return 3, {}, None, None, str(exc)
6454
+ try:
6455
+ quick = conn.execute("PRAGMA quick_check(1)").fetchone()
6456
+ except sqlite3.DatabaseError:
6457
+ quick = None
6458
+ if quick is not None and quick[0] == "ok":
6459
+ conn.rollback()
6460
+ conn.close()
6461
+ return 4, {}, None, None, "quick_check ok"
6462
+
6463
+ # Preserve the exact corrupt family before checkpointing mutates the
6464
+ # main/WAL representation. No other cctally process can open after the
6465
+ # repair marker, and the caller has already proved no old handle exists.
6466
+ _copy_db_family(path, backup)
6467
+ _fsync_directory(path.parent)
6468
+ conn.rollback()
6469
+
6470
+ try:
6471
+ checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
6472
+ except sqlite3.DatabaseError as exc:
6473
+ conn.close()
6474
+ return 3, {}, None, None, f"WAL checkpoint failed: {exc}"
6475
+ wal_path = pathlib.Path(str(path) + "-wal")
6476
+ wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0
6477
+ if checkpoint is None or int(checkpoint[0]) != 0 or wal_bytes != 0:
6478
+ conn.close()
6479
+ return 3, {}, None, None, "WAL could not be fully checkpointed"
6480
+
6481
+ # Hold this one write exclusion continuously through .recover and the
6482
+ # main-file replace. Since the WAL is empty, replacement failure leaves
6483
+ # the old main file coherent and no committed frames can be lost.
6484
+ try:
6485
+ conn.execute("BEGIN IMMEDIATE")
6486
+ except sqlite3.DatabaseError as exc:
6487
+ conn.close()
6488
+ return 3, {}, None, None, str(exc)
6489
+ try:
6490
+ source_version = int(
6491
+ conn.execute("PRAGMA user_version").fetchone()[0]
6492
+ )
6493
+ except sqlite3.DatabaseError:
6494
+ source_version = None
6495
+ counts = _table_counts_best_effort(conn)
6496
+ _copy_db_family(path, snapshot, suffixes=("",))
6497
+ return 0, counts, source_version, conn, ""
6498
+ except Exception:
6499
+ try:
6500
+ conn.rollback()
6501
+ except sqlite3.DatabaseError:
6502
+ pass
6503
+ conn.close()
6504
+ raise
6505
+
6506
+
6507
+ def cmd_db_repair(args: argparse.Namespace) -> int:
6508
+ """Claim exclusive maintenance ownership, then repair stats.db (#314)."""
6509
+ path = _cctally_core.DB_PATH
6510
+ if not path.exists():
6511
+ print("cctally: stats.db not present; nothing to repair.")
6512
+ return 0
6513
+ if not getattr(args, "yes", False):
6514
+ eprint(
6515
+ "cctally: repairing stats.db replaces the live non-re-derivable "
6516
+ "database after preserving the corrupt original. Re-run with "
6517
+ "--yes after stopping the dashboard and other cctally processes."
6518
+ )
6519
+ return 2
6520
+
6521
+ try:
6522
+ claimed, reason = _claim_repair_marker(path)
6523
+ except OSError as exc:
6524
+ eprint(f"cctally: could not create stats.db repair marker: {exc}")
6525
+ return 3
6526
+ if not claimed:
6527
+ eprint(f"cctally: {reason}")
6528
+ return 3
6529
+ try:
6530
+ rc = _cmd_db_repair_exclusive(args, path)
6531
+ except (OSError, sqlite3.DatabaseError) as exc:
6532
+ eprint(f"cctally: stats.db repair failed before completion: {exc}")
6533
+ rc = 3
6534
+ except Exception as exc: # defensive: marker cleanup still must run
6535
+ eprint(f"cctally: unexpected stats.db repair failure: {exc}")
6536
+ rc = 3
6537
+ try:
6538
+ _release_repair_marker(path)
6539
+ except OSError as exc:
6540
+ eprint(
6541
+ f"cctally: stats.db repair marker cleanup failed ({exc}); "
6542
+ f"remove {_repair_marker_path(path)} after confirming no repair runs."
6543
+ )
6544
+ return 3
6545
+ return rc
6546
+
6547
+
6548
+ def _cmd_db_repair_exclusive(args: argparse.Namespace, path: pathlib.Path) -> int:
6549
+ """Verify no pre-marker handle remains, then enter the repair body."""
6550
+ open_pids = _db_family_open_pids(path)
6551
+ if open_pids is None:
6552
+ eprint(
6553
+ "cctally: cannot verify that stats.db has no open handles on "
6554
+ "this platform; refusing the destructive repair."
6555
+ )
6556
+ return 3
6557
+ if open_pids:
6558
+ eprint(
6559
+ "cctally: stats.db is still open in process(es) "
6560
+ + ", ".join(str(pid) for pid in sorted(open_pids))
6561
+ + ". Stop the dashboard and other cctally processes, then retry."
6562
+ )
6563
+ return 3
6564
+ return _cmd_db_repair_claimed(args, path)
6565
+
6566
+
6567
+ def _cmd_db_repair_claimed(args: argparse.Namespace, path: pathlib.Path) -> int:
6568
+ """Repair body; caller owns the marker and has proved no old handles."""
6569
+
6570
+ timeout_ms = int(getattr(args, "busy_timeout_ms", 250) or 250)
6571
+ sqlite_binary = (
6572
+ getattr(args, "sqlite3_binary", None) or shutil.which("sqlite3")
6573
+ )
6574
+ if not sqlite_binary:
6575
+ eprint(
6576
+ "cctally: db repair requires the sqlite3 command-line tool for "
6577
+ 'its corruption-tolerant ".recover" operation. The live DB is '
6578
+ "untouched and no corrupt backup was created."
6579
+ )
6580
+ return 3
6581
+ recover_supported, recover_reason = _probe_sqlite_recover(
6582
+ str(sqlite_binary)
6583
+ )
6584
+ if not recover_supported:
6585
+ eprint(
6586
+ "cctally: the selected sqlite3 build does not support SQLite "
6587
+ f".recover ({recover_reason}). Install the official sqlite.org "
6588
+ "CLI built with SQLITE_ENABLE_DBPAGE_VTAB. The live DB is "
6589
+ "untouched and no corrupt backup was created."
6590
+ )
6591
+ return 3
6592
+
6593
+ stamp = _db_backup_timestamp()
6594
+ backup = _unique_sibling_path(
6595
+ path.with_name(f"{path.name}.bak-corrupt-malformed-{stamp}")
6596
+ )
6597
+ with tempfile.TemporaryDirectory(
6598
+ prefix=".cctally-repair-", dir=path.parent
6599
+ ) as scratch_raw:
6600
+ scratch = pathlib.Path(scratch_raw)
6601
+ snapshot = scratch / path.name
6602
+ recovered_path = scratch / "recovered.db"
6603
+ try:
6604
+ (
6605
+ preflight_rc,
6606
+ source_counts,
6607
+ source_version,
6608
+ guard,
6609
+ preflight_reason,
6610
+ ) = _repair_preflight_and_copy(
6611
+ path, backup, snapshot, timeout_ms=timeout_ms
6612
+ )
6613
+ except (OSError, sqlite3.DatabaseError) as exc:
6614
+ eprint(f"cctally: could not preserve stats.db before repair: {exc}")
6615
+ return 3
6616
+ if preflight_rc == 2:
6617
+ eprint(
6618
+ "cctally: refusing to repair stats.db in the prod data dir "
6619
+ "(~/.local/share/cctally) from a dev checkout. Run the "
6620
+ "installed binary, or override with "
6621
+ "CCTALLY_ALLOW_PROD_MIGRATION=1."
6622
+ )
6623
+ return 2
6624
+ if preflight_rc == 3:
6625
+ eprint(
6626
+ "cctally: could not establish a quiescent stats.db writer "
6627
+ f"guard ({preflight_reason}). The DB is still in use or too "
6628
+ "damaged to lock safely. "
6629
+ "Stop the dashboard and other cctally processes, then retry; "
6630
+ "nothing was changed."
6631
+ )
6632
+ return 3
6633
+ if preflight_rc == 4:
6634
+ eprint(
6635
+ "cctally: stats.db quick_check is ok; refusing a destructive "
6636
+ "repair. Use `cctally db backup --db stats` for a safe backup."
6637
+ )
6638
+ return 2
6639
+
6640
+ assert guard is not None
6641
+
6642
+ def close_guard() -> None:
6643
+ nonlocal guard
6644
+ if guard is None:
6645
+ return
6646
+ try:
6647
+ guard.rollback()
6648
+ except sqlite3.DatabaseError:
6649
+ pass
6650
+ guard.close()
6651
+ guard = None
6652
+
6653
+ # PRAGMA user_version above is WAL-aware. The WAL was then checkpointed
6654
+ # before the snapshot; use its main header only if the pragma itself was
6655
+ # unreadable rather than inventing a version (#148).
6656
+ if source_version is None:
6657
+ source_version = _read_user_version_header(snapshot)
6658
+ if source_version is None:
6659
+ eprint(
6660
+ "cctally: the SQLite header is too damaged to preserve "
6661
+ "PRAGMA user_version safely; refusing the automated swap. "
6662
+ f"The live DB is untouched and the corrupt backup is {backup}."
6663
+ )
6664
+ close_guard()
6665
+ return 3
6666
+
6667
+ ok, reason = _run_sqlite_recover(
6668
+ str(sqlite_binary), snapshot, recovered_path, scratch
6669
+ )
6670
+ if not ok:
6671
+ eprint(
6672
+ f"cctally: sqlite3 recovery failed: {reason}. The corrupt "
6673
+ f"original was preserved at {backup}."
6674
+ )
6675
+ close_guard()
6676
+ return 3
6677
+
6678
+ try:
6679
+ recovered_conn = sqlite3.connect(recovered_path)
6680
+ try:
6681
+ if source_version is not None:
6682
+ recovered_conn.execute(
6683
+ f"PRAGMA user_version={int(source_version)}"
6684
+ )
6685
+ recovered_conn.commit()
6686
+ recovered_version = int(
6687
+ recovered_conn.execute("PRAGMA user_version").fetchone()[0]
6688
+ )
6689
+ integrity_rows = recovered_conn.execute(
6690
+ "PRAGMA integrity_check"
6691
+ ).fetchall()
6692
+ recovered_counts = _table_counts_best_effort(recovered_conn)
6693
+ finally:
6694
+ recovered_conn.close()
6695
+ except sqlite3.DatabaseError as exc:
6696
+ eprint(
6697
+ f"cctally: recovered stats.db could not be verified ({exc}); "
6698
+ f"the live DB is untouched and the corrupt backup is {backup}."
6699
+ )
6700
+ close_guard()
6701
+ return 3
6702
+
6703
+ if integrity_rows != [("ok",)]:
6704
+ eprint(
6705
+ "cctally: recovered stats.db failed integrity_check; the live "
6706
+ f"DB is untouched and the corrupt backup is {backup}."
6707
+ )
6708
+ close_guard()
6709
+ return 3
6710
+ recovered_usage = recovered_counts.get("weekly_usage_snapshots")
6711
+ if recovered_usage is None:
6712
+ eprint(
6713
+ "cctally: recovered stats.db has no readable "
6714
+ "weekly_usage_snapshots table; refusing to replace the live DB. "
6715
+ f"The corrupt backup is {backup}."
6716
+ )
6717
+ close_guard()
6718
+ return 3
6719
+ source_usage = source_counts.get("weekly_usage_snapshots")
6720
+ if source_usage is None:
6721
+ eprint(
6722
+ "cctally: source weekly_usage_snapshots count is unreadable; "
6723
+ "refusing an automated swap that cannot prove preservation. "
6724
+ f"The live DB is untouched and the corrupt backup is {backup}."
6725
+ )
6726
+ close_guard()
6727
+ return 3
6728
+ if recovered_usage != source_usage:
6729
+ eprint(
6730
+ "cctally: recovered weekly_usage_snapshots count changed "
6731
+ f"({source_usage} -> {recovered_usage}); refusing the swap. "
6732
+ f"The corrupt backup is {backup}."
6733
+ )
6734
+ close_guard()
6735
+ return 3
6736
+
6737
+ try:
6738
+ os.chmod(recovered_path, 0o600)
6739
+ _fsync_file(recovered_path)
6740
+ # WAL is already fully checkpointed and this same guard has blocked
6741
+ # every writer since capture. Replace the coherent main file first;
6742
+ # a failed replace therefore leaves the old coherent main + empty
6743
+ # sidecars intact. New cctally opens remain blocked by the marker.
6744
+ os.replace(recovered_path, path)
6745
+ _fsync_directory(path.parent)
6746
+ close_guard()
6747
+ for suffix in ("-wal", "-shm"):
6748
+ try:
6749
+ pathlib.Path(str(path) + suffix).unlink()
6750
+ except FileNotFoundError:
6751
+ pass
6752
+ _fsync_directory(path.parent)
6753
+ except OSError as exc:
6754
+ close_guard()
6755
+ eprint(
6756
+ f"cctally: final stats.db swap failed ({exc}); the corrupt "
6757
+ f"original remains preserved at {backup}."
6758
+ )
6759
+ return 3
6760
+
6761
+ print(f"cctally: repaired stats.db; integrity_check ok; user_version {recovered_version}.")
6762
+ print(
6763
+ "cctally: weekly_usage_snapshots: "
6764
+ f"{source_usage} -> {recovered_usage}."
6765
+ )
6766
+ differences = []
6767
+ for name in sorted(set(source_counts) | set(recovered_counts)):
6768
+ before = source_counts.get(name)
6769
+ after = recovered_counts.get(name)
6770
+ if name == "weekly_usage_snapshots" or before == after:
6771
+ continue
6772
+ before_label = (
6773
+ "missing" if name not in source_counts
6774
+ else ("source unreadable" if before is None else str(before))
6775
+ )
6776
+ after_label = (
6777
+ "missing" if name not in recovered_counts
6778
+ else ("unreadable" if after is None else str(after))
6779
+ )
6780
+ differences.append(f"{name}: {before_label} -> {after_label}")
6781
+ if differences:
6782
+ print("cctally: recovered row differences: " + "; ".join(differences))
6783
+ print(f"cctally: corrupt original preserved at {backup}")
6784
+ return 0
6785
+
6786
+
6787
+ def cmd_db_backup(args: argparse.Namespace) -> int:
6788
+ """Create one consistent SQLite online-backup snapshot (#314)."""
6789
+ which = args.db
6790
+ if which == "cache":
6791
+ path, label = _cctally_core.CACHE_DB_PATH, "cache.db"
6792
+ else:
6793
+ path, label = _cctally_core.DB_PATH, "stats.db"
6794
+ if not path.exists():
6795
+ print(f"cctally: {label} not present; nothing to back up.")
6796
+ return 0
6797
+
6798
+ raw_output = (
6799
+ getattr(args, "backup_output", None)
6800
+ or getattr(args, "output", None) # direct-call compatibility
6801
+ )
6802
+ if raw_output:
6803
+ output = pathlib.Path(raw_output).expanduser()
6804
+ else:
6805
+ output = _unique_sibling_path(
6806
+ path.with_name(f"{path.name}.bak-{_db_backup_timestamp()}")
6807
+ )
6808
+ if output.exists():
6809
+ eprint(f"cctally: backup destination already exists: {output}")
6810
+ return 2
6811
+ if not output.parent.exists():
6812
+ eprint(f"cctally: backup destination directory does not exist: {output.parent}")
6813
+ return 2
6814
+
6815
+ timeout_ms = int(getattr(args, "busy_timeout_ms", 15_000) or 15_000)
6816
+ try:
6817
+ with tempfile.TemporaryDirectory(
6818
+ prefix=f".{output.name}.tmp-", dir=output.parent
6819
+ ) as scratch_raw:
6820
+ temp_path = pathlib.Path(scratch_raw) / output.name
6821
+ source = sqlite3.connect(
6822
+ f"file:{path}?mode=ro", uri=True, timeout=max(timeout_ms, 0) / 1000
6823
+ )
6824
+ destination = sqlite3.connect(temp_path)
6825
+ try:
6826
+ source.execute(f"PRAGMA busy_timeout={max(timeout_ms, 0)}")
6827
+ source.backup(destination)
6828
+ destination.commit()
6829
+ rows = destination.execute("PRAGMA integrity_check").fetchall()
6830
+ finally:
6831
+ destination.close()
6832
+ source.close()
6833
+ if rows != [("ok",)]:
6834
+ eprint(f"cctally: backup integrity_check failed for {label}")
6835
+ return 3
6836
+ os.chmod(temp_path, 0o600)
6837
+ _fsync_file(temp_path)
6838
+ try:
6839
+ # Hard-link publication is same-filesystem and fails atomically
6840
+ # if another process created the destination after validation.
6841
+ # TemporaryDirectory then removes only its original link.
6842
+ os.link(temp_path, output)
6843
+ except FileExistsError:
6844
+ eprint(f"cctally: backup destination already exists: {output}")
6845
+ return 2
6846
+ _fsync_directory(output.parent)
6847
+ except sqlite3.DatabaseError as exc:
6848
+ if which == "stats" and _is_sqlite_corruption_error(exc):
6849
+ eprint(f"cctally: {_stats_corruption_guidance()}")
6850
+ else:
6851
+ eprint(f"cctally: could not back up {label}: {exc}")
6852
+ return 3
6853
+ except OSError as exc:
6854
+ eprint(f"cctally: could not back up {label}: {exc}")
6855
+ return 3
6856
+
6857
+ print(f"cctally: backed up {label} to {output} (integrity_check ok).")
6858
+ return 0
6859
+
6860
+
6139
6861
  def cmd_db_checkpoint(args: argparse.Namespace) -> int:
6140
6862
  """Fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db /
6141
6863
  stats.db (#297).