223 lines
7.4 KiB
Python
223 lines
7.4 KiB
Python
"""Tests for the epoch production path.
|
|
|
|
Verifies that ``SessionManager.append_message`` passes ``expected_epoch``
|
|
through to storage; that a concurrent reset + write pair is atomic so
|
|
the write either fully succeeds (pre-reset) or fully fails (post-reset)
|
|
with no partial rows; and that ``_emit_to_subscribers`` reads the
|
|
in-process epoch cache after warm-up rather than hitting the DB on
|
|
every event.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from opensquilla.session.manager import SessionManager
|
|
from opensquilla.session.models import SessionNode, TranscriptEntry
|
|
from opensquilla.session.storage import SessionStorage, StaleEpochError
|
|
|
|
# ── fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def storage():
|
|
s = SessionStorage(":memory:")
|
|
await s.connect()
|
|
yield s
|
|
await s.close()
|
|
|
|
|
|
async def _make_session(storage: SessionStorage, key: str = "agent:main:prod") -> SessionNode:
|
|
node = SessionNode(session_key=key, session_id="sid-prod")
|
|
await storage.upsert_session(node)
|
|
return node
|
|
|
|
|
|
# ── append_message passes expected_epoch ────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manager_passes_expected_epoch(storage):
|
|
"""SessionManager.append_message must pass expected_epoch= to storage.
|
|
|
|
Uses real storage so we can verify the epoch guard fires on a stale write.
|
|
"""
|
|
node = await _make_session(storage)
|
|
key = node.session_key
|
|
|
|
mgr = SessionManager(storage)
|
|
|
|
# epoch=0 at this point — a normal write must succeed.
|
|
entry = await mgr.append_message(key, "user", "hello")
|
|
assert entry is not None
|
|
|
|
# Now simulate a reset: epoch bumps to 1.
|
|
await storage.increment_epoch(key)
|
|
|
|
# The manager reads the node (epoch=0 snapshot) then tries to write — but
|
|
# the storage epoch is now 1. The atomic guard must reject it.
|
|
# We can reproduce this by monkey-patching get_session to return a stale node.
|
|
stale_node = await storage.get_session(key)
|
|
stale_node.epoch = 0 # artificially stale
|
|
|
|
original_get = storage.get_session
|
|
|
|
async def _stale_get(k):
|
|
return stale_node
|
|
|
|
storage.get_session = _stale_get
|
|
try:
|
|
with pytest.raises(StaleEpochError):
|
|
await mgr.append_message(key, "assistant", "stale response")
|
|
finally:
|
|
storage.get_session = original_get
|
|
|
|
|
|
# ── append_message passes expected_epoch (mock variant) ──────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manager_passes_expected_epoch_mock():
|
|
"""Verify expected_epoch kwarg is forwarded via mock, not just via error path."""
|
|
mock_storage = MagicMock()
|
|
mock_storage.get_session = AsyncMock(
|
|
return_value=SessionNode(
|
|
session_key="agent:main:mock",
|
|
session_id="sid-mock",
|
|
epoch=7,
|
|
)
|
|
)
|
|
mock_storage.upsert_session = AsyncMock()
|
|
mock_storage.append_transcript_entry = AsyncMock()
|
|
|
|
mgr = SessionManager(mock_storage)
|
|
await mgr.append_message("agent:main:mock", "user", "hi")
|
|
|
|
mock_storage.append_transcript_entry.assert_awaited_once()
|
|
_, kwargs = mock_storage.append_transcript_entry.call_args
|
|
assert "expected_epoch" in kwargs, (
|
|
"append_transcript_entry must be called with expected_epoch= keyword"
|
|
)
|
|
assert kwargs["expected_epoch"] == 7, (
|
|
f"expected_epoch should be 7 (from node.epoch), got {kwargs['expected_epoch']}"
|
|
)
|
|
|
|
|
|
# ── concurrent reset + write is atomic ──────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_reset_during_write_atomic(storage):
|
|
"""Reset concurrent with write: result is atomic — write fully succeeds or fully fails.
|
|
|
|
Spawns 3 coroutines all holding epoch=0; fires a reset (epoch→1); all 3
|
|
writes must raise StaleEpochError and leave zero rows in transcript.
|
|
"""
|
|
node = await _make_session(storage)
|
|
key = node.session_key
|
|
|
|
captured_epoch = await storage.get_epoch(key) # 0
|
|
|
|
# Reset fires while writes are in-flight.
|
|
await storage.increment_epoch(key)
|
|
assert await storage.get_epoch(key) == 1
|
|
|
|
def _entry(label: str) -> TranscriptEntry:
|
|
return TranscriptEntry(
|
|
session_id=node.session_id,
|
|
session_key=key,
|
|
role="assistant",
|
|
content=f"concurrent {label}",
|
|
)
|
|
|
|
async def _write(label: str) -> str:
|
|
try:
|
|
await storage.append_transcript_entry(
|
|
_entry(label), expected_epoch=captured_epoch
|
|
)
|
|
return "ok"
|
|
except StaleEpochError:
|
|
return "stale"
|
|
|
|
results = await asyncio.gather(_write("A"), _write("B"), _write("C"))
|
|
|
|
# All must be rejected — no partial commit.
|
|
assert all(r == "stale" for r in results), (
|
|
f"All concurrent stale writes must be rejected; got {results}"
|
|
)
|
|
entries = await storage.get_transcript(node.session_id)
|
|
assert entries == [], f"No rows must be written after stale epoch; found {entries}"
|
|
|
|
|
|
# ── _emit_to_subscribers uses cache after warm-up ──────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_emit_no_db_query_per_event(storage):
|
|
"""_emit_to_subscribers must not call storage.get_epoch on every event.
|
|
|
|
After the first cache-miss DB hit, subsequent emits for the same session
|
|
read the in-process cache. 100 emits → < 5 DB calls.
|
|
"""
|
|
from opensquilla.gateway.rpc_sessions import _emit_to_subscribers
|
|
|
|
node = await _make_session(storage)
|
|
key = node.session_key
|
|
|
|
# Track get_epoch call count.
|
|
call_count = 0
|
|
original_get_epoch = storage.get_epoch
|
|
|
|
async def _counting_get_epoch(session_key: str) -> int:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return await original_get_epoch(session_key)
|
|
|
|
storage.get_epoch = _counting_get_epoch
|
|
|
|
class FakeConn:
|
|
async def send_event(self, event_name: str, payload: dict) -> None:
|
|
pass
|
|
|
|
fake_conn = FakeConn()
|
|
|
|
class FakeRegistry:
|
|
def get(self, conn_id: str):
|
|
return fake_conn
|
|
|
|
class FakeSubMgr:
|
|
def get_message_subscribers(self, session_key: str):
|
|
return {"conn-1"}
|
|
|
|
def get_session_subscribers(self):
|
|
return set()
|
|
|
|
# Use a real SessionManager so _epoch_cache is available.
|
|
session_manager = SessionManager(storage)
|
|
|
|
fake_ctx = SimpleNamespace(
|
|
subscription_manager=FakeSubMgr(),
|
|
session_manager=session_manager,
|
|
)
|
|
|
|
import opensquilla.gateway.websocket as _ws_module
|
|
original_get_registry = _ws_module.get_registry
|
|
_ws_module.get_registry = lambda: FakeRegistry()
|
|
try:
|
|
for _ in range(100):
|
|
await _emit_to_subscribers(
|
|
fake_ctx, key, "session.event.text_delta", {"text": "x"}
|
|
)
|
|
finally:
|
|
_ws_module.get_registry = original_get_registry
|
|
|
|
assert call_count < 5, (
|
|
f"storage.get_epoch called {call_count} times for 100 emits; "
|
|
f"expected < 5 (cache should be used after first miss)"
|
|
)
|