#!/usr/bin/env bash # mempalace-backup — consistent, verified backups of a file-backed MemPalace palace. # # WHY THIS EXISTS # The fleet primary (RFC-001) had no backups at all. A palace is not one file: # it is two SQLite databases (one of them in WAL mode), a set of chromadb HNSW # segment directories, and a few JSON sidecars. `cp -r` on a live palace can # capture a torn WAL and an index that disagrees with the sqlite. # # TWO MODES # hot (default) Zero downtime. Both SQLite files are copied with the sqlite # ONLINE BACKUP API (a transactionally consistent snapshot, # WAL included). The HNSW segment dirs are rsynced and may be # slightly skewed relative to the sqlite — which is RECOVERABLE, # because the sqlite is the source of truth and the index can be # rebuilt: `mempalace repair --mode from-sqlite`. # cold (--cold) Stops the server, copies everything byte-for-byte, restarts. # Guaranteed self-consistent including the index. The restart is # trapped so an error still brings the server back. # # BONUS: restoring the hot backup via `repair --mode from-sqlite --archive-existing` # re-CREATES the collections, which is also the only path that applies the modern # chromadb HNSW defaults (batch_size=100 / sync_threshold=1000). Palaces created # under mempalace <=3.6 are stuck on 2/2 and sync the index every 2 records. # # NOTE: synlig has no sqlite3 CLI, so every SQLite operation here goes through # python3's stdlib sqlite3 module. Do not "simplify" this to `VACUUM INTO`. set -euo pipefail PALACE="${MEMPALACE_PALACE_PATH:-$HOME/.mempalace/palace}" DEST="${MEMPALACE_BACKUP_DIR:-$HOME/backups/mempalace}" UNIT="mempalace-serve" KEEP=7 COLD=0 DRY=0 VERIFY="" usage() { cat <<'USAGE' usage: mempalace-backup [--cold] [--dest DIR] [--keep N] [--palace DIR] [--dry-run] mempalace-backup --verify BACKUP_DIR --cold quiesce the server (stop/copy/start) for a byte-consistent copy --dest DIR backup root (default: $HOME/backups/mempalace) --keep N how many backups to retain (default: 7; 0 = keep all) --palace DIR palace to back up (default: $MEMPALACE_PALACE_PATH or ~/.mempalace/palace) --dry-run report what would happen, touch nothing --verify DIR integrity-check an existing backup and print its counts USAGE } while [ $# -gt 0 ]; do case "$1" in --cold) COLD=1 ;; --dest) DEST="$2"; shift ;; --keep) KEEP="$2"; shift ;; --palace) PALACE="$2"; shift ;; --dry-run) DRY=1 ;; --verify) VERIFY="$2"; shift ;; -h|--help) usage; exit 0 ;; *) echo "mempalace-backup: unknown option $1" >&2; usage >&2; exit 2 ;; esac shift done log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; } die() { log "ERROR: $*"; exit 1; } # ── shared python helpers ──────────────────────────────────────────────────── # check_db: PRAGMA quick_check + row counts, on a copy. Prints JSON. py_check() { python3 - "$1" <<'PY' import json, os, sqlite3, sys path = sys.argv[1] out = {"file": os.path.basename(path), "bytes": os.path.getsize(path)} try: c = sqlite3.connect(f"file:{path}?mode=ro", uri=True) out["quick_check"] = c.execute("PRAGMA quick_check").fetchone()[0] tables = [r[0] for r in c.execute( "SELECT name FROM sqlite_master WHERE type='table'")] out["tables"] = len(tables) counts = {} if "embedding_metadata" in tables: counts["drawers"] = c.execute( "SELECT count(*) FROM embedding_metadata " "WHERE key='chroma:document'").fetchone()[0] for t in ("facts", "triples", "entities"): if t in tables: counts[t] = c.execute(f"SELECT count(*) FROM {t}").fetchone()[0] out["counts"] = counts c.close() except Exception as exc: # noqa: BLE001 — report, never mask out["error"] = repr(exc) print(json.dumps(out)) PY } # ── --verify: inspect an existing backup, then exit ────────────────────────── if [ -n "$VERIFY" ]; then [ -d "$VERIFY" ] || die "no such backup dir: $VERIFY" log "verifying $VERIFY" rc=0 for db in "$VERIFY"/chroma.sqlite3 "$VERIFY"/knowledge_graph.sqlite3; do [ -f "$db" ] || { log " MISSING $(basename "$db")"; rc=1; continue; } res=$(py_check "$db") log " $res" printf '%s' "$res" | grep -q '"quick_check": "ok"' || rc=1 done if [ -f "$VERIFY/MANIFEST.json" ]; then python3 - "$VERIFY/MANIFEST.json" <<'PY' import json, sys m = json.load(open(sys.argv[1])) print(" manifest: created={created_utc} mode={mode} bytes={bytes_total} " "mempalace={mempalace_version}".format(**m)) print(" index :", m.get("index_consistency", "?")) PY fi [ "$rc" -eq 0 ] && log "VERIFY OK" || log "VERIFY FAILED" exit "$rc" fi # ── preflight ─────────────────────────────────────────────────────────────── [ -d "$PALACE" ] || die "palace not found: $PALACE" [ -f "$PALACE/chroma.sqlite3" ] || die "no chroma.sqlite3 in $PALACE — wrong dir?" command -v rsync >/dev/null || die "rsync not found" STAMP="$(date -u +%Y%m%dT%H%M%SZ)" MODE=$([ "$COLD" -eq 1 ] && echo cold || echo hot) OUT="$DEST/mp-$STAMP-$MODE" TMP="$OUT.incomplete" log "palace : $PALACE ($(du -sh "$PALACE" | cut -f1))" log "dest : $OUT" log "mode : $MODE keep: $KEEP" if [ "$DRY" -eq 1 ]; then log "dry-run: would create $OUT" log "dry-run: would copy chroma.sqlite3 + knowledge_graph.sqlite3 via sqlite backup API" log "dry-run: would rsync the palace tree ($(find "$PALACE" -mindepth 1 -maxdepth 1 | wc -l) top-level entries, hidden included) minus SQLite" [ "$COLD" -eq 1 ] && log "dry-run: would stop/start $UNIT" exit 0 fi mkdir -p "$TMP" # ── cold mode: quiesce, with a trap so the server always comes back ───────── STOPPED=0 restart_if_stopped() { if [ "$STOPPED" -eq 1 ]; then log "restarting $UNIT" systemctl --user start "$UNIT" || log "WARNING: failed to start $UNIT — CHECK MANUALLY" STOPPED=0 fi } trap restart_if_stopped EXIT INT TERM if [ "$COLD" -eq 1 ]; then log "stopping $UNIT (quiesce)" systemctl --user stop "$UNIT" STOPPED=1 # Give an in-flight mine a moment to release the writer lease. sleep 2 fi # ── the copy ──────────────────────────────────────────────────────────────── # Order matters: rsync the tree FIRST, then write the two SQLite files as # consistent snapshots, so the databases are the freshest thing in the backup. # # rsync takes everything EXCEPT the SQLite files (and their -wal/-shm): the HNSW # segment dirs, the JSON sidecars, and hidden dirs such as .mempalace/origin.json # — a `"$PALACE"/*/` glob silently skips those, which is exactly the bug this # structure removes. The payoff: a backup dir IS a palace image, so restore is a # copy rather than a procedure. log "rsyncing palace tree (excluding SQLite)" rsync -a \ --exclude 'chroma.sqlite3' --exclude 'chroma.sqlite3-*' \ --exclude 'knowledge_graph.sqlite3' --exclude 'knowledge_graph.sqlite3-*' \ "$PALACE"/ "$TMP"/ # SQLite: online backup API. Consistent even mid-write, and WAL-aware — which # matters because knowledge_graph.sqlite3 runs in WAL mode (-wal/-shm present). for db in chroma.sqlite3 knowledge_graph.sqlite3; do [ -f "$PALACE/$db" ] || { log "skip $db (absent)"; continue; } log "sqlite-backup $db" python3 - "$PALACE/$db" "$TMP/$db" <<'PY' import sqlite3, sys src_path, dst_path = sys.argv[1], sys.argv[2] try: src = sqlite3.connect(f"file:{src_path}?mode=ro", uri=True) except sqlite3.OperationalError: src = sqlite3.connect(src_path) # fall back if ro open is refused dst = sqlite3.connect(dst_path) with dst: src.backup(dst) # atomic, consistent snapshot dst.close(); src.close() PY done restart_if_stopped trap - EXIT INT TERM # ── verify the COPY, not the original ─────────────────────────────────────── log "verifying copies" CHROMA_JSON=$(py_check "$TMP/chroma.sqlite3") log " $CHROMA_JSON" printf '%s' "$CHROMA_JSON" | grep -q '"quick_check": "ok"' \ || die "chroma.sqlite3 copy failed quick_check — backup NOT committed" KG_JSON='{}' if [ -f "$TMP/knowledge_graph.sqlite3" ]; then KG_JSON=$(py_check "$TMP/knowledge_graph.sqlite3") log " $KG_JSON" printf '%s' "$KG_JSON" | grep -q '"quick_check": "ok"' \ || die "knowledge_graph.sqlite3 copy failed quick_check — backup NOT committed" fi # ── manifest ──────────────────────────────────────────────────────────────── MEMPALACE_VER=$("$HOME/.local/bin/mempalace" --version 2>/dev/null | head -1 || echo unknown) python3 - "$TMP/MANIFEST.json" "$STAMP" "$MODE" "$PALACE" "$MEMPALACE_VER" \ "$CHROMA_JSON" "$KG_JSON" <<'PY' import json, os, socket, subprocess, sys out, stamp, mode, palace, ver, chroma, kg = sys.argv[1:8] def size(p): t = 0 for root, _dirs, files in os.walk(p): for f in files: try: t += os.path.getsize(os.path.join(root, f)) except OSError: pass return t man = { "created_utc": stamp, "mode": mode, "host": socket.gethostname(), "palace_path": palace, "mempalace_version": ver.strip(), "bytes_total": size(os.path.dirname(out)), "chroma": json.loads(chroma), "knowledge_graph": json.loads(kg) if kg.strip() != "{}" else None, "index_consistency": ( "byte-consistent (server quiesced)" if mode == "cold" else "sqlite is authoritative; HNSW segments may lag a few writes — " "restore with `mempalace repair --mode from-sqlite` if search misbehaves" ), "restore": [ "# a backup dir IS a palace image — restore is a copy, not a procedure", "systemctl --user stop mempalace-serve", "mv ~/.mempalace/palace ~/.mempalace/palace.broken-$(date -u +%Y%m%dT%H%M%SZ)", "mkdir -p ~/.mempalace/palace", "rsync -a --exclude MANIFEST.json / ~/.mempalace/palace/", "systemctl --user start mempalace-serve", "# hot backups only: if search misbehaves or the index count looks stale,", "# rebuild the HNSW index from the authoritative sqlite -- this also", "# re-creates collections with modern HNSW defaults (100/1000):", "mempalace repair --mode from-sqlite --archive-existing --yes", ], } with open(out, "w") as fh: json.dump(man, fh, indent=2) print(json.dumps({k: man[k] for k in ("created_utc", "mode", "bytes_total")})) PY mv "$TMP" "$OUT" log "committed $OUT ($(du -sh "$OUT" | cut -f1))" # ── retention: only ever prune AFTER a committed, verified backup ─────────── if [ "$KEEP" -gt 0 ]; then mapfile -t all < <(find "$DEST" -maxdepth 1 -type d -name 'mp-*' | sort) n=${#all[@]} if [ "$n" -gt "$KEEP" ]; then for old in "${all[@]:0:$((n - KEEP))}"; do log "pruning $old" rm -rf "$old" done fi log "retained $(find "$DEST" -maxdepth 1 -type d -name 'mp-*' | wc -l)/$KEEP" fi # Leftover .incomplete dirs mean a previous run died — surface, don't hide. for stale in "$DEST"/mp-*.incomplete; do [ -e "$stale" ] && log "NOTE: stale incomplete backup present: $stale" done log "done"