You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
335 lines
12 KiB
335 lines
12 KiB
<?php |
|
/** |
|
* @link http://www.yiiframework.com/ |
|
* @copyright Copyright (c) 2008 Yii Software LLC |
|
* @license http://www.yiiframework.com/license/ |
|
*/ |
|
|
|
namespace yiiunit\framework\db\mysql\connection; |
|
|
|
use yii\db\Connection; |
|
use yii\db\Exception; |
|
use yii\db\Transaction; |
|
|
|
/** |
|
* @group db |
|
* @group mysql |
|
*/ |
|
class DeadLockTest extends \yiiunit\framework\db\mysql\ConnectionTest |
|
{ |
|
/** @var string Shared log filename for children */ |
|
private $logFile; |
|
|
|
const CHILD_EXIT_CODE_DEADLOCK = 15; |
|
|
|
/** |
|
* Test deadlock exception. |
|
* |
|
* Accident deadlock exception lost while rolling back a transaction or savepoint |
|
* @link https://github.com/yiisoft/yii2/issues/12715 |
|
* @link https://github.com/yiisoft/yii2/pull/13346 |
|
*/ |
|
public function testDeadlockException() |
|
{ |
|
if (getenv('TRAVIS') && PHP_VERSION_ID < 70000) { |
|
$this->markTestSkipped('Skipping PHP 5 on Travis since it segfaults with pcntl'); |
|
} |
|
|
|
if (!\function_exists('pcntl_fork')) { |
|
$this->markTestSkipped('pcntl_fork() is not available'); |
|
} |
|
if (!\function_exists('posix_kill')) { |
|
$this->markTestSkipped('posix_kill() is not available'); |
|
} |
|
// HHVM does not support this (?) |
|
if (!\function_exists('pcntl_sigtimedwait')) { |
|
$this->markTestSkipped('pcntl_sigtimedwait() is not available'); |
|
} |
|
|
|
$this->setLogFile(sys_get_temp_dir() . '/deadlock_' . posix_getpid()); |
|
$this->deleteLog(); |
|
|
|
try { |
|
// to cause deadlock we do: |
|
// |
|
// 1. FIRST errornously forgot "FOR UPDATE" while read the row for next update. |
|
// 2. SECOND does update the row and locks it exclusively. |
|
// 3. FIRST tryes to update the row too, but it already has shared lock. Here comes deadlock. |
|
|
|
// FIRST child will send the signal to the SECOND child. |
|
// So, SECOND child should be forked at first to obtain its PID. |
|
|
|
$pidSecond = pcntl_fork(); |
|
if (-1 === $pidSecond) { |
|
$this->markTestIncomplete('cannot fork'); |
|
} |
|
if (0 === $pidSecond) { |
|
// SECOND child |
|
$this->setErrorHandler(); |
|
exit($this->childrenUpdateLocked()); |
|
} |
|
|
|
$pidFirst = pcntl_fork(); |
|
if (-1 === $pidFirst) { |
|
$this->markTestIncomplete('cannot fork second child'); |
|
} |
|
if (0 === $pidFirst) { |
|
// FIRST child |
|
$this->setErrorHandler(); |
|
exit($this->childrenSelectAndAccidentUpdate($pidSecond)); |
|
} |
|
|
|
// PARENT |
|
// nothing to do |
|
} catch (\Exception $e) { |
|
// wait all children |
|
while (-1 !== pcntl_wait($status)) { |
|
// nothing to do |
|
} |
|
$this->deleteLog(); |
|
throw $e; |
|
} catch (\Throwable $e) { |
|
// wait all children |
|
while (-1 !== pcntl_wait($status)) { |
|
// nothing to do |
|
} |
|
$this->deleteLog(); |
|
throw $e; |
|
} |
|
|
|
// wait all children |
|
// all must exit with success |
|
$errors = []; |
|
$deadlockHitCount = 0; |
|
while (-1 !== pcntl_wait($status)) { |
|
if (!pcntl_wifexited($status)) { |
|
$errors[] = 'child did not exit itself'; |
|
} else { |
|
$exitStatus = pcntl_wexitstatus($status); |
|
if (self::CHILD_EXIT_CODE_DEADLOCK === $exitStatus) { |
|
++$deadlockHitCount; |
|
} elseif (0 !== $exitStatus) { |
|
$errors[] = 'child exited with error status'; |
|
} |
|
} |
|
} |
|
$logContent = $this->getLogContentAndDelete(); |
|
if ($errors) { |
|
$this->fail( |
|
implode('; ', $errors) |
|
. ($logContent ? ". Shared children log:\n$logContent" : '') |
|
); |
|
} |
|
$this->assertEquals(1, $deadlockHitCount, "exactly one child must hit deadlock; shared children log:\n" . $logContent); |
|
} |
|
|
|
/** |
|
* Main body of first child process. |
|
* First child initializes test row and runs two nested [[Connection::transaction()]] |
|
* to perform following operations: |
|
* 1. `SELECT ... LOCK IN SHARE MODE` the test row with shared lock instead of needed exclusive lock. |
|
* 2. Send signal to SECOND child identified by PID [[$pidSecond]]. |
|
* 3. Waits few seconds. |
|
* 4. `UPDATE` the test row. |
|
* @param int $pidSecond |
|
* @return int Exit code. In case of deadlock exit code is [[CHILD_EXIT_CODE_DEADLOCK]]. |
|
* In case of success exit code is 0. Other codes means an error. |
|
*/ |
|
private function childrenSelectAndAccidentUpdate($pidSecond) |
|
{ |
|
try { |
|
$this->log('child 1: connect'); |
|
/** @var Connection $first */ |
|
$first = $this->getConnection(false, false); |
|
|
|
$this->log('child 1: delete'); |
|
$first->createCommand() |
|
->delete('{{customer}}', ['id' => 97]) |
|
->execute(); |
|
|
|
$this->log('child 1: insert'); |
|
// insert test row |
|
$first->createCommand() |
|
->insert('{{customer}}', [ |
|
'id' => 97, |
|
'email' => 'deadlock@example.com', |
|
'name' => 'test', |
|
'address' => 'test address', |
|
]) |
|
->execute(); |
|
|
|
$this->log('child 1: transaction'); |
|
$first->transaction(function (Connection $first) use ($pidSecond) { |
|
$first->transaction(function (Connection $first) use ($pidSecond) { |
|
$this->log('child 1: select'); |
|
// SELECT with shared lock |
|
$first->createCommand('SELECT id FROM {{customer}} WHERE id = 97 LOCK IN SHARE MODE') |
|
->execute(); |
|
|
|
$this->log('child 1: send signal to child 2'); |
|
// let child to continue |
|
if (!posix_kill($pidSecond, SIGUSR1)) { |
|
throw new \RuntimeException('Cannot send signal'); |
|
} |
|
|
|
// now child 2 tries to do the 2nd update, and hits the lock and waits |
|
|
|
// delay to let child hit the lock |
|
sleep(2); |
|
|
|
$this->log('child 1: update'); |
|
// now do the 3rd update for deadlock |
|
$first->createCommand() |
|
->update('{{customer}}', ['name' => 'first'], ['id' => 97]) |
|
->execute(); |
|
$this->log('child 1: commit'); |
|
}); |
|
}, Transaction::REPEATABLE_READ); |
|
} catch (Exception $e) { |
|
list($sqlError, $driverError, $driverMessage) = $e->errorInfo; |
|
// Deadlock found when trying to get lock; try restarting transaction |
|
if ('40001' === $sqlError && 1213 === $driverError) { |
|
return self::CHILD_EXIT_CODE_DEADLOCK; |
|
} |
|
$this->log("child 1: ! sql error $sqlError: $driverError: $driverMessage"); |
|
return 1; |
|
} catch (\Exception $e) { |
|
$this->log('child 1: ! exit <<' . \get_class($e) . ' #' . $e->getCode() . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString() . '>>'); |
|
return 1; |
|
} catch (\Throwable $e) { |
|
$this->log('child 1: ! exit <<' . \get_class($e) . ' #' . $e->getCode() . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString() . '>>'); |
|
return 1; |
|
} |
|
$this->log('child 1: exit'); |
|
return 0; |
|
} |
|
|
|
/** |
|
* Main body of second child process. |
|
* Second child at first will wait the signal from the first child in some seconds. |
|
* After receiving the signal it runs two nested [[Connection::transaction()]] |
|
* to perform `UPDATE` with the test row. |
|
* @return int Exit code. In case of deadlock exit code is [[CHILD_EXIT_CODE_DEADLOCK]]. |
|
* In case of success exit code is 0. Other codes means an error. |
|
*/ |
|
private function childrenUpdateLocked() |
|
{ |
|
// install no-op signal handler to prevent termination |
|
if (!pcntl_signal(SIGUSR1, function () {}, false)) { |
|
$this->log('child 2: cannot install signal handler'); |
|
return 1; |
|
} |
|
|
|
try { |
|
// at first, parent should do 1st select |
|
$this->log('child 2: wait signal from child 1'); |
|
if (pcntl_sigtimedwait([SIGUSR1], $info, 10) <= 0) { |
|
$this->log('child 2: wait timeout exceeded'); |
|
return 1; |
|
} |
|
|
|
$this->log('child 2: connect'); |
|
/** @var Connection $second */ |
|
$second = $this->getConnection(true, false); |
|
$second->open(); |
|
//sleep(1); |
|
$this->log('child 2: transaction'); |
|
$second->transaction(function (Connection $second) { |
|
$second->transaction(function (Connection $second) { |
|
$this->log('child 2: update'); |
|
// do the 2nd update |
|
$second->createCommand() |
|
->update('{{customer}}', ['name' => 'second'], ['id' => 97]) |
|
->execute(); |
|
|
|
$this->log('child 2: commit'); |
|
}); |
|
}, Transaction::REPEATABLE_READ); |
|
} catch (Exception $e) { |
|
list($sqlError, $driverError, $driverMessage) = $e->errorInfo; |
|
// Deadlock found when trying to get lock; try restarting transaction |
|
if ('40001' === $sqlError && 1213 === $driverError) { |
|
return self::CHILD_EXIT_CODE_DEADLOCK; |
|
} |
|
$this->log("child 2: ! sql error $sqlError: $driverError: $driverMessage"); |
|
return 1; |
|
} catch (\Exception $e) { |
|
$this->log('child 2: ! exit <<' . \get_class($e) . ' #' . $e->getCode() . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString() . '>>'); |
|
return 1; |
|
} catch (\Throwable $e) { |
|
$this->log('child 2: ! exit <<' . \get_class($e) . ' #' . $e->getCode() . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString() . '>>'); |
|
return 1; |
|
} |
|
$this->log('child 2: exit'); |
|
return 0; |
|
} |
|
|
|
/** |
|
* Set own error handler. |
|
* In case of error in child process its execution bubbles up to phpunit to continue |
|
* all the rest tests. So, all the rest tests in this case will run both in the child |
|
* and parent processes. Such mess must be prevented with child's own error handler. |
|
*/ |
|
private function setErrorHandler() |
|
{ |
|
if (PHP_VERSION_ID < 70000) { |
|
set_error_handler(function ($errno, $errstr, $errfile, $errline) { |
|
throw new \ErrorException($errstr, $errno, $errno, $errfile, $errline); |
|
}); |
|
} |
|
} |
|
|
|
/** |
|
* Sets filename for log file shared between children processes. |
|
* @param string $filename |
|
*/ |
|
private function setLogFile($filename) |
|
{ |
|
$this->logFile = $filename; |
|
} |
|
|
|
/** |
|
* Deletes shared log file. |
|
* Deletes the file [[logFile]] if it exists. |
|
*/ |
|
private function deleteLog() |
|
{ |
|
if (null !== $this->logFile && is_file($this->logFile)) { |
|
unlink($this->logFile); |
|
} |
|
} |
|
|
|
/** |
|
* Reads shared log content and deletes the log file. |
|
* Reads content of log file [[logFile]] and returns it deleting the file. |
|
* @return string|null String content of the file [[logFile]]. `false` is returned |
|
* when file cannot be read. `null` is returned when file does not exist |
|
* or [[logFile]] is not set. |
|
*/ |
|
private function getLogContentAndDelete() |
|
{ |
|
if (null !== $this->logFile && is_file($this->logFile)) { |
|
$content = file_get_contents($this->logFile); |
|
unlink($this->logFile); |
|
return $content; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* Append message to shared log. |
|
* @param string $message Message to append to the log. The message will be prepended |
|
* with timestamp and appended with new line. |
|
*/ |
|
private function log($message) |
|
{ |
|
if (null !== $this->logFile) { |
|
$time = microtime(true); |
|
$timeInt = floor($time); |
|
$timeFrac = $time - $timeInt; |
|
$timestamp = date('Y-m-d H:i:s', $timeInt) . '.' . round($timeFrac * 1000); |
|
file_put_contents($this->logFile, "[$timestamp] $message\n", FILE_APPEND | LOCK_EX); |
|
} |
|
} |
|
}
|
|
|