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
}