Сравнение различных методов отката изменений базы данных в тестах pytest для SQLAlchemy

Я работаю над проектом, который асинхронно использует FastAPI и SQLAlchemy.
Я написал тесты pytest для этого проекта и успешно реализовал откат базы данных после каждого запуска теста.
Я нашел два разных метода реализации, но не уверен в различиях между ними. Оба метода верны, или один потенциально проблематичен?

conftest.py

# pyproject.toml
#
# pytest = "^8.3.2"
# pytest-asyncio = "==0.21.2"
# #pytest-dependency = "^0.6.0"
# pytest-order = "^1.2.1"
#
# [tool.pytest.ini_options]
# addopts = "-s"
# asyncio_mode = "auto"

import asyncio
from urllib.parse import urlparse

import pytest
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

from config import settings
from depends.db import async_session as api_async_session
from main import app

url = urlparse(settings.db)._replace(scheme = "postgresql+asyncpg").geturl()
async_db = create_async_engine(url, echo=False, pool_size=50)
async_session_factory = async_sessionmaker(bind=async_db)


@pytest.fixture(scope = "session")
def event_loop(request):
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()


@pytest.fixture
async def async_session():
    async with async_db.connect() as connection:
        async with connection.begin() as transaction:
            async with async_session_factory(bind=connection) as s:
                app.dependency_overrides[api_async_session] = lambda: s
                yield s
            await transaction.rollback()


@pytest.fixture
async def async_session2():
    async with async_db.connect() as connection:
        transaction = await connection.begin()
        async with async_session_factory(bind=connection, join_transaction_mode = "create_savepoint") as s:
            app.dependency_overrides[api_async_session] = lambda: s
            yield s
        await transaction.rollback()

Я также проверил официальную документацию create_savepoint, но ее слишком сложно понять. Даже после изучения исходного кода метода __aenter__ AsyncTransaction я все еще не уверен.

create_savepoint сообщает SQLAlchemy использовать вложенные транзакции, т. е. транзакцию внутри другой транзакции, и при вызове rollback откатывается только самая внутренняя транзакция (вместо всей родительской/самой внешней транзакции). Вложенные транзакции поддерживаются не всеми СУБД, но в Postgres есть достойное объяснение их точек сохранения (так они называют вложенные транзакции): postgresql.org/docs/current/sql-savepoint.html
MatsLindh 01.08.2024 09:26
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
0
1
70
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Преимущество правильного использования точек сохранения в тестах заключается в том, что вы можете явно использовать фиксацию/откат внутри кода приложения во время теста и при этом иметь возможность откатить все в конце теста, используя оригинальная сделка. По сравнению с уровнем только одной транзакции вы не можете явно использовать откат и фиксацию в своем приложении (в таких случаях обычно вы откатываете или фиксируете в конце жизненного цикла запроса).

Здесь приведен пример синхронизации использования точек сохранения (и похоже, что он соответствует вашему второму примеру):

https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction- such-as-for-test-suites

Я думаю, что второй пример лучше, если у вас есть postgresql. Хотя вам не нужно явно использовать фиксацию много раз в течение жизненного цикла запроса. Быстрая фиксация может помочь, если вам нужно открыть замок, как в этом выдуманном примере:

  • SELECT orders.* FOR UPDATE WHERE order_id = 15
  • быстро вести бухгалтерию, чтобы подготовить заказ
  • order.state = "ready_to_ship"
  • session.commit() освободить этот заказ для других изменений
  • выполнить другую соответствующую работу, но без блокировки

В этом случае первый пример, вероятно, не будет работать, потому что ваш явный вызов фиксации нарушит откат вашего теста. В то время как во втором примере будет зафиксирована точка сохранения, поэтому внешняя истинная транзакция будет отменена.

Обновлять

На самом деле существуют еще более тонкие различия между «conditional_savepoint» (по умолчанию), «rollback_only», «create_savepoint» и «control_complete».

По умолчанию при использовании postgresql, если вы еще не создали точку сохранения, используется «rollback_only». При этом игнорируются фиксации внутри активной транзакции, но откаты распространяются на внешнюю транзакцию. Таким образом, первый пример выше не позволит последующим фиксациям или взаимодействию работать. Тогда как при использовании «create_savepoint» сеанс продолжит работать.

В приведенном ниже примере показан случай, когда код завершится сбоем:

import os
import contextlib

import pytest
from sqlalchemy import (
    Column,
    String,
    BigInteger,
    create_engine,
)
from sqlalchemy.sql import (
    select,
    or_,
)
from sqlalchemy.orm import (
    declarative_base,
    Session,
)


def get_engine(env):
    return create_engine(f"postgresql+psycopg2://{env['DB_USER']}:{env['DB_PASSWORD']}@{env['DB_HOST']}:{env['DB_PORT']}/{env['DB_NAME']}", echo=True, echo_pool='debug')

Base = declarative_base()


class Order(Base):
    __tablename__ = 'orders'
    id = Column(BigInteger, primary_key=True)
    state = Column(String, nullable=False)


def main():
    #
    # We run this script to setup the testing database.
    #
    engine = get_engine(os.environ)

    with engine.begin() as conn:
        Base.metadata.create_all(conn)

    with engine.connect() as conn:
        populate(conn)

    run_test(engine)


def populate(conn):
    with Session(conn) as session:
        session.add_all([Order(id=1, state='start'), Order(id=2, state='processing'), Order(id=3, state='finished')])
        session.commit()


def run_test(engine):

    # the session we get should already be bound to a connection
    # with a active transaction
    with our_session_maker(engine) as session:
        order = session.execute(select(Order)).scalars().first()
        order.state = 'error'
        # Actually write changes to the db.
        session.flush()
        # Now try to roll them back.
        session.rollback()
        # Try to keep using the session.
        # Now try to make another change but commit this time.
        order.state = 'fixed'
        session.commit()

    # When this session is created it should be "clean".
    # Ie. not in a transaction.
    with Session(engine) as session:
        # The state change should never occur.
        assert not session.execute(select(Order).where(or_(Order.state == 'error', Order.state == 'fixed'))).scalars().all()


@contextlib.contextmanager
def our_session_maker(engine):
    with engine.connect() as connection:
        with connection.begin() as transaction:
            # one of conditional_savepoint, create_savepoint, control_fully, rollback_only.
            # The default is join_transaction_mode='conditional_savepoint'.
            with Session(bind=connection) as s:
                yield s
                transaction.rollback()


if __name__ == '__main__':
    main()

Это создает подобную обратную трассировку (сокращенную). Вы можете видеть, что исключение возникает во втором коммите.

Traceback (most recent call last):
  File "/app/scripts/testing_sync_session_pytest.py", line 89, in <module>
    main()
  File "/app/scripts/testing_sync_session_pytest.py", line 45, in main
    run_test(engine)
  File "/app/scripts/testing_sync_session_pytest.py", line 68, in run_test
    session.commit()
...
sqlalchemy.exc.InvalidRequestError: Can't operate on closed transaction inside context manager.  Please complete the context manager before emitting further commands.

Журналы в этом случае прекращаются после отката, но вы можете видеть это:

  • транзакция начата
  • база данных изменена (через сброс)
  • происходит откат
  • немедленная ошибка, когда сеанс снова пытается получить доступ к базе данных
2024-08-04 22:51:49,974 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2024-08-04 22:51:49,975 INFO sqlalchemy.engine.Engine SELECT orders.id, orders.state 
FROM orders
2024-08-04 22:51:49,975 INFO sqlalchemy.engine.Engine [generated in 0.00015s] {}
2024-08-04 22:51:49,976 INFO sqlalchemy.engine.Engine UPDATE orders SET state=%(state)s WHERE orders.id = %(orders_id)s
2024-08-04 22:51:49,976 INFO sqlalchemy.engine.Engine [generated in 0.00008s] {'state': 'error', 'orders_id': 1}
2024-08-04 22:51:49,977 INFO sqlalchemy.engine.Engine ROLLBACK

Если используется join_transaction_mode='create_savepoint', этот пример работает как положено. Т.е.


@contextlib.contextmanager
def our_session_maker(engine):
    with engine.connect() as connection:
        with connection.begin() as transaction:
            with Session(bind=connection, join_transaction_mode='create_savepoint') as s:
                yield s
                transaction.rollback()

Глядя на журналы, которые вы можете увидеть (при использовании create_savepoint):

  • транзакция начинается
  • запускается точка сохранения
  • делается первое изменение (через сброс)
  • это изменение отменяется
  • запускается другая точка сохранения
  • это изменение зафиксировано (точка сохранения выпущена)
  • вся транзакция откатывается (как нам хотелось бы при использовании pytest)
2024-08-04 22:48:12,020 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2024-08-04 22:48:12,020 INFO sqlalchemy.engine.Engine SAVEPOINT sa_savepoint_1
2024-08-04 22:48:12,020 INFO sqlalchemy.engine.Engine [no key 0.00006s] {}
2024-08-04 22:48:12,020 INFO sqlalchemy.engine.Engine SELECT orders.id, orders.state 
FROM orders
2024-08-04 22:48:12,020 INFO sqlalchemy.engine.Engine [generated in 0.00007s] {}
2024-08-04 22:48:12,021 INFO sqlalchemy.engine.Engine UPDATE orders SET state=%(state)s WHERE orders.id = %(orders_id)s
2024-08-04 22:48:12,021 INFO sqlalchemy.engine.Engine [generated in 0.00008s] {'state': 'error', 'orders_id': 1}
2024-08-04 22:48:12,022 INFO sqlalchemy.engine.Engine ROLLBACK TO SAVEPOINT sa_savepoint_1
2024-08-04 22:48:12,022 INFO sqlalchemy.engine.Engine [no key 0.00007s] {}
2024-08-04 22:48:12,022 INFO sqlalchemy.engine.Engine SAVEPOINT sa_savepoint_2
2024-08-04 22:48:12,022 INFO sqlalchemy.engine.Engine [no key 0.00006s] {}
2024-08-04 22:48:12,023 INFO sqlalchemy.engine.Engine SELECT orders.id AS orders_id 
FROM orders 
WHERE orders.id = %(pk_1)s
2024-08-04 22:48:12,023 INFO sqlalchemy.engine.Engine [generated in 0.00007s] {'pk_1': 1}
2024-08-04 22:48:12,023 INFO sqlalchemy.engine.Engine UPDATE orders SET state=%(state)s WHERE orders.id = %(orders_id)s
2024-08-04 22:48:12,023 INFO sqlalchemy.engine.Engine [cached since 0.002251s ago] {'state': 'fixed', 'orders_id': 1}
2024-08-04 22:48:12,024 INFO sqlalchemy.engine.Engine RELEASE SAVEPOINT sa_savepoint_2
2024-08-04 22:48:12,024 INFO sqlalchemy.engine.Engine [no key 0.00005s] {}
2024-08-04 22:48:12,024 INFO sqlalchemy.engine.Engine ROLLBACK

Заключение

Было бы довольно легко использовать значение по умолчанию, "conditional_savepoint", и не заметить этого, потому что откат происходит реже, чем фиксация. Мой оригинальный сфабрикованный пример на самом деле работает нормально, но если вы используете "control_fully", он потерпит неудачу. Кажется, что для детерминированного поведения во время тестов вы бы хотели использовать "create_savepoint", если бы это было вам доступно, иначе код, который действительно работал бы в производстве, не будет работать правильно.

Больше информации

Некоторый контекст есть в разделе документации «Что нового в версии 2.0» по адресу new-transaction-join-modes-for-session

Также, как упоминалось выше, в документации есть пример присоединения-к-сессии-во-внешнюю-транзакцию-например-для-тестовых-комплектов

Я написал полный тестовый пример в соответствии с сфабрикованным примером, который вы описали для тестирования (а также попытался выполнить откат после возникновения исключения), но никаких исключений не наблюдалось, и база данных по-прежнему откатывалась правильно. Было бы лучше, если бы существовал детерминированный сценарий, позволяющий визуально наблюдать разницу.

ACE Fly 02.08.2024 05:10

@ACEFly Ты прав. Этот пример работает с режимом по умолчанию, но в других случаях не работает (как отмечено выше). Я включил пример, потому что он очень тонкий.

Ian Wilson 05.08.2024 00:58

«При этом игнорируются фиксации внутри активной транзакции, но откаты распространяются на внешнюю транзакцию». Это предложение решило для меня загадку, и теперь я точно знаю, как это сделать правильно.

ACE Fly 05.08.2024 05:21

Другие вопросы по теме