Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/agents/extensions/memory/sqlalchemy_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,16 @@ async def pop_item(self) -> TResponseInputItem | None:
select(self._messages.c.message_data).where(self._messages.c.id == row_id)
)
row = res_data.scalar_one_or_none()
await sess.execute(delete(self._messages).where(self._messages.c.id == row_id))
deleted = await sess.execute(
delete(self._messages).where(self._messages.c.id == row_id)
)

# The DELETE is the only atomic claim on this row: the select above can
# return the same id to concurrent poppers, so treat deleting zero rows as
# losing the race and look for the next item instead of handing the same
# item to two callers.
if deleted.rowcount == 0:
continue
Comment on lines +446 to +447

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restart the pop after losing the delete race

When two concurrent pop_item() calls run under MySQL/InnoDB's default REPEATABLE READ isolation, the loser can enter a tight loop here: the transaction snapshot keeps selecting the same tail row that another transaction already deleted, DELETE returns rowcount == 0, and this continue repeats without ever seeing the next item. Since SQLAlchemySession supports mysql+aiomysql, retry the pop in a new transaction or take a locking/current read before looping.

AGENTS.md reference: AGENTS.md:L134-L134

Useful? React with 👍 / 👎.


if row is None:
continue
Expand Down
37 changes: 37 additions & 0 deletions tests/extensions/memory/test_sqlalchemy_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import Iterable, Sequence
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, cast

import pytest
Expand Down Expand Up @@ -990,3 +991,39 @@ async def test_runner_with_session_settings_override(agent: Agent):
history_items = [item for item in last_input if item.get("content") != "New question"]
# Should have 2 history items (last two from the 10 we added)
assert len(history_items) == 2


@pytest.mark.asyncio
async def test_concurrent_pop_item_hands_each_item_to_one_caller(
agent: Agent, tmp_path: Path
) -> None:
"""pop_item removes an item, so concurrent callers must never receive the same one.

pop_item selects the newest id, reads its payload, then deletes it in three separate
statements. Concurrent callers can therefore select the same id before either delete
lands, and both return that item while the rows they never claimed stay in the store.
"""
item_count = 40
session_id = "concurrent_pop"
session = SQLAlchemySession.from_url(
session_id,
url=f"sqlite+aiosqlite:///{(tmp_path / 'concurrent_pop.db').as_posix()}",
create_tables=True,
)

await session.add_items(
cast(
list[TResponseInputItem],
[{"role": "user", "content": f"m{index}"} for index in range(item_count)],
)
)

popped = await asyncio.gather(*[session.pop_item() for _ in range(item_count)])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the concurrent pop test deterministic

With the SQLite URL used in this test, running 40 real writers concurrently can raise OperationalError: database is locked instead of producing a stable list of popped items: pop_item() starts with reads and then upgrades to a DELETE, and unlike add_items() it is not wrapped in the SQLite write retry helper. This makes the new regression test flaky/failing on normal SQLite scheduling; use a deterministic interleaving/fake result or add the retry path before relying on this gather.

AGENTS.md reference: AGENTS.md:L169-L169

Useful? React with 👍 / 👎.

contents = [item.get("content") for item in popped if item is not None]

assert len(contents) == item_count
# Each item belongs to exactly one caller.
assert len(set(contents)) == item_count
assert sorted(cast(list[str], contents)) == sorted(f"m{index}" for index in range(item_count))
# And nothing is left behind by a pop that returned an item it did not delete.
assert await session.get_items() == []