Skip to content

Commit c2798fd

Browse files
Oliver Nybroetaylorotwell
andauthored
Make migrate command isolated (#44743)
* Add Isolated interface to `migrate` command * Add CommandMutex with cache implementation * Remove typehints in favor of docblocks * Apply StyleCI * Add support for releasing lock again * fix db migrate command tests * cleanup * Add `--isolated` flag to command * rename file. formatting * allow exit code * fix option * fix order Co-authored-by: Taylor Otwell <[email protected]>
1 parent 4d126e8 commit c2798fd

File tree

8 files changed

+374
-3
lines changed

8 files changed

+374
-3
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
namespace Illuminate\Console;
4+
5+
use Carbon\CarbonInterval;
6+
use Illuminate\Contracts\Cache\Factory as Cache;
7+
8+
class CacheCommandMutex implements CommandMutex
9+
{
10+
/**
11+
* The cache factory implementation.
12+
*
13+
* @var \Illuminate\Contracts\Cache\Factory
14+
*/
15+
public $cache;
16+
17+
/**
18+
* The cache store that should be used.
19+
*
20+
* @var string|null
21+
*/
22+
public $store = null;
23+
24+
/**
25+
* Create a new command mutex.
26+
*
27+
* @param \Illuminate\Contracts\Cache\Factory $cache
28+
*/
29+
public function __construct(Cache $cache)
30+
{
31+
$this->cache = $cache;
32+
}
33+
34+
/**
35+
* Attempt to obtain a command mutex for the given command.
36+
*
37+
* @param \Illuminate\Console\Command $command
38+
* @return bool
39+
*/
40+
public function create($command)
41+
{
42+
return $this->cache->store($this->store)->add(
43+
$this->commandMutexName($command),
44+
true,
45+
method_exists($command, 'isolationExpiresAt')
46+
? $command->isolationExpiresAt()
47+
: CarbonInterval::hour(),
48+
);
49+
}
50+
51+
/**
52+
* Determine if a command mutex exists for the given command.
53+
*
54+
* @param \Illuminate\Console\Command $command
55+
* @return bool
56+
*/
57+
public function exists($command)
58+
{
59+
return $this->cache->store($this->store)->has(
60+
$this->commandMutexName($command)
61+
);
62+
}
63+
64+
/**
65+
* Release the mutex for the given command.
66+
*
67+
* @param \Illuminate\Console\Command $command
68+
* @return bool
69+
*/
70+
public function forget($command)
71+
{
72+
return $this->cache->store($this->store)->forget(
73+
$this->commandMutexName($command)
74+
);
75+
}
76+
77+
/**
78+
* @param \Illuminate\Console\Command $command
79+
* @return string
80+
*/
81+
protected function commandMutexName($command)
82+
{
83+
return 'framework'.DIRECTORY_SEPARATOR.'command-'.$command->getName();
84+
}
85+
86+
/**
87+
* Specify the cache store that should be used.
88+
*
89+
* @param string|null $store
90+
* @return $this
91+
*/
92+
public function useStore($store)
93+
{
94+
$this->store = $store;
95+
96+
return $this;
97+
}
98+
}

src/Illuminate/Console/Command.php

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
namespace Illuminate\Console;
44

55
use Illuminate\Console\View\Components\Factory;
6+
use Illuminate\Contracts\Console\Isolatable;
67
use Illuminate\Support\Traits\Macroable;
78
use Symfony\Component\Console\Command\Command as SymfonyCommand;
89
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Input\InputOption;
911
use Symfony\Component\Console\Output\OutputInterface;
1012

1113
class Command extends SymfonyCommand
@@ -86,6 +88,10 @@ public function __construct()
8688
if (! isset($this->signature)) {
8789
$this->specifyParameters();
8890
}
91+
92+
if ($this instanceof Isolatable) {
93+
$this->configureIsolation();
94+
}
8995
}
9096

9197
/**
@@ -106,6 +112,22 @@ protected function configureUsingFluentDefinition()
106112
$this->getDefinition()->addOptions($options);
107113
}
108114

115+
/**
116+
* Configure the console command for isolation.
117+
*
118+
* @return void
119+
*/
120+
protected function configureIsolation()
121+
{
122+
$this->getDefinition()->addOption(new InputOption(
123+
'isolated',
124+
null,
125+
InputOption::VALUE_OPTIONAL,
126+
'Do not run the command if another instance of the command is already running',
127+
false
128+
));
129+
}
130+
109131
/**
110132
* Run the console command.
111133
*
@@ -139,9 +161,38 @@ public function run(InputInterface $input, OutputInterface $output): int
139161
*/
140162
protected function execute(InputInterface $input, OutputInterface $output)
141163
{
164+
if ($this instanceof Isolatable && $this->option('isolated') !== false &&
165+
! $this->commandIsolationMutex()->create($this)) {
166+
$this->comment(sprintf(
167+
'The [%s] command is already running.', $this->getName()
168+
));
169+
170+
return (int) (is_numeric($this->option('isolated'))
171+
? $this->option('isolated')
172+
: self::SUCCESS);
173+
}
174+
142175
$method = method_exists($this, 'handle') ? 'handle' : '__invoke';
143176

144-
return (int) $this->laravel->call([$this, $method]);
177+
try {
178+
return (int) $this->laravel->call([$this, $method]);
179+
} finally {
180+
if ($this instanceof Isolatable && $this->option('isolated') !== false) {
181+
$this->commandIsolationMutex()->forget($this);
182+
}
183+
}
184+
}
185+
186+
/**
187+
* Get a command isolation mutex instance for the command.
188+
*
189+
* @return \Illuminate\Console\CommandMutex
190+
*/
191+
protected function commandIsolationMutex()
192+
{
193+
return $this->laravel->bound(CommandMutex::class)
194+
? $this->laravel->make(CommandMutex::class)
195+
: $this->laravel->make(CacheCommandMutex::class);
145196
}
146197

147198
/**
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Illuminate\Console;
4+
5+
interface CommandMutex
6+
{
7+
/**
8+
* Attempt to obtain a command mutex for the given command.
9+
*
10+
* @param \Illuminate\Console\Command $command
11+
* @return bool
12+
*/
13+
public function create($command);
14+
15+
/**
16+
* Determine if a command mutex exists for the given command.
17+
*
18+
* @param \Illuminate\Console\Command $command
19+
* @return bool
20+
*/
21+
public function exists($command);
22+
23+
/**
24+
* Release the mutex for the given command.
25+
*
26+
* @param \Illuminate\Console\Command $command
27+
* @return bool
28+
*/
29+
public function forget($command);
30+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Illuminate\Contracts\Console;
4+
5+
interface Isolatable
6+
{
7+
//
8+
}

src/Illuminate/Database/Console/Migrations/MigrateCommand.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace Illuminate\Database\Console\Migrations;
44

55
use Illuminate\Console\ConfirmableTrait;
6-
use Illuminate\Console\View\Components\Task;
6+
use Illuminate\Contracts\Console\Isolatable;
77
use Illuminate\Contracts\Events\Dispatcher;
88
use Illuminate\Database\Events\SchemaLoaded;
99
use Illuminate\Database\Migrations\Migrator;
@@ -12,7 +12,7 @@
1212
use PDOException;
1313
use Throwable;
1414

15-
class MigrateCommand extends BaseCommand
15+
class MigrateCommand extends BaseCommand implements Isolatable
1616
{
1717
use ConfirmableTrait;
1818

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Console;
4+
5+
use Illuminate\Console\CacheCommandMutex;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Contracts\Cache\Factory;
8+
use Illuminate\Contracts\Cache\Repository;
9+
use Mockery as m;
10+
use PHPUnit\Framework\TestCase;
11+
12+
class CacheCommandMutexTest extends TestCase
13+
{
14+
/**
15+
* @var \Illuminate\Console\CacheCommandMutex
16+
*/
17+
protected $mutex;
18+
19+
/**
20+
* @var \Illuminate\Console\Command
21+
*/
22+
protected $command;
23+
24+
/**
25+
* @var \Illuminate\Contracts\Cache\Factory
26+
*/
27+
protected $cacheFactory;
28+
29+
/**
30+
* @var \Illuminate\Contracts\Cache\Repository
31+
*/
32+
protected $cacheRepository;
33+
34+
protected function setUp(): void
35+
{
36+
$this->cacheFactory = m::mock(Factory::class);
37+
$this->cacheRepository = m::mock(Repository::class);
38+
$this->cacheFactory->shouldReceive('store')->andReturn($this->cacheRepository);
39+
$this->mutex = new CacheCommandMutex($this->cacheFactory);
40+
$this->command = new class extends Command
41+
{
42+
protected $name = 'command-name';
43+
};
44+
}
45+
46+
public function testCanCreateMutex()
47+
{
48+
$this->cacheRepository->shouldReceive('add')
49+
->andReturn(true)
50+
->once();
51+
$actual = $this->mutex->create($this->command);
52+
53+
$this->assertTrue($actual);
54+
}
55+
56+
public function testCannotCreateMutexIfAlreadyExist()
57+
{
58+
$this->cacheRepository->shouldReceive('add')
59+
->andReturn(false)
60+
->once();
61+
$actual = $this->mutex->create($this->command);
62+
63+
$this->assertFalse($actual);
64+
}
65+
66+
public function testCanCreateMutexWithCustomConnection()
67+
{
68+
$this->cacheRepository->shouldReceive('getStore')
69+
->with('test')
70+
->andReturn($this->cacheRepository);
71+
$this->cacheRepository->shouldReceive('add')
72+
->andReturn(false)
73+
->once();
74+
$this->mutex->useStore('test');
75+
76+
$this->mutex->create($this->command);
77+
}
78+
}

0 commit comments

Comments
 (0)