feat(replica): RFC 004 step 1 — memory read replicas (snapshot pull + local fold)

Recall becomes local on every machine. The hub serves the palace's FACTS
over GET /snapshot/manifest|drawers|ids|kg (bearer, paged): verbatim drawer
documents + metadata and raw KG rows — never the vector index, which each
replica derives with its own embedder (RFC 004 layer 3: sync the facts,
derive the senses).

replica_sync.py folds pulls idempotently: drawer upserts by id with ONE
additive provenance key (replica_origin = origin replica id), which scopes
delete reconciliation strictly to copies from that origin — locally-authored
drawers are untouchable. KG rows fold INSERT OR REPLACE by id, so
invalidations converge on re-pull; replication never deletes KG rows.
KnowledgeGraph gains dump_rows/apply_row for rowid-paged replication.

CLI: mempalace replica pull [--peer URL --token T] [--no-reconcile]
[--json]; defaults to peers.json. Step-1 boundary is explicit: writes still
belong to the origin (multi-writer memory is step 3); freshness is re-pull
until the step-2 op-log gives precise tails.

10 new tests over the production HTTP server with the sqlite_exact backend:
endpoint pagination, full fold + local search of pulled facts, idempotent
re-pull, reconcile that deletes origin copies but spares local drawers, KG
invalidation convergence, CLI. Full suite 3443 green.

(cherry picked from commit ebd772b00f73c35e571df2c2caebc6177853bb4a)
This commit is contained in:
Igor Lins e Silva 2026-07-02 06:26:04 -03:00
parent f7c174e5d4
commit 0b226029bc
5 changed files with 633 additions and 0 deletions

View File

@ -1445,6 +1445,53 @@ def cmd_artifact(args):
ls.close()
def cmd_replica(args):
"""RFC 004 step 1: read-replica operations for memory (drawers + KG)."""
import json
as_json = getattr(args, "json", False)
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
if args.replica_action == "pull":
from .replica_sync import pull_from_peers, pull_memory
try:
if args.peer:
results = [
pull_memory(
palace_path,
args.peer,
args.token or "",
reconcile_deletes=not args.no_reconcile,
)
]
else:
results = pull_from_peers(palace_path)
if not results:
_logstream_fail(
f"no peers configured ({palace_path}/peers.json) and no --peer given",
as_json,
)
except Exception as exc:
_logstream_fail(str(exc), as_json)
if as_json:
print(json.dumps(results, indent=2, ensure_ascii=False))
else:
for stats in results:
if stats.get("error"):
print(
f" {stats.get('peer_name', stats['origin_url'])}: ERROR {stats['error']}"
)
else:
print(
f" {stats.get('peer_name', stats['origin_url'])} "
f"({stats['origin_replica']}): {stats['drawers_upserted']} drawers folded, "
f"{stats['drawers_deleted']} reconciled away, "
f"KG +{stats['kg_entities']} entities / +{stats['kg_triples']} triples"
)
if any(s.get("error") for s in results):
sys.exit(1)
def cmd_palace_set_embedder(args):
"""Record (or force-override) a palace's embedder identity (RFC 001).
@ -2751,6 +2798,25 @@ def main():
"--json", action="store_true", help="Metadata as JSON (content omitted with --out)"
)
# replica (RFC 004 read replicas)
p_replica = sub.add_parser(
"replica", help="Memory read-replica operations — pull facts from an origin palace"
)
replica_sub = p_replica.add_subparsers(dest="replica_action")
p_rep_pull = replica_sub.add_parser(
"pull", help="Pull drawers + KG from the origin; derive the vector index locally"
)
p_rep_pull.add_argument(
"--peer", default=None, help="Origin base URL (default: all peers in peers.json)"
)
p_rep_pull.add_argument("--token", default=None, help="Bearer token for --peer")
p_rep_pull.add_argument(
"--no-reconcile",
action="store_true",
help="Skip deleting local copies whose upstream original is gone",
)
p_rep_pull.add_argument("--json", action="store_true", help="Machine-readable output")
p_palace = sub.add_parser("palace", help="Palace maintenance commands")
palace_sub = p_palace.add_subparsers(dest="palace_action")
p_set_embedder = palace_sub.add_parser(
@ -2821,6 +2887,13 @@ def main():
cmd_artifact(args)
return
if args.command == "replica":
if not getattr(args, "replica_action", None):
p_replica.print_help()
return
cmd_replica(args)
return
if args.command == "daemon":
if not getattr(args, "daemon_action", None):
p_daemon.print_help()

View File

@ -630,6 +630,66 @@ class KnowledgeGraph:
# ── Stats ─────────────────────────────────────────────────────────────
# -- Replication (RFC 004 step 1: read-replica snapshot) ---------------
_REPLICATION_TABLES = {
"entities": ("id", "name", "type", "properties", "created_at"),
"triples": (
"id",
"subject",
"predicate",
"object",
"valid_from",
"valid_to",
"confidence",
"source_closet",
"source_file",
"source_drawer_id",
"adapter_name",
"extracted_at",
),
}
def dump_rows(self, table: str, after_rowid: int = 0, limit: int = 500) -> list:
"""Page KG rows in rowid order for snapshot replication.
Rows are returned verbatim with a ``_rowid`` pagination cursor.
rowid order is deterministic, so pages never skip under concurrent
appends (updates in earlier pages are caught by the next full pass).
"""
columns = self._REPLICATION_TABLES.get(table)
if columns is None:
raise ValueError(f"table must be one of {sorted(self._REPLICATION_TABLES)}")
with self._lock:
conn = self._conn()
rows = conn.execute(
f"SELECT rowid, {', '.join(columns)} FROM {table} "
"WHERE rowid > ? ORDER BY rowid ASC LIMIT ?",
(int(after_rowid), max(1, min(int(limit), 1000))),
).fetchall()
return [dict(row) | {"_rowid": row["rowid"]} for row in rows]
def apply_row(self, table: str, row: dict) -> None:
"""Fold one replicated KG row in, keyed by id (INSERT OR REPLACE).
REPLACE makes invalidations (valid_to updates) and entity edits
converge on re-pull; rows are never deleted by replication.
"""
columns = self._REPLICATION_TABLES.get(table)
if columns is None:
raise ValueError(f"table must be one of {sorted(self._REPLICATION_TABLES)}")
if not row.get("id"):
raise ValueError("replicated row is missing 'id'")
values = [row.get(col) for col in columns]
with self._lock:
conn = self._conn()
with conn:
conn.execute(
f"INSERT OR REPLACE INTO {table} ({', '.join(columns)}) "
f"VALUES ({', '.join('?' for _ in columns)})",
values,
)
def stats(self):
with self._lock:
conn = self._conn()

View File

@ -6144,11 +6144,94 @@ def _http_handle_get(handler) -> None:
if not _http_serve_sync(handler, path):
handler.send_error(404, "Not Found")
return
if path.startswith("/snapshot/"):
if handler._request_rejected(require_auth=True):
return
if not _http_serve_snapshot(handler, path):
handler.send_error(404, "Not Found")
return
if handler._request_rejected(require_auth=False):
return
handler.send_error(404, "Not Found")
def _http_serve_snapshot(handler, path: str) -> bool:
"""RFC 004 step 1: content-snapshot endpoints for memory read replicas.
GET /snapshot/manifest {replica_id, drawers, kg, collection}
GET /snapshot/drawers?offset=&limit= {items: [{id, document, metadata}]}
GET /snapshot/ids?offset=&limit= {ids: [...]} (delete reconciliation)
GET /snapshot/kg?table=&after=&limit= {rows: [...]} (rowid-paged)
Facts only verbatim documents, metadata, and KG rows. Vector indexes
are never served: the replica derives its own (RFC 004 layer 3). Chroma
access happens under the global request lock like every other
Chroma-touching request; pages are small enough to keep holds short.
"""
from urllib.parse import parse_qsl
query = dict(parse_qsl(urlparse(handler.path).query))
try:
limit = max(1, min(int(query.get("limit", "500") or 500), 1000))
offset = max(0, int(query.get("offset", "0") or 0))
after = max(0, int(query.get("after", "0") or 0))
except (TypeError, ValueError):
handler._send_json(400, {"error": "limit/offset/after must be integers"})
return True
if path == "/snapshot/manifest":
with _HTTP_REQUEST_LOCK:
col = _get_collection()
drawer_count = col.count() if col else 0
kg_stats = _call_kg(lambda kg: kg.stats())
handler._send_json(
200,
{
"replica_id": _call_logstream(lambda ls: ls.replica_id),
"drawers": drawer_count,
"kg": {
"entities": kg_stats.get("entities", 0),
"triples": kg_stats.get("triples", 0),
},
"collection": _config.collection_name,
},
)
return True
if path == "/snapshot/drawers":
with _HTTP_REQUEST_LOCK:
col = _get_collection()
if col is None:
handler._send_json(503, {"error": "no palace collection available"})
return True
batch = col.get(limit=limit, offset=offset, include=["documents", "metadatas"])
ids = batch.get("ids") or []
docs = batch.get("documents") or []
metas = batch.get("metadatas") or []
items = [{"id": i, "document": d, "metadata": m or {}} for i, d, m in zip(ids, docs, metas)]
handler._send_json(200, {"items": items, "count": len(items), "offset": offset})
return True
if path == "/snapshot/ids":
with _HTTP_REQUEST_LOCK:
col = _get_collection()
if col is None:
handler._send_json(503, {"error": "no palace collection available"})
return True
batch = col.get(limit=limit, offset=offset)
ids = batch.get("ids") or []
handler._send_json(200, {"ids": ids, "count": len(ids), "offset": offset})
return True
if path == "/snapshot/kg":
table = query.get("table") or ""
try:
rows = _call_kg(lambda kg: kg.dump_rows(table, after_rowid=after, limit=limit))
except ValueError as exc:
handler._send_json(400, {"error": str(exc)})
return True
handler._send_json(200, {"table": table, "rows": rows, "count": len(rows)})
return True
return False
def _http_serve_sync(handler, path: str) -> bool:
"""RFC 004 step 0: pull-based anti-entropy endpoints for peer replicas.

172
mempalace/replica_sync.py Normal file
View File

@ -0,0 +1,172 @@
"""
replica_sync.py Memory read-replica pull engine (RFC 004 step 1)
Pulls the origin palace's *facts* — verbatim drawer documents + metadata and
knowledge-graph rows over the /snapshot/* endpoints and folds them into
this machine's local palace. The local vector index is derived here, by this
machine's own embedder, exactly per RFC 004 layer 3: sync the facts, derive
the senses. `chroma.sqlite3` is never copied.
Semantics:
- Drawer folds are upserts by drawer id; documents and metadata are stored
verbatim, plus ONE additive provenance key: ``replica_origin`` = the
origin's replica id. That key is what makes delete reconciliation safe —
only drawers this replica *copied from that origin* are ever deleted when
they disappear upstream; locally-authored drawers (a local agent's diary)
are untouchable by reconciliation.
- KG folds are INSERT OR REPLACE by row id: invalidations (valid_to edits)
converge on re-pull; replication never deletes KG rows.
- Every pass is idempotent. Freshness = re-run the pull; the step-2 op-log
replaces polling with precise tails later.
Writes on a step-1 replica remain the origin's job — this engine makes
recall local and durable, not capture. Multi-writer memory is RFC 004
step 3.
"""
import logging
from .logsync import SyncPeerError, _peer_get, load_peers
logger = logging.getLogger("mempalace.replica_sync")
_PAGE = 500
REPLICA_ORIGIN_KEY = "replica_origin"
def _pull_drawers(col, url: str, token: str, origin_id: str) -> int:
upserted = 0
offset = 0
while True:
page = _peer_get(url, token, "/snapshot/drawers", {"offset": offset, "limit": _PAGE})
items = page.get("items") or []
if not items:
break
ids = [item["id"] for item in items]
documents = [item["document"] for item in items]
metadatas = [
{**(item.get("metadata") or {}), REPLICA_ORIGIN_KEY: origin_id} for item in items
]
col.upsert(ids=ids, documents=documents, metadatas=metadatas)
upserted += len(items)
offset += len(items)
return upserted
def _pull_remote_ids(url: str, token: str) -> set:
remote_ids = set()
offset = 0
while True:
page = _peer_get(url, token, "/snapshot/ids", {"offset": offset, "limit": 1000})
ids = page.get("ids") or []
if not ids:
break
remote_ids.update(ids)
offset += len(ids)
return remote_ids
def _reconcile_deletes(col, remote_ids: set, origin_id: str) -> int:
"""Delete local copies whose upstream original is gone.
Scoped strictly to drawers stamped ``replica_origin == origin_id``
reconciliation can never touch locally-authored content.
"""
to_delete = []
offset = 0
while True:
batch = col.get(limit=1000, offset=offset, include=["metadatas"])
ids = batch.get("ids") or []
if not ids:
break
metas = batch.get("metadatas") or []
for drawer_id, meta in zip(ids, metas):
if (meta or {}).get(REPLICA_ORIGIN_KEY) == origin_id and drawer_id not in remote_ids:
to_delete.append(drawer_id)
offset += len(ids)
for start in range(0, len(to_delete), 500):
col.delete(ids=to_delete[start : start + 500])
return len(to_delete)
def _pull_kg(kg, url: str, token: str) -> dict:
counts = {}
for table in ("entities", "triples"):
applied = 0
after = 0
while True:
page = _peer_get(
url, token, "/snapshot/kg", {"table": table, "after": after, "limit": _PAGE}
)
rows = page.get("rows") or []
if not rows:
break
for row in rows:
kg.apply_row(table, row)
after = max(after, row.get("_rowid") or 0)
applied += 1
counts[table] = applied
return counts
def pull_memory(
palace_path: str,
url: str,
token: str = "",
reconcile_deletes: bool = True,
) -> dict:
"""One full read-replica pass against one origin. Returns fold stats.
Opens the LOCAL palace's collection (created on first pull) and KG;
embedding of pulled documents happens here, on this machine's embedder.
"""
from .knowledge_graph import KnowledgeGraph
from .palace import get_collection
manifest = _peer_get(url, token, "/snapshot/manifest")
origin_id = manifest.get("replica_id") or "unknown-origin"
col = get_collection(palace_path, create=True)
if col is None:
raise SyncPeerError(f"could not open local collection in {palace_path!r}")
upserted = _pull_drawers(col, url, token, origin_id)
deleted = 0
if reconcile_deletes:
deleted = _reconcile_deletes(col, _pull_remote_ids(url, token), origin_id)
import os
kg = KnowledgeGraph(
db_path=os.path.join(os.path.expanduser(palace_path), "knowledge_graph.sqlite3")
)
try:
kg_counts = _pull_kg(kg, url, token)
finally:
kg.close()
return {
"origin_url": url,
"origin_replica": origin_id,
"manifest": manifest,
"drawers_upserted": upserted,
"drawers_deleted": deleted,
"kg_entities": kg_counts["entities"],
"kg_triples": kg_counts["triples"],
}
def pull_from_peers(palace_path: str) -> list:
"""Run a pull against every peers.json entry; per-peer errors reported,
never raised (a dead origin must not block the others)."""
results = []
for peer in load_peers(palace_path):
name = peer.get("name") or peer["url"]
try:
stats = pull_memory(palace_path, peer["url"], peer.get("token", ""))
stats["peer_name"] = name
results.append(stats)
except (SyncPeerError, ValueError) as exc:
results.append({"peer_name": name, "origin_url": peer["url"], "error": str(exc)})
return results

245
tests/test_replica_sync.py Normal file
View File

@ -0,0 +1,245 @@
"""
Tests for RFC 004 step 1 memory read replicas (snapshot pull + local fold).
Origin palace served by the production HTTP server; replica palace folds
via replica_sync. Uses the sqlite_exact backend with a fake embedder (the
same pattern as test_sqlite_exact_backend.py) so no model download happens:
the point is fact replication, and vectors are derived locally by design.
"""
import http.client
import json
import math
import os
import threading
import pytest
from mempalace.knowledge_graph import KnowledgeGraph
from mempalace.replica_sync import REPLICA_ORIGIN_KEY, pull_memory
@pytest.fixture
def fake_embedder(monkeypatch):
import mempalace.backends.embedding_wrapper as embedding_wrapper
def fake_embed(texts):
return [[1.0, 0.0] if "canary" in t else [0.5, math.sqrt(0.75)] for t in texts]
monkeypatch.setenv("MEMPALACE_BACKEND_EXPLICIT", "sqlite_exact")
monkeypatch.setattr(embedding_wrapper, "_embed_texts", fake_embed)
@pytest.fixture
def origin_server(monkeypatch, fake_embedder, config, palace_path):
"""Origin palace with seeded drawers + KG, served over real HTTP."""
from mempalace import mcp_server as mcp
from mempalace.palace import get_collection
monkeypatch.setattr(mcp, "_config", config)
monkeypatch.setattr(mcp, "_logstream_by_path", {})
monkeypatch.setattr(mcp, "_collection_cache", None)
monkeypatch.setattr(mcp, "_client_cache", None)
col = get_collection(palace_path, create=True)
col.upsert(
ids=["drawer_w_r_aaa", "drawer_w_r_bbb", "drawer_w2_r2_ccc"],
documents=[
"the search canary drawer",
"alembic migrations run on postgres",
"sprint planning notes for q3",
],
metadatas=[
{"wing": "w", "room": "r", "source_file": "a.md", "filed_at": "2026-07-01T00:00:00"},
{"wing": "w", "room": "r", "source_file": "b.md", "filed_at": "2026-07-01T00:00:01"},
{"wing": "w2", "room": "r2", "added_by": "mcp", "filed_at": "2026-07-01T00:00:02"},
],
)
kg = KnowledgeGraph(db_path=os.path.join(palace_path, "knowledge_graph.sqlite3"))
kg.add_entity("Alice", entity_type="person")
kg.add_triple("Alice", "works_on", "MemPalace", valid_from="2026-01-01")
kg.close()
monkeypatch.setattr(
mcp,
"_get_kg",
lambda *a, **kw: KnowledgeGraph(
db_path=os.path.join(palace_path, "knowledge_graph.sqlite3")
),
)
httpd = mcp._build_http_server("127.0.0.1", 0)
port = httpd.server_address[1]
thread = threading.Thread(
target=httpd.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
)
thread.start()
try:
yield f"http://127.0.0.1:{port}", palace_path, mcp
finally:
httpd.shutdown()
httpd.server_close()
thread.join(timeout=5)
for ls in mcp._logstream_by_path.values():
ls.close()
@pytest.fixture
def replica_palace(tmp_dir):
p = os.path.join(tmp_dir, "replica_palace")
os.makedirs(p)
return p
def _get(url, path):
host, port = url.replace("http://", "").split(":")
conn = http.client.HTTPConnection(host, int(port), timeout=5)
try:
conn.request("GET", path)
resp = conn.getresponse()
return resp.status, json.loads(resp.read() or b"{}")
finally:
conn.close()
class TestSnapshotEndpoints:
def test_manifest_counts(self, origin_server):
url, _, _ = origin_server
status, manifest = _get(url, "/snapshot/manifest")
assert status == 200
assert manifest["drawers"] == 3
assert manifest["kg"]["triples"] == 1
assert manifest["replica_id"].startswith("rep_")
def test_drawers_paginate(self, origin_server):
url, _, _ = origin_server
status, page1 = _get(url, "/snapshot/drawers?offset=0&limit=2")
status2, page2 = _get(url, "/snapshot/drawers?offset=2&limit=2")
assert status == status2 == 200
ids = [i["id"] for i in page1["items"]] + [i["id"] for i in page2["items"]]
assert sorted(ids) == ["drawer_w2_r2_ccc", "drawer_w_r_aaa", "drawer_w_r_bbb"]
assert all("document" in i and "metadata" in i for i in page1["items"])
def test_ids_endpoint(self, origin_server):
url, _, _ = origin_server
status, page = _get(url, "/snapshot/ids?offset=0&limit=10")
assert status == 200
assert len(page["ids"]) == 3
def test_kg_pages_and_rejects_bad_table(self, origin_server):
url, _, _ = origin_server
status, triples = _get(url, "/snapshot/kg?table=triples&after=0&limit=10")
assert status == 200
assert triples["rows"][0]["subject"] == "alice"
status, bad = _get(url, "/snapshot/kg?table=users&after=0")
assert status == 400
def test_bad_pagination_is_400(self, origin_server):
url, _, _ = origin_server
status, _ = _get(url, "/snapshot/drawers?offset=nope")
assert status == 400
class TestReplicaPull:
def test_full_pull_folds_facts_and_derives_locally(
self, origin_server, replica_palace, fake_embedder
):
url, _, _ = origin_server
stats = pull_memory(replica_palace, url)
assert stats["drawers_upserted"] == 3
assert stats["drawers_deleted"] == 0
assert stats["kg_triples"] == 1
assert stats["kg_entities"] >= 1
# Facts are verbatim and searchable locally.
from mempalace.searcher import search_memories
result = search_memories("search canary", replica_palace, n_results=1)
assert result["results"][0]["text"] == "the search canary drawer"
# Provenance stamp present on the copy.
from mempalace.palace import get_collection
col = get_collection(replica_palace)
copy = col.get(ids=["drawer_w_r_aaa"], include=["metadatas"])
assert copy["metadatas"][0][REPLICA_ORIGIN_KEY].startswith("rep_")
# KG fact replicated with temporal fields intact.
kg = KnowledgeGraph(db_path=os.path.join(replica_palace, "knowledge_graph.sqlite3"))
try:
facts = kg.query_entity("Alice")
assert any(f["object"] == "MemPalace" for f in facts)
finally:
kg.close()
def test_pull_is_idempotent(self, origin_server, replica_palace, fake_embedder):
url, _, _ = origin_server
pull_memory(replica_palace, url)
stats = pull_memory(replica_palace, url)
assert stats["drawers_upserted"] == 3 # upserts, not duplicates
from mempalace.palace import get_collection
assert get_collection(replica_palace).count() == 3
def test_reconcile_deletes_only_origin_copies(
self, origin_server, replica_palace, fake_embedder
):
url, origin_palace, _ = origin_server
pull_memory(replica_palace, url)
# A locally-authored drawer must survive reconciliation forever.
from mempalace.palace import get_collection
replica_col = get_collection(replica_palace)
replica_col.upsert(
ids=["drawer_local_diary"],
documents=["local diary entry, authored on the replica"],
metadatas=[{"wing": "local", "room": "diary"}],
)
# Upstream deletes one drawer; the replica's copy reconciles away.
origin_col = get_collection(origin_palace)
origin_col.delete(ids=["drawer_w_r_bbb"])
stats = pull_memory(replica_palace, url)
assert stats["drawers_deleted"] == 1
remaining = set(replica_col.get(limit=100)["ids"])
assert "drawer_w_r_bbb" not in remaining
assert "drawer_local_diary" in remaining
def test_kg_invalidation_converges_on_repull(
self, origin_server, replica_palace, fake_embedder
):
url, origin_palace, _ = origin_server
pull_memory(replica_palace, url)
origin_kg = KnowledgeGraph(db_path=os.path.join(origin_palace, "knowledge_graph.sqlite3"))
origin_kg.invalidate("Alice", "works_on", "MemPalace", ended="2026-07-01")
origin_kg.close()
pull_memory(replica_palace, url)
kg = KnowledgeGraph(db_path=os.path.join(replica_palace, "knowledge_graph.sqlite3"))
try:
live_now = kg.query_entity("Alice", as_of="2026-07-02")
assert not any(f["object"] == "MemPalace" for f in live_now)
finally:
kg.close()
def test_cli_replica_pull(self, origin_server, replica_palace, fake_embedder, capsys):
url, _, _ = origin_server
from types import SimpleNamespace
from mempalace.cli import cmd_replica
cmd_replica(
SimpleNamespace(
palace=replica_palace,
replica_action="pull",
peer=url,
token=None,
no_reconcile=False,
json=True,
)
)
results = json.loads(capsys.readouterr().out)
assert results[0]["drawers_upserted"] == 3