From 77a4c710286ecb89fffa32ad6b3841ee145b8db4 Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Tue, 23 Jun 2026 09:45:34 +0300 Subject: [PATCH 1/6] test: reproduce transaction release race --- test/TransactionReleaseRaceTest.php | 175 ++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 test/TransactionReleaseRaceTest.php diff --git a/test/TransactionReleaseRaceTest.php b/test/TransactionReleaseRaceTest.php new file mode 100644 index 0000000..acf12e1 --- /dev/null +++ b/test/TransactionReleaseRaceTest.php @@ -0,0 +1,175 @@ +beginTransaction(); + $transaction->commit(); + + $transaction = $connection->beginTransaction(); + $transaction->commit(); + + delay(0); + + $this->expectNotToPerformAssertions(); + } + + private static function createConnection(): PostgresConnection + { + $reflection = new \ReflectionClass(RaceTestConnection::class); + $connection = $reflection->newInstanceWithoutConstructor(); + $reflection->getConstructor()->invoke($connection, new RaceTestHandle()); + + return $connection; + } +} + +final class RaceTestConnection extends PostgresHandleConnection +{ + public static function connect(PostgresConfig $config, ?Cancellation $cancellation = null): self + { + throw new SqlConnectionException('Not used in this test'); + } +} + +final class RaceTestHandle implements PostgresHandle +{ + public function close(): void + { + } + + public function commit(): void + { + } + + public function createSavepoint(string $identifier): void + { + } + + public function escapeByteA(string $data): string + { + return $data; + } + + public function execute(string $sql, array $params = []): PostgresResult + { + return new RaceTestResult(); + } + + public function getConfig(): PostgresConfig + { + return new PostgresConfig(host: 'localhost'); + } + + public function getLastUsedAt(): int + { + return 0; + } + + public function isClosed(): bool + { + return false; + } + + public function listen(string $channel): PostgresListener + { + throw new \BadMethodCallException('Not used in this test'); + } + + public function notify(string $channel, string $payload = ""): PostgresResult + { + return new RaceTestResult(); + } + + public function onClose(\Closure $onClose): void + { + EventLoop::queue($onClose); + } + + public function prepare(string $sql): PostgresStatement + { + throw new \BadMethodCallException('Not used in this test'); + } + + public function query(string $sql): PostgresResult + { + return new RaceTestResult(); + } + + public function quoteIdentifier(string $name): string + { + return $name; + } + + public function quoteLiteral(string $data): string + { + return $data; + } + + public function releaseSavepoint(string $identifier): void + { + } + + public function rollback(): void + { + } + + public function rollbackTo(string $identifier): void + { + } + + public function statementDeallocate(string $name): void + { + } + + public function statementExecute(string $name, array $params): PostgresResult + { + return new RaceTestResult(); + } +} + +final class RaceTestResult implements PostgresResult, \IteratorAggregate +{ + public function fetchRow(): ?array + { + return null; + } + + public function getColumnCount(): ?int + { + return null; + } + + public function getIterator(): \Traversable + { + return new \EmptyIterator(); + } + + public function getNextResult(): ?PostgresResult + { + return null; + } + + public function getRowCount(): ?int + { + return null; + } +} From f578783c9b330b2ad5f14b8f2ac172e960d1050d Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Tue, 23 Jun 2026 10:01:19 +0300 Subject: [PATCH 2/6] Wait for transaction release before beginning another transaction --- src/Internal/PostgresHandleConnection.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Internal/PostgresHandleConnection.php b/src/Internal/PostgresHandleConnection.php index 9a6cc57..1fa5f9f 100644 --- a/src/Internal/PostgresHandleConnection.php +++ b/src/Internal/PostgresHandleConnection.php @@ -135,6 +135,7 @@ final public function listen(string $channel): PostgresListener #[\Override] final public function beginTransaction(): PostgresTransaction { + $this->awaitPending(); $this->reserve(); try { From 0a56e14293e19fa9b3439d7b3c88e7c2694819a4 Mon Sep 17 00:00:00 2001 From: Aaron Piotrowski Date: Mon, 2 Nov 2026 19:27:36 -0600 Subject: [PATCH 3/6] Move awaitPending call into release --- src/Internal/PostgresHandleConnection.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Internal/PostgresHandleConnection.php b/src/Internal/PostgresHandleConnection.php index 1fa5f9f..edbe9e6 100644 --- a/src/Internal/PostgresHandleConnection.php +++ b/src/Internal/PostgresHandleConnection.php @@ -82,8 +82,10 @@ private function awaitPending(): void */ private function reserve(): void { + $this->awaitPending(); \assert($this->busy === null); - $this->busy = new DeferredFuture; + + $this->busy = new DeferredFuture(); } /** @@ -135,7 +137,6 @@ final public function listen(string $channel): PostgresListener #[\Override] final public function beginTransaction(): PostgresTransaction { - $this->awaitPending(); $this->reserve(); try { From 20685c77d764f39485b71d95fb0eee24fafa8491 Mon Sep 17 00:00:00 2001 From: Aaron Piotrowski Date: Mon, 2 Nov 2026 19:27:52 -0600 Subject: [PATCH 4/6] Move test into AbstractConnectionTest --- test/AbstractConnectionTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/AbstractConnectionTest.php b/test/AbstractConnectionTest.php index b321835..59e57c5 100644 --- a/test/AbstractConnectionTest.php +++ b/test/AbstractConnectionTest.php @@ -142,4 +142,17 @@ public function testTransactionsCallbacksOnDestruct(): void unset($transaction); delay(0.1); // Destructor is async, so give control to the loop to invoke callbacks. } + + public function testSequentialTransactionsWaitForPreviousRelease(): void + { + $connection = $this->executor; + + $transaction = $connection->beginTransaction(); + $transaction->onClose($this->createCallback(1)); + $transaction->commit(); + + $transaction = $connection->beginTransaction(); + $transaction->onClose($this->createCallback(1)); + $transaction->commit(); + } } From 8777a7c9209a8bd2a077349279b24f270b48564b Mon Sep 17 00:00:00 2001 From: Aaron Piotrowski Date: Mon, 2 Nov 2026 19:37:36 -0600 Subject: [PATCH 5/6] Remove separate test --- test/TransactionReleaseRaceTest.php | 175 ---------------------------- 1 file changed, 175 deletions(-) delete mode 100644 test/TransactionReleaseRaceTest.php diff --git a/test/TransactionReleaseRaceTest.php b/test/TransactionReleaseRaceTest.php deleted file mode 100644 index acf12e1..0000000 --- a/test/TransactionReleaseRaceTest.php +++ /dev/null @@ -1,175 +0,0 @@ -beginTransaction(); - $transaction->commit(); - - $transaction = $connection->beginTransaction(); - $transaction->commit(); - - delay(0); - - $this->expectNotToPerformAssertions(); - } - - private static function createConnection(): PostgresConnection - { - $reflection = new \ReflectionClass(RaceTestConnection::class); - $connection = $reflection->newInstanceWithoutConstructor(); - $reflection->getConstructor()->invoke($connection, new RaceTestHandle()); - - return $connection; - } -} - -final class RaceTestConnection extends PostgresHandleConnection -{ - public static function connect(PostgresConfig $config, ?Cancellation $cancellation = null): self - { - throw new SqlConnectionException('Not used in this test'); - } -} - -final class RaceTestHandle implements PostgresHandle -{ - public function close(): void - { - } - - public function commit(): void - { - } - - public function createSavepoint(string $identifier): void - { - } - - public function escapeByteA(string $data): string - { - return $data; - } - - public function execute(string $sql, array $params = []): PostgresResult - { - return new RaceTestResult(); - } - - public function getConfig(): PostgresConfig - { - return new PostgresConfig(host: 'localhost'); - } - - public function getLastUsedAt(): int - { - return 0; - } - - public function isClosed(): bool - { - return false; - } - - public function listen(string $channel): PostgresListener - { - throw new \BadMethodCallException('Not used in this test'); - } - - public function notify(string $channel, string $payload = ""): PostgresResult - { - return new RaceTestResult(); - } - - public function onClose(\Closure $onClose): void - { - EventLoop::queue($onClose); - } - - public function prepare(string $sql): PostgresStatement - { - throw new \BadMethodCallException('Not used in this test'); - } - - public function query(string $sql): PostgresResult - { - return new RaceTestResult(); - } - - public function quoteIdentifier(string $name): string - { - return $name; - } - - public function quoteLiteral(string $data): string - { - return $data; - } - - public function releaseSavepoint(string $identifier): void - { - } - - public function rollback(): void - { - } - - public function rollbackTo(string $identifier): void - { - } - - public function statementDeallocate(string $name): void - { - } - - public function statementExecute(string $name, array $params): PostgresResult - { - return new RaceTestResult(); - } -} - -final class RaceTestResult implements PostgresResult, \IteratorAggregate -{ - public function fetchRow(): ?array - { - return null; - } - - public function getColumnCount(): ?int - { - return null; - } - - public function getIterator(): \Traversable - { - return new \EmptyIterator(); - } - - public function getNextResult(): ?PostgresResult - { - return null; - } - - public function getRowCount(): ?int - { - return null; - } -} From c7d2cb53249ae8468645bed7ca71249fa5d9a3b7 Mon Sep 17 00:00:00 2001 From: Aaron Piotrowski Date: Mon, 2 Nov 2026 19:37:46 -0600 Subject: [PATCH 6/6] Add link to PR on test --- test/AbstractConnectionTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/AbstractConnectionTest.php b/test/AbstractConnectionTest.php index 59e57c5..67dd6aa 100644 --- a/test/AbstractConnectionTest.php +++ b/test/AbstractConnectionTest.php @@ -143,6 +143,10 @@ public function testTransactionsCallbacksOnDestruct(): void delay(0.1); // Destructor is async, so give control to the loop to invoke callbacks. } + /** + * Ensure prior transaction has fully completed before attempting to begin another transaction. + * @see https://github.com/amphp/postgres/pull/73 + */ public function testSequentialTransactionsWaitForPreviousRelease(): void { $connection = $this->executor;