283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""AC-R3-EPOCH: upsert/update paths must never roll back an incremented epoch.
|
|
|
|
AC-R3-EPOCH-1: All sessions table UPSERT/UPDATE paths do not revert epoch.
|
|
AC-R3-EPOCH-2: get_or_create after reset preserves epoch.
|
|
AC-R3-EPOCH-3: update (field patch) after reset preserves epoch.
|
|
AC-R3-EPOCH-4: concurrent reset + update leaves epoch >= reset value.
|
|
AC-R3-EPOCH-HARD-1: upsert_session uses MAX clause — stale epoch=0 cannot lower DB epoch=5.
|
|
AC-R3-EPOCH-HARD-2: DB trigger blocks direct UPDATE that would decrease epoch.
|
|
AC-R3-EPOCH-HARD-3: high-concurrency mix of increment + upsert leaves epoch >= increment count.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
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
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def storage():
|
|
s = SessionStorage(":memory:")
|
|
await s.connect()
|
|
yield s
|
|
await s.close()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def mgr(storage):
|
|
return SessionManager(storage)
|
|
|
|
|
|
# ── AC-R3-EPOCH-2 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_or_create_after_reset_preserves_epoch(storage, mgr):
|
|
"""get_or_create on an existing key must not overwrite the post-reset epoch.
|
|
|
|
Steps:
|
|
1. Create session.
|
|
2. increment_epoch (epoch -> 1).
|
|
3. Call get_or_create with same key (simulates a stale racing caller).
|
|
4. Assert epoch is still 1.
|
|
"""
|
|
key = "agent:main:epoch-goc"
|
|
await mgr.create(key)
|
|
assert await storage.get_epoch(key) == 0
|
|
|
|
await storage.increment_epoch(key)
|
|
assert await storage.get_epoch(key) == 1
|
|
|
|
# get_or_create should return the existing session without touching epoch.
|
|
node, created = await mgr.get_or_create(key)
|
|
assert not created, "session already exists — created must be False"
|
|
assert await storage.get_epoch(key) == 1, (
|
|
f"epoch rolled back by get_or_create; expected 1, got {await storage.get_epoch(key)}"
|
|
)
|
|
|
|
|
|
# ── AC-R3-EPOCH-3 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_session_after_reset_preserves_epoch(storage, mgr):
|
|
"""Patching a field via manager.update() must not revert epoch.
|
|
|
|
Steps:
|
|
1. Create session.
|
|
2. increment_epoch (epoch -> 1).
|
|
3. Call manager.update(last_channel=...).
|
|
4. Assert epoch is still 1.
|
|
"""
|
|
key = "agent:main:epoch-update"
|
|
await mgr.create(key)
|
|
|
|
await storage.increment_epoch(key)
|
|
assert await storage.get_epoch(key) == 1
|
|
|
|
# update reads the current node (epoch=1 in DB) then upserts it back.
|
|
await mgr.update(key, last_channel="tg")
|
|
|
|
assert await storage.get_epoch(key) == 1, (
|
|
"epoch rolled back by manager.update(); expected 1"
|
|
)
|
|
|
|
|
|
# ── AC-R3-EPOCH-3 (direct upsert variant) ────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_upsert_with_stale_epoch_preserves_db_epoch(storage):
|
|
"""Direct storage.upsert_session with a stale node must not lower the DB epoch.
|
|
|
|
This covers callers that hold a SessionNode snapshot from before a reset
|
|
and then call upsert_session with epoch=0 in the node.
|
|
"""
|
|
key = "agent:main:direct-upsert"
|
|
node = SessionNode(session_key=key, session_id="sid-du")
|
|
await storage.upsert_session(node)
|
|
|
|
await storage.increment_epoch(key)
|
|
assert await storage.get_epoch(key) == 1
|
|
|
|
# Simulate a stale caller that still holds node with epoch=0.
|
|
node.last_channel = "slack"
|
|
# node.epoch is still 0 — the stale value.
|
|
await storage.upsert_session(node)
|
|
|
|
assert await storage.get_epoch(key) == 1, (
|
|
"direct upsert with stale node (epoch=0) rolled back the DB epoch to 0"
|
|
)
|
|
|
|
|
|
# ── AC-R3-EPOCH-4 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_reset_and_update_session_no_rollback(storage, mgr):
|
|
"""Concurrent reset and update_session: epoch must not go below reset value.
|
|
|
|
Both orderings are tested deterministically:
|
|
- reset first, then update
|
|
- update first, then reset
|
|
In both cases the final epoch must be >= 1.
|
|
"""
|
|
# Ordering A: reset before update
|
|
key_a = "agent:main:concurrent-a"
|
|
await mgr.create(key_a)
|
|
await storage.increment_epoch(key_a) # epoch -> 1
|
|
# Now update (reads epoch=1 from DB, upserts back — must not write epoch=0)
|
|
await mgr.update(key_a, last_channel="discord")
|
|
assert await storage.get_epoch(key_a) >= 1, (
|
|
"Ordering A: update after reset rolled epoch back"
|
|
)
|
|
|
|
# Ordering B: update before reset
|
|
key_b = "agent:main:concurrent-b"
|
|
await mgr.create(key_b)
|
|
# Update first (epoch still 0)
|
|
await mgr.update(key_b, last_channel="telegram")
|
|
# Then reset
|
|
await storage.increment_epoch(key_b) # epoch -> 1
|
|
assert await storage.get_epoch(key_b) >= 1, (
|
|
"Ordering B: epoch should be 1 after reset, got something else"
|
|
)
|
|
|
|
# Ordering C: truly concurrent via asyncio.gather
|
|
key_c = "agent:main:concurrent-c"
|
|
await mgr.create(key_c)
|
|
|
|
async def _reset():
|
|
await storage.increment_epoch(key_c)
|
|
|
|
async def _update():
|
|
await mgr.update(key_c, last_channel="web")
|
|
|
|
await asyncio.gather(_reset(), _update())
|
|
final_epoch = await storage.get_epoch(key_c)
|
|
assert final_epoch >= 1, (
|
|
f"Concurrent reset+update left epoch={final_epoch}, expected >= 1"
|
|
)
|
|
|
|
|
|
# ── AC-R3-EPOCH-HARD-1 ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_with_lower_epoch_no_rollback(storage):
|
|
"""upsert_session with a stale epoch=0 node must not lower a DB epoch=5.
|
|
|
|
Verifies the MAX(sessions.epoch, excluded.epoch) clause in the UPDATE path.
|
|
"""
|
|
key = "agent:main:hard-max-epoch"
|
|
node = SessionNode(session_key=key, session_id="sid-hard1")
|
|
await storage.upsert_session(node)
|
|
|
|
# Advance to epoch 5 via five increments.
|
|
for _ in range(5):
|
|
await storage.increment_epoch(key)
|
|
assert await storage.get_epoch(key) == 5
|
|
|
|
# Caller still holds the original node with epoch=0.
|
|
node.last_channel = "web"
|
|
# node.epoch is still 0 at this point.
|
|
await storage.upsert_session(node)
|
|
|
|
assert await storage.get_epoch(key) == 5, (
|
|
"upsert with stale epoch=0 rolled back DB epoch=5"
|
|
)
|
|
|
|
|
|
# ── AC-R3-EPOCH-HARD-2 ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_trigger_blocks_epoch_decrease(storage):
|
|
"""Direct SQL UPDATE that would decrease epoch must be rejected by the trigger."""
|
|
|
|
key = "agent:main:trigger-block"
|
|
node = SessionNode(session_key=key, session_id="sid-hard2")
|
|
await storage.upsert_session(node)
|
|
await storage.increment_epoch(key)
|
|
assert await storage.get_epoch(key) == 1
|
|
|
|
# Attempt to directly lower epoch to 0 — the trigger must ABORT this.
|
|
with pytest.raises(Exception):
|
|
await storage.conn.execute(
|
|
"UPDATE sessions SET epoch = 0 WHERE session_key = ?", (key,)
|
|
)
|
|
await storage.conn.commit()
|
|
|
|
# DB epoch must still be 1.
|
|
assert await storage.get_epoch(key) == 1, (
|
|
"trigger did not block the epoch decrease — DB epoch was lowered"
|
|
)
|
|
|
|
|
|
# ── AC-R3-EPOCH-HARD-3 ────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_increment_and_upsert_high_concurrency(storage):
|
|
"""50 increments concurrent with 50 stale upserts: final epoch must be >= 50."""
|
|
key = "agent:main:high-concurrency"
|
|
node = SessionNode(session_key=key, session_id="sid-hard3")
|
|
await storage.upsert_session(node)
|
|
|
|
async def _increment():
|
|
await storage.increment_epoch(key)
|
|
|
|
async def _stale_upsert():
|
|
# node.epoch is 0 — always stale relative to the increments.
|
|
await storage.upsert_session(node)
|
|
|
|
tasks = [_increment() for _ in range(50)] + [_stale_upsert() for _ in range(50)]
|
|
await asyncio.gather(*tasks)
|
|
|
|
final_epoch = await storage.get_epoch(key)
|
|
assert final_epoch >= 50, (
|
|
f"high-concurrency mix left epoch={final_epoch}, expected >= 50"
|
|
)
|
|
|
|
|
|
# ── Epoch-guard failure must leave the shared connection usable ──────────────
|
|
|
|
|
|
async def _trigger_stale_epoch_error(storage: SessionStorage) -> SessionNode:
|
|
key = "agent:main:stale-epoch-cleanup"
|
|
node = SessionNode(session_key=key, session_id="sid-stale-cleanup")
|
|
await storage.upsert_session(node)
|
|
await storage.increment_epoch(key)
|
|
|
|
stale = TranscriptEntry(
|
|
session_id=node.session_id,
|
|
session_key=key,
|
|
role="assistant",
|
|
content="stale write",
|
|
)
|
|
with pytest.raises(StaleEpochError):
|
|
await storage.append_transcript_entry(stale, expected_epoch=0)
|
|
return node
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_epoch_error_leaves_connection_transaction_clean(storage):
|
|
await _trigger_stale_epoch_error(storage)
|
|
|
|
assert storage.conn.in_transaction is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_compaction_rewrite_survives_prior_stale_epoch_error(storage):
|
|
# rewrite_compacted_session opens an explicit BEGIN IMMEDIATE, which fails
|
|
# if the failed epoch-guarded insert dangles an implicit transaction.
|
|
node = await _trigger_stale_epoch_error(storage)
|
|
|
|
await storage.rewrite_compacted_session(node=node, summary=None, entries=[])
|