416 lines
16 KiB
Python
416 lines
16 KiB
Python
"""Tests for the session epoch counter.
|
|
|
|
Covers schema migration of the ``epoch`` column, NULL-row repair on
|
|
connect, reset-increments-epoch, stale-write rejection (single + concurrent),
|
|
SCHEMA_VERSION visibility, and epoch injection into event payloads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from opensquilla.compat import aiosqlite
|
|
from opensquilla.session.models import SessionNode, TranscriptEntry
|
|
from opensquilla.session.storage import SCHEMA_VERSION, 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:test") -> SessionNode:
|
|
node = SessionNode(session_key=key, session_id="sid-" + key.split(":")[-1])
|
|
await storage.upsert_session(node)
|
|
return node
|
|
|
|
|
|
# ── SCHEMA_VERSION constant ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_schema_version_constant_visible():
|
|
"""SCHEMA_VERSION is importable and >= 2 (epoch migration bumped it)."""
|
|
assert isinstance(SCHEMA_VERSION, int)
|
|
assert SCHEMA_VERSION >= 2
|
|
|
|
|
|
# ── epoch column default ────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_epoch_column_exists_with_default_zero(storage):
|
|
"""New databases have the epoch column defaulting to 0."""
|
|
await _make_session(storage)
|
|
epoch = await storage.get_epoch("agent:main:test")
|
|
assert epoch == 0
|
|
|
|
|
|
# ── old DB migrates ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_old_db_migrates():
|
|
"""A pre-epoch database (no epoch column) is migrated transparently on connect."""
|
|
conn = await aiosqlite.connect(":memory:")
|
|
conn.row_factory = aiosqlite.Row
|
|
await conn.execute("PRAGMA journal_mode=WAL")
|
|
# Create old-style sessions table without epoch column
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
session_key TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_tokens_fresh INTEGER NOT NULL DEFAULT 0,
|
|
estimated_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
total_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
billed_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
estimated_cost_component_usd REAL NOT NULL DEFAULT 0.0,
|
|
cost_source TEXT NOT NULL DEFAULT 'none',
|
|
missing_cost_entries INTEGER NOT NULL DEFAULT 0,
|
|
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
cache_write INTEGER NOT NULL DEFAULT 0,
|
|
compaction_count INTEGER NOT NULL DEFAULT 0,
|
|
forked_from_parent INTEGER NOT NULL DEFAULT 0,
|
|
spawn_depth INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
chat_type TEXT NOT NULL DEFAULT 'unknown',
|
|
fast_mode INTEGER NOT NULL DEFAULT 0,
|
|
send_policy TEXT NOT NULL DEFAULT 'allow',
|
|
queue_mode TEXT NOT NULL DEFAULT 'steer',
|
|
agent_id TEXT NOT NULL DEFAULT 'main',
|
|
schema_version INTEGER NOT NULL DEFAULT 1
|
|
)
|
|
"""
|
|
)
|
|
# Insert a row without epoch
|
|
await conn.execute(
|
|
"INSERT INTO sessions (session_key, session_id, created_at, updated_at) "
|
|
"VALUES (?, ?, 0, 0)",
|
|
("agent:main:old", "sid-old"),
|
|
)
|
|
await conn.commit()
|
|
await conn.close()
|
|
|
|
# Now open via SessionStorage — migration must add epoch column
|
|
# We can't easily pass an existing connection, so use a temp file.
|
|
import os
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
db_path = f.name
|
|
try:
|
|
# Build the old-style DB on disk
|
|
conn2 = await aiosqlite.connect(db_path)
|
|
await conn2.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
session_key TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_tokens_fresh INTEGER NOT NULL DEFAULT 0,
|
|
estimated_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
total_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
billed_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
estimated_cost_component_usd REAL NOT NULL DEFAULT 0.0,
|
|
cost_source TEXT NOT NULL DEFAULT 'none',
|
|
missing_cost_entries INTEGER NOT NULL DEFAULT 0,
|
|
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
cache_write INTEGER NOT NULL DEFAULT 0,
|
|
compaction_count INTEGER NOT NULL DEFAULT 0,
|
|
forked_from_parent INTEGER NOT NULL DEFAULT 0,
|
|
spawn_depth INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
chat_type TEXT NOT NULL DEFAULT 'unknown',
|
|
fast_mode INTEGER NOT NULL DEFAULT 0,
|
|
send_policy TEXT NOT NULL DEFAULT 'allow',
|
|
queue_mode TEXT NOT NULL DEFAULT 'steer',
|
|
agent_id TEXT NOT NULL DEFAULT 'main',
|
|
schema_version INTEGER NOT NULL DEFAULT 1
|
|
)
|
|
"""
|
|
)
|
|
await conn2.execute(
|
|
"INSERT INTO sessions (session_key, session_id, created_at, updated_at) "
|
|
"VALUES (?, ?, 0, 0)",
|
|
("agent:main:old", "sid-old"),
|
|
)
|
|
await conn2.commit()
|
|
await conn2.close()
|
|
|
|
# Open with SessionStorage — triggers migration
|
|
s = SessionStorage(db_path)
|
|
await s.connect()
|
|
try:
|
|
# epoch column must exist and default to 0
|
|
epoch = await s.get_epoch("agent:main:old")
|
|
assert epoch == 0, f"Expected 0, got {epoch}"
|
|
finally:
|
|
await s.close()
|
|
finally:
|
|
os.unlink(db_path)
|
|
|
|
|
|
# ── reset increments epoch ──────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reset_increments_epoch(storage):
|
|
"""increment_epoch raises epoch by 1 on each call."""
|
|
await _make_session(storage)
|
|
key = "agent:main:test"
|
|
|
|
assert await storage.get_epoch(key) == 0
|
|
|
|
e1 = await storage.increment_epoch(key)
|
|
assert e1 == 1
|
|
|
|
e2 = await storage.increment_epoch(key)
|
|
assert e2 == 2
|
|
|
|
e3 = await storage.increment_epoch(key)
|
|
assert e3 == 3
|
|
|
|
|
|
# ── startup NULL-row guard ──────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_null_epoch_rows_zeroed_on_connect():
|
|
"""If a row somehow has NULL epoch, _migrate_epoch_column zeros it."""
|
|
import os
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
db_path = f.name
|
|
try:
|
|
# Build a DB that already has the epoch column but with a NULL value
|
|
conn = await aiosqlite.connect(db_path)
|
|
await conn.execute(
|
|
"""
|
|
CREATE TABLE sessions (
|
|
session_key TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT 0,
|
|
updated_at INTEGER NOT NULL DEFAULT 0,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
total_tokens_fresh INTEGER NOT NULL DEFAULT 0,
|
|
estimated_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
total_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
billed_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
estimated_cost_component_usd REAL NOT NULL DEFAULT 0.0,
|
|
cost_source TEXT NOT NULL DEFAULT 'none',
|
|
missing_cost_entries INTEGER NOT NULL DEFAULT 0,
|
|
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
cache_write INTEGER NOT NULL DEFAULT 0,
|
|
compaction_count INTEGER NOT NULL DEFAULT 0,
|
|
forked_from_parent INTEGER NOT NULL DEFAULT 0,
|
|
spawn_depth INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'running',
|
|
chat_type TEXT NOT NULL DEFAULT 'unknown',
|
|
fast_mode INTEGER NOT NULL DEFAULT 0,
|
|
send_policy TEXT NOT NULL DEFAULT 'allow',
|
|
queue_mode TEXT NOT NULL DEFAULT 'steer',
|
|
agent_id TEXT NOT NULL DEFAULT 'main',
|
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
epoch INTEGER
|
|
)
|
|
"""
|
|
)
|
|
# Insert with explicit NULL epoch
|
|
await conn.execute(
|
|
"INSERT INTO sessions (session_key, session_id, created_at, updated_at, epoch) "
|
|
"VALUES (?, ?, 0, 0, NULL)",
|
|
("agent:main:nullrow", "sid-null"),
|
|
)
|
|
await conn.commit()
|
|
await conn.close()
|
|
|
|
s = SessionStorage(db_path)
|
|
await s.connect()
|
|
try:
|
|
epoch = await s.get_epoch("agent:main:nullrow")
|
|
assert epoch == 0
|
|
finally:
|
|
await s.close()
|
|
finally:
|
|
os.unlink(db_path)
|
|
|
|
|
|
# ── stale write rejected ────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_write_rejected(storage):
|
|
"""A write using a stale epoch (pre-reset) returns 0 rows affected.
|
|
|
|
Simulates a write that targets epoch=0 but the session has been reset
|
|
(epoch=1). The WHERE epoch = ? clause must match 0 rows.
|
|
"""
|
|
await _make_session(storage)
|
|
key = "agent:main:test"
|
|
|
|
# Advance epoch to 1 (one reset)
|
|
await storage.increment_epoch(key)
|
|
current_epoch = await storage.get_epoch(key)
|
|
assert current_epoch == 1
|
|
|
|
# Simulate a stale write: writer holds old_epoch=0 but session is at 1.
|
|
old_epoch = 0
|
|
async with storage.conn.execute(
|
|
"UPDATE sessions SET status = 'done' WHERE session_key = ? AND epoch = ?",
|
|
(key, old_epoch),
|
|
) as cur:
|
|
rows_affected = cur.rowcount
|
|
|
|
assert rows_affected == 0, "Stale write must not modify rows"
|
|
|
|
# Session status must remain unchanged
|
|
node = await storage.get_session(key)
|
|
assert node is not None
|
|
assert node.status == "running"
|
|
|
|
|
|
# ── concurrent stale writes all rejected ────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_stale_writes_all_rejected(storage):
|
|
"""Three concurrent append_transcript_entry calls with stale epoch are all rejected.
|
|
|
|
Models: turn A, B, C are in-flight capturing epoch=0; a reset fires (epoch→1);
|
|
all three must raise StaleEpochError via the real storage write path.
|
|
"""
|
|
node = await _make_session(storage)
|
|
key = "agent:main:test"
|
|
|
|
# Snapshot the epoch all three in-flight writers captured
|
|
captured_epoch = await storage.get_epoch(key) # 0
|
|
|
|
# A reset fires — epoch is now 1
|
|
await storage.increment_epoch(key)
|
|
post_reset_epoch = await storage.get_epoch(key)
|
|
assert post_reset_epoch == 1
|
|
|
|
def _make_entry(label: str) -> TranscriptEntry:
|
|
return TranscriptEntry(
|
|
session_id=node.session_id,
|
|
session_key=key,
|
|
role="assistant",
|
|
content=f"stale content from {label}",
|
|
)
|
|
|
|
async def _stale_write(label: str) -> bool:
|
|
"""Returns True if StaleEpochError was raised (write correctly rejected)."""
|
|
try:
|
|
await storage.append_transcript_entry(
|
|
_make_entry(label), expected_epoch=captured_epoch
|
|
)
|
|
return False # write was NOT rejected — bad
|
|
except StaleEpochError:
|
|
return True # correctly rejected
|
|
|
|
results = await asyncio.gather(
|
|
_stale_write("write-A"),
|
|
_stale_write("write-B"),
|
|
_stale_write("write-C"),
|
|
)
|
|
|
|
assert results == [True, True, True], (
|
|
f"All stale writes must be rejected; got {results}"
|
|
)
|
|
|
|
# No transcript entries must have been written
|
|
entries = await storage.get_transcript(node.session_id)
|
|
assert entries == [], f"No stale entries should persist; got {entries}"
|
|
|
|
|
|
# ── epoch in event payload ──────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_epoch_in_event_payload(storage):
|
|
"""_emit_to_subscribers injects 'epoch' into session.event.* payloads.
|
|
|
|
Uses a minimal fake ctx with real SessionStorage so that get_epoch()
|
|
returns the live value and confirms it appears in the payload sent to
|
|
the WS connection.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
from opensquilla.gateway.rpc_sessions import _emit_to_subscribers
|
|
|
|
node = await _make_session(storage)
|
|
key = node.session_key
|
|
|
|
# Advance epoch to 3 so we can assert the injected value is non-zero.
|
|
for _ in range(3):
|
|
await storage.increment_epoch(key)
|
|
expected_epoch = await storage.get_epoch(key)
|
|
assert expected_epoch == 3
|
|
|
|
# Capture sent payloads via a fake connection.
|
|
sent: list[tuple[str, dict]] = []
|
|
|
|
class FakeConn:
|
|
async def send_event(self, event_name: str, payload: dict) -> None:
|
|
sent.append((event_name, payload))
|
|
|
|
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()
|
|
|
|
class FakeSessionMgr:
|
|
_storage = storage
|
|
|
|
fake_ctx = SimpleNamespace(
|
|
subscription_manager=FakeSubMgr(),
|
|
session_manager=FakeSessionMgr(),
|
|
)
|
|
|
|
import opensquilla.gateway.websocket as _ws_module
|
|
original_get_registry = _ws_module.get_registry
|
|
|
|
def _patched_registry():
|
|
return FakeRegistry()
|
|
|
|
_ws_module.get_registry = _patched_registry
|
|
try:
|
|
await _emit_to_subscribers(
|
|
fake_ctx, key, "session.event.text_delta", {"text": "hello"}
|
|
)
|
|
finally:
|
|
_ws_module.get_registry = original_get_registry
|
|
|
|
assert sent, "Expected at least one event to be delivered"
|
|
_event_name, payload = sent[0]
|
|
assert "epoch" in payload, f"epoch must be injected into payload; got {payload}"
|
|
assert payload["epoch"] == expected_epoch, (
|
|
f"epoch in payload ({payload['epoch']}) must match storage epoch ({expected_epoch})"
|
|
)
|