databases icon indicating copy to clipboard operation
databases copied to clipboard

Database transaction is not rollbacked

Open sedlar opened this issue 4 years ago • 6 comments

When there are multiple queries executed in fastapi endpoint handler, the transaction rollback doesn't work properly.

@app.post("/notes/")
async def create_note(note: NoteIn):
    # WHEN We execute some query outside transaction
    await execute_some_query()

    # AND THEN execute some other inside transaction
    async with database.transaction():
        query = notes.insert().values(text=note.text, completed=note.completed)
        await database.execute(query)
        raise Exception()
    # The transaction is not rollbacked
    return {}

Full working example is available at https://github.com/sedlar/fastapi-transaction-issue.

It happens when there are queries executed outside of transaction and inside one. It also happens when there are two independent transactions.

When the transaction explicitly acquires connection, everything works as expected.

async with database.connection() as connection:
        async with connection.transaction():
                query = notes.insert().values(text=note.text, completed=note.completed)
                await database.execute(query)
                raise Exception()

This is regression against databases==0.2.6 and sqlalchemy==1.3.12

sedlar avatar Oct 05 '21 10:10 sedlar

This isn't really related to FastAPI. So maybe you can rename the title.

I could reproduce this that if a query is run before a transaction, the transaction isn't rolled back properly. I think this is related to this change:

https://github.com/encode/databases/blob/master/databases/core.py#L204

I guess rollback is called on the wrong connection, I'll take a look.

Simple way to reproduce this:

    await database.execute(User.select())

    async with database.transaction():
        await database.execute(User.insert().values({}))
        raise Exception()

The insert will be persisted here. But if the select query is omitted, it works ok.

aminalaee avatar Oct 09 '21 19:10 aminalaee

@goteguru I think this is related to the parallel transactions #328 .

Here https://github.com/encode/databases/blob/master/databases/core.py#L206 If we avoid creating new connection and use existing connection, this issue will be fixed. But that would revert the parallel transactions.

Any ideas?

aminalaee avatar Oct 10 '21 10:10 aminalaee

@aminalaee without parallel transactions a db abstraction layer is pretty much useless except for the most basic applications.

I'm not sure trying to hide the connection object from the user is a good design choice (looks simple at first glance, but the hassle and confusion later nullify the benefits in the long term). At least the transaction context manager should expose a connection object.

It's a bug nevertheless. The rolled back connection should be the same. Must check.

goteguru avatar Oct 11 '21 00:10 goteguru

I have not wrapped my head around the usage of contextvars in this scenario, but I just want to note that modifying the transaction method of Database as below (ie. removing the code dealing with contextvars) allows tests/test_databases.py::test_parallel_transactions to complete successfully:-

    def transaction(
        self, *, force_rollback: bool = False, **kwargs: typing.Any
    ) -> "Transaction":
        try:
            connection = self._connection_context.get()
            is_root = not connection._transaction_stack
            if is_root:
                get_conn = self._new_connection
            else:
                get_conn = self.connection
        except LookupError:
            get_conn = self.connection

        return Transaction(get_conn, force_rollback=force_rollback, **kwargs)

The following transaction rollback test also passes:-

@pytest.mark.parametrize("database_url", DATABASE_URLS)
@mysql_versions
@async_adapter
async def test_root_transaction(database_url):
    async with Database(database_url) as database:
        try:
            query = notes.insert().values(text="example1", completed=True)
            await database.execute(query)

            query = notes.select()
            results = await database.fetch_all(query=query)
            assert len(results) == 1

            transaction = await database.transaction()
            query = notes.update().values(text="update1")
            await database.execute(query)
            query = notes.update().values(text="update2")
            await database.execute(query)
            await transaction.rollback()

            query = notes.select()
            results = await database.fetch_all(query=query)
            assert len(results) == 1
            assert results[0]["text"] == "example1"

        finally:
            query = notes.delete()
            await database.execute(query)

tim-connolly avatar Dec 17 '21 01:12 tim-connolly

Got quite the same regression after 0.4.3 -> 0.5.5 upgrade.

funkindy avatar Feb 01 '22 09:02 funkindy

Looks like it was fixed in 0.6.0 🎉

alex-pobeditel-2004 avatar Jun 01 '22 13:06 alex-pobeditel-2004