Skip to content

Commit eae7bde

Browse files
authored
Merge pull request #1 from netsells/feature/remove-migration-states
feature/remove-migration-states
2 parents 602a125 + 70a6f0a commit eae7bde

26 files changed

+258
-1024
lines changed

.github/workflows/tests.yaml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Tests
2+
3+
on: [pull_request, workflow_dispatch]
4+
5+
jobs:
6+
test:
7+
# not self-hosted, because it's a public repo
8+
runs-on: ubuntu-latest
9+
10+
# we want to run it on combination of PHP 8.1+ and Laravel 9.1+
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
php: ['8.1']
15+
laravel: ['^9.1']
16+
17+
steps:
18+
- name: Checkout the repo
19+
uses: actions/checkout@v2
20+
21+
- name: Setup PHP
22+
uses: shivammathur/setup-php@v2
23+
with:
24+
php-version: ${{ matrix.php }}
25+
extensions: zip, gd, sqlite, json, gmp, bcmath
26+
coverage: none
27+
28+
- name: Ensure we use specific version of Laravel, and install other dependencies
29+
env:
30+
LARAVEL_VERSION: ${{ matrix.laravel }}
31+
run: composer require laravel/framework $LARAVEL_VERSION --no-interaction --no-scripts --prefer-dist
32+
33+
- name: Execute tests
34+
run: vendor/bin/phpunit

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
1-
Laravel Mutex Migrations
2-
========================
1+
Mutex Migrations for Laravel
2+
============================
33

44
Run mutually exclusive migrations from more than one server at a time.
55

6-
TODO:
6+
Using Laravel's functionality for [Atomic Locks](https://laravel.com/docs/9.x/cache#atomic-locks), this package extends the built-in `MigrateCommand` class to allow migrations to be run safely when there is the chance that they may be run concurrently against the same database.
7+
8+
## Installation
9+
10+
Install the package with:
11+
12+
`composer require netsells/laravel-mutex-migrations`
13+
14+
Optionally publish the package config file:
15+
16+
`php artisan vendor:publish --tag=mutex-migrations-config`
17+
18+
## Usage
19+
20+
Before a mutex migration can be run using the default `database` store, the store's `cache_locks` table **must** already have been created by running `php artisan cache:table` followed by a standard migration - i.e. `php artisan migrate`. Once this table exists migrations can be run safely as follows:
21+
22+
`php artisan migrate --mutex`
23+
24+
If two or more migrations happen to run concurrently, the first to acquire a lock will block the next one from running until it has finished, or until the lock times out - after 60 seconds, by default.
25+
26+
## Testing
27+
28+
`./vendor/bin/phpunit`

config/mutex-migrations.php

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,7 @@
11
<?php
22

33
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' => [
4+
'lock' => [
345
// the cache store to use to manage queued migrations; use stores that
356
// are available across application instances, such as 'database', or
367
// 'redis' to ensure migrations are mutually exclusive. N.B. mutually

src/MigrateCommandExtension.php

Lines changed: 15 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,86 +5,31 @@
55
use Illuminate\Contracts\Events\Dispatcher;
66
use Illuminate\Database\Console\Migrations\MigrateCommand;
77
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;
8+
use Illuminate\Support\Collection;
129

13-
class MigrateCommandExtension extends MigrateCommand implements SignalableCommandInterface
10+
class MigrateCommandExtension extends MigrateCommand
1411
{
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
12+
public function __construct(Migrator $migrator, Dispatcher $dispatcher)
4913
{
50-
return $this->factory->create(
51-
$this->option(self::OPTION_MUTEX),
52-
$this->option(self::OPTION_DOWN),
53-
$this,
54-
$this->components
55-
);
56-
}
14+
parent::__construct($migrator, $dispatcher);
5715

58-
public function getSubscribedSignals(): array
59-
{
60-
return [SIGINT, SIGTERM];
16+
parent::addOption(...MutexMigrateCommand::getMutexOption());
6117
}
6218

63-
public function handleSignal(int $signal): void
19+
public function handle(): int
6420
{
65-
$this->processor->terminate(false);
66-
}
21+
if ($this->option(MutexMigrateCommand::OPTION_MUTEX)) {
22+
return $this->call(MutexMigrateCommand::class, $this->getCommandOptions());
23+
}
6724

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-
));
25+
return parent::handle();
7626
}
7727

78-
/**
79-
* Additional options to add to the command.
80-
*
81-
* @return array
82-
*/
83-
private function getAdditionalOptions(): array
28+
private function getCommandOptions(): array
8429
{
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-
];
30+
return Collection::make($this->options())
31+
->reject(fn ($value, $key) => $key === MutexMigrateCommand::OPTION_MUTEX)
32+
->mapWithKeys(fn ($value, $key) => ["--$key" => $value])
33+
->all();
8934
}
9035
}

src/Mutex/MutexQueue.php

Lines changed: 0 additions & 90 deletions
This file was deleted.

src/Mutex/MutexRelay.php

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,58 +2,56 @@
22

33
namespace Netsells\LaravelMutexMigrations\Mutex;
44

5+
use Illuminate\Contracts\Cache\Lock;
6+
use Illuminate\Contracts\Cache\LockProvider;
7+
use Illuminate\Contracts\Cache\Repository;
58
use Illuminate\Database\QueryException;
69
use Illuminate\Support\Str;
710

811
class MutexRelay implements MutexRelayInterface
912
{
10-
private string $owner;
13+
public const DEFAULT_LOCK_TABLE = 'cache_locks';
1114

12-
public function __construct(private readonly MutexQueue $queue)
13-
{
14-
$this->owner = Str::random();
15+
public const KEY = 'laravel-mutex-migrations';
16+
17+
private ?Lock $lock = null;
18+
19+
public function __construct(
20+
private readonly Repository $cache,
21+
private readonly int $lockDurationSeconds = 60,
22+
private readonly string $lockTable = self::DEFAULT_LOCK_TABLE,
23+
) {
24+
//
1525
}
1626

17-
public function acquireLock(array $meta = [], callable $feedback = null): bool
27+
public function acquireLock(): bool
1828
{
1929
try {
20-
return $this->queue->push($this->owner, $meta);
30+
return $this->getLock()->block($this->lockDurationSeconds);
2131
} catch (\Throwable $th) {
2232
if ($this->isCacheTableNotFoundException($th)) {
2333
throw new DatabaseCacheTableNotFoundException();
2434
}
2535

2636
throw $th;
27-
} finally {
28-
// after pushing an item to the queue, we'll have a maximum of the
29-
// configured TTL value - 60 seconds by default - to acquire a lock
30-
// before the queued items expire
31-
while (! isset($th) && ! $this->queue->isFirst($this->owner)) {
32-
if (! $this->queue->contains($this->owner)) {
33-
throw new MutexRelayTimeoutException($this->owner);
34-
}
35-
36-
if (is_callable($feedback)) {
37-
$feedback();
38-
};
39-
40-
$wait = $this->backOff($wait ?? 1);
41-
}
4237
}
4338
}
4439

4540
public function releaseLock(): bool
4641
{
47-
return $this->queue->pull($this->owner);
42+
return $this->lock?->release() ?? false;
4843
}
4944

50-
public function hasOwnerWithMeta(array $meta): bool
45+
private function getLock(): Lock
5146
{
52-
if ($this->queue->isEmpty()) {
53-
return false;
47+
if ($this->lock) {
48+
return $this->lock;
5449
}
5550

56-
return $this->queue->contains(fn ($item) => $item === $meta);
51+
/** @var LockProvider $provider */
52+
$provider = $this->cache->getStore();
53+
54+
return $this->lock = $provider->lock(self::KEY . '.lock');
5755
}
5856

5957
private function isCacheTableNotFoundException(\Throwable $th): bool
@@ -62,13 +60,6 @@ private function isCacheTableNotFoundException(\Throwable $th): bool
6260
return false;
6361
}
6462

65-
return Str::contains($th->getMessage(), ['Base table or view new found', 'cache'], true);
66-
}
67-
68-
private function backOff(int $wait): int
69-
{
70-
sleep($wait++);
71-
72-
return $wait;
63+
return $th->getCode() === '42S02' && Str::contains($th->getMessage(), $this->lockTable);
7364
}
7465
}

0 commit comments

Comments
 (0)