Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 65 additions & 24 deletions system/Database/BaseConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use CodeIgniter\Database\Exceptions\RetryableTransactionException;
use CodeIgniter\Database\Exceptions\UniqueConstraintViolationException;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\InvalidArgumentException;
use CodeIgniter\I18n\Time;
use Exception;
use ReflectionClass;
Expand Down Expand Up @@ -228,6 +229,11 @@ abstract class BaseConnection implements ConnectionInterface
*/
protected ?DatabaseException $lastException = null;

/**
* The first database exception that caused the current transaction to fail.
*/
protected ?DatabaseException $transFailureException = null;

/**
* Connection ID
*
Expand Down Expand Up @@ -860,7 +866,7 @@ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, s
$query->setDuration($startTime, $startTime);

// This will trigger a rollback if transactions are being used
$this->handleTransStatus();
$this->handleTransStatus($exception ?? $this->lastException);

if (
$this->DBDebug
Expand Down Expand Up @@ -1082,44 +1088,67 @@ public function afterRollback(callable $callback): static
* @template TReturn
*
* @param callable(self): TReturn $callback
* @param positive-int $attempts
*
* @return false|TReturn
*/
public function transaction(callable $callback): mixed
public function transaction(callable $callback, int $attempts = 1): mixed
{
if ($attempts < 1) {
throw new InvalidArgumentException('Transaction attempts must be a positive integer.');
}

if (! $this->transEnabled) {
return $callback($this);
}

if (! $this->transBegin()) {
return false;
}
$attempts = $this->transDepth === 0 ? $attempts : 1;

for ($attempt = 1; $attempt <= $attempts; $attempt++) {
if (! $this->transBegin()) {
return false;
}

try {
$result = $callback($this);
} catch (Throwable $e) {
try {
$this->transRollback();
} catch (Throwable $rollbackException) {
log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);
$result = $callback($this);
} catch (Throwable $e) {
try {
$this->transRollback();
} catch (Throwable $rollbackException) {
log_message('error', 'Database: Transaction callback threw an exception before rollback failed: ' . $e);

throw $rollbackException;
} finally {
if ($this->transDepth > 0) {
$this->transStatus = false;
} elseif ($this->transStrict === false) {
$this->transStatus = true;
}
}

throw $rollbackException;
} finally {
if ($this->transDepth > 0) {
$this->transStatus = false;
} elseif ($this->transStrict === false) {
$this->transStatus = true;
if ($this->transDepth === 0 && $e instanceof RetryableTransactionException && $attempt < $attempts) {
$this->prepareTransactionRetry();

continue;
}

throw $e;
}

throw $e;
}
if (! $this->transComplete()) {
if ($this->transDepth === 0 && $this->transFailureException instanceof RetryableTransactionException && $attempt < $attempts) {
$this->prepareTransactionRetry();

if (! $this->transComplete()) {
return false;
continue;
}

return false;
}

return $result;
}

return $result;
return false;
}

/**
Expand All @@ -1145,7 +1174,8 @@ public function transBegin(bool $testMode = false): bool
// Reset the transaction failure flag.
// If the $testMode flag is set to TRUE transactions will be rolled back
// even if the queries produce a successful result.
$this->transFailure = $testMode;
$this->transFailure = $testMode;
$this->transFailureException = null;

if ($this->_transBegin()) {
$this->transDepth++;
Expand Down Expand Up @@ -1219,13 +1249,24 @@ public function resetTransStatus(): static
*
* @internal This method is for internal database component use only
*/
public function handleTransStatus(): void
public function handleTransStatus(?DatabaseException $exception = null): void
{
if ($this->transDepth !== 0) {
$this->transStatus = false;
$this->transFailureException ??= $exception;
}
}

/**
* Reset transaction state that should not leak into a retry attempt.
*/
protected function prepareTransactionRetry(): void
{
$this->transStatus = true;
$this->transFailureException = null;
$this->lastException = null;
}

/**
* Run and clear callbacks registered for a successful transaction commit.
*/
Expand Down
6 changes: 3 additions & 3 deletions system/Database/BasePreparedQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ public function execute(...$data)
if ($result === false) {
$query->setDuration($startTime, $startTime);

// This will trigger a rollback if transactions are being used
$this->db->handleTransStatus();

$databaseException = $this->createDatabaseException($exception);

// This will trigger a rollback if transactions are being used
$this->db->handleTransStatus($databaseException);

if ($this->db->DBDebug) {
// We call this function in order to roll-back queries
// if transactions are enabled. If we don't call this here
Expand Down
3 changes: 2 additions & 1 deletion system/Database/ConnectionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,11 @@ public function afterRollback(callable $callback): static;
* @template TReturn
*
* @param callable(self): TReturn $callback
* @param positive-int $attempts
*
* @return false|TReturn
*/
public function transaction(callable $callback): mixed;
public function transaction(callable $callback, int $attempts = 1): mixed;

/**
* Returns an instance of the query builder for this connection.
Expand Down
Loading