Skip to content

fix(memory): make SQLAlchemySession.pop_item atomic - #4206

Closed
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/sqlalchemy-pop-item-atomic
Closed

fix(memory): make SQLAlchemySession.pop_item atomic#4206
abhay-codes07 wants to merge 1 commit into
openai:mainfrom
abhay-codes07:fix/sqlalchemy-pop-item-atomic

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Summary

SQLAlchemySession.pop_item() picks the newest row id, reads its payload, then deletes it — three separate statements. Concurrent callers can complete the select for the same id before either delete lands, so both return that item. The second delete removes nothing, but its result is discarded, so the caller cannot tell. The rows no caller claimed are then never popped.

pop_item is a destructive read, so this is not a benign duplicate: an application draining a session, retrying a turn, or rewinding history processes the same item twice while other items are silently left behind.

Forty concurrent pops of a forty-item session, on main:

returned : 40
distinct : 26          <- 14 items handed to two callers, one to five
remaining: 14          <- rows nobody claimed, still in the store

The delete is the only statement that atomically claims a row, so this treats deleting zero rows as losing the race and continues to the next item. Checking rowcount keeps the existing dialect-agnostic fallback rather than requiring DELETE ... RETURNING support, which is what the current code comment ("Fallback for all dialects") is deliberately written around.

The SQLite backends are unaffected — they already claim the row with a single DELETE ... RETURNING. I verified that by running the same probe against all three backends; only the SQLAlchemy one violates the contract.

Test plan

tests/extensions/memory/test_sqlalchemy_session.py::test_concurrent_pop_item_hands_each_item_to_one_caller runs 40 concurrent pop_item() calls against a 40-item session and asserts every item is returned exactly once and the store ends empty.

Fails on main, passes with the fix:

# main
E       AssertionError: assert 29 == 40
E        +  where 29 = len({'m11', 'm12', ...})
E        +    where ... = set(['m39', 'm34', 'm34', 'm34', 'm34', 'm34', ...])
1 failed, 34 deselected

Verification from the repository root:

Command Result
make format clean
make lint all checks passed
make mypy 5 errors, all pre-existing on main, none in the touched files
make pyright 1 error, pre-existing on main (src/agents/sandbox/util/tar_utils.py:161)
uv run pytest tests/extensions/memory/test_sqlalchemy_session.py 35 passed
make tests 5769 passed

The full-suite run was done on Windows, where some sandbox symlink and tracing/realtime timing tests fail independently of this change. I diffed the failing set against a clean main checkout in the same environment: the two sets are identical (58 vs 58, no differences either way).

Issue number

Closes #4205

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

The verification script is a bash script that shells out to make; I ran the underlying steps individually instead, with the results above.


@seratch — this came out of running the same concurrency contract against every session backend rather than reading one in isolation, which is why I am fairly confident the scope is exactly this one method.

One thing worth your call: rowcount is the minimal fix and keeps the fallback working on every dialect, but it does leave pop_item as three round trips per attempt. If you would rather have the atomic form where the dialect supports it, delete(...).returning(message_data) would collapse the read and the claim into one statement on PostgreSQL, SQLite 3.35+ and MariaDB, with this rowcount path kept for everything else. I did not do that here because it adds a dialect branch to a function that was deliberately written without one.

pop_item selects the newest id, reads its payload, then deletes it in three
separate statements. Concurrent callers can select the same id before either
delete lands, so both return that item while the rows neither of them claimed
stay in the store.

With forty concurrent pops of a forty item session, twenty six distinct items
were returned -- one of them five times -- and fourteen rows were still
present afterwards, even though every call reported success.

The delete is the only atomic claim on a row, so treat deleting zero rows as
losing the race and continue to the next item instead of returning one this
caller did not remove. Checking rowcount keeps the dialect-agnostic fallback
rather than requiring DELETE ... RETURNING support.

The SQLite backends are unaffected: they claim the row with a single
DELETE ... RETURNING.
Copilot AI review requested due to automatic review settings August 5, 2026 11:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84606a26ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +446 to +447
if deleted.rowcount == 0:
continue

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 👍 / 👎.

)
)

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 👍 / 👎.

@seratch

seratch commented Aug 5, 2026

Copy link
Copy Markdown
Member

Thanks for sharing this patch. We'll close this PR in favor of #4212, which covers all similar patterns across the SDK.

@seratch seratch closed this Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQLAlchemySession.pop_item returns the same item to concurrent callers

3 participants