Skip to content

Commit d045f83

Browse files
jpheinclaude
andauthored
feat: native rename_wing backend operation + CLI command (#154) (#154)
The MCP rename_wing tool was unusably slow on postgres — it inherited the base update() which re-embeds every document via upsert(). A metadata-only wing rename should never re-embed. Changes: - Add rename_wing() default to BaseCollection (get/update loop) - Override in PostgresCollection with single SQL UPDATE (atomic, no re-embedding, milliseconds instead of minutes for 20K+ drawers) - Add update() override in PostgresCollection for metadata-only updates (skips re-embedding when no documents/embeddings change) - Simplify MCP tool_rename_wing() to delegate to col.rename_wing() - Add CLI `mempalace rename-wing --from X --to Y` subcommand with daemon routing and --dry-run support - Tests for backend, MCP tool, and CLI (7 new tests) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 21cf9cf commit d045f83

7 files changed

Lines changed: 361 additions & 41 deletions

File tree

mempalace/backends/base.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,42 @@ def update(
299299
embeddings=embeddings,
300300
)
301301

302+
def rename_wing(
303+
self, *, from_wing: str, to_wing: str, batch_size: int = 500
304+
) -> dict:
305+
"""Rename all drawers from one wing to another.
306+
307+
Default implementation iterates in batches using metadata-only
308+
``update()`` calls. Backends with native bulk-update support
309+
(e.g. PostgreSQL) should override with an atomic implementation.
310+
311+
Returns ``{"renamed": int, "errors": int}``.
312+
"""
313+
renamed = 0
314+
errors = 0
315+
while True:
316+
batch = self.get(
317+
where={"wing": from_wing},
318+
include=["metadatas"],
319+
limit=batch_size,
320+
offset=0,
321+
)
322+
if not batch.ids:
323+
break
324+
new_metas = []
325+
for meta in batch.metadatas:
326+
m = dict(meta)
327+
m["wing"] = to_wing
328+
new_metas.append(m)
329+
try:
330+
self.update(ids=batch.ids, metadatas=new_metas)
331+
renamed += len(batch.ids)
332+
except Exception:
333+
errors += len(batch.ids)
334+
if len(batch.ids) < batch_size:
335+
break
336+
return {"renamed": renamed, "errors": errors}
337+
302338

303339
# ---------------------------------------------------------------------------
304340
# Backend contract

mempalace/backends/postgres.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,64 @@ def delete(
549549
params,
550550
)
551551

552+
def update(
553+
self,
554+
*,
555+
ids: list[str],
556+
documents: Optional[list[str]] = None,
557+
metadatas: Optional[list[dict]] = None,
558+
embeddings: Optional[list[list[float]]] = None,
559+
) -> None:
560+
if documents is None and metadatas is None and embeddings is None:
561+
raise ValueError("update requires at least one of documents, metadatas, embeddings")
562+
if documents is not None or embeddings is not None:
563+
super().update(
564+
ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings,
565+
)
566+
return
567+
568+
n = len(ids)
569+
if metadatas is not None and len(metadatas) != n:
570+
raise ValueError(f"metadatas length {len(metadatas)} does not match ids length {n}")
571+
self._ensure_setup(create=True)
572+
573+
cur = self._get_conn().cursor()
574+
for i, doc_id in enumerate(ids):
575+
meta = dict(metadatas[i]) if metadatas else {}
576+
raw_wing = meta.pop("wing", None)
577+
raw_room = meta.pop("room", None)
578+
set_parts = []
579+
params: list[Any] = []
580+
if raw_wing is not None:
581+
set_parts.append(self._sql.SQL("wing = %s"))
582+
params.append(_metadata_value(raw_wing))
583+
if raw_room is not None:
584+
set_parts.append(self._sql.SQL("room = %s"))
585+
params.append(_metadata_value(raw_room))
586+
set_parts.append(self._sql.SQL("metadata = metadata || %s::jsonb"))
587+
params.append(json.dumps(meta))
588+
params.append(doc_id)
589+
cur.execute(
590+
self._sql.SQL("UPDATE {} SET {} WHERE id = %s").format(
591+
self._table_id,
592+
self._sql.SQL(", ").join(set_parts),
593+
),
594+
params,
595+
)
596+
597+
def rename_wing(
598+
self, *, from_wing: str, to_wing: str, batch_size: int = 500
599+
) -> dict:
600+
self._ensure_setup(create=True)
601+
cur = self._get_conn().cursor()
602+
cur.execute(
603+
self._sql.SQL("UPDATE {} SET wing = %s WHERE wing = %s").format(
604+
self._table_id,
605+
),
606+
[to_wing, from_wing],
607+
)
608+
return {"renamed": cur.rowcount, "errors": 0}
609+
552610
def count(self) -> int:
553611
self._ensure_setup(create=True)
554612
cur = self._get_conn().cursor()

mempalace/cli.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,91 @@ def cmd_purge(args):
14221422
print(f"\n Purged {match_count:,} drawers. Remaining: {remaining:,}\n")
14231423

14241424

1425+
def cmd_rename_wing(args):
1426+
want_json = getattr(args, "json", False)
1427+
from_wing = args.from_wing
1428+
to_wing = args.to_wing
1429+
dry_run = getattr(args, "dry_run", False)
1430+
batch_size = getattr(args, "batch_size", 500)
1431+
1432+
if _daemon_strict():
1433+
if dry_run:
1434+
try:
1435+
data = _call_daemon_tool("mempalace_list_drawers", {
1436+
"wing": from_wing, "limit": 1,
1437+
})
1438+
except DaemonError as e:
1439+
if want_json:
1440+
_emit_json({"error": str(e)})
1441+
else:
1442+
print(f"\n ERROR: {e}", file=sys.stderr)
1443+
sys.exit(2)
1444+
total = data.get("total", 0)
1445+
if want_json:
1446+
_emit_json({"dry_run": True, "from_wing": from_wing, "to_wing": to_wing, "count": total})
1447+
else:
1448+
print(f"\n Dry run: {total:,} drawers would be renamed from '{from_wing}' to '{to_wing}'\n")
1449+
return
1450+
1451+
try:
1452+
data = _call_daemon_tool("mempalace_rename_wing", {
1453+
"from_wing": from_wing,
1454+
"to_wing": to_wing,
1455+
"batch_size": batch_size,
1456+
})
1457+
except DaemonError as e:
1458+
if want_json:
1459+
_emit_json({"error": str(e)})
1460+
else:
1461+
print(f"\n ERROR: {e}", file=sys.stderr)
1462+
sys.exit(2)
1463+
1464+
if want_json:
1465+
_emit_json(data)
1466+
else:
1467+
renamed = data.get("renamed", 0)
1468+
errors = data.get("errors", 0)
1469+
print(f"\n Renamed {renamed:,} drawers: '{from_wing}' -> '{to_wing}'")
1470+
if errors:
1471+
print(f" Errors: {errors:,}")
1472+
print()
1473+
return
1474+
1475+
from .backends.chroma import ChromaBackend
1476+
from .backends.base import PalaceRef
1477+
1478+
palace_path = os.path.abspath(
1479+
os.path.expanduser(args.palace) if getattr(args, "palace", None) else MempalaceConfig().palace_path
1480+
)
1481+
backend = ChromaBackend()
1482+
try:
1483+
col = backend.get_collection(
1484+
palace=PalaceRef(id=palace_path, local_path=palace_path),
1485+
collection_name="mempalace_drawers",
1486+
)
1487+
except Exception as e:
1488+
print(f"\n Error reading palace: {e}")
1489+
sys.exit(1)
1490+
1491+
if dry_run:
1492+
matched = col.get(where={"wing": from_wing}, include=[])
1493+
count = len(matched.ids) if hasattr(matched, "ids") else len(matched.get("ids", []))
1494+
if want_json:
1495+
_emit_json({"dry_run": True, "from_wing": from_wing, "to_wing": to_wing, "count": count})
1496+
else:
1497+
print(f"\n Dry run: {count:,} drawers would be renamed from '{from_wing}' to '{to_wing}'\n")
1498+
return
1499+
1500+
result = col.rename_wing(from_wing=from_wing, to_wing=to_wing, batch_size=batch_size)
1501+
if want_json:
1502+
_emit_json({"success": True, "from_wing": from_wing, "to_wing": to_wing, **result})
1503+
else:
1504+
print(f"\n Renamed {result['renamed']:,} drawers: '{from_wing}' -> '{to_wing}'")
1505+
if result["errors"]:
1506+
print(f" Errors: {result['errors']:,}")
1507+
print()
1508+
1509+
14251510
def cmd_replay(args):
14261511
"""Drain ``~/.mempalace/pending/*.jsonl`` by re-issuing each request to the daemon.
14271512
@@ -2622,6 +2707,15 @@ def main():
26222707
)
26232708
p_purge.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
26242709

2710+
p_rename_wing = sub.add_parser(
2711+
"rename-wing",
2712+
help="Rename all drawers from one wing to another (atomic on postgres)",
2713+
)
2714+
p_rename_wing.add_argument("--from", dest="from_wing", required=True, help="Source wing name")
2715+
p_rename_wing.add_argument("--to", dest="to_wing", required=True, help="Target wing name")
2716+
p_rename_wing.add_argument("--dry-run", action="store_true", help="Count matching drawers without renaming")
2717+
p_rename_wing.add_argument("--batch-size", type=int, default=500, help="Batch size for non-postgres backends (default: 500)")
2718+
26252719
# ── rooms — manage the canonical room set (hybrid-search-taxonomy follow-up) ────
26262720
p_rooms = sub.add_parser(
26272721
"rooms",
@@ -2761,6 +2855,7 @@ def _nonneg_int(value: str) -> int:
27612855
"migrate": cmd_migrate,
27622856
"migrate-to-postgres": cmd_migrate_to_postgres,
27632857
"purge": cmd_purge,
2858+
"rename-wing": cmd_rename_wing,
27642859
"rooms": cmd_rooms,
27652860
"status": cmd_status,
27662861
"mined": cmd_mined,

mempalace/mcp_server.py

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2097,61 +2097,26 @@ def tool_rename_wing(from_wing: str, to_wing: str, batch_size: int = 500):
20972097
return _no_palace()
20982098

20992099
try:
2100-
total_result = col.get(where={"wing": from_wing}, include=[])
2101-
total = len(total_result["ids"])
2102-
if total == 0:
2103-
return {"success": True, "renamed": 0, "message": f"no drawers in wing '{from_wing}'"}
2104-
2105-
renamed = 0
2106-
errors = 0
2107-
2108-
while True:
2109-
batch = col.get(
2110-
where={"wing": from_wing},
2111-
include=["metadatas"],
2112-
limit=batch_size,
2113-
offset=0,
2114-
)
2115-
if not batch["ids"]:
2116-
break
2117-
2118-
update_ids = []
2119-
update_metas = []
2120-
for i, did in enumerate(batch["ids"]):
2121-
meta = dict(_safe_meta(batch["metadatas"][i]))
2122-
meta["wing"] = to_wing
2123-
update_ids.append(did)
2124-
update_metas.append(meta)
2125-
2126-
try:
2127-
col.update(ids=update_ids, metadatas=update_metas)
2128-
renamed += len(update_ids)
2129-
except Exception:
2130-
errors += len(update_ids)
2131-
2132-
if len(batch["ids"]) < batch_size:
2133-
break
2134-
2100+
result = col.rename_wing(
2101+
from_wing=from_wing, to_wing=to_wing, batch_size=batch_size,
2102+
)
21352103
_metadata_cache = None
21362104

21372105
_wal_log(
21382106
"rename_wing",
21392107
{
21402108
"from_wing": from_wing,
21412109
"to_wing": to_wing,
2142-
"renamed": renamed,
2143-
"errors": errors,
2110+
**result,
21442111
},
21452112
)
21462113

2147-
logger.info(f"Renamed wing: {from_wing} -> {to_wing} ({renamed} drawers)")
2114+
logger.info(f"Renamed wing: {from_wing} -> {to_wing} ({result['renamed']} drawers)")
21482115
return {
21492116
"success": True,
21502117
"from_wing": from_wing,
21512118
"to_wing": to_wing,
2152-
"renamed": renamed,
2153-
"errors": errors,
2154-
"total": total,
2119+
**result,
21552120
}
21562121
except Exception as e:
21572122
return {"success": False, "error": str(e)}

tests/test_backends.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,56 @@ def test_base_collection_update_default_rejects_mismatched_lengths():
782782
BaseCollection.update(collection, ids=["1", "2"], metadatas=[{"k": 9}])
783783

784784

785+
def test_base_collection_rename_wing_default(tmp_path):
786+
"""The ABC default rename_wing() moves drawers between wings via update()."""
787+
palace_path = tmp_path / "palace"
788+
backend = ChromaBackend()
789+
col = backend.get_collection(
790+
palace=PalaceRef(id=str(palace_path), local_path=str(palace_path)),
791+
collection_name="mempalace_drawers",
792+
create=True,
793+
)
794+
col.add(
795+
ids=["d1", "d2", "d3"],
796+
documents=["doc one", "doc two", "doc three"],
797+
metadatas=[
798+
{"wing": "old_wing", "room": "r1"},
799+
{"wing": "old_wing", "room": "r2"},
800+
{"wing": "other_wing", "room": "r1"},
801+
],
802+
)
803+
804+
result = col.rename_wing(from_wing="old_wing", to_wing="new_wing")
805+
806+
assert result["renamed"] == 2
807+
assert result["errors"] == 0
808+
809+
remaining_old = col.get(where={"wing": "old_wing"}, include=["metadatas"])
810+
assert len(remaining_old.ids) == 0
811+
812+
renamed = col.get(where={"wing": "new_wing"}, include=["metadatas"])
813+
assert len(renamed.ids) == 2
814+
assert set(renamed.ids) == {"d1", "d2"}
815+
816+
untouched = col.get(where={"wing": "other_wing"}, include=["metadatas"])
817+
assert len(untouched.ids) == 1
818+
assert untouched.ids[0] == "d3"
819+
820+
821+
def test_base_collection_rename_wing_empty_source(tmp_path):
822+
"""rename_wing() with no matching drawers returns zero renamed, no errors."""
823+
palace_path = tmp_path / "palace"
824+
backend = ChromaBackend()
825+
col = backend.get_collection(
826+
palace=PalaceRef(id=str(palace_path), local_path=str(palace_path)),
827+
collection_name="mempalace_drawers",
828+
create=True,
829+
)
830+
result = col.rename_wing(from_wing="nonexistent", to_wing="target")
831+
assert result["renamed"] == 0
832+
assert result["errors"] == 0
833+
834+
785835
def test_chroma_backend_accepts_palace_ref_kwarg(tmp_path):
786836
palace_path = tmp_path / "palace"
787837
backend = ChromaBackend()

0 commit comments

Comments
 (0)