2026-06-29 05:38:21 +02:00
|
|
|
"""``VectorStore.replace_chunks`` is a single atomic transaction: delete +
|
|
|
|
|
insert on ONE connection inside ONE asyncpg transaction, so concurrent
|
|
|
|
|
re-indexes can't interleave and an insert failure can't lose the source's
|
|
|
|
|
rows. These tests mock the asyncpg pool to assert that invariant.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.services.optimal_brain.text_chunker import Chunk
|
|
|
|
|
from roboco.services.optimal_brain.vector_store import VectorStore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _chunk(text: str, source: str, embedding: list[float]) -> Chunk:
|
|
|
|
|
return Chunk(text=text, source=source, metadata={}, embedding=embedding)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_store_with_conn() -> tuple[VectorStore, MagicMock, MagicMock]:
|
|
|
|
|
"""Build a VectorStore wired to a mock pool that yields one mock conn.
|
|
|
|
|
|
|
|
|
|
Returns ``(store, conn, pool)`` so a test can assert call order on the
|
|
|
|
|
same connection instance that the transaction/DELETE/INSERT ran on.
|
|
|
|
|
"""
|
|
|
|
|
conn = MagicMock()
|
|
|
|
|
# asyncpg's transaction() is an async context manager.
|
|
|
|
|
tx = MagicMock()
|
|
|
|
|
tx.__aenter__ = AsyncMock(return_value=tx)
|
|
|
|
|
tx.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
conn.transaction = MagicMock(return_value=tx)
|
|
|
|
|
conn.execute = AsyncMock()
|
|
|
|
|
conn.executemany = AsyncMock()
|
|
|
|
|
|
|
|
|
|
pool = MagicMock()
|
|
|
|
|
cm = MagicMock()
|
|
|
|
|
cm.__aenter__ = AsyncMock(return_value=conn)
|
|
|
|
|
cm.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
pool.acquire = MagicMock(return_value=cm)
|
|
|
|
|
|
|
|
|
|
store = VectorStore(
|
|
|
|
|
dsn="postgresql://test", table_name="chunks_test", vector_dimension=3
|
|
|
|
|
)
|
|
|
|
|
store._pool = pool # injected for the test
|
|
|
|
|
return store, conn, pool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_replace_chunks_deletes_then_inserts_in_one_transaction() -> None:
|
|
|
|
|
"""DELETE + INSERT run on a SINGLE connection inside a transaction."""
|
|
|
|
|
store, conn, pool = _make_store_with_conn()
|
|
|
|
|
source = "roboco://standards/general/std-1"
|
|
|
|
|
chunks = [
|
|
|
|
|
_chunk("aaa" * 100, source, [0.1, 0.2, 0.3]),
|
|
|
|
|
_chunk("bbb" * 100, source, [0.4, 0.5, 0.6]),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
await store.replace_chunks(source, chunks)
|
|
|
|
|
|
|
|
|
|
# Exactly one connection was acquired (not one for delete + one for add).
|
|
|
|
|
assert pool.acquire.call_count == 1
|
|
|
|
|
# The transaction was entered on that same connection.
|
|
|
|
|
assert conn.transaction.call_count == 1
|
|
|
|
|
# DELETE by source, then the batch INSERT, both on the same connection.
|
|
|
|
|
assert conn.execute.await_count == 1
|
|
|
|
|
delete_sql, delete_source = conn.execute.await_args.args
|
|
|
|
|
assert "DELETE FROM" in delete_sql
|
|
|
|
|
assert "source = $1" in delete_sql
|
|
|
|
|
assert delete_source == source
|
|
|
|
|
assert conn.executemany.await_count == 1
|
|
|
|
|
insert_sql, insert_records = conn.executemany.await_args.args
|
|
|
|
|
assert "INSERT INTO" in insert_sql
|
|
|
|
|
assert len(insert_records) == len(chunks)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_replace_chunks_with_no_chunks_still_clears_source() -> None:
|
|
|
|
|
"""An empty reingest still deletes the source's existing rows (matches
|
|
|
|
|
the prior ``delete_by_source`` then no-op ``add_chunks`` behavior)."""
|
|
|
|
|
store, conn, _pool = _make_store_with_conn()
|
|
|
|
|
source = "roboco://standards/general/std-1"
|
|
|
|
|
|
|
|
|
|
await store.replace_chunks(source, [])
|
|
|
|
|
|
|
|
|
|
assert conn.execute.await_count == 1 # DELETE ran
|
|
|
|
|
assert conn.executemany.await_count == 0 # nothing to insert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_replace_chunks_atomic_on_insert_failure() -> None:
|
|
|
|
|
"""If the INSERT fails, the transaction rolls back — the source's old
|
|
|
|
|
rows are NOT left deleted (no retrieval data loss). The DELETE and
|
|
|
|
|
INSERT share one transaction, so a mid-replace failure reverts both."""
|
|
|
|
|
store, conn, _pool = _make_store_with_conn()
|
|
|
|
|
source = "roboco://standards/general/std-1"
|
|
|
|
|
chunks = [_chunk("aaa" * 100, source, [0.1, 0.2, 0.3])]
|
|
|
|
|
# The INSERT raises inside the transaction.
|
|
|
|
|
conn.executemany.side_effect = RuntimeError("insert blew up")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RuntimeError):
|
|
|
|
|
await store.replace_chunks(source, chunks)
|
|
|
|
|
|
|
|
|
|
# The transaction context was entered; the raised error propagates out of
|
|
|
|
|
# the `async with conn.transaction()` block, so asyncpg rolls it back.
|
|
|
|
|
assert conn.transaction.call_count == 1
|
|
|
|
|
assert conn.execute.await_count == 1 # DELETE did run (inside the txn)…
|
|
|
|
|
assert conn.executemany.await_count == 1 # …but the INSERT raised → rollback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_replace_chunks_skips_chunks_without_embeddings() -> None:
|
|
|
|
|
"""Chunks lacking an embedding are dropped before insert (matches
|
|
|
|
|
``add_chunks``)."""
|
|
|
|
|
store, conn, _pool = _make_store_with_conn()
|
|
|
|
|
source = "roboco://standards/general/std-1"
|
|
|
|
|
chunks = [
|
|
|
|
|
_chunk("with-emb", source, [0.1, 0.2, 0.3]),
|
|
|
|
|
Chunk(text="no-emb", source=source, metadata={}), # no embedding
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
await store.replace_chunks(source, chunks)
|
|
|
|
|
|
|
|
|
|
assert conn.execute.await_count == 1 # DELETE ran
|
|
|
|
|
_sql, records = conn.executemany.await_args.args
|
|
|
|
|
assert len(records) == 1 # only the embedded chunk inserted
|
2026-06-30 08:08:35 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_replace_chunks_does_not_wipe_on_all_none_embeddings() -> None:
|
|
|
|
|
"""#181: chunks were passed (an embedder ran) but NONE produced a usable
|
|
|
|
|
embedding — that is an embedder failure, not a deliberate clear. Wiping the
|
|
|
|
|
source's existing rows on a failed embed would lose good index rows for
|
|
|
|
|
nothing. The replace must no-op (no DELETE) in that case, distinct from the
|
|
|
|
|
deliberate empty-list clear (which DOES delete)."""
|
|
|
|
|
store, conn, _pool = _make_store_with_conn()
|
|
|
|
|
source = "roboco://learnings/lrn-deadbeef"
|
|
|
|
|
|
|
|
|
|
chunks = [
|
|
|
|
|
Chunk(text="failed-embed-1", source=source, metadata={}), # no embedding
|
|
|
|
|
Chunk(text="failed-embed-2", source=source, metadata={}), # no embedding
|
|
|
|
|
]
|
|
|
|
|
await store.replace_chunks(source, chunks)
|
|
|
|
|
|
|
|
|
|
# No-op: no connection acquired, no DELETE, no INSERT — existing rows kept.
|
|
|
|
|
assert conn.execute.await_count == 0
|
|
|
|
|
assert conn.executemany.await_count == 0
|