""" Tests for the immediate message-embedding fast path (src/reconciler/embed_now.py). These exercise embed_messages_now end-to-end against the test database: it opens its own tracked_db sessions (patched to the test engine in conftest), so each test creates committed fixture rows and asserts on the result via the provided session. """ from unittest.mock import AsyncMock, patch import pytest from fastapi import BackgroundTasks from nanoid import generate as generate_nanoid from prometheus_client import REGISTRY from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from src import models from src.config import settings from src.reconciler.embed_now import ( embed_messages_now, embed_task_gate, reset_embed_semaphore, ) from src.vector_store import VectorStore async def _create_message_with_pending_chunks( db_session: AsyncSession, workspace: models.Workspace, peer: models.Peer, chunk_contents: list[str], ) -> tuple[str, list[int]]: """Create a message plus one pending MessageEmbedding row per chunk. Returns (message public_id, ordered embedding row ids). """ session = models.Session(name=str(generate_nanoid()), workspace_name=workspace.name) db_session.add(session) await db_session.commit() message_id = str(generate_nanoid()) message = models.Message( public_id=message_id, session_name=session.name, workspace_name=workspace.name, peer_name=peer.name, content=" ".join(chunk_contents), seq_in_session=1, ) db_session.add(message) await db_session.commit() rows = [ models.MessageEmbedding( content=chunk, message_id=message_id, workspace_name=workspace.name, session_name=session.name, peer_name=peer.name, sync_state="pending", embedding=None, ) for chunk in chunk_contents ] db_session.add_all(rows) await db_session.commit() for row in rows: await db_session.refresh(row) return message_id, [row.id for row in rows] @pytest.fixture(autouse=True) def reset_semaphore_fixture(): """Rebuild the module semaphore per test so it binds to the active loop, and clear the admission gate's in-flight count.""" reset_embed_semaphore() embed_task_gate.in_flight = 0 yield reset_embed_semaphore() embed_task_gate.in_flight = 0 @pytest.mark.asyncio class TestEmbedTaskGate: """Admission gate for immediate-embed background tasks (EMBEDDING.MAX_PENDING_EMBED_TASKS).""" async def test_admits_under_cap_and_releases_slot(self) -> None: """Under the cap, the task is scheduled; running it embeds the given ids and releases the slot.""" tasks = BackgroundTasks() with ( patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 2), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock() ) as mock_embed, ): assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True assert embed_task_gate.in_flight == 1 await tasks() mock_embed.assert_awaited_once_with(["msg_1"]) assert embed_task_gate.in_flight == 0 async def test_rejects_at_cap_without_scheduling(self) -> None: """At the cap, nothing is scheduled and False is returned.""" tasks = BackgroundTasks() with patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 1): assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True assert embed_task_gate.try_schedule(tasks, ["msg_2"]) is False assert len(tasks.tasks) == 1 assert embed_task_gate.in_flight == 1 async def test_slot_released_when_task_raises(self) -> None: """A failing task still releases its slot (finally path).""" tasks = BackgroundTasks() with ( patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 1), patch( "src.reconciler.embed_now.embed_messages_now", new=AsyncMock(side_effect=RuntimeError("boom")), ), ): assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True with pytest.raises(RuntimeError): await tasks() assert embed_task_gate.in_flight == 0 async def test_gauge_mirrors_in_flight_count(self) -> None: """With metrics enabled, the Prometheus gauge tracks the gate's in-flight count through schedule and release.""" def gauge_value() -> float | None: return REGISTRY.get_sample_value( "embed_now_tasks_in_flight", {"namespace": "test"} ) tasks = BackgroundTasks() with ( patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 2), patch.object(settings.METRICS, "ENABLED", True), patch.object(settings.METRICS, "NAMESPACE", "test"), patch("src.reconciler.embed_now.embed_messages_now", new=AsyncMock()), ): assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is True assert gauge_value() == 1 await tasks() assert gauge_value() == 0 async def test_zero_cap_disables_fast_path(self) -> None: """MAX_PENDING_EMBED_TASKS=0 rejects every schedule attempt.""" tasks = BackgroundTasks() with patch.object(settings.EMBEDDING, "MAX_PENDING_EMBED_TASKS", 0): assert embed_task_gate.try_schedule(tasks, ["msg_1"]) is False assert len(tasks.tasks) == 0 @pytest.mark.asyncio class TestEmbedMessagesNow: async def test_pgvector_happy_path_marks_synced( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], ) -> None: """pgvector-only mode: rows get a vector and flip to synced immediately.""" workspace, peer = sample_data message_id, emb_ids = await _create_message_with_pending_chunks( db_session, workspace, peer, ["hello world"] ) await embed_messages_now([message_id]) for emb_id in emb_ids: row = await db_session.get(models.MessageEmbedding, emb_id) assert row is not None await db_session.refresh(row) assert row.sync_state == "synced" assert row.embedding is not None assert row.sync_attempts == 0 async def test_no_message_ids_is_noop(self) -> None: """Empty input returns without touching the DB or embedding.""" with patch( "src.embedding_client.embedding_client.simple_batch_embed" ) as mock_embed: await embed_messages_now([]) mock_embed.assert_not_called() async def test_already_synced_rows_not_reclaimed( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], ) -> None: """A second run finds no pending rows and does not re-embed.""" workspace, peer = sample_data message_id, _ = await _create_message_with_pending_chunks( db_session, workspace, peer, ["first content"] ) await embed_messages_now([message_id]) with patch( "src.embedding_client.embedding_client.simple_batch_embed" ) as mock_embed: await embed_messages_now([message_id]) mock_embed.assert_not_called() async def test_embed_failure_leaves_rows_pending_and_leased( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], ) -> None: """Embedding failure must leave rows pending + leased, attempts untouched, so the reconciler owns retry accounting.""" workspace, peer = sample_data message_id, emb_ids = await _create_message_with_pending_chunks( db_session, workspace, peer, ["will fail"] ) with patch( "src.embedding_client.embedding_client.simple_batch_embed", new=AsyncMock(side_effect=RuntimeError("provider down")), ): await embed_messages_now([message_id]) for emb_id in emb_ids: row = await db_session.get(models.MessageEmbedding, emb_id) assert row is not None await db_session.refresh(row) assert row.sync_state == "pending" assert row.embedding is None assert row.sync_attempts == 0 # lease only, no attempt bump assert row.last_sync_at is not None # leased async def test_external_store_upserts_with_chunk_positioned_ids( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], mock_vector_store: VectorStore, ) -> None: """External-store mode: upsert each chunk with id {message_id}_{position} and mark rows synced.""" workspace, peer = sample_data message_id, emb_ids = await _create_message_with_pending_chunks( db_session, workspace, peer, ["chunk a", "chunk b", "chunk c"] ) with patch( "src.reconciler.embed_now.get_external_vector_store", return_value=mock_vector_store, ): await embed_messages_now([message_id]) upsert_mock: AsyncMock = mock_vector_store.upsert_many # pyright: ignore[reportAssignmentType] upsert_mock.assert_awaited() upserted_ids = { record.id for call in upsert_mock.await_args_list for record in call.args[1] } assert upserted_ids == { f"{message_id}_0", f"{message_id}_1", f"{message_id}_2", } for emb_id in emb_ids: row = await db_session.get(models.MessageEmbedding, emb_id) assert row is not None await db_session.refresh(row) assert row.sync_state == "synced" async def test_locked_chunk_skipped_keeps_positions_stable( self, db_session: AsyncSession, db_engine: AsyncEngine, sample_data: tuple[models.Workspace, models.Peer], mock_vector_store: VectorStore, ) -> None: """If a sibling chunk is locked by another txn, SKIP LOCKED skips it but chunk positions still come from the full sibling ordering — so the claimed chunks keep their {message_id}_0 / _2 ids (not _0 / _1).""" workspace, peer = sample_data message_id, emb_ids = await _create_message_with_pending_chunks( db_session, workspace, peer, ["chunk a", "chunk b", "chunk c"] ) locked_id = emb_ids[1] # middle chunk -> position 1 # Hold a row lock on the middle chunk from an independent transaction. lock_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) lock_session = lock_factory() await lock_session.execute( select(models.MessageEmbedding) .where(models.MessageEmbedding.id == locked_id) .with_for_update() ) try: with patch( "src.reconciler.embed_now.get_external_vector_store", return_value=mock_vector_store, ): await embed_messages_now([message_id]) finally: await lock_session.rollback() await lock_session.close() upsert_mock: AsyncMock = mock_vector_store.upsert_many # pyright: ignore[reportAssignmentType] upserted_ids = { record.id for call in upsert_mock.await_args_list for record in call.args[1] } assert upserted_ids == {f"{message_id}_0", f"{message_id}_2"} # The locked chunk stays pending; the other two are synced. locked_row = await db_session.get(models.MessageEmbedding, locked_id) assert locked_row is not None await db_session.refresh(locked_row) assert locked_row.sync_state == "pending" for emb_id in (emb_ids[0], emb_ids[2]): row = await db_session.get(models.MessageEmbedding, emb_id) assert row is not None await db_session.refresh(row) assert row.sync_state == "synced" async def test_external_store_unavailable_leaves_rows_pending( self, db_session: AsyncSession, sample_data: tuple[models.Workspace, models.Peer], mock_vector_store: VectorStore, ) -> None: """External-store mode: if upsert_many raises VectorStoreError, rows must stay pending with no vector and untouched attempts, so the reconciler heals them. embed_now never bumps sync_attempts.""" from src.exceptions import VectorStoreError workspace, peer = sample_data message_id, emb_ids = await _create_message_with_pending_chunks( db_session, workspace, peer, ["chunk a", "chunk b"] ) upsert_mock: AsyncMock = mock_vector_store.upsert_many # pyright: ignore[reportAssignmentType] upsert_mock.side_effect = VectorStoreError("vector store down") with patch( "src.reconciler.embed_now.get_external_vector_store", return_value=mock_vector_store, ): await embed_messages_now([message_id]) for emb_id in emb_ids: row = await db_session.get(models.MessageEmbedding, emb_id) assert row is not None await db_session.refresh(row) assert row.sync_state == "pending" assert row.embedding is None assert row.sync_attempts == 0 # embed_now never bumps attempts