fix: add timestamps for DELETE operations in history (#4492)

This commit is contained in:
VictorECDSA
2026-03-26 21:22:10 +08:00
committed by GitHub
parent 7e06aeeada
commit 1aecfadf45
2 changed files with 158 additions and 4 deletions
+13
View File
@@ -1320,12 +1320,19 @@ class Memory(MemoryBase):
if existing_memory is None:
raise ValueError(f"Memory with id {memory_id} not found")
prev_value = existing_memory.payload.get("data", "")
# Preserve original created_at and record deletion time
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
updated_at = datetime.now(timezone.utc).isoformat()
self.vector_store.delete(vector_id=memory_id)
self.db.add_history(
memory_id,
prev_value,
None,
"DELETE",
created_at=created_at,
updated_at=updated_at,
actor_id=existing_memory.payload.get("actor_id"),
role=existing_memory.payload.get("role"),
is_deleted=1,
@@ -2460,6 +2467,10 @@ class AsyncMemory(MemoryBase):
raise ValueError(f"Memory with id {memory_id} not found")
prev_value = existing_memory.payload.get("data", "")
# Preserve original created_at and record deletion time
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
updated_at = datetime.now(timezone.utc).isoformat()
await asyncio.to_thread(self.vector_store.delete, vector_id=memory_id)
await asyncio.to_thread(
self.db.add_history,
@@ -2467,6 +2478,8 @@ class AsyncMemory(MemoryBase):
prev_value,
None,
"DELETE",
created_at=created_at,
updated_at=updated_at,
actor_id=existing_memory.payload.get("actor_id"),
role=existing_memory.payload.get("role"),
is_deleted=1,
+145 -4
View File
@@ -1,4 +1,5 @@
import json
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
@@ -10,7 +11,7 @@ from mem0.memory.utils import normalize_facts
class MockVectorMemory:
"""Mock memory object for testing incomplete payloads."""
def __init__(self, memory_id: str, payload: dict, score: float = 0.8):
self.id = memory_id
self.payload = payload
@@ -104,7 +105,7 @@ def test_collection_name_preserved_after_reset(mock_sqlite, mock_llm_factory, mo
reset_calls = [call for call in mock_vector_factory.call_args_list if len(mock_vector_factory.call_args_list) > 2]
if reset_calls:
reset_config = reset_calls[-1][0][1]
reset_config = reset_calls[-1][0][1]
assert reset_config.collection_name == test_collection_name, f"Reset used wrong collection name: {reset_config.collection_name}"
@@ -129,13 +130,13 @@ def test_search_handles_incomplete_payloads(mock_sqlite, mock_llm_factory, mock_
complete_memory = MockVectorMemory("mem_2", {"data": "content", "hash": "def456"})
mock_vector_store.search.return_value = [incomplete_memory, complete_memory]
mock_embedder = MagicMock()
mock_embedder.embed.return_value = [0.1, 0.2, 0.3]
memory.embedding_model = mock_embedder
result = memory._search_vector_store("test", {"user_id": "test"}, 10)
assert len(result) == 2
memories_by_id = {mem["id"]: mem for mem in result}
@@ -572,3 +573,143 @@ def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm
# It should NOT be called a 3rd time inside _update_memory
assert embedder.embed.call_count == 2
mock_vector_store.update.assert_called_once()
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
def test_delete_memory_history_has_timestamps(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that deleting a memory records created_at and updated_at in history.
Ensures DELETE operations preserve the original creation timestamp
and record the deletion time for proper audit trails.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"created_at": "2024-01-01T00:00:00+00:00",
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] == "2024-01-01T00:00:00+00:00"
assert call_kwargs["updated_at"] is not None
datetime.fromisoformat(call_kwargs["updated_at"]) # verify valid ISO timestamp
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
def test_delete_memory_normalizes_non_utc_created_at(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that non-UTC created_at timestamps are normalized to UTC on delete."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"created_at": "2024-01-01T05:00:00+05:00", # UTC+5
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] == "2024-01-01T00:00:00+00:00" # normalized to UTC
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
def test_delete_memory_missing_created_at(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""Test that delete works when created_at is absent from the payload (pre-existing memories)."""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] is None
assert call_kwargs["updated_at"] is not None
datetime.fromisoformat(call_kwargs["updated_at"]) # verify valid ISO timestamp
@pytest.mark.asyncio
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.main.SQLiteManager')
async def test_async_delete_memory_history_has_timestamps(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Test that async deleting a memory records created_at and updated_at in history.
Ensures async DELETE operations preserve the original creation timestamp
and record the deletion time for proper audit trails.
"""
mock_embedder_factory.return_value = MagicMock()
mock_vector_store = MagicMock()
mock_vector_factory.return_value = mock_vector_store
mock_llm_factory.return_value = MagicMock()
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import AsyncMemory
config = MemoryConfig()
memory = AsyncMemory(config)
existing_memory = MagicMock()
existing_memory.payload = {
"data": "I like Python.",
"created_at": "2024-01-01T00:00:00+00:00",
"actor_id": None,
"role": None,
}
mock_vector_store.get.return_value = existing_memory
await memory.delete("mem-123")
call_kwargs = memory.db.add_history.call_args.kwargs
assert call_kwargs["created_at"] == "2024-01-01T00:00:00+00:00"
assert call_kwargs["updated_at"] is not None
datetime.fromisoformat(call_kwargs["updated_at"]) # verify valid ISO timestamp