-
Notifications
You must be signed in to change notification settings - Fork 2
fix(transactions): prevent nested transaction from destroying outer EntityManager on connection error #533
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,9 +22,29 @@ | |||||||||||||||||||||||||||||
| use Exception; | ||||||||||||||||||||||||||||||
| use libs\utils\ITransactionService; | ||||||||||||||||||||||||||||||
| use ErrorException; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Class DoctrineTransactionService | ||||||||||||||||||||||||||||||
| * @package App\Services\Utils | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * Root-aware transaction wrapper. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * Root (outermost) transaction: | ||||||||||||||||||||||||||||||
| * - owns the connection lifecycle (may retry on transient errors) | ||||||||||||||||||||||||||||||
| * - sets isolation level | ||||||||||||||||||||||||||||||
| * - flushes the EntityManager and issues the real COMMIT | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * Nested transaction (called while a DB transaction is already active): | ||||||||||||||||||||||||||||||
| * - uses Doctrine DBAL nesting counter (beginTransaction / commit) | ||||||||||||||||||||||||||||||
| * - flushes the EntityManager so auto-generated IDs are available | ||||||||||||||||||||||||||||||
| * - does NOT retry, reset the EntityManager, or close the connection | ||||||||||||||||||||||||||||||
| * - on error, rolls back only the nested level and re-throws, | ||||||||||||||||||||||||||||||
| * letting the root transaction decide how to handle the failure | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * Savepoints are enabled (setNestTransactionsWithSavepoints) so that | ||||||||||||||||||||||||||||||
| * nested rollBack() issues ROLLBACK TO SAVEPOINT — undoing only the | ||||||||||||||||||||||||||||||
| * inner work without poisoning the outer transaction. This allows the | ||||||||||||||||||||||||||||||
| * "catch inner error and continue" pattern to work correctly. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| final class DoctrineTransactionService implements ITransactionService | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
|
|
@@ -61,47 +81,15 @@ public function shouldReconnect(\Exception $e):bool | |||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| if($e instanceof ErrorException && str_contains($e->getMessage(), "Packets out of order")){ | ||||||||||||||||||||||||||||||
| Log::debug | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| sprintf | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "DoctrineTransactionService::shouldReconnect %s Packets out of order true", | ||||||||||||||||||||||||||||||
| get_class($e), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if($e instanceof RetryableException) { | ||||||||||||||||||||||||||||||
| Log::debug | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| sprintf | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "DoctrineTransactionService::shouldReconnect %s true", | ||||||||||||||||||||||||||||||
| get_class($e), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if($e instanceof ConnectionLost) { | ||||||||||||||||||||||||||||||
| Log::debug | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| sprintf | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "DoctrineTransactionService::shouldReconnect %s true", | ||||||||||||||||||||||||||||||
| get_class($e), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if($e instanceof ConnectionException) { | ||||||||||||||||||||||||||||||
| Log::debug | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| sprintf | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "DoctrineTransactionService::shouldReconnect %s true", | ||||||||||||||||||||||||||||||
| get_class($e), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if($e instanceof \PDOException){ | ||||||||||||||||||||||||||||||
|
|
@@ -131,58 +119,133 @@ public function shouldReconnect(\Exception $e):bool | |||||||||||||||||||||||||||||
| * @return mixed|null | ||||||||||||||||||||||||||||||
| * @throws Exception | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| public function transaction(Closure $callback, int $isolationLevel = TransactionIsolationLevel::READ_COMMITTED) | ||||||||||||||||||||||||||||||
| public function transaction(Closure $callback, int $isolationLevel = TransactionIsolationLevel::READ_COMMITTED) | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| $em = Registry::getManager($this->manager_name); | ||||||||||||||||||||||||||||||
| $conn = $em->getConnection(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Detect whether we are already inside a DB transaction. | ||||||||||||||||||||||||||||||
| $isNested = $conn->isTransactionActive(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if ($isNested) { | ||||||||||||||||||||||||||||||
| return $this->runNestedTransaction($callback); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return $this->runRootTransaction($callback, $isolationLevel); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Root (outermost) transaction: may retry on transient connection errors, | ||||||||||||||||||||||||||||||
| * sets isolation level, flushes, and issues the real COMMIT. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * @param Closure $callback | ||||||||||||||||||||||||||||||
| * @param int $isolationLevel | ||||||||||||||||||||||||||||||
| * @return mixed|null | ||||||||||||||||||||||||||||||
| * @throws Exception | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| private function runRootTransaction(Closure $callback, int $isolationLevel) | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| $retry = 0; | ||||||||||||||||||||||||||||||
| $done = false; | ||||||||||||||||||||||||||||||
| $result = null; | ||||||||||||||||||||||||||||||
| $retry = 0; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| while ($retry < self::MaxRetries) { | ||||||||||||||||||||||||||||||
| $em = Registry::getManager($this->manager_name); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| while (!$done and $retry < self::MaxRetries) { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| $em = Registry::getManager($this->manager_name); | ||||||||||||||||||||||||||||||
| if (!$em->isOpen()) { | ||||||||||||||||||||||||||||||
| Log::warning("DoctrineTransactionService::transaction: entity manager is closed!, trying to re open..."); | ||||||||||||||||||||||||||||||
| Log::warning("DoctrineTransactionService::runRootTransaction: entity manager is closed, resetting..."); | ||||||||||||||||||||||||||||||
| $em = Registry::resetManager($this->manager_name); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| $em->getConnection()->setTransactionIsolation($isolationLevel); | ||||||||||||||||||||||||||||||
| $em->getConnection()->beginTransaction(); // suspend auto-commit | ||||||||||||||||||||||||||||||
| $result = $callback($this); | ||||||||||||||||||||||||||||||
| $em->flush(); | ||||||||||||||||||||||||||||||
| $em->getConnection()->commit(); | ||||||||||||||||||||||||||||||
| $done = true; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| catch (Exception $ex) { | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| $conn = $em->getConnection(); | ||||||||||||||||||||||||||||||
| $conn->setNestTransactionsWithSavepoints(true); | ||||||||||||||||||||||||||||||
| $conn->setTransactionIsolation($isolationLevel); | ||||||||||||||||||||||||||||||
| $conn->beginTransaction(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| $result = $callback($this); | ||||||||||||||||||||||||||||||
| $em->flush(); | ||||||||||||||||||||||||||||||
| $conn->commit(); | ||||||||||||||||||||||||||||||
| return $result; | ||||||||||||||||||||||||||||||
| } catch (\Throwable $inner) { | ||||||||||||||||||||||||||||||
| if ($conn->isTransactionActive()) { | ||||||||||||||||||||||||||||||
| $conn->rollBack(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw $inner; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } catch (Exception $ex) { | ||||||||||||||||||||||||||||||
| $retry++; | ||||||||||||||||||||||||||||||
| $em->getConnection()->close(); | ||||||||||||||||||||||||||||||
| $em->close(); | ||||||||||||||||||||||||||||||
| if($em->getConnection()->isTransactionActive()) | ||||||||||||||||||||||||||||||
| $em->getConnection()->rollBack(); | ||||||||||||||||||||||||||||||
| Registry::resetManager($this->manager_name); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if($this->shouldReconnect($ex)){ | ||||||||||||||||||||||||||||||
| Log::warning | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| sprintf | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "DoctrineTransactionService::transaction should reconnect %s retry %s", | ||||||||||||||||||||||||||||||
| $ex->getMessage(), | ||||||||||||||||||||||||||||||
| $retry | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| if ($retry === self::MaxRetries) { | ||||||||||||||||||||||||||||||
| Log::warning(sprintf("DoctrineTransactionService::transaction Max Retry Reached %s", $retry)); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if ($this->shouldReconnect($ex)) { | ||||||||||||||||||||||||||||||
| Log::warning(sprintf( | ||||||||||||||||||||||||||||||
| "DoctrineTransactionService::runRootTransaction reconnectable error '%s', retry %d/%d", | ||||||||||||||||||||||||||||||
| $ex->getMessage(), | ||||||||||||||||||||||||||||||
| $retry, | ||||||||||||||||||||||||||||||
| self::MaxRetries | ||||||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Root only: destructive recovery is safe here because | ||||||||||||||||||||||||||||||
| // no outer transaction holds references to this EM/connection. | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| if ($em->isOpen()) { | ||||||||||||||||||||||||||||||
| $em->clear(); | ||||||||||||||||||||||||||||||
| $em->close(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } catch (\Throwable $ignore) { | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| $em->getConnection()->close(); | ||||||||||||||||||||||||||||||
| } catch (\Throwable $ignore) { | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Registry::resetManager($this->manager_name); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if ($retry >= self::MaxRetries) { | ||||||||||||||||||||||||||||||
| Log::warning(sprintf("DoctrineTransactionService::runRootTransaction Max Retry Reached %d", $retry)); | ||||||||||||||||||||||||||||||
| Log::error($ex); | ||||||||||||||||||||||||||||||
| throw $ex; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Log::warning("DoctrineTransactionService::transaction rolling back TX"); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| Log::warning("DoctrineTransactionService::runRootTransaction rolling back TX"); | ||||||||||||||||||||||||||||||
| Log::warning($ex); | ||||||||||||||||||||||||||||||
| throw $ex; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return $result; | ||||||||||||||||||||||||||||||
| throw new \RuntimeException("DoctrineTransactionService::runRootTransaction exceeded max retries."); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Nested transaction: runs inside an already-active transaction. | ||||||||||||||||||||||||||||||
| * Flushes so auto-generated IDs are available to the caller, | ||||||||||||||||||||||||||||||
| * but does NOT retry, close the connection, or reset the EntityManager. | ||||||||||||||||||||||||||||||
| * On failure, rolls back the nested level and re-throws to the root. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * @param Closure $callback | ||||||||||||||||||||||||||||||
| * @return mixed|null | ||||||||||||||||||||||||||||||
| * @throws \Throwable | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| private function runNestedTransaction(Closure $callback) | ||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||
| $em = Registry::getManager($this->manager_name); | ||||||||||||||||||||||||||||||
| $conn = $em->getConnection(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| $conn->beginTransaction(); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
Comment on lines
+231
to
+237
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Confirm DBAL version and whether Connection exposes the savepoint nesting method.
composer show doctrine/dbal 2>/dev/null || true
php -r '
$lock = json_decode(file_get_contents("composer.lock"), true);
foreach ($lock["packages"] ?? [] as $pkg) {
if ($pkg["name"] === "doctrine/dbal") {
echo "doctrine/dbal ", $pkg["version"], PHP_EOL;
break;
}
}
' 2>/dev/null || true
rg -nP 'function\s+setNestTransactionsWithSavepoints\b|setNestTransactionsWithSavepoints\s*\(' -C2Repository: OpenStackweb/summit-api Length of output: 2121 🏁 Script executed: # Read the DoctrineTransactionService.php file to see the full runNestedTransaction() method
wc -l app/Services/Utils/DoctrineTransactionService.phpRepository: OpenStackweb/summit-api Length of output: 120 🏁 Script executed: # Read the complete runNestedTransaction method (lines around 231-237 and beyond)
sed -n '230,280p' app/Services/Utils/DoctrineTransactionService.phpRepository: OpenStackweb/summit-api Length of output: 670 🏁 Script executed: # Also check the root transaction method around line 160 for comparison
sed -n '150,170p' app/Services/Utils/DoctrineTransactionService.phpRepository: OpenStackweb/summit-api Length of output: 940 🏁 Script executed: # Search for all calls to setNestTransactionsWithSavepoints in the file
rg -n 'setNestTransactionsWithSavepoints' app/Services/Utils/DoctrineTransactionService.phpRepository: OpenStackweb/summit-api Length of output: 207 Ensure savepoints are enabled before nested The nested transaction path (line 236) does not enable savepoints before calling Add the savepoint configuration to the nested path: Suggested fix private function runNestedTransaction(Closure $callback)
{
$em = Registry::getManager($this->manager_name);
$conn = $em->getConnection();
+ $conn->setNestTransactionsWithSavepoints(true);
$conn->beginTransaction();📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| $result = $callback($this); | ||||||||||||||||||||||||||||||
| $em->flush(); | ||||||||||||||||||||||||||||||
| $conn->commit(); | ||||||||||||||||||||||||||||||
| return $result; | ||||||||||||||||||||||||||||||
| } catch (\Throwable $ex) { | ||||||||||||||||||||||||||||||
| if ($conn->isTransactionActive()) { | ||||||||||||||||||||||||||||||
| $conn->rollBack(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // No resetManager, no close(), no retry — let the root handle recovery. | ||||||||||||||||||||||||||||||
| throw $ex; | ||||||||||||||||||||||||||||||
|
Comment on lines
+243
to
+248
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nested rollback does not revert Doctrine ORM in-memory changes. A savepoint rollback only restores DB state; it does not undo mutations already made to managed entities in the 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: OpenStackweb/summit-api
Length of output: 1109
🏁 Script executed:
Repository: OpenStackweb/summit-api
Length of output: 10359
Do not retry after an ambiguous
commit()failure.Line 167:
commit()can throw an exception after the database has already committed but before the client receives the response. Because this exception flows into the reconnect retry path at line 178, the entire callback will execute again, duplicating writes and side effects.Track when
commit()has started and bypass retry logic for commit-phase failures:🐛 Suggested guard for ambiguous commit failures
📝 Committable suggestion
🤖 Prompt for AI Agents