UPDATE выполнился, объект старый: синхронизация Session SQLAlchemy
Сервис загрузил запись, выполнил UPDATE через SQLAlchemy и тут же вернул старое название из того же объекта. В базе изменение уже есть. Проблема возникает, когда массовая SQL-команда и загруженное состояние Session живут по разным правилам синхронизации. Такой дефект особенно неприятен в ответе команды: следующая страница показывает новое значение, а ответ сохранения — прежнее.
В Session есть уже созданные объекты
Session хранит соответствие первичного ключа и ORM-экземпляра. Команда UPDATE изменяет строки базы; режим synchronize_session определяет, как соотнести изменение с этими экземплярами. False отключает согласование. fetch получает затронутые ключи и обновляет либо делает недействительными соответствующие атрибуты. По умолчанию auto выбирает стратегию с учётом возможностей базы.
Разберём именно ORM UPDATE с условием WHERE через Session.execute. Пакетное обновление по списку словарей и первичным ключам имеет другой путь исполнения. Не переносите вывод о синхронизации одной формы команды на любую операцию, которую команда разработки называет bulk. Сначала посмотрите, какой statement и набор параметров действительно передаются execute.
Увидим базу и объект отдельно
Нужен SQLAlchemy. Первый UPDATE намеренно использует False и показывает устаревший атрибут. Затем expire заставляет перечитать его. Вторая команда использует fetch, после чего загруженный объект сразу соответствует изменению. Всё выполняется на временной SQLite.
from sqlalchemy import create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
from sqlalchemy import update
with Session(engine) as db:
db.add(Item(id=1, name="old"))
db.commit()
with Session(engine) as db:
item = db.get(Item, 1)
db.execute(update(Item).where(Item.id == 1).values(name="first"),
execution_options={"synchronize_session": False})
assert item.name == "old"
stored = db.scalar(select(Item.name).where(Item.id == 1))
assert stored == "first"
print({"object_before_expire": item.name, "database": stored})
db.expire(item, ["name"])
assert item.name == "first"
db.execute(update(Item).where(Item.id == 1).values(name="second"),
execution_options={"synchronize_session": "fetch"})
assert item.name == "second"
db.commit()
with Session(engine) as db:
assert db.get(Item, 1).name == "second"
print({"committed": db.get(Item, 1).name})
engine.dispose()
Когда False допустим
Отключённая синхронизация может быть уместна в отдельной Session для массовой операции, если после неё приложение не читает уже загруженные сущности. Тогда работа заканчивается фиксацией и закрытием. Но если тот же объект участвует в ответе или следующем бизнес-решении, старое состояние нужно явно обновить либо выбрать подходящую стратегию.
expire помечает атрибут как требующий загрузки. Сам вызов не является чтением нового значения: SQL возникает при последующем доступе. refresh, напротив, сразу перечитывает выбранное состояние. Выбирайте способ по месту построения ответа и не выносите незагруженные атрибуты за пределы живой Session в надежде, что они обновятся сами.
Стратегия evaluate вычисляет условие на Python-объектах и подходит не каждому SQL-выражению. Если условие содержит сложную функцию базы, такая оценка может быть невозможна. fetch обычно яснее для команды, которой нужно согласовать затронутые экземпляры, но проверьте фактические SQL-команды и поддержку RETURNING вашим драйвером.
Не смешивайте этот дефект с потерей чужой правки. Здесь обсуждается видимость собственного массового обновления внутри процесса, а не защита от двух редакторов. Для конкурентной записи нужны отдельные условия версии или блокировка. Синхронизация Session не делает UPDATE автоматически безопасным относительно другого запроса.
Сохраните тест с заранее загруженным объектом. Если тест создаёт новую Session только после commit, он увидит правильную базу и пропустит ошибочный ответ команды. Именно сочетание object-before и database-after обнаруживает эту категорию рассогласования. Основы времени жизни Session разобраны в материале об одной Session на запрос.
Продолжить на практике
Добавьте команду массового изменения к учебному CRUD и проверяйте не только базу после commit, но и немедленный ответ. В бесплатном курсе FastAPI есть связка SQLAlchemy и HTTP-тестов, подходящая для такой регрессии. Откройте бесплатную программу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
