X

Magento 2: Планировщик задач

В Magento 2 предусмотрен планировщик задач. По сути планировщик, это php скрипт который запускается через cron (имеется ввиду утилиту Linux) каждую минуту. Просмотреть список команд, можно выполнив в консоли команду от имени пользователя от которого работает magento..

crontab -l

выведет что-то типа

#~ MAGENTO START
* * * * * /usr/bin/php /var/www/html/magento2/bin/magento cron:run | grep -v Ran jobs by schedule >> /var/www/html/magento2/var/log/magento.cron.log
* * * * * /usr/bin/php /var/www/html/magento2/update/cron.php >> /var/www/html/magento2/var/log/update.cron.log
* * * * * /usr/bin/php /var/www/html/magento2/bin/magento setup:cron:run >> /var/www/html/magento2/var/log/setup.cron.log
#~ MAGENTO END

установить эти команды в cron можно выполнив

php bin/magento cron:install

удалить, выполнив

bin/magento cron:remove

Итак, крон запускается каждую минуту. После запуска выполняется метод execute из класса CronCommand, который наследуется от класса Symfony\Component\Console\Command\Command

// vendor/magento/module-cron/Console/Command/CronCommand.php

namespace Magento\Cron\Console\Command;

use Symfony\Component\Console\Command\Command;
...
class CronCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        ...
        /** @var \Magento\Framework\App\Cron $cronObserver */        $cronObserver = $objectManager->create(\Magento\Framework\App\Cron::class, ['parameters' => $params]);
        $cronObserver->launch();
        $output->writeln('<info>' . 'Ran jobs by schedule.' . '</info>');
    }
}

В нем парсятся и проверяются входные данные и если все Ок, создается экземпляр класса \Magento\Framework\App\Cron у которого вызывается метод launch

// vendor/magento/framework/App/Cron.php

public function launch()
{
    $this->_state->setAreaCode(Area::AREA_CRONTAB);
    $configLoader = $this->objectManager->get(\Magento\Framework\ObjectManager\ConfigLoaderInterface::class);
    $this->objectManager->configure($configLoader->load(Area::AREA_CRONTAB));

    $this->areaList->getArea(Area::AREA_CRONTAB)->load(Area::PART_TRANSLATE);

    /** @var \Magento\Framework\Event\ManagerInterface $eventManager */    $eventManager = $this->objectManager->get(\Magento\Framework\Event\ManagerInterface::class);
    $eventManager->dispatch('default');
    $this->_response->setCode(0);
    return $this->_response;
}

Далее происходит создание события "default" и его выполнение. Любой модуль может подписаться на это событие. Как вы можете заметить, событие вызывается в зоне crontab  (areaCode = crontab), поэтому подписку надо делать в файле <ModuleName>/etc/crontab/events.xml, например так:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
   <event name="catalog_product_get_final_price">
       <observer name="catalogrule" instance="Magento\CatalogRule\Observer\ProcessAdminFinalPriceObserver" />
   </event>
</config>

это событие запускается каждый раз когда вызывается php bin/magento cron:run, т.е. при настройках по-умолчанию - каждую минуту.

Одним из подписчиков на это событие является класс: Magento\Cron\Observer\ProcessCronQueueObserver, который и отвечает за запуск задач по расписанию.

Задачи конфигурируются с помощью фалов <ModuleName>/etc/crontab.xml расположенных в модулях. Пример:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
   <group id="default">
       <job name="backend_clean_cache" instance="Magento\Backend\Cron\CleanCache" method="execute">
           <schedule>30 2 * * *</schedule>
       </job>
   </group>
</config>

Обработка задач происходит в методе Magento\Cron\Observer\ProcessCronQueueObserver::execute()

В котором, в цикле происходит обход групп и для каждой группы выполняются два метода которые отвечают за очистку и наполнение таблицы cron_schedule.

// vendor/magento/module-cron/Observer/ProcessCronQueueObserver.php

public function execute(\Magento\Framework\Event\Observer $observer)
{
    $pendingJobs = $this->_getPendingSchedules();
    $currentTime = $this->dateTime->gmtTimestamp();
    $jobGroupsRoot = $this->_config->getJobs();

    $phpPath = $this->phpExecutableFinder->find() ?: 'php';

    foreach ($jobGroupsRoot as $groupId => $jobsRoot) {
        $this->_cleanup($groupId);   // <<<<<<<<<<<<<<<<
        $this->_generate($groupId);  // <<<<<<<<<<<<<<<<
        ...
        foreach ($pendingJobs as $schedule) {
        ...
        }
    }
}

В magento 2.3.0 этот метод немного поменялся, добавили сортировку групп, чтобы группы имеющие флаг Use Separate Process выполнялись первыми. Выглядит это так

public function execute(\Magento\Framework\Event\Observer $observer)
{
    $currentTime = $this->dateTime->gmtTimestamp();
    $jobGroupsRoot = $this->_config->getJobs();
    // sort jobs groups to start from used in separated process
    uksort(
        $jobGroupsRoot,
        function ($a, $b) {
            return $this->getCronGroupConfigurationValue($b, 'use_separate_process')
                - $this->getCronGroupConfigurationValue($a, 'use_separate_process');
        }
    );

    $phpPath = $this->phpExecutableFinder->find() ?: 'php';

    foreach ($jobGroupsRoot as $groupId => $jobsRoot) {
        if (!$this->isGroupInFilter($groupId)) {
            continue;
        }
        if ($this->_request->getParam(self::STANDALONE_PROCESS_STARTED) !== '1'
            && $this->getCronGroupConfigurationValue($groupId, 'use_separate_process') == 1
        ) {
            $this->_shell->execute(
                $phpPath . ' %s cron:run --group=' . $groupId . ' --' . Cli::INPUT_KEY_BOOTSTRAP . '='
                . self::STANDALONE_PROCESS_STARTED . '=1',
                [
                    BP . '/bin/magento'
                ]
            );
            continue;
        }

        $this->lockGroup(
            $groupId,
            function ($groupId) use ($currentTime, $jobsRoot) {
                $this->cleanupJobs($groupId, $currentTime);
                $this->generateSchedules($groupId);
                $this->processPendingJobs($groupId, $jobsRoot, $currentTime);
            }
        );
    }
}

Формат у таблицы cron_schedule такой:

  • schedule_id - номер записи
  • job_code - id задачи (например: indexer_update_all_views)
  • status - статус выполнения. Может быть:
    • pending - задача запланирована на выполнение, дата запуска указана в колонке scheduled_at
    • running - означает, что задача была запущена на выполнение и работает
    • success - означает, что что задача была выполнена
    • missed - означает, что прошлый запуск этой задачи не завершился, до момента запуска текущей задачи. Другими словами в момент запуска этой задачи, она проверила эту таблицу и нашла запись с таким же job_code и статусом running.  Если после этого задача так и не смогла запуститься в период указанный в настройках, например 5 минут, то она помечается, как missed. Период, задается в админке:
      Magento Admin > Store > Configuration > Advanced > System > Cron (Scheduled Tasks) > Cron configuration options > Missed If Not Run Within
    • error - указывает, что выполнение прошло с ошибкой. Пояснения к ошибке находятся в колонке messages.
  • messages - поясняющее сообщение, используется в случае ошибок
  • created_at - время, когда задача добавлена в таблицу
  • scheduled_at - время, когда запланировано выполнение
  • executed_at - время, когда задача была запущена на выполнение
  • finished_at - время, когда задача закончила работу

Для каждой из задач в файле /etc/crontab.xml задается группа

<!-- vendor/magento/module-catalog/etc/crontab.xml -->

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="default">  <!-- <<<<<<<<<<<<<<<<<<<<<<<<<<< -->
        <job name="catalog_index_refresh_price" instance="Magento\Catalog\Cron\RefreshSpecialPrices" method="execute">
            <schedule>0 * * * *</schedule>
        </job>
        ...
    </group>
</config>

Из коробки, у нас есть 4е группы:

  • default - основная группа
  • indexer - индексаторы
  • consumers - группа очереди сообщений модуль Magento_MessageQueue
  • ddg_automation - группа dotmailer модуля (dotdigital Engagement Cloud)

Например, в группу index входят задачи с такими id:

indexer_clean_all_changelogs
indexer_reindex_all_invalid
indexer_update_all_views
magento_targetrule_index_reindex

Если вам необходимо создать свою группу, например для того чтобы запускать интеграции со сторонними сервисами, то делается это с помощью файла <ModuleName>/etc/cron_groups.xml

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
   <group id="example2">
       <schedule_generate_every tooltip="Be careful changes in schedule may affect date picker for scheduled updates">1</schedule_generate_every>
       <schedule_ahead_for>4</schedule_ahead_for>
       <schedule_lifetime>2</schedule_lifetime>
       <history_cleanup_every>10</history_cleanup_every>
       <history_success_lifetime>60</history_success_lifetime>
       <history_failure_lifetime>600</history_failure_lifetime>
       <use_separate_process>1</use_separate_process>
   </group>
</config>

Далее используете id новой группы в файле <ModuleName>/etc/crontab.xml

Наиболее просто найти список всех задач и их расписание, можно при помощи утилиты n98-magerun2: https://github.com/netz98/n98-magerun2

Сделать это можно с помощью команды: php n98-magerun2.phar sys:cron:list

Для каждой из групп в админке задаются настройки. Найти настройки можно тут

Magento Admin > Store > Configuration > Advanced > System > Cron (Scheduled Tasks)

Magento 2: Настройки крон

Разберемся с настройками для групп

  • Generate Schedules Every - добавлять задачи в таблицу cron_schedule каждые N минут
  • Schedule Ahead for - время которое будет добавлено к времени scheduled_at
  • Missed if Not Run Within - время после истечения которого задача будет отмечена как missed, в случае если в списке есть задача в статусе running. Т.е. если задача не была выполнена в течении N минут, после scheduled_at, она будет отмечена как missed. Задача не может быть выполнена, в случае, если уже есть другая задача с тем же job_code в статусе running.
  • History Cleanup Every - История задач будет очищаться, каждые N минут
  • Success History Lifetime - История задач со статусом success будет очищаться каждые N минут
  • Failure History Lifetime - История задач со статусами missed и error будет очищаться каждые N минут
  • Use Separate Process - Использовать отдельный процесс для запуска задач из этой группы.

На Use Separate Process остановимся подробнее. Эта опция используется в классе Magento\Cron\Observer\ProcessCronQueueObserver, в методе execute

и выглядит так

// vendor/magento/module-cron/Observer/ProcessCronQueueObserver.php

public function execute(\Magento\Framework\Event\Observer $observer)
{
    $pendingJobs = $this->_getPendingSchedules();
    $currentTime = $this->dateTime->gmtTimestamp();
    $jobGroupsRoot = $this->_config->getJobs();

    $phpPath = $this->phpExecutableFinder->find() ?: 'php';

    foreach ($jobGroupsRoot as $groupId => $jobsRoot) {
        $this->_cleanup($groupId);
        $this->_generate($groupId);
        if ($this->_request->getParam('group') !== null
            && $this->_request->getParam('group') !== '\'' . ($groupId) . '\''
            && $this->_request->getParam('group') !== $groupId
        ) {
            continue;
        }
        if (($this->_request->getParam(self::STANDALONE_PROCESS_STARTED) !== '1') && (
                $this->_scopeConfig->getValue(
                    'system/cron/' . $groupId . '/use_separate_process',
                    \Magento\Store\Model\ScopeInterface::SCOPE_STORE
                ) == 1
            )
        ) {
            $this->_shell->execute(    // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
                $phpPath . ' %s cron:run --group=' . $groupId . ' --' . Cli::INPUT_KEY_BOOTSTRAP . '='
                . self::STANDALONE_PROCESS_STARTED . '=1',
                [
                    BP . '/bin/magento'
                ]
            );
            continue;
        }
...
}

т.е. при установке этого флага, запуск будет произведен не в текущем скрипте крона, а в отдельном, который будет запущен вот такой командой

php bin/magento cron:run --group=<имя-группы> --bootstrap=standaloneProcessStarted=1

Если изучить этот метод чуть дальше, станет понятно для чего нужен запуск в отдельном процессе, немного ниже видно как запускаются задачи:

// vendor/magento/module-cron/Observer/ProcessCronQueueObserver.php

public function execute(\Magento\Framework\Event\Observer $observer) {
    ...
    foreach ($jobGroupsRoot as $groupId => $jobsRoot) { // <<<<<<<<<<<<<<<<<
        ...
        /** @var \Magento\Cron\Model\Schedule $schedule */        foreach ($pendingJobs as $schedule) { // <<<<<<<<<<<<<<<<<

            $jobConfig = isset($jobsRoot[$schedule->getJobCode()]) ? $jobsRoot[$schedule->getJobCode()] : null;
            if (!$jobConfig) {
                continue;
            }

            $scheduledTime = strtotime($schedule->getScheduledAt());
            if ($scheduledTime > $currentTime) {
                continue;
            }

            try {
                if ($schedule->tryLockJob()) { 
                    $this->_runJob($scheduledTime,    // <<<<<<<<<<<<<<<<<
                                $currentTime, 
                                $jobConfig, 
                                $schedule, 
                                $groupId
                    ); 
                }
            } catch (\Exception $e) {
                $schedule->setMessages($e->getMessage());
                if ($schedule->getStatus() === Schedule::STATUS_ERROR) {
                    $this->logger->critical($e);
                }
                if ($schedule->getStatus() === Schedule::STATUS_MISSED
                    && $this->state->getMode() === State::MODE_DEVELOPER
                ) {
                    $this->logger->info(
                        sprintf(
                            "%s Schedule Id: %s Job Code: %s",
                            $schedule->getMessages(),
                            $schedule->getScheduleId(),
                            $schedule->getJobCode()
                        )
                    );
                }
            }
            $schedule->save();
        }
    }
}

Тут мы видим, что группы обрабатываются в цикле, затем во вложенном цикле обрабатываются задачи.  Делается все поочередно, и для каждой из них вызывается метод

$this->_runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId);

если посмотреть реализацию этого метода, которая находится в этом же классе, мы увидим что там в конечном итоге, запускается выполнение коллбека указанной задачи

// vendor/magento/module-cron/Observer/ProcessCronQueueObserver.php

protected function _runJob($scheduledTime, $currentTime, $jobConfig, $schedule, $groupId)
{
...
try {
    call_user_func_array($callback, [$schedule]);    // <<<<<<<<<<<<<
} catch (\Exception $e) {
    $schedule->setStatus(Schedule::STATUS_ERROR);
    throw $e;
}
...
}

Это означает, что в случае, если у нас будет какая-то длительная операция, например индексирование, на время её выполнения все другие задачи будут остановлены, и продолжены, только после того как она выполнится. Это может привести к тому, что они истекут по таймауту на выполнение. Поэтому для длительных задач, таких как индексирование или интеграции, необходимо использовать запуск в отдельном процессе.

После того, как задача будет выполнена, её статус в таблице cron_schedule обновится с running на success. Очень часто бывает, что в случае криво написанных интеграций или других задач, выполнение прерывается с фатальной ошибкой, например, при превышении памяти отведенной на запуск php процесса. В таком случае, задача останется висеть в статусе running, в этом случае все последующие добавленные задачи не будут выполнены и будут переводиться в статус missed. В этом случае, необходимо вручную изменить статус задачи на error, выполнив такой SQL код:

UPDATE cron_schedule SET 
`status` = 'error',
`messages` = 'Status changed manually due to cron hung'
WHERE
`status` = 'running'
AND
`job_code` = '<id-нужного-индексатора>'
LIMIT 1

Полезные ссылки

Категории: Magento
Тэги: cronmagento 2

Комментарии (0)