Skip to content

Model::createOrFirst() in Postgres transaction may trigger SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: current transaction is aborted, commands ignored until end of transaction block #48143

Closed
@mpyw

Description

@mpyw

Laravel Version

10.20.0

PHP Version

irrelevant

Database Driver & Version

PostgreSQL (Version: irrelevant)

Description

With the recent release of Laravel v10.20.0, due to #47973, there has been an overlap in functionality with my library: mpyw/laravel-retry-on-duplicate-key, which was originally designed to offer a feature now implemented by Laravel. Although this in itself is not an issue and I intend to update my library accordingly, I've identified a potential concern with how Laravel's new feature manages Postgres transactions.

Postgres has a distinctive behavior where, if an error occurs within a transaction, all subsequent queries fail unless the current transaction is rolled back or committed. However, this can be handled by setting a SAVEPOINT, which limits the rollback to that point. In my library, for Postgres and when the transaction level is greater than 0, I've incorporated a mechanism using DB::transaction() to create a SAVEPOINT. If Laravel's new feature doesn't adopt a similar approach to handle Postgres transactions, it could lead to errors: SQLSTATE[25P02]: In failed sql transaction: 7 ERROR: current transaction is aborted, commands ignored until end of transaction block.

class RetryOnDuplicateKey
{
    protected ConnectionInterface $connection;

    public function __construct(ConnectionInterface $connection)
    {
        $this->connection = $connection;
    }

    /**
     * Retries once on duplicate key errors.
     *
     * @param mixed ...$args
     * @return mixed
     */
    public function __invoke(callable $callback, ...$args)
    {
        try {
            return $this->withSavepoint(fn () => $callback(...$args));
        } catch (PDOException $e) {
            if ((new UniqueViolationDetector($this->connection))->violated($e)) {
                $this->forceReferringPrimaryConnection();
                return $this->withSavepoint(fn () => $callback(...$args));
            }
            throw $e;
        }
    }

    /**
     * Make sure to fetch the latest data on the next try.
     */
    protected function forceReferringPrimaryConnection(): void
    {
        $connection = $this->connection;

        if ($connection instanceof Connection) {
            $connection->recordsHaveBeenModified();
        }
    }

    /**
     * @phpstan-template T
     * @phpstan-param callable(): T $callback
     * @phpstan-return T
     * @return mixed
     * @noinspection PhpDocMissingThrowsInspection
     * @noinspection PhpUnhandledExceptionInspection
     */
    protected function withSavepoint(callable $callback)
    {
        return $this->needsSavepoint()
            ? $this->connection->transaction(fn () => $callback())
            : $callback();
    }

    protected function needsSavepoint(): bool
    {
        // In Postgres, savepoints allow recovery from errors.
        // This ensures retrying should work also in transactions.
        return $this->connection instanceof PostgresConnection
            && $this->connection->transactionLevel() > 0;
    }
}

Steps To Reproduce

User::create(['email' => 'example@example.com'], ['name' => 'example']);

DB::transaction(function () {
    User::createOrFirst(['email' => 'example@example.com'], ['name' => 'example']);
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions