fix(crud): preserve cache invalidation across get_or_create retry

`get_or_create_peers` and `get_or_create_scopes` mutate existing rows, then
insert new ones inside `db.begin_nested()`. When a concurrent writer creates
one of those rows first, the insert raises IntegrityError and the function
retries.

`begin_nested()` autoflushes the pending UPDATEs *before* opening the
savepoint, so the rollback neither undoes them nor expires the now-clean ORM
state. The retry then compared already-updated values, found no change, and
dropped those peers from `changed_peers` — skipping the cache purge while the
row change committed anyway, leaving entries stale until the 300s TTL.

Carry the mutated names into the retry via `_pending_invalidation` so the
purge cannot be lost.

The scopes facade mirrors `get_or_create_peers`, so both copies carried this.
The peer path is pre-existing and runs on every message ingest.

Also add the missing /v3 prefix to `_SCOPES_ROUTE_GUIDANCE`, which pointed
callers at a 404.

Adds tests/crud/test_get_or_create_retry_invalidation.py, which drives a real
racing session and fails without this change.
This commit is contained in:
Vineeth Voruganti 2026-07-28 18:21:32 -04:00
parent a17ce4176f
commit 679aa25388
6 changed files with 244 additions and 10 deletions

View File

@ -43,6 +43,7 @@ async def get_or_create_peers(
peers: list[schemas.PeerCreate],
*,
_retry: bool = False,
_pending_invalidation: list[str] | None = None,
) -> GetOrCreateResult[list[models.Peer]]:
"""
Get an existing list of peers or create new peers if they don't exist.
@ -53,6 +54,8 @@ async def get_or_create_peers(
workspace_name: Name of the workspace
peers: List of peer creation schemas
_retry: Whether to retry the operation
_pending_invalidation: Names of peers already mutated by a prior attempt,
whose cache keys must still be purged. See the retry branch below.
Returns:
GetOrCreateResult containing the list of peers and whether any were created
@ -123,11 +126,26 @@ async def get_or_create_peers(
raise ConflictException(
f"Unable to create or get peers: {peer_names}"
) from None
return await get_or_create_peers(db, workspace_name, peers, _retry=True)
# `begin_nested()` autoflushes the mutations above *before* opening the
# savepoint, so they are already committed-in-transaction and the rollback
# doesn't undo them — nor does it expire the now-clean ORM state. The retry
# would therefore compare already-updated values, find no change, and skip
# the purge. Carry the names forward so the invalidation can't be lost.
return await get_or_create_peers(
db,
workspace_name,
peers,
_retry=True,
_pending_invalidation=(_pending_invalidation or [])
+ [p.name for p in changed_peers],
)
# Capture peer names eagerly so the closure holds plain strings, not ORM objects
_cache_keys_to_invalidate = [
peer_cache_key(workspace_name, p.name) for p in changed_peers + new_peers
peer_cache_key(workspace_name, name)
for name in dict.fromkeys(
(_pending_invalidation or []) + [p.name for p in changed_peers + new_peers]
)
]
async def _invalidate_peer_cache():

View File

@ -51,6 +51,7 @@ async def get_or_create_scopes(
scopes: list[schemas.ScopeCreate],
*,
_retry: bool = False,
_pending_invalidation: list[str] | None = None,
) -> GetOrCreateResult[list[models.Peer]]:
"""
Get existing scopes or create new ones if they don't exist.
@ -66,6 +67,9 @@ async def get_or_create_scopes(
db: Database session
workspace_name: Name of the workspace
scopes: List of scope creation schemas (unprefixed names)
_retry: Whether this is the retry attempt
_pending_invalidation: Names of scope peers already mutated by a prior
attempt, whose cache keys must still be purged. See the retry branch.
Returns:
GetOrCreateResult containing the backing peers and whether any were
@ -122,10 +126,24 @@ async def get_or_create_scopes(
raise ConflictException(
f"Unable to create or get scopes: {sorted(peer_names)}"
) from None
return await get_or_create_scopes(db, workspace_name, scopes, _retry=True)
# `begin_nested()` autoflushes the mutations above *before* opening the
# savepoint, so they survive the rollback and leave the ORM state clean —
# the retry would compare already-updated values, find no change, and skip
# the purge. Carry the names forward so the invalidation can't be lost.
return await get_or_create_scopes(
db,
workspace_name,
scopes,
_retry=True,
_pending_invalidation=(_pending_invalidation or [])
+ [p.name for p in changed_peers],
)
_cache_keys_to_invalidate = [
peer_cache_key(workspace_name, p.name) for p in changed_peers + new_peers
peer_cache_key(workspace_name, name)
for name in dict.fromkeys(
(_pending_invalidation or []) + [p.name for p in changed_peers + new_peers]
)
]
async def _invalidate_peer_cache():

View File

@ -41,7 +41,7 @@ router = APIRouter(
# scopes facade so the observer mechanics stay internal.
_SCOPES_ROUTE_GUIDANCE = (
"Scope membership is managed via the scopes routes "
"(/workspaces/{workspace_id}/scopes/{scope_id}/sessions) or the `scopes` "
"(/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions) or the `scopes` "
"field at session creation."
)

View File

@ -207,10 +207,10 @@ def _create_store_by_type(store_type: str) -> VectorStore:
except ImportError as exc:
raise RuntimeError(
"VECTOR_STORE.TYPE is set to 'lancedb', but the 'lancedb' package "
"is not installed (for example on macOS Intel, where it is omitted "
"from dependencies because PyPI has no wheel). "
"Use TYPE 'pgvector' or 'turbopuffer', or install lancedb manually. "
f"Original import error: {exc}"
+ "is not installed (for example on macOS Intel, where it is omitted "
+ "from dependencies because PyPI has no wheel). "
+ "Use TYPE 'pgvector' or 'turbopuffer', or install lancedb manually. "
+ f"Original import error: {exc}"
) from exc
return LanceDBVectorStore()

View File

@ -0,0 +1,196 @@
"""Regression tests for cache invalidation across the get_or_create retry path.
`get_or_create_peers` / `get_or_create_scopes` mutate existing rows, then insert
new ones inside `db.begin_nested()`. A concurrent writer that creates one of those
rows first makes the insert raise `IntegrityError`, and the function retries.
The subtlety: `begin_nested()` autoflushes the pending mutations *before* opening
the savepoint, so the rollback neither undoes them nor expires the ORM state. A
retry that recomputed "what changed" from that state would see no change and skip
the cache purge — while the row change still commits anyway, leaving the cache
stale until TTL. These tests pin the purge.
The race is real (a second session committing a real row, producing a real
IntegrityError from the database); only its *timing* is made deterministic, by
hooking the one point that sits between the SELECT and the INSERT.
"""
from unittest.mock import AsyncMock, patch
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
AsyncSessionTransaction,
async_sessionmaker,
)
from src import crud, models, schemas
from src.crud.peer import peer_cache_key
from src.crud.scope import SCOPE_PEER_CONFIGURATION
from src.utils.scopes import scope_peer_name
class _RaceOnBeginNested:
"""Commit a racing row on entry to `begin_nested()`, then delegate.
That entry point is after the function's SELECT and metadata mutation but
before its INSERT flushes — precisely the window a real concurrent writer
has to slip through to trigger the IntegrityError retry.
"""
_db: AsyncSession
_engine: AsyncEngine
_rows: list[models.Peer]
_real: AsyncSessionTransaction | None
fired: bool
def __init__(self, db: AsyncSession, engine: AsyncEngine, rows: list[models.Peer]):
self._db = db
self._engine = engine
self._rows = rows
self._real = None
self.fired = False
def __call__(self):
return self
async def __aenter__(self):
if self._rows:
Session = async_sessionmaker(bind=self._engine, expire_on_commit=False)
async with Session() as other:
other.add_all(self._rows)
await other.commit()
self._rows = [] # race only once; the retry must succeed
self.fired = True
self._real = AsyncSession.begin_nested(self._db)
return await self._real.__aenter__()
async def __aexit__(self, *exc_info: object):
assert self._real is not None
return await self._real.__aexit__(*exc_info)
@pytest.mark.asyncio
async def test_peer_retry_still_invalidates_mutated_peer(
db_session: AsyncSession,
db_engine: AsyncEngine,
sample_data: tuple[models.Workspace, models.Peer],
):
"""A peer mutated before a losing race still gets its cache key purged."""
test_workspace, existing_peer = sample_data
racer_name = str(generate_nanoid())
# Give the existing peer metadata we will then change, so it is a real update.
existing_peer.h_metadata = {"v": "old"}
await db_session.commit()
race = _RaceOnBeginNested(
db_session,
db_engine,
[models.Peer(name=racer_name, workspace_name=test_workspace.name)],
)
with (
patch("src.crud.peer.safe_cache_delete", new=AsyncMock()) as mock_delete,
patch.object(db_session, "begin_nested", race),
):
result = await crud.get_or_create_peers(
db_session,
test_workspace.name,
[
schemas.PeerCreate(name=existing_peer.name, metadata={"v": "new"}),
schemas.PeerCreate(name=racer_name),
],
)
await db_session.commit()
await result.post_commit()
assert race.fired, "the race must actually have fired"
purged = {call.args[0] for call in mock_delete.await_args_list}
assert (
peer_cache_key(test_workspace.name, existing_peer.name) in purged
), "the mutated peer's cache key must still be purged after the retry"
# The mutation really did land — which is what makes a missed purge stale.
await db_session.refresh(existing_peer)
assert existing_peer.h_metadata == {"v": "new"}
@pytest.mark.asyncio
async def test_scope_retry_still_invalidates_mutated_scope(
db_session: AsyncSession,
db_engine: AsyncEngine,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Same guarantee for the scopes facade, which mirrors get_or_create_peers."""
test_workspace, _ = sample_data
kept_scope, racing_scope = str(generate_nanoid()), str(generate_nanoid())
seeded = await crud.get_or_create_scopes(
db_session,
test_workspace.name,
[schemas.ScopeCreate(name=kept_scope, metadata={"v": "old"})],
)
await db_session.commit()
await seeded.post_commit()
# The racer creates the second scope's backing peer — as a *valid* scope peer,
# so the flow reaches the insert rather than tripping the legacy-collision 409.
race = _RaceOnBeginNested(
db_session,
db_engine,
[
models.Peer(
name=scope_peer_name(racing_scope),
workspace_name=test_workspace.name,
configuration=dict(SCOPE_PEER_CONFIGURATION),
)
],
)
with (
patch("src.crud.scope.safe_cache_delete", new=AsyncMock()) as mock_delete,
patch.object(db_session, "begin_nested", race),
):
result = await crud.get_or_create_scopes(
db_session,
test_workspace.name,
[
schemas.ScopeCreate(name=kept_scope, metadata={"v": "new"}),
schemas.ScopeCreate(name=racing_scope),
],
)
await db_session.commit()
await result.post_commit()
assert race.fired, "the race must actually have fired"
purged = {call.args[0] for call in mock_delete.await_args_list}
assert (
peer_cache_key(test_workspace.name, scope_peer_name(kept_scope)) in purged
), "the mutated scope peer's cache key must still be purged after the retry"
@pytest.mark.asyncio
async def test_peer_no_race_does_not_invalidate_unchanged_peer(
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Baseline: with no race, an unchanged peer is not purged."""
test_workspace, existing_peer = sample_data
existing_peer.h_metadata = {"v": "same"}
await db_session.commit()
with patch("src.crud.peer.safe_cache_delete", new=AsyncMock()) as mock_delete:
result = await crud.get_or_create_peers(
db_session,
test_workspace.name,
[schemas.PeerCreate(name=existing_peer.name, metadata={"v": "same"})],
)
await db_session.commit()
await result.post_commit()
assert mock_delete.await_count == 0, "an unchanged peer must not be purged"

View File

@ -2,7 +2,9 @@
import pytest
from src.cache.client import _redact_cache_url
from src.cache.client import (
_redact_cache_url, # pyright: ignore[reportPrivateUsage]
)
class TestRedactCacheUrl: