Skip to content

Commit fa96ba7

Browse files
committed
fix(sessions): compute turn_index per turn and 409 duplicate appends
1 parent f147874 commit fa96ba7

2 files changed

Lines changed: 99 additions & 3 deletions

File tree

api/oss/src/dbs/postgres/sessions/turns/dao.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from uuid import UUID
33

44
from sqlalchemy import delete as sa_delete, select
5+
from sqlalchemy.exc import IntegrityError
56

67
from oss.src.core.sessions.turns.dtos import (
78
HarnessKind,
@@ -11,6 +12,7 @@
1112
)
1213
from oss.src.core.sessions.turns.interfaces import SessionTurnsDAOInterface
1314
from oss.src.core.shared.dtos import Windowing
15+
from oss.src.core.shared.exceptions import EntityCreationConflict
1416
from oss.src.dbs.postgres.sessions.turns.dbes import SessionTurnDBE
1517
from oss.src.dbs.postgres.sessions.turns.mappings import (
1618
map_turn_dbe_to_dto,
@@ -44,9 +46,26 @@ async def append(
4446
turn=turn,
4547
)
4648
async with self.engine.session() as session:
47-
session.add(dbe)
48-
await session.commit()
49-
await session.refresh(dbe)
49+
try:
50+
session.add(dbe)
51+
await session.commit()
52+
await session.refresh(dbe)
53+
except IntegrityError as e:
54+
await session.rollback()
55+
error_str = str(e.orig) if e.orig else str(e)
56+
if "ix_session_turns_project_id_session_id_turn_index" in error_str:
57+
raise EntityCreationConflict(
58+
entity="Session turn",
59+
message=(
60+
f"Session turn {turn.turn_index} already exists for "
61+
f"session {turn.session_id}."
62+
),
63+
conflict={
64+
"session_id": turn.session_id,
65+
"turn_index": turn.turn_index,
66+
},
67+
) from e
68+
raise
5069
return map_turn_dbe_to_dto(turn_dbe=dbe)
5170

5271
async def fetch_turn(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""A duplicate session turn maps to HTTP 409 instead of an uncaught database error."""
2+
3+
from contextlib import asynccontextmanager
4+
from uuid import uuid4
5+
6+
import pytest
7+
from sqlalchemy.exc import IntegrityError
8+
9+
from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurnCreate
10+
from oss.src.dbs.postgres.sessions.turns.dao import SessionTurnsDAO
11+
from oss.src.utils.exceptions import ConflictException, intercept_exceptions
12+
13+
14+
class _FakeSession:
15+
def __init__(self):
16+
self.rollback_count = 0
17+
18+
def add(self, _dbe):
19+
pass
20+
21+
async def commit(self):
22+
raise IntegrityError(
23+
"INSERT INTO session_turns ...",
24+
{},
25+
Exception(
26+
"duplicate key value violates unique constraint "
27+
'"ix_session_turns_project_id_session_id_turn_index"'
28+
),
29+
)
30+
31+
async def refresh(self, _dbe):
32+
raise AssertionError("a failed insert must not refresh")
33+
34+
async def rollback(self):
35+
self.rollback_count += 1
36+
37+
38+
class _FakeEngine:
39+
def __init__(self):
40+
self.session_handle = _FakeSession()
41+
42+
@asynccontextmanager
43+
async def session(self):
44+
yield self.session_handle
45+
46+
47+
@pytest.mark.anyio
48+
async def test_duplicate_turn_returns_409_and_rolls_back(anyio_backend):
49+
assert anyio_backend == "asyncio"
50+
engine = _FakeEngine()
51+
dao = SessionTurnsDAO(engine=engine)
52+
session_id = "sess-duplicate-1"
53+
turn = SessionTurnCreate(
54+
session_id=session_id,
55+
stream_id=uuid4(),
56+
turn_index=3,
57+
harness_kind=HarnessKind.PI,
58+
)
59+
60+
@intercept_exceptions(verbose=False)
61+
async def append_turn():
62+
return await dao.append(project_id=uuid4(), user_id=None, turn=turn)
63+
64+
with pytest.raises(ConflictException) as exc_info:
65+
await append_turn()
66+
67+
assert engine.session_handle.rollback_count == 1
68+
assert exc_info.value.status_code == 409
69+
assert exc_info.value.detail == {
70+
"message": f"Session turn 3 already exists for session {session_id}.",
71+
"conflict": {"session_id": session_id, "turn_index": 3},
72+
}
73+
74+
75+
@pytest.fixture
76+
def anyio_backend():
77+
return "asyncio"

0 commit comments

Comments
 (0)