fix(memory): exclude vector-store-rejected records from ADD results (#7066)

This commit is contained in:
Diwakar Ray Yadav
2026-09-24 22:01:31 +05:30
committed by GitHub
parent f4acc89a29
commit 2c6ff619d1
2 changed files with 197 additions and 16 deletions
+41 -15
View File
@@ -22,7 +22,7 @@ from mem0.configs.prompts import (
PROCEDURAL_MEMORY_SYSTEM_PROMPT, PROCEDURAL_MEMORY_SYSTEM_PROMPT,
generate_additive_extraction_prompt, generate_additive_extraction_prompt,
) )
from mem0.exceptions import LLMError from mem0.exceptions import LLMError, VectorStoreError
from mem0.exceptions import ValidationError as Mem0ValidationError from mem0.exceptions import ValidationError as Mem0ValidationError
from mem0.memory.base import MemoryBase from mem0.memory.base import MemoryBase
from mem0.memory.notices import ( from mem0.memory.notices import (
@@ -1049,19 +1049,31 @@ class Memory(MemoryBase):
all_ids = [r[0] for r in records] all_ids = [r[0] for r in records]
all_payloads = [r[3] for r in records] all_payloads = [r[3] for r in records]
# Only records confirmed to be stored make it into history, entity
# links, and the returned results — a record the store rejected must
# never be reported back as a successful ADD.
persisted_records = []
try: try:
self.vector_store.insert( self.vector_store.insert(
vectors=all_vectors, vectors=all_vectors,
ids=all_ids, ids=all_ids,
payloads=all_payloads, payloads=all_payloads,
) )
persisted_records = records
except Exception: except Exception:
# Fallback: insert one by one # Fallback: insert one by one
for mid, vec, pay in zip(all_ids, all_vectors, all_payloads): for rec in records:
try: try:
self.vector_store.insert(vectors=[vec], ids=[mid], payloads=[pay]) self.vector_store.insert(vectors=[rec[2]], ids=[rec[0]], payloads=[rec[3]])
persisted_records.append(rec)
except Exception as e: except Exception as e:
logger.error(f"Failed to insert memory {mid}: {e}") logger.error(f"Failed to insert memory {rec[0]}: {e}")
if not persisted_records:
self.db.save_messages(messages, session_scope)
raise VectorStoreError(
f"Failed to insert any of the {len(records)} extracted memories into the vector store"
)
# Batch history # Batch history
history_records = [ history_records = [
@@ -1073,7 +1085,7 @@ class Memory(MemoryBase):
"created_at": r[3].get("created_at"), "created_at": r[3].get("created_at"),
"is_deleted": 0, "is_deleted": 0,
} }
for r in records for r in persisted_records
] ]
try: try:
self.db.batch_add_history(history_records) self.db.batch_add_history(history_records)
@@ -1087,12 +1099,12 @@ class Memory(MemoryBase):
# Phase 7: Batch entity linking # Phase 7: Batch entity linking
try: try:
all_texts = [r[1] for r in records] all_texts = [r[1] for r in persisted_records]
all_entities = extract_entities_batch(all_texts) all_entities = extract_entities_batch(all_texts)
# 7a: Global dedup — collect unique entities across all memories # 7a: Global dedup — collect unique entities across all memories
global_entities = {} # normalized_key -> (entity_type, entity_text, set of memory_ids) global_entities = {} # normalized_key -> (entity_type, entity_text, set of memory_ids)
for idx, (memory_id, text, embedding, payload) in enumerate(records): for idx, (memory_id, text, embedding, payload) in enumerate(persisted_records):
entities = all_entities[idx] if idx < len(all_entities) else [] entities = all_entities[idx] if idx < len(all_entities) else []
for entity_type, entity_text in entities: for entity_type, entity_text in entities:
key = self._normalize_entity_text(entity_text) key = self._normalize_entity_text(entity_text)
@@ -1196,7 +1208,7 @@ class Memory(MemoryBase):
returned_memories = [ returned_memories = [
{"id": r[0], "memory": r[1], "event": "ADD"} {"id": r[0], "memory": r[1], "event": "ADD"}
for r in records for r in persisted_records
] ]
keys, encoded_ids = process_telemetry_filters(filters) keys, encoded_ids = process_telemetry_filters(filters)
@@ -2709,6 +2721,10 @@ class AsyncMemory(MemoryBase):
all_ids = [r[0] for r in records] all_ids = [r[0] for r in records]
all_payloads = [r[3] for r in records] all_payloads = [r[3] for r in records]
# Only records confirmed to be stored make it into history, entity
# links, and the returned results — a record the store rejected must
# never be reported back as a successful ADD.
persisted_records = []
try: try:
await asyncio.to_thread( await asyncio.to_thread(
self.vector_store.insert, self.vector_store.insert,
@@ -2716,12 +2732,22 @@ class AsyncMemory(MemoryBase):
ids=all_ids, ids=all_ids,
payloads=all_payloads, payloads=all_payloads,
) )
persisted_records = records
except Exception: except Exception:
for mid, vec, pay in zip(all_ids, all_vectors, all_payloads): for rec in records:
try: try:
await asyncio.to_thread(self.vector_store.insert, vectors=[vec], ids=[mid], payloads=[pay]) await asyncio.to_thread(
self.vector_store.insert, vectors=[rec[2]], ids=[rec[0]], payloads=[rec[3]]
)
persisted_records.append(rec)
except Exception as e: except Exception as e:
logger.error(f"Failed to insert memory {mid} (async): {e}") logger.error(f"Failed to insert memory {rec[0]} (async): {e}")
if not persisted_records:
await asyncio.to_thread(self.db.save_messages, messages, session_scope)
raise VectorStoreError(
f"Failed to insert any of the {len(records)} extracted memories into the vector store"
)
# Batch history # Batch history
history_records = [ history_records = [
@@ -2733,7 +2759,7 @@ class AsyncMemory(MemoryBase):
"created_at": r[3].get("created_at"), "created_at": r[3].get("created_at"),
"is_deleted": 0, "is_deleted": 0,
} }
for r in records for r in persisted_records
] ]
try: try:
await asyncio.to_thread(self.db.batch_add_history, history_records) await asyncio.to_thread(self.db.batch_add_history, history_records)
@@ -2749,12 +2775,12 @@ class AsyncMemory(MemoryBase):
# Phase 7: Batch entity linking # Phase 7: Batch entity linking
try: try:
all_texts = [r[1] for r in records] all_texts = [r[1] for r in persisted_records]
all_entities = await asyncio.to_thread(extract_entities_batch, all_texts) all_entities = await asyncio.to_thread(extract_entities_batch, all_texts)
# 7a: Global dedup # 7a: Global dedup
global_entities = {} global_entities = {}
for idx, (memory_id, text, embedding, payload) in enumerate(records): for idx, (memory_id, text, embedding, payload) in enumerate(persisted_records):
entities = all_entities[idx] if idx < len(all_entities) else [] entities = all_entities[idx] if idx < len(all_entities) else []
for entity_type, entity_text in entities: for entity_type, entity_text in entities:
key = self._normalize_entity_text(entity_text) key = self._normalize_entity_text(entity_text)
@@ -2856,7 +2882,7 @@ class AsyncMemory(MemoryBase):
returned_memories = [ returned_memories = [
{"id": r[0], "memory": r[1], "event": "ADD"} {"id": r[0], "memory": r[1], "event": "ADD"}
for r in records for r in persisted_records
] ]
keys, encoded_ids = process_telemetry_filters(effective_filters) keys, encoded_ids = process_telemetry_filters(effective_filters)
+156 -1
View File
@@ -1,3 +1,4 @@
import json
import logging import logging
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -6,7 +7,7 @@ from unittest.mock import MagicMock, Mock
import pytest import pytest
from mem0.exceptions import LLMError from mem0.exceptions import LLMError, VectorStoreError
from mem0.memory.main import AsyncMemory, Memory from mem0.memory.main import AsyncMemory, Memory
@@ -1178,3 +1179,157 @@ class TestAddPipelineEntityEmbeddingCountGuard:
assert any("padding/truncating" in r.message for r in caplog.records), ( assert any("padding/truncating" in r.message for r in caplog.records), (
"expected count-mismatch warning was not emitted" "expected count-mismatch warning was not emitted"
) )
class TestPartialInsertFailure:
"""Records the vector store rejects must never be reported as successful ADDs (#6911)."""
LLM_RESPONSE = json.dumps(
{
"memory": [
{"text": "User's name is Aryan"},
{"text": "User is allergic to penicillin"},
{"text": "User works as an engineer"},
]
}
)
@pytest.fixture
def mock_memory(self, mocker):
mock_llm, _ = _setup_mocks(mocker)
memory = Memory()
memory.config = mocker.MagicMock()
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.custom_instructions = None
memory.api_version = "v1.1"
memory.db.get_last_messages = MagicMock(return_value=[])
memory.db.save_messages = MagicMock()
memory.db.batch_add_history = MagicMock()
memory.embedding_model.embed_batch = Mock(side_effect=lambda texts, action: [[0.1, 0.2, 0.3] for _ in texts])
mocker.patch("mem0.memory.main.extract_entities_batch", return_value=[])
mocker.patch("mem0.memory.main.capture_event")
return memory
def test_rejected_records_are_not_reported_as_add(self, mock_memory):
"""A record rejected by the vector store must be absent from results and history."""
poison = "User is allergic to penicillin"
real_insert = mock_memory.vector_store.insert
def flaky_insert(vectors, ids, payloads):
if len(ids) > 1:
raise RuntimeError("batch insert rejected")
if payloads[0]["data"] == poison:
raise RuntimeError("record rejected by vector store")
return real_insert(vectors=vectors, ids=ids, payloads=payloads)
mock_memory.vector_store.insert = Mock(side_effect=flaky_insert)
mock_memory.llm.generate_response.return_value = self.LLM_RESPONSE
result = mock_memory._add_to_vector_store(
messages=[{"role": "user", "content": "My name is Aryan. I work as an engineer."}],
metadata={},
filters={},
infer=True,
)
assert [r["memory"] for r in result] == [
"User's name is Aryan",
"User works as an engineer",
]
assert all(r["event"] == "ADD" for r in result)
history = mock_memory.db.batch_add_history.call_args.args[0]
assert {h["new_memory"] for h in history} == {
"User's name is Aryan",
"User works as an engineer",
}
def test_all_inserts_failed_raises_vector_store_error(self, mock_memory):
"""If nothing persisted, add() must raise VectorStoreError instead of reporting success."""
mock_memory.vector_store.insert = Mock(side_effect=RuntimeError("vector store down"))
mock_memory.llm.generate_response.return_value = self.LLM_RESPONSE
with pytest.raises(VectorStoreError, match="Failed to insert any"):
mock_memory._add_to_vector_store(
messages=[{"role": "user", "content": "test"}],
metadata={},
filters={},
infer=True,
)
# Raw messages are still saved so a later retry can re-extract them.
mock_memory.db.save_messages.assert_called_once()
@pytest.mark.asyncio
class TestAsyncPartialInsertFailure:
"""Async mirror of TestPartialInsertFailure (#6911)."""
LLM_RESPONSE = TestPartialInsertFailure.LLM_RESPONSE
@pytest.fixture
def mock_async_memory(self, mocker):
mock_llm, _ = _setup_mocks(mocker)
memory = AsyncMemory()
memory.config = mocker.MagicMock()
memory.config.custom_instructions = None
memory.config.custom_update_memory_prompt = None
memory.custom_instructions = None
memory.api_version = "v1.1"
memory.db.get_last_messages = MagicMock(return_value=[])
memory.db.save_messages = MagicMock()
memory.db.batch_add_history = MagicMock()
memory.embedding_model.embed_batch = Mock(side_effect=lambda texts, action: [[0.1, 0.2, 0.3] for _ in texts])
mocker.patch("mem0.memory.main.extract_entities_batch", return_value=[])
mocker.patch("mem0.memory.main.capture_event")
return memory
async def test_rejected_records_are_not_reported_as_add(self, mock_async_memory):
poison = "User is allergic to penicillin"
real_insert = mock_async_memory.vector_store.insert
def flaky_insert(vectors, ids, payloads):
if len(ids) > 1:
raise RuntimeError("batch insert rejected")
if payloads[0]["data"] == poison:
raise RuntimeError("record rejected by vector store")
return real_insert(vectors=vectors, ids=ids, payloads=payloads)
mock_async_memory.vector_store.insert = Mock(side_effect=flaky_insert)
mock_async_memory.llm.generate_response.return_value = self.LLM_RESPONSE
result = await mock_async_memory._add_to_vector_store(
messages=[{"role": "user", "content": "My name is Aryan. I work as an engineer."}],
metadata={},
effective_filters={},
infer=True,
)
assert [r["memory"] for r in result] == [
"User's name is Aryan",
"User works as an engineer",
]
history = mock_async_memory.db.batch_add_history.call_args.args[0]
assert {h["new_memory"] for h in history} == {
"User's name is Aryan",
"User works as an engineer",
}
async def test_all_inserts_failed_raises_vector_store_error(self, mock_async_memory):
mock_async_memory.vector_store.insert = Mock(side_effect=RuntimeError("vector store down"))
mock_async_memory.llm.generate_response.return_value = self.LLM_RESPONSE
with pytest.raises(VectorStoreError, match="Failed to insert any"):
await mock_async_memory._add_to_vector_store(
messages=[{"role": "user", "content": "test"}],
metadata={},
effective_filters={},
infer=True,
)
mock_async_memory.db.save_messages.assert_called_once()