Completed
Push — ezp-30696 ( 9bb3ad...3bd812 )
by
unknown
49:02 queued 18:35
created

ReindexCommand::getPhpProcess()   B

Complexity

Conditions 6
Paths 17

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
nc 17
nop 2
dl 0
loc 30
rs 8.8177
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of the eZ Publish Kernel package.
5
 *
6
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
7
 * @license For full copyright and license information view LICENSE file distributed with this source code.
8
 */
9
namespace eZ\Bundle\EzPublishCoreBundle\Command;
10
11
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
12
use eZ\Publish\SPI\Persistence\Content\ContentInfo;
13
use eZ\Publish\Core\Search\Common\Indexer;
14
use eZ\Publish\Core\Search\Common\IncrementalIndexer;
15
use Doctrine\DBAL\Driver\Statement;
16
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
17
use Symfony\Component\Console\Helper\ProgressBar;
18
use Symfony\Component\Console\Input\InputInterface;
19
use Symfony\Component\Console\Input\InputOption;
20
use Symfony\Component\Console\Output\OutputInterface;
21
use Symfony\Component\Process\PhpExecutableFinder;
22
use Symfony\Component\Process\ProcessBuilder;
23
use RuntimeException;
24
use DateTime;
25
use PDO;
26
27
class ReindexCommand extends ContainerAwareCommand
28
{
29
    /**
30
     * @var \eZ\Publish\Core\Search\Common\Indexer|\eZ\Publish\Core\Search\Common\IncrementalIndexer
31
     */
32
    private $searchIndexer;
33
34
    /**
35
     * @var \Doctrine\DBAL\Connection
36
     */
37
    private $connection;
38
39
    /**
40
     * @var string
41
     */
42
    private $phpPath;
43
44
    /**
45
     * @var \Psr\Log\LoggerInterface
46
     */
47
    private $logger;
48
49
    /**
50
     * @var string
51
     */
52
    private $siteaccess;
53
54
    /**
55
     * @var string
56
     */
57
    private $env;
58
59
    /**
60
     * @var bool
61
     */
62
    private $isDebug;
63
64
    /**
65
     * Initialize objects required by {@see execute()}.
66
     *
67
     * @param InputInterface $input
68
     * @param OutputInterface $output
69
     */
70
    public function initialize(InputInterface $input, OutputInterface $output)
71
    {
72
        parent::initialize($input, $output);
73
        $this->searchIndexer = $this->getContainer()->get('ezpublish.spi.search.indexer');
74
        $this->connection = $this->getContainer()->get('ezpublish.api.storage_engine.legacy.connection');
75
        $this->logger = $this->getContainer()->get('logger');
76
        $this->env = $this->getContainer()->getParameter('kernel.environment');
77
        $this->isDebug = $this->getContainer()->getParameter('kernel.debug');
78
        if (!$this->searchIndexer instanceof Indexer) {
79
            throw new RuntimeException(
80
                sprintf(
81
                    'Expected to find Search Engine Indexer but found "%s" instead',
82
                    get_parent_class($this->searchIndexer)
83
                )
84
            );
85
        }
86
    }
87
88
    /**
89
     * {@inheritdoc}
90
     */
91
    protected function configure()
92
    {
93
        $this
94
            ->setName('ezplatform:reindex')
95
            ->setDescription('Recreate or Refresh search engine index')
96
            ->addOption(
97
                'iteration-count',
98
                'c',
99
                InputOption::VALUE_OPTIONAL,
100
                'Number of objects to be indexed in a single iteration, for avoiding using too much memory',
101
                50
102
            )->addOption(
103
                'no-commit',
104
                null,
105
                InputOption::VALUE_NONE,
106
                'Do not commit after each iteration'
107
            )->addOption(
108
                'no-purge',
109
                null,
110
                InputOption::VALUE_NONE,
111
                'Do not purge before indexing'
112
            )->addOption(
113
                'since',
114
                null,
115
                InputOption::VALUE_OPTIONAL,
116
                'Refresh changes since a given time, any format understood by DateTime. Implies "no-purge", can not be combined with "content-ids" or "subtree"'
117
            )->addOption(
118
                'content-ids',
119
                null,
120
                InputOption::VALUE_OPTIONAL,
121
                'Comma separated list of content id\'s to refresh (deleted/updated/added). Implies "no-purge", can not be combined with "since" or "subtree"'
122
            )->addOption(
123
                'subtree',
124
                null,
125
                InputOption::VALUE_OPTIONAL,
126
                'Location Id to index subtree of (incl self). Implies "no-purge", can not be combined with "since" or "content-ids"'
127
            )->addOption(
128
                'processes',
129
                null,
130
                InputOption::VALUE_OPTIONAL,
131
                'Number of child processes to run in parallel for iterations, if set to "auto" it will set to number of CPU cores -1, set to "1" or "0" to disable',
132
                'auto'
133
            )->setHelp(
134
                <<<EOT
135
The command <info>%command.name%</info> indexes current configured database in configured search engine index.
136
137
138
Example usage:
139
- Refresh (add/update) index changes since yesterday:
140
  <comment>ezplatform:reindex --since=yesterday</comment>
141
  See: http://php.net/manual/en/datetime.formats.php
142
143
- Refresh (add/update/remove) index on a set of content id's:
144
  <comment>ezplatform:reindex --content-ids=2,34,68</comment>
145
146
- Refresh (add/update) index of a subtree:
147
  <comment>ezplatform:reindex --subtree=45</comment>
148
149
- Refresh (add/update) index disabling use of child proccesses and initial purging,
150
  & let search engine handle commits using auto commit:
151
  <comment>ezplatform:reindex --no-purge --no-commit --processes=0</comment>
152
153
EOT
154
            );
155
    }
156
157
    /**
158
     * {@inheritdoc}
159
     */
160
    protected function execute(InputInterface $input, OutputInterface $output)
161
    {
162
        $commit = !$input->getOption('no-commit');
163
        $iterationCount = $input->getOption('iteration-count');
164
        $this->siteaccess = $input->getOption('siteaccess');
0 ignored issues
show
Documentation Bug introduced by
It seems like $input->getOption('siteaccess') can also be of type array<integer,string> or boolean. However, the property $siteaccess is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
165
        if (!is_numeric($iterationCount) || (int) $iterationCount < 1) {
166
            throw new InvalidArgumentException('--iteration-count', "Option should be > 0, got '{$iterationCount}'");
167
        }
168
169
        if (!$this->searchIndexer instanceof IncrementalIndexer) {
170
            $output->writeln(<<<EOT
171
DEPRECATED:
172
Running indexing against an Indexer that has not been updated to use IncrementalIndexer abstract.
173
174
Options that won't be taken into account:
175
- since
176
- content-ids
177
- subtree
178
- processes
179
- no-purge
180
EOT
181
            );
182
            $this->searchIndexer->createSearchIndex($output, (int) $iterationCount, !$commit);
183
        } else {
184
            $output->writeln('Re-indexing started for search engine: ' . $this->searchIndexer->getName());
185
            $output->writeln('');
186
187
            $return = $this->indexIncrementally($input, $output, $iterationCount, $commit);
188
189
            $output->writeln('');
190
            $output->writeln('Finished re-indexing');
191
192
            return $return;
193
        }
194
    }
195
196
    protected function indexIncrementally(InputInterface $input, OutputInterface $output, $iterationCount, $commit)
197
    {
198
        if ($contentIds = $input->getOption('content-ids')) {
199
            $contentIds = explode(',', $contentIds);
200
            $output->writeln(sprintf(
201
                'Indexing list of content id\'s (%s)' . ($commit ? ', with commit' : ''),
202
                \count($contentIds)
203
            ));
204
205
            return $this->searchIndexer->updateSearchIndex($contentIds, $commit);
206
        }
207
208
        if ($since = $input->getOption('since')) {
209
            $stmt = $this->getStatementContentSince(new DateTime($since));
210
            $count = (int)$this->getStatementContentSince(new DateTime($since), true)->fetchColumn();
211
            $purge = false;
212
        } elseif ($locationId = (int) $input->getOption('subtree')) {
213
            $stmt = $this->getStatementSubtree($locationId);
214
            $count = (int) $this->getStatementSubtree($locationId, true)->fetchColumn();
215
            $purge = false;
216
        } else {
217
            $stmt = $this->getStatementContentAll();
218
            $count = (int) $this->getStatementContentAll(true)->fetchColumn();
219
            $purge = !$input->getOption('no-purge');
220
        }
221
222
        if (!$count) {
223
            $output->writeln('<error>Could not find any items to index, aborting.</error>');
224
225
            return 1;
226
        }
227
228
        $iterations = ceil($count / $iterationCount);
229
        $processes = $input->getOption('processes');
230
        $processCount = $processes === 'auto' ? $this->getNumberOfCPUCores() - 1 : (int) $processes;
231
        $processCount = min($iterations, $processCount);
232
        $processMessage = $processCount > 1 ? "using $processCount parallel child processes" : 'using single (current) process';
233
234
        if ($purge) {
235
            $output->writeln('Purging index...');
236
            $this->searchIndexer->purge();
237
238
            $output->writeln(
239
                "<info>Re-Creating index for {$count} items across $iterations iteration(s), $processMessage:</info>"
240
            );
241
        } else {
242
            $output->writeln(
243
                "<info>Refreshing index for {$count} items across $iterations iteration(s), $processMessage:</info>"
244
            );
245
        }
246
247
        $progress = new ProgressBar($output);
248
        $progress->start($iterations);
249
250
        if ($processCount > 1) {
251
            $this->runParallelProcess($progress, $stmt, (int) $processCount, (int) $iterationCount, $commit);
252
        } else {
253
            // if we only have one process, or less iterations to warrant running several, we index it all inline
254
            foreach ($this->fetchIteration($stmt, $iterationCount) as $contentIds) {
255
                $this->searchIndexer->updateSearchIndex($contentIds, $commit);
256
                $progress->advance(1);
257
            }
258
        }
259
260
        $progress->finish();
261
    }
262
263
    private function runParallelProcess(ProgressBar $progress, Statement $stmt, $processCount, $iterationCount, $commit)
264
    {
265
        /**
266
         * @var \Symfony\Component\Process\Process[]|null[]
267
         */
268
        $processes = array_fill(0, $processCount, null);
269
        $generator = $this->fetchIteration($stmt, $iterationCount);
270
        do {
271
            foreach ($processes as $key => $process) {
272
                if ($process !== null && $process->isRunning()) {
273
                    continue;
274
                }
275
276
                if ($process !== null) {
277
                    // One of the processes just finished, so we increment progress bar
278
                    $progress->advance(1);
279
280
                    if (!$process->isSuccessful()) {
281
                        $this->logger->error('Child indexer process returned: ' . $process->getExitCodeText());
282
                    }
283
                }
284
285
                if (!$generator->valid()) {
286
                    unset($processes[$key]);
287
                    continue;
288
                }
289
290
                $processes[$key] = $this->getPhpProcess($generator->current(), $commit);
291
                $processes[$key]->start();
292
                $generator->next();
293
            }
294
295
            if (!empty($processes)) {
296
                sleep(1);
297
            }
298
        } while (!empty($processes));
299
    }
300
301
    /**
302
     * @param DateTime $since
303
     * @param bool $count
304
     *
305
     * @return \Doctrine\DBAL\Driver\Statement
306
     */
307
    private function getStatementContentSince(DateTime $since, $count = false)
308
    {
309
        $q = $this->connection->createQueryBuilder()
310
            ->select($count ? 'count(c.id)' : 'c.id')
311
            ->from('ezcontentobject', 'c')
312
            ->where('c.status = :status')->andWhere('c.modified >= :since')
313
            ->orderBy('c.modified')
314
            ->setParameter('status', ContentInfo::STATUS_PUBLISHED, PDO::PARAM_INT)
315
            ->setParameter('since', $since->getTimestamp(), PDO::PARAM_INT);
316
317
        return $q->execute();
318
    }
319
320
    /**
321
     * @param mixed $locationId
322
     * @param bool $count
323
     *
324
     * @return \Doctrine\DBAL\Driver\Statement
325
     */
326
    private function getStatementSubtree($locationId, $count = false)
327
    {
328
        /**
329
         * @var \eZ\Publish\SPI\Persistence\Content\Location\Handler
330
         */
331
        $locationHandler = $this->getContainer()->get('ezpublish.spi.persistence.location_handler');
332
        $location = $locationHandler->load($locationId);
333
        $q = $this->connection->createQueryBuilder()
334
            ->select($count ? 'count(DISTINCT c.id)' : 'DISTINCT c.id')
335
            ->from('ezcontentobject', 'c')
336
            ->innerJoin('c', 'ezcontentobject_tree', 't', 't.contentobject_id = c.id')
337
            ->where('c.status = :status')
338
            ->andWhere('t.path_string LIKE :path')
339
            ->setParameter('status', ContentInfo::STATUS_PUBLISHED, PDO::PARAM_INT)
340
            ->setParameter('path', $location->pathString . '%', PDO::PARAM_STR);
341
342
        return $q->execute();
343
    }
344
345
    /**
346
     * @param bool $count
347
     *
348
     * @return \Doctrine\DBAL\Driver\Statement
349
     */
350
    private function getStatementContentAll($count = false)
351
    {
352
        $q = $this->connection->createQueryBuilder()
353
            ->select($count ? 'count(c.id)' : 'c.id')
354
            ->from('ezcontentobject', 'c')
355
            ->where('c.status = :status')
356
            ->setParameter('status', ContentInfo::STATUS_PUBLISHED, PDO::PARAM_INT);
357
358
        return $q->execute();
359
    }
360
361
    /**
362
     * @param \Doctrine\DBAL\Driver\Statement $stmt
363
     * @param int $iterationCount
364
     *
365
     * @return \Generator Return an array of arrays, each array contains content id's of $iterationCount.
366
     */
367
    private function fetchIteration(Statement $stmt, $iterationCount)
368
    {
369
        do {
370
            $contentIds = [];
371
            for ($i = 0; $i < $iterationCount; ++$i) {
372
                if ($contentId = $stmt->fetch(PDO::FETCH_COLUMN)) {
373
                    $contentIds[] = $contentId;
374
                } elseif (empty($contentIds)) {
375
                    return;
376
                } else {
377
                    break;
378
                }
379
            }
380
381
            yield $contentIds;
382
        } while (!empty($contentId));
383
    }
384
385
    /**
386
     * @param array $contentIds
387
     * @param bool $commit
388
     *
389
     * @return \Symfony\Component\Process\Process
390
     */
391
    private function getPhpProcess(array $contentIds, $commit)
392
    {
393
        if (empty($contentIds)) {
394
            throw new InvalidArgumentException('--content-ids', '$contentIds can not be empty');
395
        }
396
397
        $consolePath = file_exists('bin/console') ? 'bin/console' : 'app/console';
398
        $subProcessArgs = [
399
            $consolePath,
400
            'ezplatform:reindex',
401
            '--content-ids=' . implode(',', $contentIds),
402
            '--env=' . $this->env,
403
        ];
404
        if ($this->siteaccess) {
405
            $subProcessArgs[] = '--siteaccess=' . $this->siteaccess;
406
        }
407
        if (!$this->isDebug) {
408
            $subProcessArgs[] = '--no-debug';
409
        }
410
411
        $process = new ProcessBuilder($subProcessArgs);
0 ignored issues
show
Deprecated Code introduced by
The class Symfony\Component\Process\ProcessBuilder has been deprecated with message: since version 3.4, to be removed in 4.0. Use the Process class instead.

This class, trait or interface has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the type will be removed from the class and what other constant to use instead.

Loading history...
412
        $process->setTimeout(null);
413
        $process->setPrefix($this->getPhpPath());
414
415
        if (!$commit) {
416
            $process->add('--no-commit');
417
        }
418
419
        return $process->getProcess();
420
    }
421
422
    /**
423
     * @return string
424
     */
425 View Code Duplication
    private function getPhpPath()
426
    {
427
        if ($this->phpPath) {
428
            return $this->phpPath;
429
        }
430
431
        $phpFinder = new PhpExecutableFinder();
432
        $this->phpPath = $phpFinder->find();
0 ignored issues
show
Documentation Bug introduced by
It seems like $phpFinder->find() can also be of type false. However, the property $phpPath is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
433
        if (!$this->phpPath) {
434
            throw new \RuntimeException(
435
                'The php executable could not be found, it\'s needed for executing parable sub processes, so add it to your PATH environment variable and try again'
436
            );
437
        }
438
439
        return $this->phpPath;
440
    }
441
442
    /**
443
     * @return int
444
     */
445
    private function getNumberOfCPUCores()
446
    {
447
        $cores = 1;
448
        if (is_file('/proc/cpuinfo')) {
449
            // Linux (and potentially Windows with linux sub systems)
450
            $cpuinfo = file_get_contents('/proc/cpuinfo');
451
            preg_match_all('/^processor/m', $cpuinfo, $matches);
452
            $cores = \count($matches[0]);
453
        } elseif (\DIRECTORY_SEPARATOR === '\\') {
454
            // Windows
455
            if (($process = @popen('wmic cpu get NumberOfCores', 'rb')) !== false) {
456
                fgets($process);
457
                $cores = (int) fgets($process);
458
                pclose($process);
459
            }
460
        } elseif (($process = @popen('sysctl -a', 'rb')) !== false) {
461
            // *nix (Linux, BSD and Mac)
462
            $output = stream_get_contents($process);
463
            if (preg_match('/hw.ncpu: (\d+)/', $output, $matches)) {
464
                $cores = (int) $matches[1][0];
465
            }
466
            pclose($process);
467
        }
468
469
        return $cores;
470
    }
471
}
472