Skip to content

Commit d54f60c

Browse files
committed
fix(memory/vector): skip a corrupt vec_metadata row instead of aborting the search
The sqlite-vec search loop rebuilt VectorMetadata inline with raw datetime.fromisoformat(row["created_at"]) and json.loads(row["metadata_json"]). A single row with an unparseable datetime or malformed JSON (schema drift across an upgrade, a partially written row) raised mid-loop and aborted the whole search, losing every other result. #2947 healed the entity_refs shape for this exact reason but left created_at/metadata_json unguarded. Route row construction through a _row_to_metadata helper that skips an unreadable row and normalizes entity_refs, so one bad row no longer takes down the search.
1 parent 81fe9d5 commit d54f60c

2 files changed

Lines changed: 71 additions & 17 deletions

File tree

headroom/memory/adapters/sqlite_vector.py

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,42 @@ async def remove_batch(self, memory_ids: list[str]) -> int:
648648
conn.commit()
649649
return len(rowids)
650650

651+
def _row_to_metadata(self, row: Any) -> VectorMetadata | None:
652+
"""Build ``VectorMetadata`` from a ``vec_metadata`` row, skipping a bad row.
653+
654+
A single row with an unparseable ``created_at`` / ``valid_until`` or
655+
malformed ``entity_refs`` / ``metadata_json`` JSON (schema drift across an
656+
upgrade, a partially written row) otherwise raises mid-loop and aborts
657+
the *whole* search, losing every other result. #2947 healed the
658+
``entity_refs`` shape for exactly this reason ("took down whole memory
659+
searches rather than the one bad row"), but ``created_at`` and
660+
``metadata_json`` in this loop were left unguarded. Return ``None`` so
661+
the caller skips just the offending row.
662+
"""
663+
try:
664+
created_at = datetime.fromisoformat(row["created_at"])
665+
valid_until = datetime.fromisoformat(row["valid_until"]) if row["valid_until"] else None
666+
entity_refs = normalize_entity_refs(json.loads(row["entity_refs"]))
667+
metadata = json.loads(row["metadata_json"])
668+
except (ValueError, TypeError):
669+
logger.warning(
670+
"Skipping unreadable vec_metadata row (memory_id=%s)",
671+
row["memory_id"],
672+
)
673+
return None
674+
return VectorMetadata(
675+
memory_id=row["memory_id"],
676+
user_id=row["user_id"],
677+
session_id=row["session_id"],
678+
agent_id=row["agent_id"],
679+
importance=row["importance"],
680+
created_at=created_at,
681+
valid_until=valid_until,
682+
entity_refs=entity_refs,
683+
content=row["content"],
684+
metadata=metadata,
685+
)
686+
651687
async def search(self, filter: VectorFilter) -> list[VectorSearchResult]:
652688
"""Search for similar vectors.
653689
@@ -722,23 +758,11 @@ async def search(self, filter: VectorFilter) -> list[VectorSearchResult]:
722758
if similarity < filter.min_similarity:
723759
continue
724760

725-
# Build metadata for filtering
726-
meta = VectorMetadata(
727-
memory_id=row["memory_id"],
728-
user_id=row["user_id"],
729-
session_id=row["session_id"],
730-
agent_id=row["agent_id"],
731-
importance=row["importance"],
732-
created_at=datetime.fromisoformat(row["created_at"]),
733-
valid_until=(
734-
datetime.fromisoformat(row["valid_until"])
735-
if row["valid_until"]
736-
else None
737-
),
738-
entity_refs=json.loads(row["entity_refs"]),
739-
content=row["content"],
740-
metadata=json.loads(row["metadata_json"]),
741-
)
761+
# Build metadata for filtering. A single corrupt/legacy row
762+
# is skipped rather than aborting the whole search (#2947).
763+
meta = self._row_to_metadata(row)
764+
if meta is None:
765+
continue
742766

743767
# Apply filters
744768
if not self._passes_filter(meta, filter):

tests/test_sqlite_vector_index.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ async def test_index_and_search(self, index):
7777
assert results[0].memory.id == memories[0].id
7878
assert results[0].similarity > 0.99 # Should be ~1.0 for exact match
7979

80+
@pytest.mark.asyncio
81+
async def test_search_skips_corrupt_row_instead_of_aborting(self, index):
82+
"""A single vec_metadata row with an unparseable created_at (or malformed
83+
metadata_json) must not abort the whole search. #2947 healed the
84+
entity_refs shape for this reason, but created_at/metadata_json in the
85+
same loop were unguarded, so one bad row lost every other result."""
86+
np.random.seed(1)
87+
good = Memory(
88+
content="good", user_id="alice", embedding=np.random.randn(384).astype(np.float32)
89+
)
90+
bad = Memory(
91+
content="bad", user_id="alice", embedding=np.random.randn(384).astype(np.float32)
92+
)
93+
await index.index(good)
94+
await index.index(bad)
95+
96+
# Simulate a schema-drift / partially written row on disk.
97+
conn = index._get_conn()
98+
conn.execute(
99+
"UPDATE vec_metadata SET created_at=? WHERE memory_id=?", ("not-a-date", bad.id)
100+
)
101+
conn.commit()
102+
103+
filter = VectorFilter(query_vector=good.embedding, top_k=5, user_id="alice")
104+
results = await index.search(filter) # must not raise
105+
106+
ids = {r.memory.id for r in results}
107+
assert good.id in ids # the good result survives
108+
assert bad.id not in ids # the corrupt row is skipped, not fatal
109+
80110
@pytest.mark.asyncio
81111
async def test_true_delete(self, index):
82112
"""Test that delete actually removes entries."""

0 commit comments

Comments
 (0)