Skip to content

Commit a8b93b0

Browse files
authored
fix: correct race condition in with_for_update (#607)
This corrects an issue in the `with_for_update` behavior: - Before the change, passing `with_for_update` to service.update() or repository.update() only affected the post-flush session.refresh() call. The row that gets copied and mutated was always retrieved with a plain SELECT, so two concurrent writers could both read the same version - Now the `with_for_update` flag is honored when the row is first fetched (both in the service’s item_id branch and inside SQLAlchemyAsyncRepository.get()). When you call service.update(..., with_for_update=True) (or pass the richer dict form/ForUpdateArg), the initial SELECT ... FOR UPDATE runs, so the session holds the expected lock before any field copying or merges occur.
1 parent c08ef25 commit a8b93b0

12 files changed

Lines changed: 1182 additions & 740 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
- id: unasyncd
2323
additional_dependencies: ["ruff"]
2424
- repo: https://github.com/charliermarsh/ruff-pre-commit
25-
rev: "v0.14.2"
25+
rev: "v0.14.5"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff

advanced_alchemy/repository/_async.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage]
4040
from sqlalchemy.sql import ColumnElement
4141
from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate
42-
from sqlalchemy.sql.selectable import ForUpdateParameter
42+
from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter
4343

4444
from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception
4545
from advanced_alchemy.filters import StatementFilter, StatementTypeT
@@ -202,6 +202,7 @@ async def get(
202202
error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty,
203203
load: Optional[LoadSpec] = None,
204204
execution_options: Optional[dict[str, Any]] = None,
205+
with_for_update: ForUpdateParameter = None,
205206
) -> ModelT: ...
206207

207208
async def get_one(
@@ -1033,6 +1034,31 @@ def _get_base_stmt(
10331034
statement = cast("StatementTypeT", statement.execution_options(**execution_options))
10341035
return statement
10351036

1037+
def _apply_for_update_options(
1038+
self,
1039+
statement: Select[tuple[ModelT]],
1040+
with_for_update: ForUpdateParameter,
1041+
) -> Select[tuple[ModelT]]:
1042+
"""Apply FOR UPDATE options to a SELECT statement when requested."""
1043+
1044+
if with_for_update in (None, False):
1045+
return statement
1046+
if with_for_update is True:
1047+
return statement.with_for_update()
1048+
if isinstance(with_for_update, ForUpdateArg):
1049+
with_for_update_kwargs: dict[str, Any] = {
1050+
"nowait": with_for_update.nowait,
1051+
"read": with_for_update.read,
1052+
"skip_locked": with_for_update.skip_locked,
1053+
"key_share": with_for_update.key_share,
1054+
}
1055+
if getattr(with_for_update, "of", None):
1056+
with_for_update_kwargs["of"] = with_for_update.of
1057+
return statement.with_for_update(**with_for_update_kwargs)
1058+
if isinstance(with_for_update, dict): # pyright: ignore
1059+
return statement.with_for_update(**with_for_update)
1060+
return statement
1061+
10361062
def _get_delete_many_statement(
10371063
self,
10381064
*,
@@ -1071,6 +1097,7 @@ async def get(
10711097
load: Optional[LoadSpec] = None,
10721098
execution_options: Optional[dict[str, Any]] = None,
10731099
uniquify: Optional[bool] = None,
1100+
with_for_update: ForUpdateParameter = None,
10741101
) -> ModelT:
10751102
"""Get instance identified by `item_id`.
10761103
@@ -1085,6 +1112,7 @@ async def get(
10851112
load: Set relationships to be loaded
10861113
execution_options: Set default execution options
10871114
uniquify: Optionally apply the ``unique()`` method to results before returning.
1115+
with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement.
10881116
10891117
Returns:
10901118
The retrieved instance.
@@ -1107,6 +1135,7 @@ async def get(
11071135
execution_options=execution_options,
11081136
)
11091137
statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)])
1138+
statement = self._apply_for_update_options(statement, with_for_update)
11101139
instance = (await self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none()
11111140
instance = self.check_not_found(instance)
11121141
self._expunge(instance, auto_expunge=auto_expunge)
@@ -1486,7 +1515,11 @@ async def update(
14861515
id_attribute=id_attribute,
14871516
)
14881517
existing_instance = await self.get(
1489-
item_id, id_attribute=id_attribute, load=load, execution_options=execution_options
1518+
item_id,
1519+
id_attribute=id_attribute,
1520+
load=load,
1521+
execution_options=execution_options,
1522+
with_for_update=with_for_update,
14901523
)
14911524
mapper = None
14921525
with (

advanced_alchemy/repository/_sync.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from sqlalchemy.orm.strategy_options import _AbstractLoad # pyright: ignore[reportPrivateUsage]
4141
from sqlalchemy.sql import ColumnElement
4242
from sqlalchemy.sql.dml import ReturningDelete, ReturningUpdate
43-
from sqlalchemy.sql.selectable import ForUpdateParameter
43+
from sqlalchemy.sql.selectable import ForUpdateArg, ForUpdateParameter
4444

4545
from advanced_alchemy.exceptions import ErrorMessages, NotFoundError, RepositoryError, wrap_sqlalchemy_exception
4646
from advanced_alchemy.filters import StatementFilter, StatementTypeT
@@ -203,6 +203,7 @@ def get(
203203
error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty,
204204
load: Optional[LoadSpec] = None,
205205
execution_options: Optional[dict[str, Any]] = None,
206+
with_for_update: ForUpdateParameter = None,
206207
) -> ModelT: ...
207208

208209
def get_one(
@@ -1034,6 +1035,31 @@ def _get_base_stmt(
10341035
statement = cast("StatementTypeT", statement.execution_options(**execution_options))
10351036
return statement
10361037

1038+
def _apply_for_update_options(
1039+
self,
1040+
statement: Select[tuple[ModelT]],
1041+
with_for_update: ForUpdateParameter,
1042+
) -> Select[tuple[ModelT]]:
1043+
"""Apply FOR UPDATE options to a SELECT statement when requested."""
1044+
1045+
if with_for_update in (None, False):
1046+
return statement
1047+
if with_for_update is True:
1048+
return statement.with_for_update()
1049+
if isinstance(with_for_update, ForUpdateArg):
1050+
with_for_update_kwargs: dict[str, Any] = {
1051+
"nowait": with_for_update.nowait,
1052+
"read": with_for_update.read,
1053+
"skip_locked": with_for_update.skip_locked,
1054+
"key_share": with_for_update.key_share,
1055+
}
1056+
if getattr(with_for_update, "of", None):
1057+
with_for_update_kwargs["of"] = with_for_update.of
1058+
return statement.with_for_update(**with_for_update_kwargs)
1059+
if isinstance(with_for_update, dict): # pyright: ignore
1060+
return statement.with_for_update(**with_for_update)
1061+
return statement
1062+
10371063
def _get_delete_many_statement(
10381064
self,
10391065
*,
@@ -1072,6 +1098,7 @@ def get(
10721098
load: Optional[LoadSpec] = None,
10731099
execution_options: Optional[dict[str, Any]] = None,
10741100
uniquify: Optional[bool] = None,
1101+
with_for_update: ForUpdateParameter = None,
10751102
) -> ModelT:
10761103
"""Get instance identified by `item_id`.
10771104
@@ -1086,6 +1113,7 @@ def get(
10861113
load: Set relationships to be loaded
10871114
execution_options: Set default execution options
10881115
uniquify: Optionally apply the ``unique()`` method to results before returning.
1116+
with_for_update: Optional FOR UPDATE clause / parameters to apply to the SELECT statement.
10891117
10901118
Returns:
10911119
The retrieved instance.
@@ -1108,6 +1136,7 @@ def get(
11081136
execution_options=execution_options,
11091137
)
11101138
statement = self._filter_select_by_kwargs(statement, [(id_attribute, item_id)])
1139+
statement = self._apply_for_update_options(statement, with_for_update)
11111140
instance = (self._execute(statement, uniquify=loader_options_have_wildcard)).scalar_one_or_none()
11121141
instance = self.check_not_found(instance)
11131142
self._expunge(instance, auto_expunge=auto_expunge)
@@ -1487,7 +1516,11 @@ def update(
14871516
id_attribute=id_attribute,
14881517
)
14891518
existing_instance = self.get(
1490-
item_id, id_attribute=id_attribute, load=load, execution_options=execution_options
1519+
item_id,
1520+
id_attribute=id_attribute,
1521+
load=load,
1522+
execution_options=execution_options,
1523+
with_for_update=with_for_update,
14911524
)
14921525
mapper = None
14931526
with (

advanced_alchemy/repository/memory/_async.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ async def get(
454454
load: Optional[LoadSpec] = None,
455455
execution_options: Optional[dict[str, Any]] = None,
456456
uniquify: Optional[bool] = None,
457+
with_for_update: ForUpdateParameter = None,
457458
) -> ModelT:
458459
return self._find_or_raise_not_found(item_id)
459460

advanced_alchemy/repository/memory/_sync.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ def get(
455455
load: Optional[LoadSpec] = None,
456456
execution_options: Optional[dict[str, Any]] = None,
457457
uniquify: Optional[bool] = None,
458+
with_for_update: ForUpdateParameter = None,
458459
) -> ModelT:
459460
return self._find_or_raise_not_found(item_id)
460461

advanced_alchemy/service/_async.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,8 +746,12 @@ async def update(
746746
if item_id is not None:
747747
# When item_id is provided, update existing instance rather than replacing it
748748
# This preserves relationships and database-managed fields
749-
existing_instance = await self.repository.get(
750-
item_id, id_attribute=id_attribute, load=load, execution_options=execution_options
749+
existing_instance: ModelT = await self.repository.get(
750+
item_id,
751+
id_attribute=id_attribute,
752+
load=load,
753+
execution_options=execution_options,
754+
with_for_update=with_for_update,
751755
)
752756

753757
# Extract attributes from converted model to update existing instance

advanced_alchemy/service/_sync.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,8 +745,12 @@ def update(
745745
if item_id is not None:
746746
# When item_id is provided, update existing instance rather than replacing it
747747
# This preserves relationships and database-managed fields
748-
existing_instance = self.repository.get(
749-
item_id, id_attribute=id_attribute, load=load, execution_options=execution_options
748+
existing_instance: ModelT = self.repository.get(
749+
item_id,
750+
id_attribute=id_attribute,
751+
load=load,
752+
execution_options=execution_options,
753+
with_for_update=with_for_update,
750754
)
751755

752756
# Extract attributes from converted model to update existing instance

pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,16 @@ module = [
462462
"dishka.*",
463463
]
464464

465+
[[tool.mypy.overrides]]
466+
follow_imports = "skip"
467+
ignore_missing_imports = true
468+
module = [
469+
"pytest",
470+
"pytest.*",
471+
"_pytest",
472+
"_pytest.*",
473+
]
474+
465475
[[tool.mypy.overrides]]
466476
module = "advanced_alchemy._serialization"
467477
warn_unused_ignores = false
@@ -506,6 +516,10 @@ module = "examples.flask.*"
506516
disable_error_code = "unreachable"
507517
module = "tests.integration.test_repository"
508518

519+
[[tool.mypy.overrides]]
520+
module = "tests.unit.test_exceptions"
521+
warn_unreachable = false
522+
509523

510524
[tool.pyright]
511525
disableBytesTypePromotions = true

tests/unit/test_extensions/test_flask.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Generator, Sequence
66
from pathlib import Path
7+
from typing import cast
78

89
import pytest
910
from flask import Flask, Response
@@ -74,7 +75,7 @@ class Repo(SQLAlchemyAsyncRepository[User]):
7475

7576
@pytest.fixture(scope="session")
7677
def tmp_path_session(tmp_path_factory: pytest.TempPathFactory) -> Path:
77-
return tmp_path_factory.mktemp("test_extensions_flask")
78+
return cast("Path", tmp_path_factory.mktemp("test_extensions_flask"))
7879

7980

8081
@pytest.fixture(scope="session")

tests/unit/test_repository.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from sqlalchemy.exc import InvalidRequestError, SQLAlchemyError
1818
from sqlalchemy.ext.asyncio import AsyncSession
1919
from sqlalchemy.orm import InstrumentedAttribute, Mapped, Session, mapped_column
20+
from sqlalchemy.sql.selectable import ForUpdateArg
2021
from sqlalchemy.types import TypeEngine
2122

2223
from advanced_alchemy import base
@@ -378,6 +379,89 @@ async def test_sqlalchemy_repo_get_member(
378379
mock_repo.session.commit.assert_not_called() # pyright: ignore[reportFunctionMemberAccess]
379380

380381

382+
async def test_sqlalchemy_repo_get_with_for_update(
383+
mock_repo: SQLAlchemyAsyncRepository[Any],
384+
mocker: MockerFixture,
385+
) -> None:
386+
"""Ensure FOR UPDATE options are applied when requested."""
387+
388+
statement = MagicMock()
389+
statement.options.return_value = statement
390+
statement.execution_options.return_value = statement
391+
statement.with_for_update.return_value = statement
392+
mock_repo.statement = statement
393+
394+
mocker.patch.object(mock_repo, "_get_loader_options", return_value=([], False))
395+
mocker.patch.object(mock_repo, "_get_base_stmt", return_value=statement)
396+
mocker.patch.object(mock_repo, "_apply_filters", return_value=statement)
397+
mocker.patch.object(mock_repo, "_filter_select_by_kwargs", return_value=statement)
398+
execute_result = MagicMock()
399+
execute_result.scalar_one_or_none.return_value = MagicMock()
400+
execute = mocker.patch.object(mock_repo, "_execute", return_value=execute_result)
401+
402+
instance = await maybe_async(mock_repo.get("instance-id", with_for_update=True))
403+
404+
assert instance is execute_result.scalar_one_or_none.return_value
405+
statement.with_for_update.assert_called_once_with()
406+
execute.assert_called_once_with(statement, uniquify=False)
407+
408+
409+
async def test_sqlalchemy_repo_get_with_for_update_dict(
410+
mock_repo: SQLAlchemyAsyncRepository[Any],
411+
mocker: MockerFixture,
412+
) -> None:
413+
statement = MagicMock()
414+
statement.options.return_value = statement
415+
statement.execution_options.return_value = statement
416+
statement.with_for_update.return_value = statement
417+
mock_repo.statement = statement
418+
419+
mocker.patch.object(mock_repo, "_get_loader_options", return_value=([], False))
420+
mocker.patch.object(mock_repo, "_get_base_stmt", return_value=statement)
421+
mocker.patch.object(mock_repo, "_apply_filters", return_value=statement)
422+
mocker.patch.object(mock_repo, "_filter_select_by_kwargs", return_value=statement)
423+
execute_result = MagicMock()
424+
execute_result.scalar_one_or_none.return_value = MagicMock()
425+
mocker.patch.object(mock_repo, "_execute", return_value=execute_result)
426+
427+
await maybe_async(
428+
mock_repo.get(
429+
"instance-id",
430+
with_for_update={"nowait": True, "read": False},
431+
)
432+
)
433+
434+
statement.with_for_update.assert_called_once_with(nowait=True, read=False)
435+
436+
437+
async def test_sqlalchemy_repo_get_with_for_update_arg(
438+
mock_repo: SQLAlchemyAsyncRepository[Any],
439+
mocker: MockerFixture,
440+
) -> None:
441+
statement = MagicMock()
442+
statement.options.return_value = statement
443+
statement.execution_options.return_value = statement
444+
statement.with_for_update.return_value = statement
445+
mock_repo.statement = statement
446+
447+
mocker.patch.object(mock_repo, "_get_loader_options", return_value=([], False))
448+
mocker.patch.object(mock_repo, "_get_base_stmt", return_value=statement)
449+
mocker.patch.object(mock_repo, "_apply_filters", return_value=statement)
450+
mocker.patch.object(mock_repo, "_filter_select_by_kwargs", return_value=statement)
451+
execute_result = MagicMock()
452+
execute_result.scalar_one_or_none.return_value = MagicMock()
453+
mocker.patch.object(mock_repo, "_execute", return_value=execute_result)
454+
455+
await maybe_async(
456+
mock_repo.get(
457+
"instance-id",
458+
with_for_update=ForUpdateArg(nowait=True, key_share=True),
459+
)
460+
)
461+
462+
statement.with_for_update.assert_called_once_with(nowait=True, read=False, skip_locked=False, key_share=True)
463+
464+
381465
async def test_sqlalchemy_repo_get_one_member(
382466
mock_repo: SQLAlchemyAsyncRepository[Any],
383467
monkeypatch: MonkeyPatch,

0 commit comments

Comments
 (0)