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:
parent
f7c174e5d4
commit
0b226029bc
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue