Skip to content

Commit 140c380

Browse files
authored
Merge pull request #1697 from margaretjgu/fix/backup-retention
fix(backups): add max_backups retention to bound backup disk usage
2 parents 7d7dc78 + 9be2b97 commit 140c380

10 files changed

Lines changed: 467 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
---
88

9+
## [Unreleased]
10+
11+
### Bug Fixes
12+
13+
- **Backup retention to prevent unbounded disk usage.** `mempalace migrate` (full-palace `<palace>.pre-migrate.<timestamp>` copies) and `mempalace repair max-seq-id` (`chroma.sqlite3.max-seq-id-backup-<timestamp>` copies) each wrote a fresh, full-size, timestamped backup every run and never deleted the old ones. On a machine that mines or repairs on a schedule, those copies could silently accumulate until they filled the disk — one palace was found with hundreds of GB of stale backups beside a few hundred MB of live data, hidden from a normal `du` of the home directory. A new `max_backups` setting (default `10`, env `MEMPALACE_MAX_BACKUPS`, or `config.json`) now prunes the oldest backups after each new one is written. Set it to `0` to keep every backup. Pruning is keyed by filesystem mtime, scoped strictly to each backup's own naming pattern (live data is never touched), and best-effort so a deletion failure can never abort a migration or repair that already succeeded.
14+
15+
---
16+
917
## [3.3.6] — 2026-05-24
1018

1119
### Features

mempalace/backups.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Retention pruning for timestamped palace backups.
2+
3+
``mempalace migrate`` and ``mempalace repair max-seq-id`` each write a fresh,
4+
timestamped backup every time they run and historically never deleted the old
5+
ones. On a machine that mines or repairs on a schedule those full-size copies
6+
accumulate silently — a real palace was found with hundreds of gigabytes of
7+
backups sitting beside only a few hundred megabytes of live data, nearly
8+
filling the disk. This module prunes the backup set down to a bounded count
9+
after each new backup is written.
10+
11+
The retention count comes from ``MempalaceConfig.max_backups`` (default 10).
12+
"""
13+
14+
import glob
15+
import os
16+
import shutil
17+
18+
19+
def prune_backups(pattern, max_backups, *, log=None):
20+
"""Delete the oldest backups matching ``pattern`` so at most ``max_backups`` remain.
21+
22+
Args:
23+
pattern: A glob pattern matching the backup paths (files or
24+
directories). The caller is responsible for ``glob.escape``-ing
25+
any literal, non-wildcard portion that can contain glob
26+
metacharacters — palace paths sometimes do (e.g. a ``[``).
27+
max_backups: Number of most-recent backups to keep. ``None`` or any
28+
value ``<= 0`` disables pruning and returns immediately, so a
29+
backup set is never touched when the user has opted out.
30+
log: Optional callable (e.g. ``print``) for human-readable progress.
31+
32+
Returns:
33+
The list of paths that were successfully removed.
34+
35+
Recency is determined by filesystem mtime rather than by parsing the
36+
timestamp out of the name, so it stays correct even when two backup
37+
producers use different timestamp formats. Deletion failures are logged
38+
and skipped: pruning is best-effort cleanup and must never abort the
39+
migrate/repair operation that just completed successfully.
40+
"""
41+
if max_backups is None or max_backups <= 0:
42+
return []
43+
44+
scored = []
45+
for path in glob.glob(pattern):
46+
try:
47+
scored.append((os.path.getmtime(path), path))
48+
except OSError:
49+
# Vanished between glob and stat (concurrent prune / cleanup);
50+
# nothing for us to remove.
51+
continue
52+
53+
if len(scored) <= max_backups:
54+
return []
55+
56+
# Newest first; the path breaks mtime ties so ordering is deterministic.
57+
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
58+
59+
removed = []
60+
for _mtime, path in scored[max_backups:]:
61+
try:
62+
if os.path.isdir(path) and not os.path.islink(path):
63+
shutil.rmtree(path)
64+
else:
65+
os.remove(path)
66+
except OSError as exc:
67+
if log:
68+
log(f" Backup prune: could not remove {path}: {exc}")
69+
continue
70+
removed.append(path)
71+
if log:
72+
log(f" Backup prune: removed old backup {path}")
73+
74+
return removed

mempalace/config.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str:
198198
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
199199
DEFAULT_BACKEND = "chroma"
200200

201+
# How many timestamped palace backups to retain before the oldest are
202+
# pruned. Applies to the accumulating backups written by ``mempalace
203+
# migrate`` and ``mempalace repair max-seq-id`` — see
204+
# ``MempalaceConfig.max_backups``.
205+
DEFAULT_MAX_BACKUPS = 10
206+
201207

202208
@lru_cache(maxsize=1)
203209
def get_configured_collection_name() -> str:
@@ -691,6 +697,36 @@ def topic_tunnel_min_count(self):
691697
parsed = 1
692698
return max(1, parsed)
693699

700+
@property
701+
def max_backups(self) -> int:
702+
"""Number of timestamped palace backups to retain before pruning.
703+
704+
Applies to the accumulating, timestamped backups created by
705+
``mempalace migrate`` (``<palace>.pre-migrate.<timestamp>``) and
706+
``mempalace repair max-seq-id``
707+
(``chroma.sqlite3.max-seq-id-backup-<timestamp>``). Each of those
708+
commands writes a fresh full-size copy every run and historically
709+
never deleted the old ones, so on a machine that mines or repairs on
710+
a schedule the backup set could silently grow until it filled the
711+
disk. After each backup is written, copies beyond this count (oldest
712+
first) are removed.
713+
714+
Reads ``MEMPALACE_MAX_BACKUPS`` env first, then ``max_backups`` in
715+
``config.json``, then the default of ``10``. A value of ``0`` disables
716+
pruning and keeps every backup (use when an external retention policy
717+
manages cleanup). Negative or non-numeric values fall back to the
718+
default rather than crashing migrate/repair.
719+
"""
720+
env_val = os.environ.get("MEMPALACE_MAX_BACKUPS")
721+
if env_val is not None:
722+
coerced = self._try_coerce_int(env_val, minimum=0)
723+
if coerced is not None:
724+
return coerced
725+
coerced = self._try_coerce_int(
726+
self._file_config.get("max_backups", DEFAULT_MAX_BACKUPS), minimum=0
727+
)
728+
return DEFAULT_MAX_BACKUPS if coerced is None else coerced
729+
694730
@property
695731
def hook_silent_save(self):
696732
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""

mempalace/migrate.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"""
2020

2121
import errno
22+
import glob
2223
import os
2324
import shutil
2425
import sqlite3
@@ -28,6 +29,9 @@
2829
from contextlib import closing
2930
from datetime import datetime
3031

32+
from .backups import prune_backups
33+
from .config import MempalaceConfig
34+
3135

3236
def _restore_stale_palace(palace_path: str, stale_path: str) -> None:
3337
"""Roll back a failed swap.
@@ -293,6 +297,16 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
293297
print(f"\n Backing up to {backup_path}...")
294298
shutil.copytree(palace_path, backup_path)
295299

300+
# Enforce backup retention so repeated migrations cannot fill the disk
301+
# with full-palace copies. The backup we just created is the newest, so
302+
# it survives; only older ``.pre-migrate.*`` siblings beyond the limit
303+
# are removed. Best-effort — never let cleanup fail the migration.
304+
prune_backups(
305+
glob.escape(palace_path) + ".pre-migrate.*",
306+
MempalaceConfig().max_backups,
307+
log=print,
308+
)
309+
296310
# Build fresh palace in a temp directory (avoids chromadb reading old state).
297311
# Wrap the whole import-and-swap dance in try/finally so the temp dir is
298312
# cleaned up if any of the chromadb writes, the verify count, or the

mempalace/repair.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,12 +1529,26 @@ def repair_max_seq_id(
15291529
return result
15301530

15311531
if backup:
1532+
import glob
1533+
1534+
from .backups import prune_backups
1535+
from .config import MempalaceConfig
1536+
15321537
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
15331538
backup_path = os.path.join(palace_path, f"chroma.sqlite3.max-seq-id-backup-{timestamp}")
15341539
shutil.copy2(db_path, backup_path)
15351540
result["backup"] = backup_path
15361541
print(f" Backup: {backup_path}")
15371542

1543+
# Retain only the most recent N backups (the copy just written is the
1544+
# newest and is kept). Without this, every max-seq-id repair leaves a
1545+
# full chroma.sqlite3 copy behind that is never cleaned up.
1546+
prune_backups(
1547+
os.path.join(glob.escape(palace_path), "chroma.sqlite3.max-seq-id-backup-*"),
1548+
MempalaceConfig().max_backups,
1549+
log=print,
1550+
)
1551+
15381552
_close_chroma_handles(palace_path)
15391553

15401554
with sqlite3.connect(db_path) as conn:

tests/test_backups.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for backup retention pruning (mempalace.backups.prune_backups).
2+
3+
These guard the fix for unbounded backup growth: ``mempalace migrate`` and
4+
``mempalace repair max-seq-id`` each drop a fresh full-size, timestamped copy
5+
every run, and used to never delete the old ones — a palace was found with
6+
hundreds of GB of stale backups beside a few hundred MB of live data.
7+
"""
8+
9+
import os
10+
11+
import pytest
12+
13+
from mempalace.backups import prune_backups
14+
15+
16+
def _make_backup_dir(parent, name, mtime):
17+
"""Create a directory backup with a fixed mtime."""
18+
path = parent / name
19+
path.mkdir()
20+
(path / "chroma.sqlite3").write_text("db")
21+
os.utime(path, (mtime, mtime))
22+
return path
23+
24+
25+
def _make_backup_file(parent, name, mtime):
26+
"""Create a file backup with a fixed mtime."""
27+
path = parent / name
28+
path.write_text("db")
29+
os.utime(path, (mtime, mtime))
30+
return path
31+
32+
33+
def test_prune_keeps_newest_and_removes_oldest(tmp_path):
34+
# 5 backups, mtimes 100..500; keep 2 newest (400, 500).
35+
paths = [_make_backup_file(tmp_path, f"b.{i}", mtime=i * 100) for i in range(1, 6)]
36+
37+
removed = prune_backups(str(tmp_path / "b.*"), max_backups=2)
38+
39+
surviving = {p.name for p in tmp_path.iterdir()}
40+
assert surviving == {"b.4", "b.5"}
41+
assert set(removed) == {str(paths[0]), str(paths[1]), str(paths[2])}
42+
43+
44+
def test_prune_removes_directory_backups(tmp_path):
45+
"""migrate writes directory backups (full copytree) — must rmtree them."""
46+
_make_backup_dir(tmp_path, "palace.pre-migrate.1", mtime=100)
47+
_make_backup_dir(tmp_path, "palace.pre-migrate.2", mtime=200)
48+
keep = _make_backup_dir(tmp_path, "palace.pre-migrate.3", mtime=300)
49+
50+
removed = prune_backups(str(tmp_path / "palace.pre-migrate.*"), max_backups=1)
51+
52+
assert keep.is_dir()
53+
assert len(removed) == 2
54+
assert not (tmp_path / "palace.pre-migrate.1").exists()
55+
assert not (tmp_path / "palace.pre-migrate.2").exists()
56+
57+
58+
def test_prune_noop_when_under_limit(tmp_path):
59+
_make_backup_file(tmp_path, "b.1", mtime=100)
60+
_make_backup_file(tmp_path, "b.2", mtime=200)
61+
62+
removed = prune_backups(str(tmp_path / "b.*"), max_backups=10)
63+
64+
assert removed == []
65+
assert len(list(tmp_path.iterdir())) == 2
66+
67+
68+
def test_prune_noop_when_exactly_at_limit(tmp_path):
69+
_make_backup_file(tmp_path, "b.1", mtime=100)
70+
_make_backup_file(tmp_path, "b.2", mtime=200)
71+
72+
removed = prune_backups(str(tmp_path / "b.*"), max_backups=2)
73+
74+
assert removed == []
75+
76+
77+
@pytest.mark.parametrize("disabled", [0, -1, None])
78+
def test_prune_disabled_keeps_everything(tmp_path, disabled):
79+
for i in range(1, 6):
80+
_make_backup_file(tmp_path, f"b.{i}", mtime=i * 100)
81+
82+
removed = prune_backups(str(tmp_path / "b.*"), max_backups=disabled)
83+
84+
assert removed == []
85+
assert len(list(tmp_path.iterdir())) == 5
86+
87+
88+
def test_prune_no_matches(tmp_path):
89+
assert prune_backups(str(tmp_path / "nope.*"), max_backups=3) == []
90+
91+
92+
def test_prune_only_touches_matching_pattern(tmp_path):
93+
"""Live data and unrelated files must never be swept up by a backup glob."""
94+
_make_backup_file(tmp_path, "chroma.sqlite3.max-seq-id-backup-1", mtime=100)
95+
_make_backup_file(tmp_path, "chroma.sqlite3.max-seq-id-backup-2", mtime=200)
96+
_make_backup_file(tmp_path, "chroma.sqlite3.max-seq-id-backup-3", mtime=300)
97+
# The live database and an unrelated file — must survive.
98+
live = _make_backup_file(tmp_path, "chroma.sqlite3", mtime=400)
99+
other = _make_backup_file(tmp_path, "tunnels.json", mtime=400)
100+
101+
prune_backups(
102+
str(tmp_path / "chroma.sqlite3.max-seq-id-backup-*"),
103+
max_backups=1,
104+
)
105+
106+
assert live.exists()
107+
assert other.exists()
108+
assert (tmp_path / "chroma.sqlite3.max-seq-id-backup-3").exists()
109+
assert not (tmp_path / "chroma.sqlite3.max-seq-id-backup-1").exists()
110+
assert not (tmp_path / "chroma.sqlite3.max-seq-id-backup-2").exists()
111+
112+
113+
def test_prune_respects_glob_escape_for_metacharacter_paths(tmp_path):
114+
"""Palace paths can contain glob metacharacters like ``[``.
115+
116+
Without ``glob.escape`` the pattern would silently match nothing (the
117+
bracket is read as a character class), leaving backups unpruned. Callers
118+
escape the literal prefix; this confirms the helper prunes correctly once
119+
they do.
120+
"""
121+
import glob
122+
123+
weird = tmp_path / "weird[name]"
124+
weird.mkdir()
125+
for i in range(1, 4):
126+
_make_backup_file(weird, f"chroma.sqlite3.max-seq-id-backup-{i}", mtime=i * 100)
127+
128+
pattern = os.path.join(glob.escape(str(weird)), "chroma.sqlite3.max-seq-id-backup-*")
129+
removed = prune_backups(pattern, max_backups=1)
130+
131+
assert len(removed) == 2
132+
assert (weird / "chroma.sqlite3.max-seq-id-backup-3").exists()
133+
134+
135+
def test_prune_is_best_effort_on_delete_failure(tmp_path, monkeypatch):
136+
"""A failed deletion is logged and skipped, never raised — pruning must
137+
not undo a migrate/repair that already succeeded."""
138+
for i in range(1, 5):
139+
_make_backup_file(tmp_path, f"b.{i}", mtime=i * 100)
140+
141+
real_remove = os.remove
142+
143+
def flaky_remove(path):
144+
if path.endswith("b.1"):
145+
raise OSError("permission denied")
146+
return real_remove(path)
147+
148+
monkeypatch.setattr(os, "remove", flaky_remove)
149+
150+
logs = []
151+
removed = prune_backups(str(tmp_path / "b.*"), max_backups=2, log=logs.append)
152+
153+
# b.1 and b.2 were over the limit; b.1 failed, b.2 succeeded.
154+
assert str(tmp_path / "b.2") in removed
155+
assert str(tmp_path / "b.1") not in removed
156+
assert (tmp_path / "b.1").exists()
157+
assert any("could not remove" in line for line in logs)

0 commit comments

Comments
 (0)