Skip to content

A failed statement in a transaction is not registered as aborting it, so the commit silently turns into a rollback #1731

Description

@AlexKirkouski

Description

PostgreSQL aborts the whole transaction on any server error: every following command in it fails, and COMMIT performs a rollback instead of committing, reporting success.

The server knows about this and keeps a per-session mark for it: while a transaction is marked as broken, cleaning up temporary data is skipped and the commit fails loudly instead of silently discarding the work. But the mark is only set for the queries the session builds itself. Statements executed outside that route — DDL issued by service operations and by the structure synchronization at startup, batch inserts, and raw SQL from INTERNAL DB / runSQL — leave the transaction unmarked when they fail.

The result for an application developer: an operation fails somewhere inside, the failure is logged, execution continues, everything afterwards fails too (current transaction is aborted), and the apply ends without an error even though nothing was written.

apply () {
    NEWSESSION {
        // any raw statement that the server rejects - a wrong column, a missing table
        INTERNAL DB <{ ... }>;   // fails, logged, execution goes on

        someProperty() <- 1;     // fails with "current transaction is aborted"
        APPLY;                   // reports success - and commits nothing
    }
}

The same happens with the service operations that drop columns or synchronize the structure: the per-item error is logged, the loop keeps going, and the final commit reports success without having committed anything.

Reason

A failure whose result is thrown away is bad enough; a failure that is reported as success loses data with no trace. The mark exists exactly to prevent that, and the statements most likely to fail against a live database — schema changes and raw application SQL — are the ones that never set it.

Two things make the fix non-trivial, and both need to be part of it:

  • Not every failure carrying an SQL state comes from the server. The driver produces 08006 and 08003 on its own when the socket breaks or the connection is already closed, and a statement that succeeded can still fail while being closed. Marking those would be wrong in the opposite direction: the transaction is alive, and the mark would skip cleaning up a temporary table and put it back to be reused with its rows still in it. The state of the transaction has to be taken from the connection, not guessed from the error.
  • A failure inside a transaction is not always final. The structure synchronization deliberately takes a savepoint before dropping a data column and rolls back to it when the drop fails, because that failure is expected. After such a rollback the transaction is healthy again and the mark has to be lifted, the way it already is for the internal retry path — otherwise the server would stop starting up on a migration.

Fix

A statement that the server rejects inside a transaction marks that transaction as broken, whichever way the statement was executed. Rolling back to a savepoint lifts the mark. The commit of a broken transaction keeps failing loudly instead of silently rolling back.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions