Files
ScadaBridge/docker/vacuum-site-localdb.sh
T
Joseph Doherty 25c15dd164 ops(docker)+test(host)+docs: close register rows 104 + 105 — offline VACUUM recipe applied to the rig; build-time pin that no replicated table is a cascading-FK parent; record row 106 (LocalDb as_of_seq=0 full-snapshot loop)
Row 104: docker/vacuum-site-localdb.sh stops both nodes of a pair together, sets
auto_vacuum=INCREMENTAL + VACUUM on each node volume with integrity_check before/after,
then starts both (DRY_RUN=1 reports). Run on all three rig pairs: six files 0 -> 2, ok,
~11% smaller; site-a replication reconnected with backlog 0 and no watermark change —
VACUUM is replication-invisible (ledger keys by declared PK, oplog seq is INTEGER PRIMARY
KEY AUTOINCREMENT). Topology guide gains the recipe.

Row 105: SiteLocalDbReplicatedTableFkGuardTests builds the real site schema via OnReady
(unreplicated config so 0.3.3's own guard cannot mask the culprit) and asserts every
SiteLocalDbSetup.ReplicatedTables entry (now internal) exists and has no inbound
CASCADE/SET NULL/SET DEFAULT foreign key, with a negative control. The constraint is
enforced at build instead of at the first replicated boot.

Row 106 (found during 104's live verification, NOT caused by it — the identical 127k-row
full snapshot also landed at the earlier deploy restart): SnapshotStreamer.SendAsync stamps
as_of_seq = MAX(seq), which is 0 once a converged sender's oplog is ack-pruned to empty;
a zero-watermark receiver (rebuilt node) is never lifted, so the empty-oplog horizon rule
re-streams a full snapshot on every reconnect. Sender-side one-line fix for LocalDb 0.3.4:
as_of_seq = max(MAX(seq), last_acked_seq).
2026-08-20 09:37:48 -04:00

109 lines
5.1 KiB
Bash
Executable File

#!/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> # 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-<x>-node-<a|b>-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."