#!/usr/bin/env bash # docker/vacuum-site-localdb.sh # # One-off maintenance: convert an EXISTING site LocalDb file to auto_vacuum=INCREMENTAL. # # Why this exists (deferred-work register row 104, arch-review 2026-08-19 P4-6): SQLite only # honours `PRAGMA auto_vacuum` on a table-less database, and until Low-wave T4 the pragma ran # AFTER LocalDb had created its bookkeeping tables — so every site file created before that # fix is permanently auto_vacuum=NONE, and the event-log cap trim's `PRAGMA incremental_vacuum` # is a silent no-op on it: freed `site_events` pages are reused but the file never shrinks. # Fresh files are created correctly now (`SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile`); # this script is the recipe for the files that already exist. # # Changing auto_vacuum on a populated file requires a full VACUUM (a rewrite of the file), # which is why it is NOT done automatically at boot: on a ~1 GB file it holds an exclusive # lock for minutes. It must run with BOTH nodes of the pair STOPPED: # - stopped together, per the topology guide's "stop and start BOTH nodes together" rule # (the pair is a replication unit; a rolling restart is unsupported); # - VACUUM is CDC-invisible (it fires no triggers and changes no row content), the LWW # ledger keys by declared primary key (`(table_name, pk_json)`), and the oplog `seq` # watermark is an INTEGER PRIMARY KEY AUTOINCREMENT — all preserved by VACUUM. So the # pair's replication state is untouched; it simply resumes when the nodes restart. # # Usage: # bash docker/vacuum-site-localdb.sh # site = a | b | c (one pair) # bash docker/vacuum-site-localdb.sh --all # every site pair, one at a time # DRY_RUN=1 bash docker/vacuum-site-localdb.sh a # report the current pragma only # # Destructive? No data is deleted; but the pair is OFFLINE for the duration (seconds on the # rig, minutes on a 1 GB production file). Each file is integrity-checked before AND after. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE="docker compose -f ${SCRIPT_DIR}/docker-compose.yml" VOLUME_PREFIX="scadabridge-site" # volumes: scadabridge-site--node--data DB_FILE="site-localdb.db" # LocalDb:Path on the rig = /app/data/site-localdb.db SQLITE_IMAGE="alpine:3.20" # throwaway container; sqlite3 installed at run time DRY_RUN="${DRY_RUN:-0}" usage() { sed -n '2,30p' "${BASH_SOURCE[0]}"; exit 1; } [[ $# -eq 1 ]] || usage if [[ "$1" == "--all" ]]; then SITES=(a b c) else [[ "$1" =~ ^[abc]$ ]] || usage SITES=("$1") fi # Runs a sqlite3 script against the DB inside a node's data volume and prints stdout. sql_on_volume() { local volume="$1" script="$2" docker run --rm -v "${volume}:/data" "$SQLITE_IMAGE" sh -ec \ "apk add --no-cache --quiet sqlite >/dev/null 2>&1; cd /data; sqlite3 '${DB_FILE}' \"${script}\"" } vacuum_site() { local site="$1" local nodes=("site-${site}-a" "site-${site}-b") # compose SERVICE names (containers carry a scadabridge- prefix) local volumes=("${VOLUME_PREFIX}-${site}-node-a-data" "${VOLUME_PREFIX}-${site}-node-b-data") echo "=== site-${site}" for v in "${volumes[@]}"; do if ! docker volume inspect "$v" >/dev/null 2>&1; then echo " volume ${v} does not exist — skipping site-${site}"; return 0 fi done if [[ "$DRY_RUN" == "1" ]]; then for v in "${volumes[@]}"; do echo " ${v}: auto_vacuum=$(sql_on_volume "$v" 'PRAGMA auto_vacuum;') (0=NONE 1=FULL 2=INCREMENTAL)" done return 0 fi echo " stopping BOTH nodes together: ${nodes[*]}" $COMPOSE stop "${nodes[@]}" >/dev/null for v in "${volumes[@]}"; do echo " ${v}:" local before size_before size_after ic before="$(sql_on_volume "$v" 'PRAGMA auto_vacuum;')" size_before="$(docker run --rm -v "${v}:/data" "$SQLITE_IMAGE" stat -c %s "/data/${DB_FILE}")" ic="$(sql_on_volume "$v" 'PRAGMA integrity_check;')" if [[ "$ic" != "ok" ]]; then echo " integrity_check BEFORE vacuum is not ok — aborting without touching the file:"; echo "$ic" $COMPOSE start "${nodes[@]}" >/dev/null; exit 2 fi if [[ "$before" == "2" ]]; then echo " already auto_vacuum=INCREMENTAL (${size_before} bytes) — nothing to do" continue fi # The pragma is recorded in the header by the VACUUM that follows it (SQLite docs: # "auto-vacuuming can only be changed by a VACUUM after setting the pragma"). sql_on_volume "$v" 'PRAGMA auto_vacuum = INCREMENTAL; VACUUM;' >/dev/null ic="$(sql_on_volume "$v" 'PRAGMA integrity_check;')" size_after="$(docker run --rm -v "${v}:/data" "$SQLITE_IMAGE" stat -c %s "/data/${DB_FILE}")" echo " auto_vacuum ${before} -> $(sql_on_volume "$v" 'PRAGMA auto_vacuum;'); size ${size_before} -> ${size_after} bytes; integrity_check=${ic}" [[ "$ic" == "ok" ]] || { echo " integrity_check AFTER vacuum failed — pair left STOPPED for inspection"; exit 3; } done echo " starting BOTH nodes together" $COMPOSE start "${nodes[@]}" >/dev/null } for s in "${SITES[@]}"; do vacuum_site "$s"; done echo "done."