Adds bin/mempalace-backup and docs/backup-and-recovery.md — the mechanism a palace actually needs, none of it site-specific. Why a palace cannot be backed up with cp: it is chroma.sqlite3 (authoritative), knowledge_graph.sqlite3 (usually WAL, so -wal/-shm make a plain copy a same-instant gamble), derived HNSW segment dirs, hallways.json, the embedder descriptor, and a HIDDEN .mempalace/origin.json. Both SQLite files are therefore copied through the online-backup API. Two bugs are documented because both produce a backup that looks fine: "$PALACE"/*/ silently skips the hidden dir, and per-directory rsync collides the identically named data_level0.bin in every HNSW segment. Treating the palace as one tree fixes both and makes a backup a faithful palace IMAGE, so restore is a copy rather than a procedure. Two modes: hot (default, zero downtime, ~4 s, index may lag but SQLite is authoritative and repair --mode from-sqlite rebuilds) and cold (--cold, ~5 s downtime, byte-consistent, restart trapped so a failed run still brings the server back). Verification runs on the COPY — quick_check plus row counts — and the backup is committed by mv only after it passes, with retention pruned only after a verified commit, so a broken new backup cannot delete the last good one. Documented because they are easy to get wrong: the sqlite3 CLI is often absent where the Python module is present; mempalace_embedder.json must be restored with the drawers or search silently degrades; a tested restore means running status AND search against the restored copy, since search is what actually exercises the index; mempalace-serve is a USER unit, so root systemctl reports "not found"; Persistent=true is what makes a missed window run after boot; and installing against the system Python couples the palace's availability to distribution upgrades, with the uv-managed-interpreter fix plus the two PATH traps that bite scripted upgrades. Also moves docs/synlig-primary-runbook.md out to a private fleet repository, leaving a stub that explains the split, since a host inventory is operator data for one deployment rather than part of a public toolkit. The path stays valid so existing links do not break. Remaining host references in the README, RFCs and ARCHITECTURE are left alone deliberately: they are load-bearing prose, contain no secrets, and are best generalised as they are next edited rather than in one churn-heavy pass.
12 KiB
Backing up and recovering a palace
A palace is not one file, so cp -r is not a backup. This document explains what
a palace is made of, why the obvious approaches silently corrupt it, and how
bin/mempalace-backup avoids that — plus how to actually restore one and prove
the restore worked.
Host-specific details (which machine is primary, addresses, users, offsite
targets) deliberately live outside this repo. Substitute <PRIMARY_HOST> and
<USER> throughout.
1. What is actually in a palace
Run ls -a on a palace directory and you get something like this — sizes are
from a real ~25k-drawer palace, for a sense of proportion:
| Path | Size | What it is |
|---|---|---|
chroma.sqlite3 |
167 MB | The authoritative store. Drawers, chunks, metadata, embeddings. |
<uuid>/ (one per segment) |
50 MB, 2.8 MB | chromadb HNSW index segments (data_level0.bin, index_metadata.pickle). Derived data. |
knowledge_graph.sqlite3 |
64 KB | KG triples + entities. Usually in WAL mode — see §2. |
knowledge_graph.sqlite3-wal / -shm |
varies | Live write-ahead log. Their existence is the whole problem. |
hallways.json |
10.8 MB | Within-wing co-occurrence links, rebuilt by mempalace hallways. |
known_entities.json, mempalace_embedder.json |
< 1 KB | Entity list; which embedder model this palace was built with. |
.mempalace/origin.json |
small | Provenance. Hidden — see the glob trap in §3. |
.collection_type_fixed, .blob_seq_ids_migrated |
0 B | Migration marker files. Presence is the signal. |
Two consequences worth internalising:
- The SQLite files are the truth; the index segments are derived. If the index is stale or damaged you can rebuild it from SQLite. The reverse is not true. This asymmetry is what makes a zero-downtime backup possible at all.
mempalace_embedder.jsonis not optional. Restoring drawers next to a different embedder model gives you a palace whose stored vectors and future queries live in different spaces — search silently degrades rather than failing. Back it up, and check it after a restore.
2. Why cp is unsafe, specifically
SQLite in WAL mode keeps recent commits in -wal and shared state in -shm.
Copying those three files with cp gives you no guarantee they are from the same
instant: the copier can read chroma.sqlite3 before a checkpoint and -wal
after it. The result usually opens — which is the dangerous part — and then
misbehaves later, or fails PRAGMA integrity_check under load.
rsync has exactly the same problem: it is not a snapshot, it is a sequence of
reads.
The supported approach is SQLite's online backup API, which takes a transactionally consistent copy of a live database while writers continue:
import sqlite3
src = sqlite3.connect(f"file:{src_path}?mode=ro", uri=True)
dst = sqlite3.connect(dst_path)
with dst:
src.backup(dst) # WAL-aware, consistent, no writer downtime
Two practical notes:
VACUUM INTOis a fine alternative in principle, but it needs either thesqlite3CLI or a Python build new enough for it. Do not assume the CLI is present: a stock Ubuntu server often has the Pythonsqlite3module and nosqlite3binary. Check withcommand -v sqlite3before writing tooling that depends on it.- Open the source with
mode=roso a backup can never be the thing that creates a-walfile in a directory you meant only to read.
3. Two backup modes, and the honest tradeoff
bin/mempalace-backup implements both. Neither is strictly better.
hot (default) — zero downtime
rsyncthe whole palace tree excluding the SQLite files.- Write both SQLite files via the online-backup API.
The server never stops. The index segments are copied while they may be being written, so in principle they can lag the SQLite by a few records. That is acceptable because the SQLite is authoritative: if search misbehaves after a restore, rebuild the index (§5).
Measured on a 223 MB palace: ~4 s.
cold (--cold) — byte-consistent
- Stop the server (
systemctl --user stop mempalace-serve). - Copy everything, including the index, at rest.
- Restart the server.
The restart is in a trap … EXIT INT TERM, so an interruption mid-copy still
brings the server back. Measured downtime: ~5 s.
Recommended schedule: hot daily, cold weekly. The cold copy is your byte-exact reference; the hot copies bound your data loss between them.
The glob trap that cost two rewrites
Two bugs are worth naming because both produce a backup that looks fine:
"$PALACE"/*/skips hidden directories..mempalace/origin.jsonis silently omitted, so the restored palace loses its provenance. Shell globs do not match dotfiles withoutdotglob/GLOBIGNOREfiddling.- Per-directory rsync collides identically named files. Every HNSW segment
contains a
data_level0.bin. Copying segment dirs into one destination merges them and the last write wins — a corrupt index that still loads.
The fix for both is to treat the palace as one tree: a single
rsync -a --exclude of the SQLite files, so a backup directory is a faithful
image of the palace. That makes restore a copy rather than a procedure.
4. Verify the copy, not the original
A backup you have not read back is a hypothesis. After copying, and before committing the backup or pruning old ones, the tool runs against the copy:
PRAGMA quick_checkon both databases- row counts (
drawers,triples,entities) - a
MANIFEST.jsonrecording counts, sizes, mode, tool version, and the index-consistency caveat for hot backups
Sequencing matters as much as the checks:
- write into
<dest>.incomplete/ - verify
mvinto place (atomic commit)- only now prune old backups to the retention limit
Retention pruning that runs before verification can delete your last good
backup on the night the new one is broken. A leftover .incomplete directory is
a visible symptom, not silent data loss, so the tool reports stale ones.
5. Restoring
Because a backup is a palace image, restore is a copy:
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 <backup-dir>/ ~/.mempalace/palace/
systemctl --user start mempalace-serve
Keep the old directory until the restore is proven. It costs disk and buys you a second attempt.
If the restore came from a hot backup and search behaves oddly, rebuild the derived index from the authoritative SQLite:
mempalace repair --mode from-sqlite
Note that a full repair --mode from-sqlite --archive-existing re-creates the
chromadb collections, which means new collections pick up current HNSW
tuning defaults. On palaces created by older versions this is also the only way
to escape legacy hnsw:batch_size=2 / hnsw:sync_threshold=2 settings, which sync
the index every two records. Check yours with:
SELECT c.name, k.key, k.str_value FROM collections c
JOIN collection_metadata k ON k.collection_id = c.id
WHERE k.key LIKE 'hnsw%';
6. Prove the restore, don't assume it
"The files copied" is not a tested restore. Restore to a throwaway directory and make the palace answer questions:
rsync -a --exclude MANIFEST.json <backup-dir>/ /tmp/restore-test/
export MEMPALACE_PALACE_PATH=/tmp/restore-test
mempalace status # drawer count — compare to the source
mempalace search "some phrase you know is in there"
status proves the schema and metadata survived. search is the stronger
test: it exercises the HNSW segments, so it is what tells you whether a
hot backup's index came across usable. On the reference run above it returned
correctly ranked results (cosine_sim=0.398 bm25=2.992) from a zero-downtime
backup — the theoretical index skew did not materialise.
Do this at least once when you set backups up, and again after any version upgrade that touches storage.
7. Scheduling that survives a reboot
Use systemd user timers with lingering enabled, so backups run without anyone logging in:
loginctl enable-linger <USER> # required, or user units die at logout
systemctl --user enable --now mempalace-backup.timer mempalace-backup-cold.timer
systemctl --user list-timers 'mempalace*'
Units are in contrib/systemd/. Two settings earn their
keep:
Persistent=true— a timer whose window was missed (host powered off) runs shortly after boot instead of skipping the day.RandomizedDelaySec— avoids a thundering herd if other maintenance shares the hour.
mempalace-serve is itself a user unit in the reference deployment. That is
worth stating explicitly because systemctl status mempalace-serve as root
reports "not found" and invites the conclusion that the server is not installed.
Always systemctl --user.
The interpreter is part of your durability story
If the tool is installed against the system Python (/usr/bin/python3), then
a distribution release upgrade — which can remove or replace that interpreter —
breaks the server and its backups together, at the moment you least want it.
Install against a managed interpreter instead:
uv python install 3.12
uv tool install --force --python "$HOME/.local/bin/python3.12" 'mempalace==<version>'
systemctl --user restart mempalace-serve
Now an OS upgrade cannot take the interpreter away. Two gotchas:
uvis typically in~/.local/bin, which is not on a non-interactive ssh PATH. Scripted upgrades mustexport PATH="$HOME/.local/bin:$PATH".uv python listprints$HOME-relative paths, so a regex anchored on a leading/matches inside.local/shareand yields a bogus absolute path. Resolve the shim and assert on it:readlink -f ~/.local/bin/python3.12 | grep share/uv/python.
Restart the server after any upgrade. Recent versions refuse mutating tools
when the served library drifts from what is installed on disk, and
mempalace_reconnect cannot clear that — it reopens the database but cannot
reload Python modules.
8. Sizing and offsite
At ~225 MB per copy, 7 hot + 7 cold is ~3 GB — trivial against any modern disk,
so prefer generous retention over clever incremental schemes. If space is
genuinely tight, zstd -T0 on the SQLite files compresses well, at the cost of
making a backup no longer a directly-mountable palace image.
Local backups protect against corruption and mistakes, not against losing the
host. Until a copy leaves the machine, a single disk or a single rm ends the
palace. Whatever offsite target you choose (object store, NAS, another host),
two rules matter more than the choice:
- Put a copy of the restore procedure with the backups. Documentation that lives only inside the thing you are restoring is not available at the moment you need it.
- Verify the offsite copy by restoring from it, not by checking that the upload exited 0.
9. Usage
mempalace-backup [--cold] [--dest DIR] [--keep N] [--palace DIR] [--dry-run]
mempalace-backup --verify <backup-dir>
| Flag | Meaning |
|---|---|
--cold |
Quiesce the server for a byte-consistent copy (default: hot) |
--dest DIR |
Backup root (default ~/backups/mempalace) |
--keep N |
Retention count, pruned only after a verified commit (default 7) |
--palace DIR |
Palace to back up (default ~/.mempalace/palace) |
--verify DIR |
Re-verify an existing backup and print its manifest |
--dry-run |
Report what would happen, write nothing |
Backups are named mp-<UTC timestamp>-<hot|cold>, so they sort
chronologically and their mode is visible without opening the manifest.