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.

Продолжить на практике