cctally 1.74.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.
@@ -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,655 @@ 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 _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
+
6139
6832
  def cmd_db_checkpoint(args: argparse.Namespace) -> int:
6140
6833
  """Fast, non-destructive WAL drain (TRUNCATE checkpoint) for cache.db /
6141
6834
  stats.db (#297).