From 1aecfadf4582e46f9f3adb7f350d7a04d6d56252 Mon Sep 17 00:00:00 2001 From: VictorECDSA Date: Thu, 26 Mar 2026 21:22:10 +0800 Subject: [PATCH] fix: add timestamps for DELETE operations in history (#4492) --- mem0/memory/main.py | 13 ++++ tests/test_memory.py | 149 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 78e50fb39..91ed15810 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -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, diff --git a/tests/test_memory.py b/tests/test_memory.py index 827ed523d..900d4db5d 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -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