Skip to content

Commit 602a125

Browse files
committed
Initial commit
0 parents  commit 602a125

31 files changed

+1570
-0
lines changed

.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
composer.lock
2+
.phpunit.result.cache
3+
vendor/
4+
5+
cache/
6+
!cache/.gitkeep

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Laravel Mutex Migrations
2+
========================
3+
4+
Run mutually exclusive migrations from more than one server at a time.
5+
6+
TODO:

composer.json

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "netsells/laravel-mutex-migrations",
3+
"description": "Run mutually exclusive migrations from more than one server at a time",
4+
"type": "library",
5+
"license": "MIT",
6+
"keywords": [
7+
"laravel",
8+
"netsells",
9+
"migrations",
10+
"mutex"
11+
],
12+
"authors": [
13+
{
14+
"name": "Tom Moore",
15+
"email": "[email protected]"
16+
},
17+
{
18+
"name": "Sam Jordan",
19+
"email": "[email protected]"
20+
}
21+
],
22+
"require": {
23+
"php": "^8.1",
24+
"laravel/framework": "^9.3"
25+
},
26+
"require-dev": {
27+
"orchestra/testbench": "^7.7",
28+
"spatie/fork": "^1.1"
29+
},
30+
"autoload": {
31+
"psr-4": {
32+
"Netsells\\LaravelMutexMigrations\\": "src/"
33+
}
34+
},
35+
"autoload-dev": {
36+
"psr-4": {
37+
"Netsells\\LaravelMutexMigrations\\Tests\\": "tests/"
38+
}
39+
},
40+
"extra": {
41+
"laravel": {
42+
"providers": [
43+
"Netsells\\LaravelMutexMigrations\\ServiceProvider"
44+
]
45+
}
46+
},
47+
"config": {
48+
"sort-packages": true
49+
}
50+
}

config/mutex-migrations.php

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
return [
4+
'command' => [
5+
6+
// configuration for running migrations in maintenance mode
7+
'down' => [
8+
// options for the artisan down command called during a down
9+
// migration @see \Illuminate\Foundation\Console\DownCommand
10+
'options' => [
11+
// The path that users should be redirected to
12+
'--redirect' => null,
13+
// The view that should be pre-rendered for display during
14+
// maintenance mode
15+
'--render' => null,
16+
// The number of seconds after which the request may be retried
17+
'--retry' => null,
18+
// The number of seconds after which the browser may refresh
19+
'--refresh' => null,
20+
// The secret phrase that may be used to bypass maintenance mode
21+
'--secret' => null,
22+
// The status code that should be used when returning the
23+
// maintenance mode response
24+
'--status' => null,
25+
],
26+
27+
// preserves maintenance mode after an exception during a down
28+
// migration
29+
'sticky' => true,
30+
],
31+
],
32+
33+
'queue' => [
34+
// the cache store to use to manage queued migrations; use stores that
35+
// are available across application instances, such as 'database', or
36+
// 'redis' to ensure migrations are mutually exclusive. N.B. mutually
37+
// exclusive migrations using the 'database' store can only work after
38+
// the store's `cache` table has been created (by a standard migration!)
39+
'store' => env('MUTEX_MIGRATIONS_STORE', 'database'),
40+
41+
// the maximum number of seconds a mutex migration should wait while
42+
// trying to acquire a lock - effectively the time an instance of a
43+
// mutex migration has to complete - before an exception is thrown
44+
'ttl_seconds' => env('MUTEX_MIGRATIONS_TTL_SECONDS', 60),
45+
]
46+
];

phpunit.xml

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
4+
bootstrap="vendor/autoload.php"
5+
colors="true">
6+
<testsuites>
7+
<testsuite name="Integration">
8+
<directory suffix="Test.php">./tests/Integration</directory>
9+
</testsuite>
10+
<testsuite name="Unit">
11+
<directory suffix="Test.php">./tests/Unit</directory>
12+
</testsuite>
13+
</testsuites>
14+
<php>
15+
<env name="DB_CONNECTION" value="testing"/>
16+
<env name="MUTEX_MIGRATIONS_STORE" value="file"/>
17+
</php>
18+
</phpunit>

src/MigrateCommandExtension.php

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Netsells\LaravelMutexMigrations;
4+
5+
use Illuminate\Contracts\Events\Dispatcher;
6+
use Illuminate\Database\Console\Migrations\MigrateCommand;
7+
use Illuminate\Database\Migrations\Migrator;
8+
use Netsells\LaravelMutexMigrations\Processors\MigrationProcessorFactory;
9+
use Netsells\LaravelMutexMigrations\Processors\MigrationProcessorInterface;
10+
use Symfony\Component\Console\Command\SignalableCommandInterface;
11+
use Symfony\Component\Console\Input\InputOption;
12+
13+
class MigrateCommandExtension extends MigrateCommand implements SignalableCommandInterface
14+
{
15+
private const OPTION_DOWN = 'down';
16+
17+
private const OPTION_MUTEX = 'mutex';
18+
19+
private MigrationProcessorInterface $processor;
20+
21+
public function __construct(
22+
Migrator $migrator,
23+
Dispatcher $dispatcher,
24+
private readonly MigrationProcessorFactory $factory
25+
) {
26+
$this->extendSignature();
27+
28+
parent::__construct($migrator, $dispatcher);
29+
}
30+
31+
public function handle()
32+
{
33+
$this->processor = $this->createProcessor();
34+
35+
try {
36+
$this->processor->start();
37+
38+
return parent::handle();
39+
} catch (\Throwable $th) {
40+
$this->components->error($th->getMessage());
41+
42+
return self::FAILURE;
43+
} finally {
44+
$this->processor->terminate(isset($th));
45+
}
46+
}
47+
48+
private function createProcessor(): MigrationProcessorInterface
49+
{
50+
return $this->factory->create(
51+
$this->option(self::OPTION_MUTEX),
52+
$this->option(self::OPTION_DOWN),
53+
$this,
54+
$this->components
55+
);
56+
}
57+
58+
public function getSubscribedSignals(): array
59+
{
60+
return [SIGINT, SIGTERM];
61+
}
62+
63+
public function handleSignal(int $signal): void
64+
{
65+
$this->processor->terminate(false);
66+
}
67+
68+
private function extendSignature(): void
69+
{
70+
$this->signature = join(PHP_EOL, array_merge(
71+
[$this->signature],
72+
array_map(function ($option) {
73+
return '{--' . join(" : ", [$option[0], $option[3]]) . '}';
74+
}, $this->getAdditionalOptions())
75+
));
76+
}
77+
78+
/**
79+
* Additional options to add to the command.
80+
*
81+
* @return array
82+
*/
83+
private function getAdditionalOptions(): array
84+
{
85+
return [
86+
[self::OPTION_MUTEX, null, InputOption::VALUE_NONE, 'Run a mutually exclusive migration'],
87+
[self::OPTION_DOWN, null, InputOption::VALUE_NONE, 'Enable maintenance mode during a migration']
88+
];
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Netsells\LaravelMutexMigrations\Mutex;
4+
5+
class DatabaseCacheTableNotFoundException extends \Exception
6+
{
7+
public function __construct()
8+
{
9+
parent::__construct(
10+
'Mutex migrations cannot be run using the database store until the required cache tables have been created. Run `php artisan cache:table` to create the required migration followed by a standard migration to create the tables.'
11+
);
12+
}
13+
}

src/Mutex/MutexQueue.php

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Netsells\LaravelMutexMigrations\Mutex;
4+
5+
use Illuminate\Contracts\Cache\LockProvider;
6+
use Illuminate\Contracts\Cache\Repository;
7+
8+
class MutexQueue
9+
{
10+
public const KEY = 'laravel-mutex-migrations';
11+
12+
public function __construct(
13+
private readonly Repository $cache,
14+
private readonly int $ttl_seconds = 60
15+
) {
16+
//
17+
}
18+
19+
public function push(string $item, array $meta = []): bool
20+
{
21+
if ($this->contains($item)) {
22+
return false;
23+
}
24+
25+
return $this->withAtomicLock(function () use ($item, $meta) {
26+
$items = $this->getItems();
27+
$items[$item] = $meta;
28+
29+
return $this->putItems($items);
30+
});
31+
}
32+
33+
public function pull(string $item): bool
34+
{
35+
if (! $this->contains($item)) {
36+
return false;
37+
}
38+
39+
return $this->withAtomicLock(function () use ($item) {
40+
$filteredItems = array_filter(
41+
$this->getItems(),
42+
fn (string $key) => $key !== $item,
43+
ARRAY_FILTER_USE_KEY
44+
);
45+
46+
return empty($filteredItems)
47+
? $this->cache->forget(self::KEY)
48+
: $this->putItems($filteredItems);
49+
});
50+
}
51+
52+
public function contains(string|callable $item): bool
53+
{
54+
if (is_string($item)) {
55+
return in_array($item, array_keys($this->getItems()));
56+
}
57+
58+
return ! empty(array_filter($this->getItems(), $item, ARRAY_FILTER_USE_BOTH));
59+
}
60+
61+
public function isEmpty(): bool
62+
{
63+
return empty($this->getItems());
64+
}
65+
66+
public function isFirst(string $item): bool
67+
{
68+
$keys = array_keys($this->getItems());
69+
70+
return reset($keys) === $item;
71+
}
72+
73+
private function withAtomicLock(callable $callback)
74+
{
75+
/** @var LockProvider $provider */
76+
$provider = $this->cache->getStore();
77+
78+
return $provider->lock(self::KEY . '.lock', 3)->block(2, $callback);
79+
}
80+
81+
private function getItems(): array
82+
{
83+
return $this->cache->get(self::KEY, []);
84+
}
85+
86+
private function putItems(array $items): bool
87+
{
88+
return $this->cache->put(self::KEY, $items, $this->ttl_seconds);
89+
}
90+
}

0 commit comments

Comments
 (0)