From 5b07b869b0bd853999cbfe1a2ce75ad7f2d90ad8 Mon Sep 17 00:00:00 2001 From: jp Date: Sat, 25 Apr 2026 11:06:25 -0700 Subject: [PATCH] fix(palace_graph): skip None metadata in build_graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChromaDB can return None for drawers without metadata (legacy data, partial writes — same root cause as upstream #1020 / our PR #1094). build_graph at line 95 called meta.get("room", "") unconditionally, which AttributeErrors on None and takes out every consumer of build_graph for the whole call path: graph_stats, find_tunnels, traverse, and (most visibly) the daemon's /stats endpoint. Caught 2026-04-25 by palace-daemon's verify-routes.sh smoke test against the canonical 151K-drawer palace — /stats was 500-ing on a single None drawer. Adds `if meta is None: continue` guard. Closes the same gap upstream's #999 None-metadata audit closed in searcher.py / mcp_server.py / miner.status, just in a different file the audit didn't reach. The graph-build is recoverable: skipping a single None drawer doesn't distort the graph since build_graph already filters `room and room != "general" and wing` — a missing-metadata drawer was never going to participate anyway. Test: TestBuildGraph::test_none_metadata_does_not_crash mixes a None entry into a 3-drawer fixture and asserts the two real drawers are processed normally. Co-Authored-By: Claude Opus 4.7 (1M context) --- mempalace/palace_graph.py | 10 ++++++++++ tests/test_palace_graph.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/mempalace/palace_graph.py b/mempalace/palace_graph.py index a26a202..5e4cec7 100644 --- a/mempalace/palace_graph.py +++ b/mempalace/palace_graph.py @@ -92,6 +92,16 @@ def build_graph(col=None, config=None): while offset < total: batch = col.get(limit=1000, offset=offset, include=["metadatas"]) for meta in batch["metadatas"]: + # ChromaDB can return ``None`` for drawers without metadata + # (legacy data, partial writes — upstream #1020 territory). + # Skip these silently rather than crash the whole graph + # build — a single None drawer shouldn't take down /stats + # or any caller of build_graph for the entire palace. Caught + # 2026-04-25 by palace-daemon's verify-routes.sh smoke test + # against the canonical 151K palace. Closes the same gap as + # upstream #999 / fork PR #1094 in a different read path. + if meta is None: + continue room = meta.get("room", "") wing = meta.get("wing", "") hall = meta.get("hall", "") diff --git a/tests/test_palace_graph.py b/tests/test_palace_graph.py index 7bc45e0..34375dc 100644 --- a/tests/test_palace_graph.py +++ b/tests/test_palace_graph.py @@ -54,6 +54,27 @@ class TestBuildGraph: assert nodes == {} assert edges == [] + def test_none_metadata_does_not_crash(self): + """ChromaDB can return None for drawers without metadata (legacy + data, partial writes — upstream #1020 territory). build_graph + must skip None entries silently rather than crash the whole + graph build with AttributeError. Caught 2026-04-25 by + palace-daemon's verify-routes.sh smoke test against the + canonical 151K palace; /stats was 500-ing on a single None + drawer and taking out every consumer of build_graph for the + whole call path.""" + col = _make_fake_collection( + [ + {"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}, + None, # legacy / partial-write drawer with no metadata + {"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-02"}, + ] + ) + nodes, edges = build_graph(col=col) + # The two real drawers were processed; the None one was skipped. + assert "auth" in nodes + assert nodes["auth"]["count"] == 2 + def test_single_wing_no_edges(self): col = _make_fake_collection( [