From 884a039bba032919b909aca428965b133aed7939 Mon Sep 17 00:00:00 2001 From: krishna3554 Date: Wed, 26 Aug 2026 14:15:40 +0530 Subject: [PATCH 1/2] fix: store timestamps in UTC so recall expiry filtering is order-correct Recall filters expiries inside SQL by comparing ISO strings lexicographically, but records stored whatever UTC offset the client sent. When the local wall-clock date crossed a date boundary relative to UTC, still-valid memories were treated as expired and silently dropped, while list/status reported them as active. Normalise created_at/updated_at/expires_at (and the recall 'now' parameter) to +00:00 on write so string comparison matches chronological order. Cursor encoding uses the same normalisation for consistency. Fixes part of #9 --- services/dma-api/src/dma_api/repository.py | 25 ++++++++++---- services/dma-api/tests/test_recall.py | 39 +++++++++++++++++++++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/services/dma-api/src/dma_api/repository.py b/services/dma-api/src/dma_api/repository.py index 9cb5019..6b8b38d 100644 --- a/services/dma-api/src/dma_api/repository.py +++ b/services/dma-api/src/dma_api/repository.py @@ -7,7 +7,7 @@ import sqlite3 from base64 import urlsafe_b64decode, urlsafe_b64encode from dataclasses import dataclass -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from dma_api.models import MemoryType @@ -165,7 +165,7 @@ def create_or_get(self, record: MemoryRecord, idempotency_key: str) -> tuple[Mem ) connection.execute( """UPDATE memories SET content = ?, version = ?, updated_at = ?, expires_at = ?, metadata_json = ? WHERE id = ?""", - (updated.content, updated.version, updated.updated_at.isoformat(), updated.expires_at.isoformat() if updated.expires_at else None, json.dumps(updated.metadata, separators=(",", ":"), sort_keys=True), updated.id), + (updated.content, updated.version, self._utc_isoformat(updated.updated_at), self._utc_isoformat(updated.expires_at) if updated.expires_at else None, json.dumps(updated.metadata, separators=(",", ":"), sort_keys=True), updated.id), ) connection.execute("DELETE FROM memory_search WHERE memory_id = ?", (updated.id,)) connection.execute("INSERT INTO memory_search (content, memory_id, tenant_id, agent_id, type) VALUES (?, ?, ?, ?, ?)", (updated.content, updated.id, updated.tenant_id, updated.agent_id, updated.type.value)) @@ -186,9 +186,9 @@ def create_or_get(self, record: MemoryRecord, idempotency_key: str) -> tuple[Mem record.content, record.type.value, record.version, - record.created_at.isoformat(), - record.updated_at.isoformat(), - record.expires_at.isoformat() if record.expires_at else None, + self._utc_isoformat(record.created_at), + self._utc_isoformat(record.updated_at), + self._utc_isoformat(record.expires_at) if record.expires_at else None, json.dumps(record.metadata, separators=(",", ":"), sort_keys=True), ), ) @@ -238,7 +238,7 @@ def recall( "m.agent_id = ?", "(m.expires_at IS NULL OR m.expires_at > ?)", ] - parameters: list[object] = [search_query, tenant_id, agent_id, now.isoformat()] + parameters: list[object] = [search_query, tenant_id, agent_id, self._utc_isoformat(now)] if types: placeholders = ", ".join("?" for _ in types) where.append(f"m.type IN ({placeholders})") @@ -458,9 +458,20 @@ def _has_current_marker(cls, content: str) -> bool: def _normalise_semantic(content: str) -> str: return " ".join(content.split()).casefold() + @staticmethod + def _utc_isoformat(value: datetime) -> str: + """Serialise a datetime so lexical comparison matches chronological order. + + Timestamps are stored with a fixed +00:00 offset because recall filters + expiry inside SQL using string comparison; mixed offsets would break it. + """ + if value.tzinfo is not None: + value = value.astimezone(UTC) + return value.isoformat() + @staticmethod def _encode_cursor(record: MemoryRecord) -> str: - raw = f"{record.created_at.isoformat()}|{record.id}".encode() + raw = f"{SQLiteMemoryRepository._utc_isoformat(record.created_at)}|{record.id}".encode() return urlsafe_b64encode(raw).decode().rstrip("=") @staticmethod diff --git a/services/dma-api/tests/test_recall.py b/services/dma-api/tests/test_recall.py index 5aa9fe4..87744f4 100644 --- a/services/dma-api/tests/test_recall.py +++ b/services/dma-api/tests/test_recall.py @@ -1,11 +1,13 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime, timedelta, timezone from fastapi.testclient import TestClient from dma_api.config import Settings from dma_api.main import create_app +from dma_api.models import MemoryType +from dma_api.repository import MemoryRecord, SQLiteMemoryRepository def _headers(key: str) -> dict[str, str]: @@ -59,3 +61,38 @@ def test_recall_excludes_expired_and_non_matching_types(tmp_path) -> None: assert response.status_code == 200 assert [item["type"] for item in response.json()["results"]] == ["procedural"] + + +def test_recall_includes_memories_expiring_after_now_regardless_of_offset(tmp_path) -> None: + """A non-UTC expiry must not be treated as expired by lexical comparison.""" + repository = SQLiteMemoryRepository(tmp_path / "dma.db") + repository.initialize() + now = datetime(2026, 8, 27, 20, 0, 0, tzinfo=UTC) + expires_soon = datetime(2026, 8, 27, 10, 0, 0, tzinfo=timezone(timedelta(hours=-11))) + assert expires_soon.astimezone(UTC) > now + repository.create_or_get( + MemoryRecord( + id="mem_offsetexpiry00000000000001", + tenant_id="tenant-a", + agent_id="coding-agent", + content="Staging cluster deployment notes.", + type=MemoryType.EPISODIC, + version=1, + created_at=now, + updated_at=now, + expires_at=expires_soon, + metadata={}, + ), + "idempotency-offset-expiry-0001", + ) + + matches = repository.recall( + tenant_id="tenant-a", + agent_id="coding-agent", + query="staging cluster deployment", + types=None, + limit=5, + now=now, + ) + + assert [record.id for record, _ in matches] == ["mem_offsetexpiry00000000000001"] From 9e1aa5bf8bc6c132303a0f958eec230d64126084 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:22:32 +0000 Subject: [PATCH 2/2] Apply remaining changes Co-authored-by: krishna3554 <87197325+krishna3554@users.noreply.github.com> --- benchmarks/runner/memory_eval.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/benchmarks/runner/memory_eval.py b/benchmarks/runner/memory_eval.py index 2c16ca9..5ef7196 100644 --- a/benchmarks/runner/memory_eval.py +++ b/benchmarks/runner/memory_eval.py @@ -5,6 +5,7 @@ import argparse import json import statistics +import sys import tempfile from collections import defaultdict from dataclasses import dataclass @@ -261,7 +262,7 @@ def main() -> None: print(f"New failures: {current_failures - baseline_failures}") print(f"Fixed failures: {baseline_failures - current_failures}") if arguments.fail: - exit(1) + sys.exit(1) else: print("Benchmark failures match baseline") print(output)