Spaces:
Running
Running
File size: 8,012 Bytes
bbfd266 1794eb9 bbfd266 1794eb9 bbfd266 1794eb9 bbfd266 1794eb9 bbfd266 1794eb9 bbfd266 1794eb9 bbfd266 1794eb9 bbfd266 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | #!/bin/sh
#
# Self-healing guard for the SQLite database on the Hugging Face bucket volume.
#
# /data lives on a bucket volume. That is fine for whole files, but a container
# stopped in the middle of a SQLite write - which is exactly what a Space
# rebuild does - can leave a torn page behind. Afterwards every query touching
# the damaged table fails with "database disk image is malformed": the service
# still starts, /healthz still answers, but the admin UI can no longer list
# accounts.
#
# Runs on every boot, before the application starts:
# 1. keeps a consistent rotating snapshot (sqlite3 .backup folds in the WAL)
# 2. verifies the database with PRAGMA integrity_check
# 3. when verification fails, rebuilds the database with sqlite3 .recover and
# only accepts the result if it verifies AND did not lose accounts;
# otherwise it falls back to the newest snapshot that verifies
# 4. when the database file is missing outright - the volume can drop it while
# its WAL survives - restores/recover a snapshot instead of letting the
# application create an empty database and silently lose every account
#
# The original file is never deleted: a failed repair leaves the database
# exactly as it was, and every replaced database is kept for inspection. A
# repaired database is additionally copied to backups/ under a fresh name, so a
# good copy exists independently of the swap in step 3.
# Install $1 as the live database $2. A plain byte copy, because the rename a
# previous repair used is what left the new file unsynced on the volume.
install_database() {
[ -f "$1" ] || return 1
rm -f "$2-wal" "$2-shm"
cp "$1" "$2"
}
# Pick a usable database from the snapshot directory. Sets CHOSEN_SNAPSHOT (a
# global, never stdout: the sqlite3 CLI prints banner text such as
# "defensive off" around .recover, which silently corrupts a command
# substitution and once made this guard install an empty database).
# A snapshot that does not verify is repaired on the spot, because the damaged
# copies are exactly the ones that still hold every account row.
choose_snapshot() {
_dir="$1"
_want="$2"
CHOSEN_SNAPSHOT=""
for _candidate in $(ls -1t "$_dir"/backend.db.* 2>/dev/null); do
case "$_candidate" in
*.tmp) continue ;;
esac
[ -f "$_candidate" ] || continue
if [ "$(timeout 300 sqlite3 "$_candidate" 'PRAGMA integrity_check;' 2>/dev/null | head -n 1)" = "ok" ]; then
CHOSEN_SNAPSHOT="$_candidate"
return 0
fi
_repaired="$_candidate.recovered"
rm -f "$_repaired"
if sqlite3 "$_candidate" .recover 2>/dev/null | sqlite3 "$_repaired" >/dev/null 2>&1; then
if [ -s "$_repaired" ] && [ "$(timeout 300 sqlite3 "$_repaired" 'PRAGMA integrity_check;' 2>/dev/null | head -n 1)" = "ok" ]; then
_accounts="$(timeout 60 sqlite3 "$_repaired" 'SELECT COUNT(*) FROM provider_accounts;' 2>/dev/null | head -n 1)"
case "$_accounts" in
''|*[!0-9]*) _accounts="" ;;
esac
if [ -z "$_want" ] || [ -z "$_accounts" ] || [ "$_accounts" -ge "$_want" ]; then
CHOSEN_SNAPSHOT="$_repaired"
return 0
fi
fi
fi
rm -f "$_repaired"
done
return 1
}
sqlite_preflight() {
db_dir="${1:-/data}"
db="$db_dir/backend.db"
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "sqlite3 not installed; skipping database preflight" >&2
return 0
fi
backup_dir="$db_dir/backups"
mkdir -p "$backup_dir" 2>/dev/null || true
# ---- 0. the volume dropped the database but kept its WAL ---------------- #
if [ ! -f "$db" ]; then
if [ ! -f "$db-wal" ]; then
echo "$db not present yet; nothing to verify"
return 0
fi
echo "WARN: $db is missing while a WAL survives; the bucket volume lost the main file" >&2
if ! choose_snapshot "$backup_dir" "" || [ -z "$CHOSEN_SNAPSHOT" ]; then
echo "ERROR: no usable snapshot to restore after the main database went missing" >&2
return 0
fi
if install_database "$CHOSEN_SNAPSHOT" "$db"; then
sqlite3 "$db" "PRAGMA journal_mode=WAL;" >/dev/null 2>&1 || true
echo "database restored from $CHOSEN_SNAPSHOT after the volume lost the main file" >&2
else
echo "ERROR: could not restore $CHOSEN_SNAPSHOT to $db" >&2
fi
return 0
fi
# ---- 1. rotating pre-boot snapshot ------------------------------------- #
# Slot 1 is always "the database as it was found"; older slots hold previous
# boots, so if slot 1 is itself damaged the fallback still has a candidate.
if sqlite3 "$db" ".backup '$backup_dir/backend.db.tmp'" 2>/dev/null; then
i=4
while [ "$i" -ge 1 ]; do
if [ -f "$backup_dir/backend.db.$i" ]; then
mv "$backup_dir/backend.db.$i" "$backup_dir/backend.db.$((i + 1))"
fi
i=$((i - 1))
done
mv "$backup_dir/backend.db.tmp" "$backup_dir/backend.db.1"
echo "database snapshot written to $backup_dir/backend.db.1"
else
echo "WARN: could not snapshot the database" >&2
fi
# ---- 2. verify (bounded so a huge database cannot stall the boot) ------- #
check="$(timeout 300 sqlite3 "$db" 'PRAGMA integrity_check;' 2>&1 | head -n 1)"
if [ "$check" = "ok" ]; then
echo "database integrity check passed"
return 0
fi
echo "WARN: database integrity check failed: $check" >&2
# How many accounts the damaged database still exposes to a plain scan. The
# repair must never end up with fewer than this.
src_accounts="$(timeout 60 sqlite3 "$db" 'SELECT COUNT(*) FROM provider_accounts;' 2>/dev/null | head -n 1)"
case "$src_accounts" in
''|*[!0-9]*) src_accounts="" ;;
esac
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
broken="$db_dir/backend.db.corrupt-$stamp"
recovered="$db_dir/backend.db.recovered-$stamp"
# ---- 3a. rebuild from every page that is still readable ---------------- #
# .recover is the canonical SQLite repair: it re-emits the schema and every
# readable row into a fresh database, so indexes are rebuilt and mis-ordered
# b-tree pages are rewritten.
if sqlite3 "$db" .recover 2>/dev/null | sqlite3 "$recovered" 2>/dev/null; then
new_check="$(timeout 300 sqlite3 "$recovered" 'PRAGMA integrity_check;' 2>/dev/null | head -n 1)"
new_accounts="$(timeout 60 sqlite3 "$recovered" 'SELECT COUNT(*) FROM provider_accounts;' 2>/dev/null | head -n 1)"
case "$new_accounts" in
''|*[!0-9]*) new_accounts="" ;;
esac
if [ "$new_check" != "ok" ]; then
echo "WARN: recovered database still fails verification ($new_check)" >&2
elif [ -n "$src_accounts" ] && [ -n "$new_accounts" ] && [ "$new_accounts" -lt "$src_accounts" ]; then
echo "WARN: recovery would lose accounts ($new_accounts < $src_accounts); not using it" >&2
else
# The application opens the database with journal_mode(WAL); keep that.
sqlite3 "$recovered" "PRAGMA journal_mode=WAL;" >/dev/null 2>&1 || true
mv "$db" "$broken"
install_database "$recovered" "$db"
# Keep the repaired content under its own name too: the swap above is a
# rename, and this volume has been observed not to sync the file that a
# rename creates.
cp "$db" "$backup_dir/backend.db.repaired-$stamp" 2>/dev/null || true
echo "database rebuilt from recovered pages; accounts=$new_accounts (damaged copy kept at $broken)" >&2
return 0
fi
fi
rm -f "$recovered"
# ---- 3b. otherwise restore the newest snapshot that verifies ----------- #
if choose_snapshot "$backup_dir" "$src_accounts" && [ -n "$CHOSEN_SNAPSHOT" ]; then
mv "$db" "$broken"
if install_database "$CHOSEN_SNAPSHOT" "$db"; then
echo "database restored from $CHOSEN_SNAPSHOT (damaged copy kept at $broken)" >&2
return 0
fi
echo "ERROR: could not install $CHOSEN_SNAPSHOT; restoring the damaged original" >&2
mv "$broken" "$db" 2>/dev/null || true
return 0
fi
echo "ERROR: automatic recovery failed and no valid snapshot exists; leaving the database untouched" >&2
return 0
}
|