Skip to content
Merged
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
34 changes: 34 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Tests

on: [pull_request, workflow_dispatch]

jobs:
test:
# not self-hosted, because it's a public repo
runs-on: ubuntu-latest

# we want to run it on combination of PHP 8.1+ and Laravel 9.1+
strategy:
fail-fast: false
matrix:
php: ['8.1']
laravel: ['^9.1']

steps:
- name: Checkout the repo
uses: actions/checkout@v2

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: zip, gd, sqlite, json, gmp, bcmath
coverage: none

- name: Ensure we use specific version of Laravel, and install other dependencies
env:
LARAVEL_VERSION: ${{ matrix.laravel }}
run: composer require laravel/framework $LARAVEL_VERSION --no-interaction --no-scripts --prefer-dist

- name: Execute tests
run: vendor/bin/phpunit
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
Laravel Mutex Migrations
========================
Mutex Migrations for Laravel
============================

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

TODO:
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.

## Installation

Install the package with:

`composer require netsells/laravel-mutex-migrations`

Optionally publish the package config file:

`php artisan vendor:publish --tag=mutex-migrations-config`

## Usage

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:

`php artisan migrate --mutex`

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.

## Testing

`./vendor/bin/phpunit`
31 changes: 1 addition & 30 deletions config/mutex-migrations.php
Original file line number Diff line number Diff line change
@@ -1,36 +1,7 @@
<?php

return [
'command' => [

// configuration for running migrations in maintenance mode
'down' => [
// options for the artisan down command called during a down
// migration @see \Illuminate\Foundation\Console\DownCommand
'options' => [
// The path that users should be redirected to
'--redirect' => null,
// The view that should be pre-rendered for display during
// maintenance mode
'--render' => null,
// The number of seconds after which the request may be retried
'--retry' => null,
// The number of seconds after which the browser may refresh
'--refresh' => null,
// The secret phrase that may be used to bypass maintenance mode
'--secret' => null,
// The status code that should be used when returning the
// maintenance mode response
'--status' => null,
],

// preserves maintenance mode after an exception during a down
// migration
'sticky' => true,
],
],

'queue' => [
'lock' => [
// the cache store to use to manage queued migrations; use stores that
// are available across application instances, such as 'database', or
// 'redis' to ensure migrations are mutually exclusive. N.B. mutually
Expand Down
85 changes: 15 additions & 70 deletions src/MigrateCommandExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,86 +5,31 @@
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Database\Console\Migrations\MigrateCommand;
use Illuminate\Database\Migrations\Migrator;
use Netsells\LaravelMutexMigrations\Processors\MigrationProcessorFactory;
use Netsells\LaravelMutexMigrations\Processors\MigrationProcessorInterface;
use Symfony\Component\Console\Command\SignalableCommandInterface;
use Symfony\Component\Console\Input\InputOption;
use Illuminate\Support\Collection;

class MigrateCommandExtension extends MigrateCommand implements SignalableCommandInterface
class MigrateCommandExtension extends MigrateCommand
{
private const OPTION_DOWN = 'down';

private const OPTION_MUTEX = 'mutex';

private MigrationProcessorInterface $processor;

public function __construct(
Migrator $migrator,
Dispatcher $dispatcher,
private readonly MigrationProcessorFactory $factory
) {
$this->extendSignature();

parent::__construct($migrator, $dispatcher);
}

public function handle()
{
$this->processor = $this->createProcessor();

try {
$this->processor->start();

return parent::handle();
} catch (\Throwable $th) {
$this->components->error($th->getMessage());

return self::FAILURE;
} finally {
$this->processor->terminate(isset($th));
}
}

private function createProcessor(): MigrationProcessorInterface
public function __construct(Migrator $migrator, Dispatcher $dispatcher)
{
return $this->factory->create(
$this->option(self::OPTION_MUTEX),
$this->option(self::OPTION_DOWN),
$this,
$this->components
);
}
parent::__construct($migrator, $dispatcher);

public function getSubscribedSignals(): array
{
return [SIGINT, SIGTERM];
parent::addOption(...MutexMigrateCommand::getMutexOption());
}

public function handleSignal(int $signal): void
public function handle(): int
{
$this->processor->terminate(false);
}
if ($this->option(MutexMigrateCommand::OPTION_MUTEX)) {
return $this->call(MutexMigrateCommand::class, $this->getCommandOptions());
}

private function extendSignature(): void
{
$this->signature = join(PHP_EOL, array_merge(
[$this->signature],
array_map(function ($option) {
return '{--' . join(" : ", [$option[0], $option[3]]) . '}';
}, $this->getAdditionalOptions())
));
return parent::handle();
}

/**
* Additional options to add to the command.
*
* @return array
*/
private function getAdditionalOptions(): array
private function getCommandOptions(): array
{
return [
[self::OPTION_MUTEX, null, InputOption::VALUE_NONE, 'Run a mutually exclusive migration'],
[self::OPTION_DOWN, null, InputOption::VALUE_NONE, 'Enable maintenance mode during a migration']
];
return Collection::make($this->options())
->reject(fn ($value, $key) => $key === MutexMigrateCommand::OPTION_MUTEX)
->mapWithKeys(fn ($value, $key) => ["--$key" => $value])
->all();
}
}
90 changes: 0 additions & 90 deletions src/Mutex/MutexQueue.php

This file was deleted.

59 changes: 25 additions & 34 deletions src/Mutex/MutexRelay.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,56 @@

namespace Netsells\LaravelMutexMigrations\Mutex;

use Illuminate\Contracts\Cache\Lock;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\QueryException;
use Illuminate\Support\Str;

class MutexRelay implements MutexRelayInterface
{
private string $owner;
public const DEFAULT_LOCK_TABLE = 'cache_locks';

public function __construct(private readonly MutexQueue $queue)
{
$this->owner = Str::random();
public const KEY = 'laravel-mutex-migrations';

private ?Lock $lock = null;

public function __construct(
private readonly Repository $cache,
private readonly int $lockDurationSeconds = 60,
private readonly string $lockTable = self::DEFAULT_LOCK_TABLE,
) {
//
}

public function acquireLock(array $meta = [], callable $feedback = null): bool
public function acquireLock(): bool
{
try {
return $this->queue->push($this->owner, $meta);
return $this->getLock()->block($this->lockDurationSeconds);
} catch (\Throwable $th) {
if ($this->isCacheTableNotFoundException($th)) {
throw new DatabaseCacheTableNotFoundException();
}

throw $th;
} finally {
// after pushing an item to the queue, we'll have a maximum of the
// configured TTL value - 60 seconds by default - to acquire a lock
// before the queued items expire
while (! isset($th) && ! $this->queue->isFirst($this->owner)) {
if (! $this->queue->contains($this->owner)) {
throw new MutexRelayTimeoutException($this->owner);
}

if (is_callable($feedback)) {
$feedback();
};

$wait = $this->backOff($wait ?? 1);
}
}
}

public function releaseLock(): bool
{
return $this->queue->pull($this->owner);
return $this->lock?->release() ?? false;
}

public function hasOwnerWithMeta(array $meta): bool
private function getLock(): Lock
{
if ($this->queue->isEmpty()) {
return false;
if ($this->lock) {
return $this->lock;
}

return $this->queue->contains(fn ($item) => $item === $meta);
/** @var LockProvider $provider */
$provider = $this->cache->getStore();

return $this->lock = $provider->lock(self::KEY . '.lock');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a big deal, because in the current form this scenario won't happen, however normally I think the first thing a method like that should do is check if $this->lock is already set, and return that instead, only assigning the lock property once. I would have an expectation like this, because the method assigns a single shared class property, rather than just returning $provider->lock() result.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100% - I'd definitely code it like that ordinarily and think I didn't for the precise reason that it seemed like it wouldn't occur. Anyway, I added an early return if the instance already exists.

}

private function isCacheTableNotFoundException(\Throwable $th): bool
Expand All @@ -62,13 +60,6 @@ private function isCacheTableNotFoundException(\Throwable $th): bool
return false;
}

return Str::contains($th->getMessage(), ['Base table or view new found', 'cache'], true);
}

private function backOff(int $wait): int
{
sleep($wait++);

return $wait;
return $th->getCode() === '42S02' && Str::contains($th->getMessage(), $this->lockTable);
}
}
Loading