Skip to content

Commit 4a1773f

Browse files
authored
fix: make session mutations atomic (#4212)
1 parent b47a0e4 commit 4a1773f

13 files changed

Lines changed: 5588 additions & 504 deletions

docs/sessions/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ Notes:
450450

451451
- `from_uri(...)` creates and owns the `AsyncMongoClient` and closes it on `session.close()`. An owned-client session is terminal after `close()`, and subsequent session operations raise `RuntimeError`. If your application already manages a client, construct `MongoDBSession(...)` directly with `client=...`; in that case `session.close()` is a no-op, and lifecycle plus session usability stay with the caller.
452452
- Connect to [MongoDB Atlas](https://www.mongodb.com/products/platform) by passing an `mongodb+srv://user:password@cluster.example.mongodb.net` URI to `from_uri(...)` with no other changes.
453-
- Two collections are used and both names are configurable via `sessions_collection=` (default `agent_sessions`) and `messages_collection=` (default `agent_messages`). Indexes are created automatically on first use. Each message document carries a monotonically increasing `seq` counter that preserves ordering across concurrent writers and processes.
453+
- Two collections are used and both names are configurable via `sessions_collection=` (default `agent_sessions`) and `messages_collection=` (default `agent_messages`). Indexes are created automatically on first use. Each non-empty `add_items()` call writes one logical-batch document whose monotonically increasing `seq` orders the batch by its final item; legacy per-item message documents remain readable. A logical batch must fit within MongoDB's single-document size limit; an oversized batch fails atomically without storing a partial batch.
454454
- Use `await session.ping()` to verify connectivity before your first run.
455455

456456
### Advanced SQLite sessions

src/agents/extensions/memory/advanced_sqlite_session.py

Lines changed: 385 additions & 309 deletions
Large diffs are not rendered by default.

src/agents/extensions/memory/async_sqlite_session.py

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
coerce_session_settings,
1717
resolve_session_limit,
1818
)
19+
from ...memory.sqlite_session import _await_mutation
1920

2021

2122
class AsyncSQLiteSession(SessionABC):
@@ -57,6 +58,7 @@ def __init__(
5758
self.sessions_table = sessions_table
5859
self.messages_table = messages_table
5960
self._connection: aiosqlite.Connection | None = None
61+
self._quarantined_connections: set[aiosqlite.Connection] = set()
6062
self._lock = asyncio.Lock()
6163
self._init_lock = asyncio.Lock()
6264
self._closed = False
@@ -102,9 +104,46 @@ async def _get_connection(self) -> aiosqlite.Connection:
102104

103105
async with self._init_lock:
104106
if self._connection is None:
105-
self._connection = await aiosqlite.connect(str(self.db_path))
106-
await self._connection.execute("PRAGMA journal_mode=WAL")
107-
await self._init_db_for_connection(self._connection)
107+
connect_task = asyncio.ensure_future(aiosqlite.connect(str(self.db_path)))
108+
try:
109+
connection = await asyncio.shield(connect_task)
110+
except BaseException as acquisition_error:
111+
connection = None
112+
cleanup_cancellation: asyncio.CancelledError | None = None
113+
try:
114+
connection = await _await_mutation(connect_task)
115+
except asyncio.CancelledError as exc:
116+
cleanup_cancellation = exc
117+
try:
118+
connection = connect_task.result()
119+
except BaseException:
120+
pass
121+
except BaseException:
122+
pass
123+
close_error = (
124+
await self._close_owned_connection(connection)
125+
if connection is not None
126+
else None
127+
)
128+
if isinstance(acquisition_error, asyncio.CancelledError):
129+
raise
130+
if cleanup_cancellation is not None:
131+
raise cleanup_cancellation from None
132+
if isinstance(close_error, asyncio.CancelledError):
133+
raise close_error from None
134+
raise
135+
assert connection is not None
136+
try:
137+
await connection.execute("PRAGMA journal_mode=WAL")
138+
await self._init_db_for_connection(connection)
139+
except BaseException as initialization_error:
140+
close_error = await self._close_owned_connection(connection)
141+
if isinstance(initialization_error, asyncio.CancelledError):
142+
raise
143+
if isinstance(close_error, asyncio.CancelledError):
144+
raise close_error from None
145+
raise
146+
self._connection = connection
108147

109148
return self._connection
110149

@@ -121,6 +160,71 @@ async def _locked_connection(self) -> AsyncIterator[aiosqlite.Connection]:
121160
conn = await self._get_connection()
122161
yield conn
123162

163+
@asynccontextmanager
164+
async def _write_connection(self) -> AsyncIterator[aiosqlite.Connection]:
165+
"""Provide a connection that cannot retain a failed write transaction."""
166+
async with self._locked_connection() as conn:
167+
try:
168+
yield conn
169+
except BaseException as operation_error:
170+
rollback_task = asyncio.create_task(conn.rollback())
171+
rollback_error: BaseException | None = None
172+
rollback_cancellation: asyncio.CancelledError | None = None
173+
try:
174+
await _await_mutation(rollback_task)
175+
except asyncio.CancelledError as exc:
176+
rollback_cancellation = exc
177+
try:
178+
rollback_task.result()
179+
except BaseException as outcome_error:
180+
rollback_error = outcome_error
181+
except BaseException as exc:
182+
rollback_error = exc
183+
184+
invalidation_error = None
185+
if rollback_error is not None:
186+
invalidation_error = await self._invalidate_connection(conn)
187+
188+
if isinstance(operation_error, asyncio.CancelledError):
189+
raise
190+
if rollback_cancellation is not None:
191+
raise rollback_cancellation from None
192+
if isinstance(invalidation_error, asyncio.CancelledError):
193+
raise invalidation_error from None
194+
raise
195+
196+
async def _invalidate_connection(self, conn: aiosqlite.Connection) -> BaseException | None:
197+
"""Close and evict a connection that could not roll back safely."""
198+
close_error = await self._close_owned_connection(conn)
199+
if self._connection is conn:
200+
self._connection = None
201+
if str(self.db_path) == ":memory:" or close_error is not None:
202+
self._closed = True
203+
return close_error
204+
205+
async def _close_owned_connection(self, conn: aiosqlite.Connection) -> BaseException | None:
206+
"""Close an owned connection or retain it for a later cleanup retry."""
207+
close_task = asyncio.create_task(conn.close())
208+
cancellation: asyncio.CancelledError | None = None
209+
close_error: BaseException | None = None
210+
try:
211+
await _await_mutation(close_task)
212+
except asyncio.CancelledError as exc:
213+
cancellation = exc
214+
try:
215+
close_task.result()
216+
except BaseException as outcome_error:
217+
close_error = outcome_error
218+
except BaseException as exc:
219+
close_error = exc
220+
221+
if close_error is not None:
222+
self._quarantined_connections.add(conn)
223+
self._closed = True
224+
else:
225+
self._quarantined_connections.discard(conn)
226+
return cancellation or close_error
227+
124228
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
125229
"""Retrieve the conversation history for this session.
126230
@@ -206,7 +310,7 @@ async def add_items(self, items: list[TResponseInputItem]) -> None:
206310
if not items:
207311
return
208312

209-
async with self._locked_connection() as conn:
313+
async with self._write_connection() as conn:
210314
await conn.execute(
211315
f"""
212316
INSERT OR IGNORE INTO {self.sessions_table} (session_id) VALUES (?)
@@ -231,15 +335,16 @@ async def add_items(self, items: list[TResponseInputItem]) -> None:
231335
(self.session_id,),
232336
)
233337

234-
await conn.commit()
338+
await _await_mutation(conn.commit())
235339

236340
async def pop_item(self) -> TResponseInputItem | None:
237341
"""Remove and return the most recent item from the session.
238342
239343
Returns:
240344
The most recent item if it exists, None if the session is empty
241345
"""
242-
async with self._locked_connection() as conn:
346+
347+
async with self._write_connection() as conn:
243348
cursor = await conn.execute(
244349
f"""
245350
DELETE FROM {self.messages_table}
@@ -256,7 +361,7 @@ async def pop_item(self) -> TResponseInputItem | None:
256361

257362
result = await cursor.fetchone()
258363
await cursor.close()
259-
await conn.commit()
364+
await _await_mutation(conn.commit())
260365

261366
while result:
262367
message_data = result[0]
@@ -278,13 +383,14 @@ async def pop_item(self) -> TResponseInputItem | None:
278383
)
279384
result = await cursor.fetchone()
280385
await cursor.close()
281-
await conn.commit()
386+
await _await_mutation(conn.commit())
282387

283388
return None
284389

285390
async def clear_session(self) -> None:
286391
"""Clear all items for this session."""
287-
async with self._locked_connection() as conn:
392+
393+
async with self._write_connection() as conn:
288394
await conn.execute(
289395
f"DELETE FROM {self.messages_table} WHERE session_id = ?",
290396
(self.session_id,),
@@ -293,18 +399,42 @@ async def clear_session(self) -> None:
293399
f"DELETE FROM {self.sessions_table} WHERE session_id = ?",
294400
(self.session_id,),
295401
)
296-
await conn.commit()
402+
await _await_mutation(conn.commit())
297403

298404
async def close(self) -> None:
299405
"""Close the database connection.
300406
301407
The session becomes terminal from the first close attempt: subsequent
302408
operations raise RuntimeError rather than reopening the database. Repeated
303-
and concurrent calls are safe no-ops.
409+
and concurrent calls are safe. A repeated call retries any owned
410+
connection whose previous close did not complete.
304411
"""
305412
async with self._lock:
306413
self._closed = True
307-
if self._connection is None:
308-
return
309-
await self._connection.close()
310-
self._connection = None
414+
connections = set(self._quarantined_connections)
415+
if self._connection is not None:
416+
connections.add(self._connection)
417+
418+
first_error: BaseException | None = None
419+
cancellation: asyncio.CancelledError | None = None
420+
for connection in connections:
421+
close_task = asyncio.create_task(self._close_owned_connection(connection))
422+
try:
423+
close_error = await asyncio.shield(close_task)
424+
except asyncio.CancelledError as exc:
425+
if cancellation is None:
426+
cancellation = exc
427+
try:
428+
close_error = await _await_mutation(close_task)
429+
except asyncio.CancelledError:
430+
close_error = close_task.result()
431+
if close_error is None:
432+
if self._connection is connection:
433+
self._connection = None
434+
elif first_error is None:
435+
first_error = close_error
436+
437+
if cancellation is not None:
438+
raise cancellation
439+
if first_error is not None:
440+
raise first_error

0 commit comments

Comments
 (0)