migration: Prevent parallel migration runs

This commit is contained in:
Igor Scheller 2020-06-01 22:00:33 +02:00 committed by msquare
parent 2899f9605e
commit 4995aa2a0b
3 changed files with 150 additions and 35 deletions

View File

@ -25,20 +25,27 @@ $errorHandler->setHandler(Handler::ENV_PRODUCTION, new NullHandler());
$migration = $app->get('db.migration'); $migration = $app->get('db.migration');
$migration->setOutput(function ($text) { echo $text . PHP_EOL; }); $migration->setOutput(function ($text) { echo $text . PHP_EOL; });
if (isset($argv[1]) && in_array(strtolower($argv[1]), ['help', '--help', '-h'])) { $script = array_shift($argv);
echo PHP_EOL . 'Usage: ' . $argv[0] . ' [up|down] [one-step]' . PHP_EOL . PHP_EOL; $argv = array_map('strtolower', $argv);
if (in_array('help', $argv) || in_array('--help', $argv) || in_array('-h', $argv)) {
echo PHP_EOL . 'Usage: ' . $script . ' [up|down] [one-step] [force|-f]' . PHP_EOL . PHP_EOL;
exit; exit;
} }
$method = Migrate::UP; $method = Migrate::UP;
if (isset($argv[1]) && strtolower($argv[1]) == 'down') { if (in_array('down', $argv)) {
$argv = array_values($argv); $argv = array_values($argv);
$method = Migrate::DOWN; $method = Migrate::DOWN;
} }
$oneStep = false; $oneStep = false;
if (isset($argv[2]) && strtolower($argv[2]) == 'one-step') { if (in_array('one-step', $argv)) {
$oneStep = true; $oneStep = true;
} }
$migration->run($baseDir, $method, $oneStep); $force = false;
if (in_array('force', $argv) || in_array('--force', $argv) || in_array('-f', $argv)) {
$force = true;
}
$migration->run($baseDir, $method, $oneStep, $force);

View File

@ -3,11 +3,13 @@
namespace Engelsystem\Database\Migration; namespace Engelsystem\Database\Migration;
use Engelsystem\Application; use Engelsystem\Application;
use Exception;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder as SchemaBuilder; use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable;
class Migrate class Migrate
{ {
@ -49,10 +51,13 @@ class Migrate
* @param string $path * @param string $path
* @param string $type (up|down) * @param string $type (up|down)
* @param bool $oneStep * @param bool $oneStep
* @param bool $forceMigration
*/ */
public function run($path, $type = self::UP, $oneStep = false) public function run($path, $type = self::UP, $oneStep = false, $forceMigration = false)
{ {
$this->initMigration(); $this->initMigration();
$this->lockTable($forceMigration);
$migrations = $this->mergeMigrations( $migrations = $this->mergeMigrations(
$this->getMigrations($path), $this->getMigrations($path),
$this->getMigrated() $this->getMigrated()
@ -62,29 +67,37 @@ class Migrate
$migrations = $migrations->reverse(); $migrations = $migrations->reverse();
} }
foreach ($migrations as $migration) { try {
/** @var array $migration */ foreach ($migrations as $migration) {
$name = $migration['migration']; /** @var array $migration */
$name = $migration['migration'];
if ( if (
($type == self::UP && isset($migration['id'])) ($type == self::UP && isset($migration['id']))
|| ($type == self::DOWN && !isset($migration['id'])) || ($type == self::DOWN && !isset($migration['id']))
) { ) {
($this->output)('Skipping ' . $name); ($this->output)('Skipping ' . $name);
continue; continue;
}
($this->output)('Migrating ' . $name . ' (' . $type . ')');
if (isset($migration['path'])) {
$this->migrate($migration['path'], $name, $type);
}
$this->setMigrated($name, $type);
if ($oneStep) {
break;
}
} }
} catch (Throwable $e) {
$this->unlockTable();
($this->output)('Migrating ' . $name . ' (' . $type . ')'); throw $e;
if (isset($migration['path'])) {
$this->migrate($migration['path'], $name, $type);
}
$this->setMigrated($name, $type);
if ($oneStep) {
return;
}
} }
$this->unlockTable();
} }
/** /**
@ -143,6 +156,7 @@ class Migrate
{ {
return $this->getTableQuery() return $this->getTableQuery()
->orderBy('id') ->orderBy('id')
->where('migration', '!=', 'lock')
->get(); ->get();
} }
@ -184,10 +198,45 @@ class Migrate
$table->insert(['migration' => $migration]); $table->insert(['migration' => $migration]);
} }
/**
* Lock the migrations table
*
* @param bool $forceMigration
*
* @throws Throwable
*/
protected function lockTable($forceMigration = false)
{
$this->schema->getConnection()->transaction(function () use ($forceMigration) {
$lock = $this->getTableQuery()
->where('migration', 'lock')
->lockForUpdate()
->first();
if ($lock && !$forceMigration) {
throw new Exception('Unable to acquire migration table lock');
}
$this->getTableQuery()
->insert(['migration' => 'lock']);
});
}
/**
* Unlock a previously locked table
*/
protected function unlockTable()
{
$this->getTableQuery()
->where('migration', 'lock')
->delete();
}
/** /**
* Get a list of migration files * Get a list of migration files
* *
* @param string $dir * @param string $dir
*
* @return Collection * @return Collection
*/ */
protected function getMigrations($dir) protected function getMigrations($dir)
@ -212,6 +261,7 @@ class Migrate
* List all migration files from the given directory * List all migration files from the given directory
* *
* @param string $dir * @param string $dir
*
* @return array * @return array
*/ */
protected function getMigrationFiles($dir) protected function getMigrationFiles($dir)

View File

@ -4,12 +4,13 @@ namespace Engelsystem\Test\Unit\Database\Migration;
use Engelsystem\Application; use Engelsystem\Application;
use Engelsystem\Database\Migration\Migrate; use Engelsystem\Database\Migration\Migrate;
use Engelsystem\Test\Unit\TestCase;
use Exception;
use Illuminate\Database\Capsule\Manager as CapsuleManager; use Illuminate\Database\Capsule\Manager as CapsuleManager;
use Illuminate\Database\Schema\Builder as SchemaBuilder; use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class MigrateTest extends TestCase class MigrateTest extends TestCase
{ {
@ -33,11 +34,18 @@ class MigrateTest extends TestCase
/** @var Migrate|MockObject $migration */ /** @var Migrate|MockObject $migration */
$migration = $this->getMockBuilder(Migrate::class) $migration = $this->getMockBuilder(Migrate::class)
->setConstructorArgs([$builder, $app]) ->setConstructorArgs([$builder, $app])
->onlyMethods(['initMigration', 'getMigrationFiles', 'getMigrated', 'migrate', 'setMigrated']) ->onlyMethods([
'initMigration',
'getMigrationFiles',
'getMigrated',
'migrate',
'setMigrated',
'lockTable',
'unlockTable',
])
->getMock(); ->getMock();
$migration->expects($this->atLeastOnce()) $this->setExpects($migration, 'initMigration', null, null, $this->atLeastOnce());
->method('initMigration');
$migration->expects($this->atLeastOnce()) $migration->expects($this->atLeastOnce())
->method('getMigrationFiles') ->method('getMigrationFiles')
->willReturn([ ->willReturn([
@ -46,12 +54,10 @@ class MigrateTest extends TestCase
'foo/4567_11_01_000000_do_stuff.php', 'foo/4567_11_01_000000_do_stuff.php',
'foo/9999_99_99_999999_another_foo.php', 'foo/9999_99_99_999999_another_foo.php',
]); ]);
$migration->expects($this->atLeastOnce()) $this->setExpects($migration, 'getMigrated', null, new Collection([
->method('getMigrated') ['id' => 1, 'migration' => '1234_01_23_123456_init_foo'],
->willReturn(new Collection([ ['id' => 2, 'migration' => '4567_11_01_000000_do_stuff'],
['id' => 1, 'migration' => '1234_01_23_123456_init_foo'], ]), $this->atLeastOnce());
['id' => 2, 'migration' => '4567_11_01_000000_do_stuff'],
]));
$migration->expects($this->atLeastOnce()) $migration->expects($this->atLeastOnce())
->method('migrate') ->method('migrate')
->withConsecutive( ->withConsecutive(
@ -72,6 +78,8 @@ class MigrateTest extends TestCase
['9876_03_22_210000_random_hack', Migrate::UP], ['9876_03_22_210000_random_hack', Migrate::UP],
['4567_11_01_000000_do_stuff', Migrate::DOWN] ['4567_11_01_000000_do_stuff', Migrate::DOWN]
); );
$this->setExpects($migration, 'lockTable', null, null, $this->atLeastOnce());
$this->setExpects($migration, 'unlockTable', null, null, $this->atLeastOnce());
$migration->run('foo', Migrate::UP); $migration->run('foo', Migrate::UP);
@ -111,6 +119,49 @@ class MigrateTest extends TestCase
$migration->run('foo', Migrate::DOWN, true); $migration->run('foo', Migrate::DOWN, true);
} }
/**
* @covers \Engelsystem\Database\Migration\Migrate::run
*/
public function testRunExceptionUnlockTable()
{
/** @var Application|MockObject $app */
$app = $this->getMockBuilder(Application::class)
->onlyMethods(['instance'])
->getMock();
/** @var SchemaBuilder|MockObject $builder */
$builder = $this->getMockBuilder(SchemaBuilder::class)
->disableOriginalConstructor()
->getMock();
/** @var Migrate|MockObject $migration */
$migration = $this->getMockBuilder(Migrate::class)
->setConstructorArgs([$builder, $app])
->onlyMethods([
'initMigration',
'lockTable',
'getMigrations',
'getMigrated',
'migrate',
'unlockTable',
])
->getMock();
$this->setExpects($migration, 'initMigration');
$this->setExpects($migration, 'lockTable');
$this->setExpects($migration, 'unlockTable');
$this->setExpects($migration, 'getMigrations', null, collect([
['migration' => '1234_01_23_123456_init_foo', 'path' => '/foo']
]));
$this->setExpects($migration, 'getMigrated', null, collect([]));
$migration->expects($this->once())
->method('migrate')
->willReturnCallback(function () {
throw new Exception();
});
$this->expectException(Exception::class);
$migration->run('');
}
/** /**
* @covers \Engelsystem\Database\Migration\Migrate::getMigrated * @covers \Engelsystem\Database\Migration\Migrate::getMigrated
* @covers \Engelsystem\Database\Migration\Migrate::getMigrationFiles * @covers \Engelsystem\Database\Migration\Migrate::getMigrationFiles
@ -118,6 +169,8 @@ class MigrateTest extends TestCase
* @covers \Engelsystem\Database\Migration\Migrate::initMigration * @covers \Engelsystem\Database\Migration\Migrate::initMigration
* @covers \Engelsystem\Database\Migration\Migrate::migrate * @covers \Engelsystem\Database\Migration\Migrate::migrate
* @covers \Engelsystem\Database\Migration\Migrate::setMigrated * @covers \Engelsystem\Database\Migration\Migrate::setMigrated
* @covers \Engelsystem\Database\Migration\Migrate::lockTable
* @covers \Engelsystem\Database\Migration\Migrate::unlockTable
*/ */
public function testRunIntegration() public function testRunIntegration()
{ {
@ -145,6 +198,7 @@ class MigrateTest extends TestCase
$migrations = $db->table('migrations')->get(); $migrations = $db->table('migrations')->get();
$this->assertCount(3, $migrations); $this->assertCount(3, $migrations);
$this->assertFalse($migrations->contains('migration', 'lock'));
$this->assertTrue($migrations->contains('migration', '2001_04_11_123456_create_lorem_ipsum_table')); $this->assertTrue($migrations->contains('migration', '2001_04_11_123456_create_lorem_ipsum_table'));
$this->assertTrue($migrations->contains('migration', '2017_12_24_053300_another_stuff')); $this->assertTrue($migrations->contains('migration', '2017_12_24_053300_another_stuff'));
@ -163,5 +217,9 @@ class MigrateTest extends TestCase
$this->assertCount(0, $migrations); $this->assertCount(0, $migrations);
$this->assertFalse($schema->hasTable('lorem_ipsum')); $this->assertFalse($schema->hasTable('lorem_ipsum'));
$db->table('migrations')->insert(['migration' => 'lock']);
$this->expectException(Exception::class);
$migration->run(__DIR__ . '/Stub', Migrate::UP);
} }
} }