diff --git a/backup-db.sh b/backup-db.sh index 9798730..1575787 100755 --- a/backup-db.sh +++ b/backup-db.sh @@ -6,7 +6,7 @@ DB_DIR="$(dirname "$(realpath "$0")")" DB_PATH="${CODE_SEARCH_DB:-$DB_DIR/code_index.db}" BACKUP_DIR="${CODE_SEARCH_BACKUP_DIR:-$DB_DIR/backups}" TIMESTAMP=$(date +%Y-%m-%d_%H%M) -MAX_BACKUPS="${CODE_SEARCH_MAX_BACKUPS:-14}" +MAX_BACKUPS="${CODE_SEARCH_MAX_BACKUPS:-4}" mkdir -p "$BACKUP_DIR" @@ -15,16 +15,49 @@ if [ ! -f "$DB_PATH" ]; then exit 1 fi -# Use Python sqlite3 backup for consistency (sqlite3 CLI not installed) +# Skip when nothing changed since the last good backup. Each copy is a full +# 15G file. The stamp records the source state (size, ns mtime, inode) and the +# backup file it produced; skip only if that file still exists, the source +# state matches, and there are no pending WAL frames. +STAMP_FILE="$BACKUP_DIR/.last-source-stamp" +source_state() { stat -c '%s %.9Y %i' "$DB_PATH"; } +wal_size() { stat -c %s "$DB_PATH-wal" 2>/dev/null || echo 0; } + +BEFORE="$(source_state)" +WAL_BEFORE="$(wal_size)" +if [ "$WAL_BEFORE" -eq 0 ] && [ -f "$STAMP_FILE" ]; then + IFS=$'\t' read -r LAST_STATE LAST_BACKUP < "$STAMP_FILE" || true + if [ "${LAST_STATE:-}" = "$BEFORE" ] && [ -n "${LAST_BACKUP:-}" ] \ + && [ -f "$BACKUP_DIR/$LAST_BACKUP" ]; then + echo "Unchanged since $LAST_BACKUP; skipping backup" + exit 0 + fi +fi + +# Use Python sqlite3 backup for consistency (sqlite3 CLI not installed). +# Write to a temp name and rename on success so a killed run never leaves a +# truncated file that looks like a valid backup. +BACKUP_NAME="code_index_$TIMESTAMP.db" +TMP_BACKUP="$BACKUP_DIR/$BACKUP_NAME.tmp" +trap 'rm -f "$TMP_BACKUP"' EXIT +rm -f "$TMP_BACKUP" python3 -c " import sqlite3, sys src = sqlite3.connect('$DB_PATH') -dst = sqlite3.connect('$BACKUP_DIR/code_index_$TIMESTAMP.db') +dst = sqlite3.connect('$TMP_BACKUP') src.backup(dst) dst.close() src.close() " -echo "Backed up to $BACKUP_DIR/code_index_$TIMESTAMP.db ($(du -sh "$BACKUP_DIR/code_index_$TIMESTAMP.db" | cut -f1))" +mv -f "$TMP_BACKUP" "$BACKUP_DIR/$BACKUP_NAME" +echo "Backed up to $BACKUP_DIR/$BACKUP_NAME ($(du -sh "$BACKUP_DIR/$BACKUP_NAME" | cut -f1))" + +# Record the stamp only if the source did not move during the copy. +if [ "$WAL_BEFORE" -eq 0 ] && [ "$(wal_size)" -eq 0 ] && [ "$(source_state)" = "$BEFORE" ]; then + printf '%s\t%s\n' "$BEFORE" "$BACKUP_NAME" > "$STAMP_FILE" +else + rm -f "$STAMP_FILE" +fi # Rotate: keep only the newest MAX_BACKUPS cd "$BACKUP_DIR" diff --git a/tests/test_backup_db.py b/tests/test_backup_db.py new file mode 100644 index 0000000..7f8dfcd --- /dev/null +++ b/tests/test_backup_db.py @@ -0,0 +1,89 @@ +"""Regression tests for backup-db.sh skip-if-unchanged behavior. + +Each backup is a full copy of the index, so an unchanged DB must not produce a +new copy on every scheduled run. These tests use a throwaway SQLite file and a +temp backup dir; they never touch the real index. +""" + +from __future__ import annotations + +import os +import sqlite3 +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "backup-db.sh" + + +def run_backup(db: Path, backups: Path) -> str: + env = {**os.environ, "CODE_SEARCH_DB": str(db), "CODE_SEARCH_BACKUP_DIR": str(backups)} + result = subprocess.run(["bash", str(SCRIPT)], env=env, capture_output=True, text=True, check=True) + return result.stdout + + +def make_db(path: Path) -> None: + """Create a WAL-mode DB, like the real index.""" + conn = sqlite3.connect(path) + conn.execute("pragma journal_mode=wal") + conn.execute("create table t (a)") + conn.commit() + conn.close() + + +def test_unchanged_db_is_skipped(tmp_path: Path) -> None: + db, backups = tmp_path / "index.db", tmp_path / "backups" + make_db(db) + + assert "Backed up" in run_backup(db, backups) + assert "skipping backup" in run_backup(db, backups) + assert len(list(backups.glob("code_index_*.db"))) == 1 + + +def test_changed_db_is_backed_up_again(tmp_path: Path) -> None: + db, backups = tmp_path / "index.db", tmp_path / "backups" + make_db(db) + run_backup(db, backups) + + # No mtime bump: a same-second write must still be detected. + conn = sqlite3.connect(db) + conn.execute("insert into t values (1)") + conn.commit() + conn.close() + + assert "Backed up" in run_backup(db, backups) + + +def test_missing_stamp_forces_backup(tmp_path: Path) -> None: + db, backups = tmp_path / "index.db", tmp_path / "backups" + make_db(db) + run_backup(db, backups) + (backups / ".last-source-stamp").unlink() + + assert "Backed up" in run_backup(db, backups) + + +def test_pending_wal_forces_backup(tmp_path: Path) -> None: + db, backups = tmp_path / "index.db", tmp_path / "backups" + make_db(db) + run_backup(db, backups) + + writer = sqlite3.connect(db) + writer.execute("pragma wal_autocheckpoint=0") + writer.execute("insert into t values (2)") + writer.commit() + try: + assert (tmp_path / "index.db-wal").stat().st_size > 0 + assert "Backed up" in run_backup(db, backups) + finally: + writer.close() + + +def test_deleted_newest_backup_forces_backup(tmp_path: Path) -> None: + db, backups = tmp_path / "index.db", tmp_path / "backups" + make_db(db) + run_backup(db, backups) + for backup in backups.glob("code_index_*.db"): + backup.unlink() + + assert "Backed up" in run_backup(db, backups) + assert not list(backups.glob("*.tmp"))