fleet-memory/hindsight-api-slim/tests/test_entity_resolver.py
Nicolò Boschi e6333719ee
fix(entity_resolver): prevent _pending_stats/_pending_cooccurrences memory leak (#662)
* fix(entity_resolver): prevent _pending_stats/_pending_cooccurrences memory leak

Add discard_pending_stats() to EntityResolver to clean up both pending dicts
for the current task key. Call it at the start of each _run_db_work attempt so
that exceptions between accumulation and flush_pending_stats() — including
deadlock retries — never leave stale entries keyed by recycled task IDs.

Fixes #660

* test(entity_resolver): add unit tests for discard_pending_stats()

Covers: clears both dicts for current task, is idempotent when empty,
and does not touch entries belonging to other task keys.
No database required — purely in-memory logic.
2026-03-23 16:06:04 +01:00

114 lines
4.2 KiB
Python

"""
Tests for EntityResolver edge cases.
"""
import uuid
from datetime import datetime, timezone
import asyncpg
import pytest
from hindsight_api.engine.entity_resolver import EntityResolver
from hindsight_api.pg0 import resolve_database_url
# ---------------------------------------------------------------------------
# Unit tests for discard_pending_stats() — no database required
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_discard_pending_stats_clears_both_dicts():
"""discard_pending_stats() must remove entries for the current task from
both _pending_stats and _pending_cooccurrences."""
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
key = resolver._task_key()
resolver._pending_stats[key] = [object()] # type: ignore[list-item]
resolver._pending_cooccurrences[key] = [object()] # type: ignore[list-item]
resolver.discard_pending_stats()
assert key not in resolver._pending_stats
assert key not in resolver._pending_cooccurrences
@pytest.mark.asyncio
async def test_discard_pending_stats_is_idempotent():
"""Calling discard_pending_stats() when nothing is pending must not raise."""
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
resolver.discard_pending_stats()
resolver.discard_pending_stats() # second call — still safe
@pytest.mark.asyncio
async def test_discard_pending_stats_does_not_affect_other_task_keys():
"""discard_pending_stats() must only remove the current task's entries,
leaving entries keyed under other task IDs untouched."""
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
other_key = -1 # A fake key that can never be a real task id
resolver._pending_stats[other_key] = [object()] # type: ignore[list-item]
resolver._pending_cooccurrences[other_key] = [object()] # type: ignore[list-item]
resolver.discard_pending_stats() # discards current task's key only
assert other_key in resolver._pending_stats, "other task's stats must be preserved"
assert other_key in resolver._pending_cooccurrences, "other task's cooccurrences must be preserved"
@pytest.mark.asyncio
async def test_resolve_entities_batch_handles_unicode_lower_conflicts(pg0_db_url):
"""
Existing entities with PostgreSQL/Python lowercase mismatches should resolve
to the conflicted row instead of leaving a missing entity_id.
"""
resolved_url = await resolve_database_url(pg0_db_url)
pool = await asyncpg.create_pool(resolved_url, min_size=1, max_size=2, command_timeout=30)
bank_id = f"test-entity-resolver-{uuid.uuid4().hex[:8]}"
event_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
resolver = EntityResolver(pool=pool, entity_lookup="full")
try:
async with pool.acquire() as conn:
existing_entity_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $3, 1)
RETURNING id
""",
bank_id,
"İstanbul",
event_date,
)
resolved_ids = await resolver.resolve_entities_batch(
bank_id=bank_id,
entities_data=[
{
"text": "istanbul",
"nearby_entities": [],
"event_date": event_date,
}
],
context="unicode case mismatch",
unit_event_date=event_date,
conn=conn,
)
entity_rows = await conn.fetch(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1
ORDER BY canonical_name
""",
bank_id,
)
assert resolved_ids == [existing_entity_id]
assert len(entity_rows) == 1
assert entity_rows[0]["id"] == existing_entity_id
assert entity_rows[0]["canonical_name"] == "İstanbul"
finally:
await pool.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
await pool.close()