Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions backup-db.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Comment on lines +27 to +31
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"
Comment on lines +40 to +43
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"
Expand Down
89 changes: 89 additions & 0 deletions tests/test_backup_db.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading