Passed
Push — master ( eff446...11d98b )
by Gaetano
05:37
created

MassMigrateCommand::executeAsChild()   C

Complexity

Conditions 13
Paths 120

Size

Total Lines 88
Code Lines 49

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 182

Importance

Changes 0
Metric Value
cc 13
eloc 49
nc 120
nop 5
dl 0
loc 88
ccs 0
cts 39
cp 0
crap 182
rs 6.45
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Kaliop\eZMigrationBundle\Command;
4
5
use Kaliop\eZMigrationBundle\API\Exception\AfterMigrationExecutionException;
6
use Symfony\Component\Console\Input\ArrayInput;
7
use Symfony\Component\Console\Input\InputOption;
8
use Symfony\Component\Console\Input\InputInterface;
9
use Symfony\Component\Console\Output\Output;
10
use Symfony\Component\Console\Output\OutputInterface;
11
use Symfony\Component\Process\PhpExecutableFinder;
12
use Kaliop\eZMigrationBundle\API\Value\Migration;
13
use Kaliop\eZMigrationBundle\API\Value\MigrationDefinition;
14
use Kaliop\eZMigrationBundle\Core\Helper\ProcessManager;
15
use Kaliop\eZMigrationBundle\Core\Process\Process;
16
use Kaliop\eZMigrationBundle\Core\Process\ProcessBuilder;
17
18
class MassMigrateCommand extends MigrateCommand
19
{
20
    const COMMAND_NAME = 'kaliop:migration:mass_migrate';
21
22
    // Note: in this array, we lump together in STATUS_DONE everything which is not failed or suspended
23
    protected $migrationsDone = array(Migration::STATUS_DONE => 0, Migration::STATUS_FAILED => 0, Migration::STATUS_SKIPPED => 0);
24
    protected $migrationsAlreadyDone = array();
25
26
    /**
27 78
     * @todo (!important) can we rename the option --separate-process ?
28
     */
29 78
    protected function configure()
30
    {
31
        parent::configure();
32 78
33 78
        $this
34 78
            ->setName(self::COMMAND_NAME)
35 78
            ->setAliases(array())
36 78
            ->setDescription('Executes available migration definitions, using parallelism.')
37 78
            ->addOption('concurrency', 'r', InputOption::VALUE_REQUIRED, "The number of executors to run in parallel", 2)
38
            ->setHelp(<<<EOT
39
This command is designed to scan recursively a directory for migration files and execute them all in parallel.
40
One child process will be spawned for each subdirectory found.
41
The maximum number of processes to run in parallel is specified via the 'concurrency' option.
42
<info>NB: this command does not guarantee that any given migration will be executed before another. Take care about dependencies.</info>
43
<info>NB: the rule that each migration filename has to be unique still applies, even if migrations are spread across different directories.</info>
44
Unlike for the 'normal' migration command, it is not recommended to use the <info>--separate-process</info> option, as it will make execution slower if you have many migrations
45
EOT
46 78
            )
47
        ;
48
    }
49
50
    /**
51
     * Execute the command.
52
     *
53
     * @param InputInterface $input
54
     * @param OutputInterface $output
55
     * @return null|int null or 0 if everything went fine, or an error code
56
     */
57
    protected function execute(InputInterface $input, OutputInterface $output)
58
    {
59
        $start = microtime(true);
60
61
        $this->setOutput($output);
62
        $this->setVerbosity($output->getVerbosity());
63
64
        $isChild = $input->getOption('child');
65
66
        if ($isChild) {
67
            $this->setVerbosity(self::VERBOSITY_CHILD);
68
        }
69
70
        $this->getContainer()->get('ez_migration_bundle.step_executed_listener.tracing')->setOutput($output);
71
72
        // q: is it worth declaring a new, dedicated migration service ?
73
        $migrationService = $this->getMigrationService();
74
        $migrationService->setLoader($this->getContainer()->get('ez_migration_bundle.loader.filesystem_recursive'));
75
76
        $force = $input->getOption('force');
77
78
        $toExecute = $this->buildMigrationsList($input->getOption('path'), $migrationService, $force, $isChild);
0 ignored issues
show
Bug introduced by
It seems like $input->getOption('path') can also be of type string[]; however, parameter $paths of Kaliop\eZMigrationBundle...::buildMigrationsList() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

78
        $toExecute = $this->buildMigrationsList(/** @scrutinizer ignore-type */ $input->getOption('path'), $migrationService, $force, $isChild);
Loading history...
79
80
        if (!count($toExecute)) {
81
            $this->writeln('<info>No migrations to execute</info>');
82
            return 0;
83
        }
84
85
        if ($isChild) {
86
            return $this->executeAsChild($input, $output, $toExecute, $force, $migrationService);
87
        } else {
88
            return $this->executeAsParent($input, $output, $toExecute, $start);
89
        }
90
    }
91
92
    /**
93
     * @param InputInterface $input
94
     * @param OutputInterface $output
95
     * @param MigrationDefinition[] $toExecute
96
     * @param float $start
97
     * @return int
98
     */
99
    protected function executeAsParent($input, $output, $toExecute, $start)
100
    {
101
        $paths = $this->groupMigrationsByPath($toExecute);
102
        $this->printMigrationsList($toExecute, $input, $output, $paths);
103
104
        // ask user for confirmation to make changes
105
        if (!$this->askForConfirmation($input, $output, null)) {
106
            return 0;
107
        }
108
109
        // For cli scripts, this means: do not die if anyone yanks out our stdout.
110
        // We presume that users who want to halt migrations do send us a KILL signal, and that a lost tty is
111
        // generally a mistake, and that carrying on with executing migrations is the best outcome
112
        if ($input->getOption('survive-disconnected-tty')) {
113
            ignore_user_abort(true);
114
        }
115
116
        $concurrency = $input->getOption('concurrency');
117
        $this->writeln("Executing migrations using " . count($paths) . " processes with a concurrency of $concurrency");
118
119
        // allow forcing handling of sigchild. Useful on eg. Debian and Ubuntu
120
        if ($input->getOption('force-sigchild-enabled')) {
121
            Process::forceSigchildEnabled(true);
122
        }
123
124
        $builder = new ProcessBuilder();
125
        $executableFinder = new PhpExecutableFinder();
126
        if (false !== ($php = $executableFinder->find())) {
127
            $builder->setPrefix($php);
128
        }
129
130
        // mandatory args and options
131
        $builderArgs = $this->createChildProcessArgs($input);
132
133
        $processes = array();
134
        /** @var MigrationDefinition $migrationDefinition */
135
        foreach($paths as $path => $count) {
136
            $this->writeln("<info>Queueing processing of: $path ($count migrations)</info>", OutputInterface::VERBOSITY_VERBOSE);
137
138
            $process = $builder
139
                ->setArguments(array_merge($builderArgs, array('--path=' . $path)))
140
                ->getProcess();
141
142
            $this->writeln('<info>Command: ' . $process->getCommandLine() . '</info>', OutputInterface::VERBOSITY_VERBOSE);
143
144
            // allow long migrations processes by default
145
            $process->setTimeout($this->subProcessTimeout);
146
            $processes[] = $process;
147
        }
148
149
        $this->writeln("<info>Starting queued processes...</info>");
150
151
        $total = count($toExecute);
152
        $this->migrationsDone = array(0, 0, 0);
153
154
        $processManager = new ProcessManager();
155
        $processManager->runParallel($processes, $concurrency, 500, array($this, 'onChildProcessOutput'));
156
157
        $subprocessesFailed = 0;
158
        foreach ($processes as $i => $process) {
159
            if (!$process->isSuccessful()) {
160
                $errorOutput = $process->getErrorOutput();
161
                if ($errorOutput === '') {
162
                    $errorOutput = "(process used to execute migrations failed with no stderr output. Its exit code was: " . $process->getExitCode();
163
                    if ($process->getExitCode() == -1) {
164
                        $errorOutput .= ". If you are using Debian or Ubuntu linux, please consider using the --force-sigchild-enabled option.";
165
                    }
166
                    $errorOutput .= ")";
167
                }
168
                /// @todo should we always add the exit code, even when $errorOutput is not null ?
169
                $this->writeErrorln("\n<error>Subprocess $i failed! Reason: " . $errorOutput . "</error>\n");
170
                $subprocessesFailed++;
171
            }
172
        }
173
174
        if ($input->getOption('clear-cache')) {
175
            $command = $this->getApplication()->find('cache:clear');
176
            $inputArray = new ArrayInput(array('command' => 'cache:clear'));
177
            $command->run($inputArray, $output);
178
        }
179
180
        $missed = $total - $this->migrationsDone[Migration::STATUS_DONE] - $this->migrationsDone[Migration::STATUS_FAILED] - $this->migrationsDone[Migration::STATUS_SKIPPED];
181
        $this->writeln("\nExecuted ".$this->migrationsDone[Migration::STATUS_DONE].' migrations'.
182
            ', failed '.$this->migrationsDone[Migration::STATUS_FAILED].
183
            ', skipped '.$this->migrationsDone[Migration::STATUS_SKIPPED].
184
            ($missed ? ", missed $missed" : ''));
185
186
        $time = microtime(true) - $start;
187
        // since we use subprocesses, we can not measure max memory used
188
        $this->writeln("<info>Time taken: ".sprintf('%.2f', $time)." secs</info>");
189
190
        return $subprocessesFailed + $this->migrationsDone['failed'] + $missed;
191
    }
192
193
    /**
194
     * @param InputInterface $input
195
     * @param OutputInterface $output
196
     * @param MigrationDefinition[] $toExecute
197
     * @param bool $force
198
     * @param $migrationService
199
     * @return int
200
     * @todo does it make sense to honour the `survive-disconnected-tty` flag when executing as child?
201
     */
202
    protected function executeAsChild($input, $output, $toExecute, $force, $migrationService)
0 ignored issues
show
Unused Code introduced by
The parameter $output is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

202
    protected function executeAsChild($input, /** @scrutinizer ignore-unused */ $output, $toExecute, $force, $migrationService)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
203
    {
204
        // @todo disable signal slots that are harmful during migrations, if any
205
206
        if ($input->getOption('separate-process')) {
207
            $builder = new ProcessBuilder();
208
            $executableFinder = new PhpExecutableFinder();
209
            if (false !== $php = $executableFinder->find()) {
210
                $builder->setPrefix($php);
211
            }
212
213
            $builderArgs = parent::createChildProcessArgs($input);
214
        }
215
216
        // allow forcing handling of sigchild. Useful on eg. Debian and Ubuntu
217
        if ($input->getOption('force-sigchild-enabled')) {
218
            Process::forceSigchildEnabled(true);
219
        }
220
221
        $aborted = false;
222
        $executed = 0;
223
        $failed = 0;
224
        $skipped = 0;
225
        $total = count($toExecute);
226
227
        foreach ($toExecute as  $name => $migrationDefinition) {
228
            // let's skip migrations that we know are invalid - user was warned and he decided to proceed anyway
229
            if ($migrationDefinition->status == MigrationDefinition::STATUS_INVALID) {
230
                $this->writeln("<comment>Skipping migration (invalid definition?) Path: ".$migrationDefinition->path."</comment>", self::VERBOSITY_CHILD);
0 ignored issues
show
Bug introduced by
self::VERBOSITY_CHILD of type double is incompatible with the type integer expected by parameter $verbosity of Kaliop\eZMigrationBundle...tractCommand::writeln(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

230
                $this->writeln("<comment>Skipping migration (invalid definition?) Path: ".$migrationDefinition->path."</comment>", /** @scrutinizer ignore-type */ self::VERBOSITY_CHILD);
Loading history...
231
                $skipped++;
232
                continue;
233
            }
234
235
            if ($input->getOption('separate-process')) {
236
237
                try {
238
                    $this->executeMigrationInSeparateProcess($migrationDefinition, $migrationService, $builder, $builderArgs, false);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $builderArgs does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $builder does not seem to be defined for all execution paths leading up to this point.
Loading history...
239
240
                    $executed++;
241
                } catch (\Exception $e) {
242
                    $failed++;
243
244
                    $errorMessage = preg_replace('/^\n*(\[[0-9]*\])?(Migration failed|Failure after migration end)! Reason: +/', '', $e->getMessage());
245
                    if ($e instanceof AfterMigrationExecutionException) {
246
                        $errorMessage = "Failure after migration end! Path: " . $migrationDefinition->path . ", Reason: " . $errorMessage;
247
                    } else {
248
                        $errorMessage = "Migration failed! Path: " . $migrationDefinition->path . ", Reason: " . $errorMessage;
249
                    }
250
251
                    $this->writeErrorln("\n<error>$errorMessage</error>", self::VERBOSITY_CHILD);
0 ignored issues
show
Bug introduced by
self::VERBOSITY_CHILD of type double is incompatible with the type integer expected by parameter $verbosity of Kaliop\eZMigrationBundle...Command::writeErrorln(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

251
                    $this->writeErrorln("\n<error>$errorMessage</error>", /** @scrutinizer ignore-type */ self::VERBOSITY_CHILD);
Loading history...
252
253
                    if (!$input->getOption('ignore-failures')) {
254
                        $aborted = true;
255
                        break;
256
                    }
257
                }
258
259
            } else {
260
261
                try {
262
                    $this->executeMigrationInProcess($migrationDefinition, $force, $migrationService, $input);
263
264
                    $executed++;
265
                } catch(\Exception $e) {
266
                    $failed++;
267
268
                    $errorMessage = $e->getMessage();
269
                    $this->writeErrorln("\n<error>Migration failed! Path: " . $migrationDefinition->path . ", Reason: " . $errorMessage . "</error>", self::VERBOSITY_CHILD);
270
271
                    if (!$input->getOption('ignore-failures')) {
272
                        $aborted = true;
273
                        break;
274
                    }
275
                }
276
277
            }
278
        }
279
280
        if ($aborted) {
281
            $this->writeErrorln("\n<error>Migration execution aborted</error>", self::VERBOSITY_CHILD);
282
        }
283
284
        $missed = $total - $executed - $failed - $skipped;
285
        $this->writeln("Migrations executed: $executed, failed: $failed, skipped: $skipped, missed: $missed", self::VERBOSITY_CHILD);
286
287
        // We do not return an error code > 0 if migrations fail but , but only on proper fatals.
288
        // The parent will analyze the output of the child process to gather the number of executed/failed migrations anyway
289
        return 0;
290
    }
291
292
    /**
293
     * @param string $type
294
     * @param string $buffer
295
     * @param null|\Symfony\Component\Process\Process $process
296
     */
297
    public function onChildProcessOutput($type, $buffer, $process=null)
298
    {
299
        $lines = explode("\n", trim($buffer));
300
301
        foreach ($lines as $line) {
302
            if (preg_match('/Migrations executed: ([0-9]+), failed: ([0-9]+), skipped: ([0-9]+)/', $line, $matches)) {
303
                $this->migrationsDone[Migration::STATUS_DONE] += $matches[1];
304
                $this->migrationsDone[Migration::STATUS_FAILED] += $matches[2];
305
                $this->migrationsDone[Migration::STATUS_SKIPPED] += $matches[3];
306
307
                // swallow the recap lines unless we are in verbose mode
308
                if ($this->verbosity <= Output::VERBOSITY_NORMAL) {
309
                    return;
310
                }
311
            }
312
313
            // we tag the output with the id of the child process
314
            if (trim($line) !== '') {
315
                $msg = '[' . ($process ? $process->getPid() : ''). '] ' . trim($line);
316
                if ($type == 'err') {
317
                    $this->writeErrorln($msg, OutputInterface::VERBOSITY_NORMAL, OutputInterface::OUTPUT_RAW);
318
                } else {
319
                    // swallow output of child processes in quiet mode
320
                    if ($this->verbosity > Output::VERBOSITY_QUIET) {
321
                        $this->writeLn($msg, OutputInterface::VERBOSITY_NORMAL, OutputInterface::OUTPUT_RAW);
322
                    }
323
                }
324
            }
325
        }
326
    }
327
328
    /**
329
     * @param string $paths
330
     * @param $migrationService
331
     * @param bool $force
332
     * @param bool $isChild when not in child mode, do not waste time parsing migrations
333
     * @return MigrationDefinition[] parsed or unparsed, depending on
334
     *
335
     * @todo this does not scale well with many definitions or migrations
336
     */
337
    protected function buildMigrationsList($paths, $migrationService, $force = false, $isChild = false)
338
    {
339
        $migrationDefinitions = $migrationService->getMigrationsDefinitions($paths);
340
        $migrations = $migrationService->getMigrations();
341
342
        $this->migrationsAlreadyDone = array(Migration::STATUS_DONE => 0, Migration::STATUS_FAILED => 0, Migration::STATUS_SKIPPED => 0, Migration::STATUS_STARTED => 0);
343
344
        $allowedStatuses = array(Migration::STATUS_TODO);
345
        if ($force) {
346
            $allowedStatuses = array_merge($allowedStatuses, array(Migration::STATUS_DONE, Migration::STATUS_FAILED, Migration::STATUS_SKIPPED));
347
        }
348
349
        // filter away all migrations except 'to do' ones
350
        $toExecute = array();
351
        foreach($migrationDefinitions as $name => $migrationDefinition) {
352
            if (!isset($migrations[$name]) || (($migration = $migrations[$name]) && in_array($migration->status, $allowedStatuses))) {
353
                $toExecute[$name] = $isChild ? $migrationService->parseMigrationDefinition($migrationDefinition) : $migrationDefinition;
354
            }
355
            // save the list of non-executable migrations as well (even when using 'force')
356
            if (!$isChild && isset($migrations[$name]) && (($migration = $migrations[$name]) && $migration->status != Migration::STATUS_TODO)) {
357
                $this->migrationsAlreadyDone[$migration->status]++;
358
            }
359
        }
360
361
        // if user wants to execute 'all' migrations: look for some which are registered in the database even if not
362
        // found by the loader
363
        if (empty($paths)) {
364
            foreach ($migrations as $migration) {
365
                if (in_array($migration->status, $allowedStatuses) && !isset($toExecute[$migration->name])) {
366
                    $migrationDefinitions = $migrationService->getMigrationsDefinitions(array($migration->path));
367
                    if (count($migrationDefinitions)) {
368
                        $migrationDefinition = reset($migrationDefinitions);
369
                        $toExecute[$migration->name] = $isChild ? $migrationService->parseMigrationDefinition($migrationDefinition) : $migrationDefinition;
370
                    } else {
371
                        // q: shall we raise a warning here ?
372
                    }
373
                }
374
            }
375
        }
376
377
        ksort($toExecute);
378
379
        return $toExecute;
380
    }
381
382
    /**
383
     * We use a more compact output when there are *many* migrations
384
     * @param MigrationDefinition[] $toExecute
385
     * @param array $paths
386
     * @param InputInterface $input
387
     * @param OutputInterface $output
388
     */
389
    protected function printMigrationsList($toExecute, InputInterface $input, OutputInterface $output, $paths = array())
390
    {
391
        $output->writeln('Found ' . count($toExecute) . ' migrations in ' . count($paths) . ' directories');
392
        $output->writeln('In the same directories, migrations previously executed: ' . $this->migrationsAlreadyDone[Migration::STATUS_DONE] .
393
            ', failed: ' . $this->migrationsAlreadyDone[Migration::STATUS_FAILED] . ', skipped: '. $this->migrationsAlreadyDone[Migration::STATUS_SKIPPED]);
394
        if ($this->migrationsAlreadyDone[Migration::STATUS_STARTED]) {
395
            $output->writeln('<info>In the same directories, migrations currently executing: ' . $this->migrationsAlreadyDone[Migration::STATUS_STARTED] . '</info>');
396
        }
397
    }
398
399
    /**
400
     * @param MigrationDefinition[] $toExecute
401
     * @return array key: folder name, value: number of migrations found
402
     */
403
    protected function groupMigrationsByPath($toExecute)
404
    {
405
        $paths = array();
406
        foreach($toExecute as $name => $migrationDefinition) {
407
            $path = dirname($migrationDefinition->path);
408
            if (!isset($paths[$path])) {
409
                $paths[$path] = 1;
410
            } else {
411
                $paths[$path]++;
412
            }
413
        }
414
415
        ksort($paths);
416
417
        return $paths;
418
    }
419
420
    /**
421
     * Returns the command-line arguments needed to execute a separate subprocess that will run a set of migrations
422
     * (except path, which should be added after this call)
423
     * @param InputInterface $input
424
     * @return array
425
     * @todo check if it is a good idea to pass on the current verbosity
426
     * @todo shall we pass to child processes the `survive-disconnected-tty` flag?
427
     */
428
    protected function createChildProcessArgs(InputInterface $input)
429
    {
430
        $kernel = $this->getContainer()->get('kernel');
431
432
        // mandatory args and options
433
        $builderArgs = array(
434
            $_SERVER['argv'][0], // sf console
435
            self::COMMAND_NAME, // name of sf command. Can we get it from the Application instead of hardcoding?
436
            '--env=' . $kernel-> getEnvironment(), // sf env
437
            '--child'
438
        );
439
        // sf/ez env options
440
        if (!$kernel->isDebug()) {
441
            $builderArgs[] = '--no-debug';
442
        }
443
        if ($input->getOption('siteaccess')) {
444
            $builderArgs[] = '--siteaccess=' . $input->getOption('siteaccess');
445
        }
446
        // 'optional' options
447
        // note: options 'clear-cache', 'no-interaction', 'path' and 'survive-disconnected-tty' we never propagate
448
        if ($input->getOption('admin-login')) {
449
            $builderArgs[] = '--admin-login=' . $input->getOption('admin-login');
450
        }
451
        if ($input->getOption('default-language')) {
452
            $builderArgs[] = '--default-language=' . $input->getOption('default-language');
453
        }
454
        if ($input->getOption('force')) {
455
            $builderArgs[] = '--force';
456
        }
457
        if ($input->getOption('no-transactions')) {
458
            $builderArgs[] = '--no-transactions';
459
        }
460
        if ($input->getOption('separate-process')) {
461
            $builderArgs[] = '--separate-process';
462
        }
463
        // useful in case the subprocess has a migration step of type process/run
464
        if ($input->getOption('force-sigchild-enabled')) {
465
            $builderArgs[] = '--force-sigchild-enabled';
466
        }
467
        if ($input->getOption('ignore-failures')) {
468
            $builderArgs[] = '--ignore-failures';
469
        }
470
471
        return $builderArgs;
472
    }
473
}
474