Skip to content

SQLAlchemySession.pop_item returns the same item to concurrent callers #4205

Description

@abhay-codes07

Please read this first

  • Have you read the docs? Yes — Sessions and the Session protocol, which documents pop_item as "Remove and return the most recent item from the session."
  • Have you searched for related issues? Yes. Searched open and closed issues and PRs for pop_item, concurrent, race, duplicate item, and SQLAlchemySession. Nothing covers concurrent pop_item.

Describe the bug

SQLAlchemySession.pop_item() is not atomic. It selects the newest row id, reads that row's payload, then deletes it — three separate statements:

res = await sess.execute(subq)              # 1. pick the newest id
row_id = res.scalar_one_or_none()
res_data = await sess.execute(              # 2. read its payload
    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))  # 3. delete

Two concurrent callers can complete step 1 for the same row_id before either reaches step 3. Both then return that item. The second delete removes nothing, but the caller has no way to know — the delete result is discarded. Meanwhile the rows that no caller claimed are never popped and stay in the store.

pop_item is a destructive read, so returning the same item to two callers is not a benign duplicate: an application draining a session, retrying a turn, or rewinding history will process the same item twice while silently leaving other items behind.

The SQLite backends are unaffected — they claim the row with a single DELETE ... RETURNING.

Debug information

  • Agents SDK version: 0.19.4 (reproduced on main at 8f7e6d76)
  • Related library versions: SQLAlchemy 2.x, aiosqlite
  • Python version: 3.12
  • Operating system: Windows 11 (not platform specific — this is statement-level atomicity)
  • Model and model provider: none needed
  • Does the issue reproduce with the latest Agents SDK release? Yes.
  • Does the issue occur consistently or intermittently? Consistently, though the exact counts vary run to run.

Repro steps

import asyncio
import tempfile
from collections import Counter
from pathlib import Path

from agents.extensions.memory import SQLAlchemySession

N = 40


async def main() -> None:
    db = Path(tempfile.mkdtemp()) / "pop.db"
    session = SQLAlchemySession.from_url(
        "s", url=f"sqlite+aiosqlite:///{db.as_posix()}", create_tables=True
    )
    await session.add_items([{"role": "user", "content": f"m{i}"} for i in range(N)])

    popped = await asyncio.gather(*[session.pop_item() for _ in range(N)])
    contents = [item["content"] for item in popped if item is not None]

    print("returned :", len(contents))
    print("distinct :", len(set(contents)))
    print("repeats  :", {k: v for k, v in Counter(contents).items() if v > 1})
    print("remaining:", len(await session.get_items()))


asyncio.run(main())

Actual behavior

returned : 40
distinct : 26
repeats  : {'m34': 5, 'm21': 2, 'm14': 2, ...}
remaining: 14

Forty successful pops of a forty-item session returned twenty-six distinct items, one of them five times, and left fourteen rows in the store.

Expected behavior

returned : 40
distinct : 40
repeats  : {}
remaining: 0

Each item is removed and returned exactly once, which is what the SQLite backends already do.

Root-cause hypothesis

(hypothesis) The DELETE is the only statement that atomically claims a row, but its result is discarded, so a caller that deleted nothing still returns the payload it read. Treating rowcount == 0 as losing the race and continuing to the next item makes the claim atomic without needing DELETE ... RETURNING, which would restrict the dialects this fallback path is deliberately written to support.

Proposed scope

Check the delete's rowcount in SQLAlchemySession.pop_item and continue the existing loop when it is zero. No public API change, no schema change, and no new dialect requirement.

I have a fix with a regression test ready and will open a PR referencing this issue.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions