Magento 2 - komendy CLI, crontab i planowanie procesów

Magento 2 – komendy CLI, crontab i planowanie procesów

Dawno nie było nic z o Magento i postanowiłam się poprawić w tym obszarze. Dość niedawno udostępniłam na FB zagadkę związana z tematem, który chciałabym dziś poruszyć. Na ogół uruchomienie sklepu nie kończy się na zainstalowaniu silnika i wgraniu skórki, mimo iż znaczna część małych e-commerce’ów tak działa. Najczęściej jest to dopiero początek pracy nad budową sprawnie działających mechanizmów sprzedaży. Mimo iż klienci na ogół tego nie widzą, w tle pracuje wiele procesów, które w trybie niejednokrotnie ciągłym dbają o to, by nasze zamówienie zostało prawidłowo przeprocesowane.

Dlaczego o tym wspominam na wstępie? Zapewne jeśli czytasz ten artykuł, będziesz chciał zbudować taki mechanizm, który będzie miał za zadanie coś ważnego wykonać. Być może prześlesz swoje zamówienia do ERP’a, a może będziesz chciał wysłać maile z przypomnieniem o płatności. Może Twoim celem będzie anulowanie zamówień, które zostały złożone ponad miesiąc temu i jeszcze nie zostały opłacone. To wszystko wymaga pracy z bazą danych i ustawienia tak poszczególnych procesów, aby te na siebie nie nachodziły. W artykule tym pokażę Wam jak można zaprojektować taki ekosystem by móc nad nim całkowicie panować i nie narazić się na niechciane sytuacje jak w zagadce poniżej, kiedy to oczekiwalibyśmy wartości w bazie A=5, B=1, a otrzymaliśmy A=4, B=1. Taka pomyłka może nam bowiem rozsypać całą logikę procesowania zamówień.

Harmonogram procesów w Magento 2

Zacznijmy może od tego, że Magento posiada swój własny crontab,  w ramach którego realizowane są core’owe zadania Magento takie jak np:

  • reindeksacja danych
  • wysyłanie powiadomień e-mail
  • aktualizowanie kursów walut
  • generowanie Google Sitemaps
  • generowanie i wysyłanie powiadomień

Instalacja cron’a w Magento 2

Aby jednak mogło się to wydarzyć, mimo wszystko potrzebujemy wsparcia ze strony serwera, a mianowicie potrzebujemy zainstalować w crontab’ie systemu zadania, które będą uruchamiać mechanizm magentowy. Służy do tego polecenie:

bin/magento cron:install

Po jego wykonaniu, jeśli użyjemy polecenia w konsoli na naszym serwerze:

crontab -l

powinniśmy zobaczyć:

#~ MAGENTO START c5f9e5ed71cceaabc4d4fd9b3e827a2b
* * * * * /usr/bin/php /var/www/html/magento2/bin/magento cron:run 2>&1 | 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 c5f9e5ed71cceaabc4d4fd9b3e827a2b

Wraz z poleceniem bin/magento cron:install można również użyć parametru –force:

bin/magento cron:install --force

Magento 2 posiada swój wbudowany system zarządzania zadaniami. W odróżnieniu od Magento 1 mamy możliwość stworzenia dowolnej ilości kolejek dla zaplanowanych zadań, dlatego też w momencie gdy nasze zadanie musi wykonać się w konkretnej godzinie i nie może zostać zablokowane przez inny proces, należy dla niego przygotować osobną.

#~ MAGENTO START c5f9e5ed71cceaabc4d4fd9b3e827a2b
[...]
#~ MAGENTO END c5f9e5ed71cceaabc4d4fd9b3e827a2b

Zadania cron w Magento 2

Zadania cron w Magento 2 definiowane są w pliku crontab.xml znajdującym się w katalogu etc modułu:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <group id="<group_name>">
        <job name="<job_name>" instance="<classpath>" method="<method>">
            <schedule><time></schedule>
        </job>
    </group>
</config>
Parametr Opis
<group_name> Kolejka w ramach którego będzie wykonywane zadanie
<job_name> Unikalna nazwa zadania
<classpath> Nazwa klasy, w jakiej ulokowana jest logika zadania
<method> Metoda klasy, która zostanie uruchomiona
<time> Harmonogram wykonywania wyrażony w formacie:

<Minute> <Hour> <Day_of_the_Month> <Month_of_the_Year> <Day_of_the_Week>

Status wszystkich wykonanych zadań można przejrzeć w bazie danych w tabeli cron_schedule:

SELECT * FROM cron_schedule;

Poniżej znajduje się przykład najprostszego mechanizmu, który pozwoli nam potwierdzić, że nasze zadanie cron działa:

<?php

namespace Hello\World\Cron;

class Test
{

    public function execute()
    {

        $writer = new ZendLogWriterStream(BP . '/var/log/cron.log');
        $logger = new ZendLogLogger();
        $logger->addWriter($writer);
        $logger->info(__METHOD__);

        return $this;

    }
}

Tworzenie dodatkowych kolejek/grup zadań cron w Magento 2

Zadania w danej kolejce wykonywane są kolejno wg harmonogramu. W związku z tym może dojść do sytuacji, w której zaplanowane zadanie oczekuje wciąż na wykonanie, mimo iż czas jego wykonania już minął. Jak już wspomniałam wcześniej, jeśli mamy takie zadania, które muszą się wykonać o zaplanowanych godzinach, dobrze jest stworzyć dla nich osobną kolejkę. W tym celu wykorzystuje się plik cron_groups.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/cron_groups.xsd">
    <group id="<group_name>">
        <schedule_generate_every>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>
Parametr Opis
schedule_generate_every Częstotliwość zapisywania zadań w tabeli cron_schedule, wyrażona w minutach
schedule_ahead_for Planowanie zadań w tabeli cron_schedule do przodu. Jeśli nasz cron wykonuje się co godzinę, a nasz parametr będzie miał wartość 240, to podczas pierwszego uruchomienia się mechanizmu zostaną zaplanowane 4 zadania, następnie zapisane do cron_schedule i będą oczekiwały na przetworzenie
schedule_lifetime Czas wyrażony w minutach, liczony od planowanej daty przetwarzania, w jakim zadanie powinno zostać zdjęte z kolejki. Jeśli czas ten zostanie przekroczony zadanie otrzyma status missed i nie będzie przetwarzane.
history_cleanup_every Czas wyrażony w minutach, przez jaki będą trzymane w bazie pominięte, bądź podjęte procesy
history_success_lifetime Czas wyrażony w minutach, przez jaki będą trzymane w bazie procesy, które wykonały się z sukcesem
history_failure_lifetime Czas wyrażony w minutach, przez jaki będą trzymane w bazie procesy, które nie wykonały się z sukcesem
use_separate_process Przyjmuje wartości 1 i 0. Decyduje o tym, czy kolejka ma być przetwarzana w osobnym procesie PHP

Polecenie bin/magento cron:run uruchamiania wszystkie kolejki, aby uruchomić ręcznie konkretną należy dodać parametr --group:

bin/magento cron:run --group index

Polecenia CLI w Magento

Crontab Magento jest najbardziej rekomendowanym sposobem definiowania procesów. Jednak czasem zdarzają się takie, które możemy chcieć uruchomić ręcznie. Mamy możliwość uruchomienia konkretnego lejka i to się sprawdza, jeśli w ramach niego wykonujemy tylko jedną operację, gorzej jeśli jest ich kilka. Wówczas z pomocą przychodzą komendy CLI. Możemy wstrzyknąć do nich klasę naszego procesu i wyzwolić ją dzięki temu.

Aby zdefiniować nową komendę CLI użyjemy pliku di.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">   
    <type name="MagentoFrameworkConsoleCommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="<name>" xsi:type="object"><classpath></item>
            </argument>
        </arguments>
    </type>
</config>
Parametr Opis
<name> Unikalna nazwa naszej komendy
<classpath> Nazwa klasy, w jakiej ulokowana jest logika zadania

Poniżej znajduje się logika naszej klasy:

<?php
    namespace Hello\World\Console\Command;

    use Hello\World\Cron\Test;
    use SymfonyComponentConsoleCommandCommand;
    use SymfonyComponentConsoleInputInputInterface;
    use SymfonyComponentConsoleInputInputOption;
    use SymfonyComponentConsoleOutputOutputInterface;

    /**
     * Class SomeCommand
     */
    class SomeCommand extends Command
    {
        /** @var string **/
        const NAME = 'helloworld:testcronjob';

        /** @var Test **/
        protected $testCronJob;

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

        /**
         * @inheritDoc
         */
        protected function configure()
        {
            $options = [];
            $this->setName(self::NAME)
                 ->setDescription('Test cron job runner')
                 ->setDefinition($options);

            parent::configure();
        }

        /**
         * Execute the command
         *
         * @param InputInterface $input
         * @param OutputInterface $output
         *
         * @return null|int
         */
        protected function execute(InputInterface $input, OutputInterface $output)
        {
            try {
                $this->testCronJob->execute();
            } catch (Exception $e) {
                $output->writeln(sprintf('<error>%s</error>', $e->getMessage()));
            }
        }
    }

Zastosowanie takiego mechanizmu CLI, jest też dobrym i najszybszym sposobem wykonania testu, czy nasz mechanizm wciąż działa, np mamy połączenie z ERP.

Czasami zdarza się również, że potrzebujemy stworzyć proces totalnie odseparowany od mechanizmów cron’owych Magento. Załóżmy, że nasze zadanie wykonuje się co 5 minut. W ciągu dnia wykona się więc 288 razy, gdyby wykonywał się co minutę wpisów w tabeli cron_schedule byłoby 5 razy więcej, a więc 1440, natomiast gdybyśmy mieli takich procesów 10 – byłoby ich 14400.  Zdarza się również, że takich małych procesów może być o wiele wiele więcej. Korzystanie wówczas ze standardowych mechanizmów Magento może sprawić, że ilość procesów read i write na tabeli spowoduje nie tylko niesamowity przyrost w ramach auto_increment’a, co ostatecznie doprowadzi do zamknięcia zakresu typu int, ale i zwyczajnie w świecie zaczną pojawiać się DEAD LOCK’i. W takich przypadkach lepszym rozwiązaniem jest wykorzystywanie crontab’a systemu i polecenia CLI.

Polecenia CLI w Magento 2 – parametry

W ramach poleceń CLI możemy wykorzystywać również parametry, dzięki którym z zewnątrz będziemy w stanie przekazać potrzebne wartości. Możemy tego dokonać na dwa sposoby za pomocą metody setDefiniction():

$options = [
                 new InputOption(
                     self::NAME,
                     null,
                     InputOption::VALUE_REQUIRED,
                     'Name'
                 )
            ];

$this->setName(self::NAME)
     ->setDescription('Test cron job runner')
     ->setDefinition($options);

bądź używając metody setArgument():

$this->addArgument(self::NAME, InputArgument::OPTIONAL, 'Name');

Planowanie, podgląd i zabezpieczenie procesów w Magento 2

Wspomniałam już trochę o tym, co powinniśmy wziąć pod uwagę pod kątem decyzji, który z mechanizmów cron’owych wybrać. Nie możemy jednak zapomnieć o tym, że jeśli mamy wiele procesów działających w tle, część z nich może zacząć na siebie nachodzić. Mogą wówczas wydarzyć się sytuacje, które totalnie sparaliżują prawidłowe procesowanie zamówień.

Aby zabezpieczyć zadania cron, które mamy zdefiniowane w crontabie systemowym, można użyć polecenia flock np.:

*/5 * * * * /usr/bin/flock -n /tmp/ms.lockfile /usr/bin/php /path/to/magento/bin helloworld:testcronjob

Jednak nie zabezpieczy nas to przed dodatkowymi niechcianymi zmianami w bazie. W momencie, kiedy wykonujemy save() na modelu, nie zmienia się w bazie tylko ta jedna wartość, którą chcieliśmy zmienić. Magento pobiera i zapisuje cały wiersz. Oznacza to, że jeśli dwa procesy w tym samym czasie pobiorą rekord z bazy i zmienią dwie różne wartości, po czym dokonają zapisu, końcowy stan wiersza nie będzie zawierał wszystkich aktualnych wartości. Dlatego też dla pojedynczych zmian w przypadku wielu procesów zalecam wykorzystywanie Magento\Framework\App\ResourceConnection. Wstrzyknięcie tej klasy do konstruktora pozwoli nam na wykonanie:

$connection = $this->connection->getConnection();
$connection->query(
      sprintf("UPDATE sales_order SET somecolumn = 1 WHERE entity_id = %s", $order->getId())
);

Dzięki temu nasz proces będzie zmieniał tylko jedną konkretną wartość w przeciwieństwie do update’u na wszystkich kolumnach podczas wykonywania save() na modelu.

To nie uchroni nas jednak przez niespodziankami w 100%. Czasami, któryś z procesów pracuje zbyt długo i dane wynikowe potrzebne do kolejnego mogą jeszcze nie zostać wprowadzone do bazy.

Megaplaza Cron Schedule

Jeśli korzystamy z crontab’a Magentowego możemy sprawdzić jakie procesy wykonują się za pomocą modułu Cron Schedule dostarczonego przez Megaplaza. Znajdziemy w nim nie tylko diagram prezentujący wszystkie procesy, ale i możemy sprawdzić ile każdy z nich trwał:

CRONV

W przypadku zadań zdefiniowanych w systemowym crontabie możemy wykorzystać narzędzie cronv, które zostało napisane w języku GO. Jego wykorzystanie jest na prawdę proste – polecenie:

$ crontab -l | cronv
[Cron Tasks] 1 tasks.
[Cron Tasks] './crontab.html' generated.

zamieni nam:

$ crontab -l
1 * * * * /home/ijozwiak/public/project/bin/magneto c:f

na plik crontab.html:

Niestety bez dodatkowego wsparcia nie uzyskamy za jego pomocą informacji o tym, ile konkretny proces trwał. Bez problemu możemy jednak to zmierzyć dodając przed naszą komendą CLI dodatkowe polecenie time, np.:

time bin/magento helloworld:testcronjob

wówczas po wykonaniu procesu pojawią nam się dodatkowe dane:

real    0m0,610s - całkowity czas wykonywania procesu
user    0m0,549s - czas jaki przypadł na użytkownika systemu
sys     0m0,062s - czas jaki przypadł na system

Jeśli te dane będą zbyt mało precyzyjne można również wesprzeć się innymi narzędziami profilująco-monitorującymi takimi jak xdebug, xhprof czy new relic.

Niektórym może nie odpowiadać sposób prezentacji wyników na timeline’ie tj. urwane polecenia cron, jeśli są zbyt długie. Poradziłam sobie z tym w ten sposób, że przed każdym procesem wprowadzam komentarz jak poniżej:

$ crontab -l
# czyszczenie cache
1 * * * * /home/ijozwiak/public/project/bin/magneto c:f

Następnie zapisuję listę zadań do pliku:

crontab -l > crontab.txt

I przepuszczam to przez skrypt sh:

#!/bin/bash

input=$1
tmp='tmp.txt'

if [[ -n "$input" ]]; then
  touch $tmp
  while read -r line; do
    lineB=$(($line-1))
    lineBefore=''
    job=`cat $input  | sed -n "${line}p"  | sed s/|//g`
    command=`echo "$job" | cut -d " " -f 6- | sed s/|//g`
    if [ $lineB -gt 0 ]; then
      lineBefore=`cat $input  | sed -n "${lineB}p" | sed s/#//g`
    fi
    if [ -n "$lineBefore" ] ; then
      output=`echo "$job" | sed "s|$command|$lineBefore|g"`
      echo "$output" >> $tmp
    else
      echo "$job" >> $tmp
    fi
  done < <(cat $input | egrep -vn ^# | awk '{print $1}' | cut -d ":" -f 1);  cat $tmp | cronv -d 1d --from-time=0:00 -t Crontab
  rm $tmp
else
  echo "Podaj plik źródłowy"
fi

za pomocą polecenia:

./cronv.sh crontab.txt

W outpucie dostaje dużo czytelniejszy wynik:

Kolejność wykonywania się procesów

W przypadku crontab’a Magento 2 jeśli wykorzystujemy jedną kolejkę nie powinna wydarzyć się sytuacja, w której szybciej wykona się proces niż powinien – oczywiście o ile prawidłowo skonfigurujemy czasy i nie wystąpi po drodze żaden błąd.

Natomiast, gdy stosujemy crontab’a systemowego, może dojść do sytuacji, że np będziemy próbować przetwarzać dane z tabel tymczasowych, nim zdążymy je jeszcze pobrać. Może się też przydarzyć, że przetworzymy przestarzałe dane, ponieważ z powodu błędu nie pobraliśmy nowych i np włączymy dostępność produktów, których nie ma już na stanie. Aby się przed tym zabezpieczyć, niezależnie od miejsca zdefiniowania zadania, dobrze jest zawsze mieć dostęp do informacji:

  • kiedy ostatnio dany proces się wykonał
  • jakie procesy muszą się wykonać przed nim i jak świeże powinny być to dane aby proces mógł je wykorzystać
  • czy proces przetworzył już dane, które zamierzamy przeprocesować, jeśli tak to nie powinien robić tego ponownie, chyba że użyjemy parametru –force

Podsumowanie

Mam nadzieję, że materiał zebrany w ramach tego artykułu pomoże Wam w codziennej pracy z integracjami, bowiem to właśnie podczas ich tworzenia i aktualizacji najczęściej spotykamy się z  opisanymi sytuacjami.

Dziękuję, że poświęciłeś swój czas na przeczytanie tego artykułu, jeśli masz jeszcze chwilę, podziel się swoimi wrażeniami i >>zostaw komentarz<< w wątku do tego posta.