image

В процессе эволюции более-менее крупного проекта может настать ситуация, когда количество запланированных задач (cron jobs) становится настолько большим, что поддержка их становится ночным кошмаром devops'ов. Для решения этой проблемы мне пришла в голову идея создать реализацию планировщика на PHP, тем самым сделав его частью проекта, а сами задачи — частью его конфигурации. В этом случае необходимое и достаточное количество cron jobs будет равно единице.


Некоторое время назад мне довелось разрабатывать модуль для планирования событий. Некое упрощенное подобие Google/Apple Calendar для пользователей приложения. Для хранения дат и правил повторения событий было решено использовать формат iCalendar (RFC 5545), позволяющий одной строкой описать график повторения какого-либо события с учетом дней недели, месяцев, количества повторений и многого другого. Несколько примеров:


FREQ=WEEKLY;BYDAY=SU,WE — Еженедельно в субботу и среду
FREQ=MONTHLY;COUNT=5 — Каждый месяц, пять раз
FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU — Каждый второй год в каждую субботу января


Как видите данный стандарт позволяет описать правила повторения события гораздо более гибко, чем предлагает cron.


Для работы с форматом iCalendar была найдена замечательная библиотека (не пожалейте звезду):
https://github.com/simshaun/recurr


Имея инструмент для работы с RRULE (Recurrence Rule) дело осталось за малым. Написать несколько классов, позволяющих планировать и запускать задачи (являющиеся каким угодно проявлением PHP callable типа).


Установка библиотеки:


composer require hutnikau/job-scheduler


Планирование и запуск задач:


\Scheduler\Job\Job — Класс, представляющий задачу


Для создания его экземпляра потребуется правило его повторения (RRULE) и экземпляр типа callable:


$startTime = new \DateTime('2017-12-12 20:00:00');
$rule = new \Scheduler\Job\RRule('FREQ=MONTHLY;COUNT=5', $startTime); //run monthly, at 20:00:00 starting from the 12th of December 2017, 5 times
$job = new \Scheduler\Job\Job($rule, function () {
    //do something
});

Альтернативный вариант — использовать \Scheduler\Job\Job::createFromString():


$job = \Scheduler\Job\Job::createFromString(
    'FREQ=MONTHLY;COUNT=5', //Recurrence rule 
    '2017-12-28T21:00:00',  //Start date
    function() {},          //Callback
    'Europe/Minsk'          //Timezone. If $timezone is omitted, the current timezone will be used
);

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


Добавляем задачу в планировщик:


$scheduler = new \Scheduler\Scheduler()
$scheduler->addJob($job);

Так же можно передать массив задач в конструктор:


$scheduler = new \Scheduler\Scheduler([
    $job,
    //more jobs here
])

Запускаем запланированные задачи:


$jobRunner = new \Scheduler\JobRunner\JobRunner();
$from      = new \DateTime('2017-12-12 20:00:00');
$to        = new \DateTime('2017-12-12 20:10:00');
$reports   = $jobRunner->run($scheduler, $from, $to, true);

В данном примере будут выполнены все задачи, запланированные на указанный промежуток времени (10 минут). Таким образом вам потребуется всего один cron job, запускающий JobRunner.


Можно опустить параметр $to, таким образом будут выполнены все задачи, начиная от $from до текущего момента.


Последний параметр определяет, будут ли выполнены задачи, время выполнения которых попало точно на пограничные значения ('2017-12-12 20:00:00' и '2017-12-12 20:10:00' из примера выше).


При запуске планировщика при помощи cron я советую сохранять время последнего запуска, и при следующем запуске передавать его в параметр $from прибавив одну секунду, так как точность cron'а не идеальна, и существует вероятность пропустить какие-либо задачи или выполнить их дважды.


$jobRunner->run(...)возвращает массив результатов выполненных задач (массив объектов типа \Scheduler\Action\Report).


\Scheduler\Action\Report {
    /* Methods */
    public mixed getReport ( void )
    public Action getAction ( void )
    public string getType ( void )
}

Вызвав \Scheduler\Action\Report::getReport() можно получить результат выполнения callable (возвращенное им значение).


В случае, если при выполнении задачи было брошено исключение, \Scheduler\Action\Report::getReport() вернет то самое исключение.


Метод \Scheduler\Action\Report::getAction() вернет экземпляр типа \Scheduler\Action\ActionInterface, который описывает выполненное действие. Используя его можно узнать время выполнения действия или получить само действие (Job).


Так же стоит обратить внимание, что если запланированная задача должна была выполниться более одного раза (например если в RRULE был использован интервал MINUTELY, и разница между $from и $to, переданным в JobRunner 10 минут), то действие будет выполнено несколько раз. Другими словами они не будут сгруппированы.


Вот, пожалуй, и все. Библиотека действительно мала, но надеюсь окажется кому-либо полезной.
Конструктивная критика и помощь в развитии приветствуются.


GitHub
packagist

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


  1. MetaDone
    09.01.2018 10:46

    Интересная штука, но есть пара вопросов:
    1. Для запуска судя по всему нужно чтоб скрипт дернуло что-то внешнее (http-запрос или cron)? Если да то вылезает минус с ресурсоемкими задачами — придется ждать пока они завершаться (беглый взгляд не увидел асинхронности)
    2. Не смотрели в сторону https://github.com/Cron/Cron?
    3. Пространство имен Scheduler — было бы неплохо добавить имя вендора
    4. Не думали сделать что-то типа такого?


    1. goodnickoff Автор
      09.01.2018 11:09

      Да, в начале статьи я упомянул что необходимо иметь один cron job для запуска. Асинхронность или контроль за тем, чтобы не запускались новые процессы пока не завершился предыдущий подразумевается делать на высшем уровне, т.е. там где будет кофигурироваться и запускаться библиотека.
      Изначальная идея была в использовании формата iCalendar, который позволяет куда более гибкую конфигурацию графиков выполнения.
      Делать UI для этой библиотеки не планировалось. На мой взгляд это не лучшая практика. Такие вещи должны быть скрыты от пользователя и не должны управляться через GUI.


      1. MetaDone
        09.01.2018 11:30

        в последнем примере дело не в UI, а в идее единого интерфейса для комманд и запуска одной команды для разруливания задач, чтоб она уже сама если время наступило запускала все остальное.
        В итоге получилось бы или как в последнем примере или как здесь.
        Ну или можно было бы вообще отвязаться от крона и использовать https://github.com/amphp/amp, ориентироваться на этот кусок, но это привнесет кучу других проблем с проверкой состояния демона, разруливания по отдельным процессам и т.п.


        1. goodnickoff Автор
          09.01.2018 11:38

          Вы можете элементарно обернуть запуск JobRunner'а, если вам хочется запускать его при помощи демона. Следуя примеру который вы привели это примерно 10-15 строк кода.


          1. MetaDone
            09.01.2018 11:49

            Не все так просто — у вас все равно все будет в 1 процессе. В текущей реализации если задача, которую запустит этот демон, вылетит с ошибкой, то все выполение застопорится т.к. запускается не в отдельных процессах. Так же придется самому дописывать контроль над тем что нужная ресурсоемкая задача не запущена, перезапуск демона чтоб он не прибил то что в процессе ну и кучу чего еще


            1. goodnickoff Автор
              09.01.2018 12:36

              Да, для более продвинутого использования придется много чего дописывать. Моя реализация довольно примитивна, о чем я упомянул в названии статьи. Но это только начало! Буду рад пул реквесту от вас!


  1. hamnsk
    09.01.2018 10:56
    +1

    Простите а чем вам systemd не угодил и раскладка конфигурации через Ansible, Puppet, Chef, SaltStack, Fabric и тд…

    1. Как вы будите мониторить работают ли сейчас ваши джобы, и как вы поймете какую из завсших надо прибить, ну малоли
    2. Как мониторить включены не включены конкретные джобы?
    3. Как быть с консьюмерами?

    Основная задаче не только раскатать и абы как запустить, а замониторить, что все работает как планировалось, а не как придется. И в случае падения и ошибок видеть это в Zabbix, Graylog, etc.


    1. goodnickoff Автор
      09.01.2018 11:27

      Для моих целей необходима была реализация на PHP, чтобы сделать ее независимой от посторонних инструментов, сделать ее частью проекта, а сами job'ы сделать частью его конфигурации. Например так же как конфигурируется роутер различных CMS и фреймворков.


      Можно бесконечно развивать и прикручивать к нему все что придет в голову. Логгирование, мониторинг, многопоточность и GUI. Только на мой взгляд это зона ответственности других инструментов. У меня нет желания лепить монструозную библиотеку. Данная реализация предоставляет необходимое API, чтобы вы могли заставить их работать с любым другим инструментом для любых ваших целей.


      1. hamnsk
        09.01.2018 11:55

        Так и назовите статью, изобретение велосипедов и костылизация. Вот скажите как сделать autodiscovery ваших скриптов, как понять какие активны какие нет, ну чтобы добавить итемы в мониторинг, или все делать ручками, или парсить конфиги? Как получить стату, какие именно сейчас или конкретный джоб на исполнении? Не ответили про консьюмеры как быть с ними? А если мне надо запускать джобы в несколько потоков? Опять же ваш АПИ возвращает код возврата, если скрипт форкается то какой код будет передан? С кодом возврата у пыхи всегда проблема, опять же зависит от разработчиков могут вернуть все что угодно. Далее получается что конфиг крона, который понятен куче людей почти на интуитивном уровне перенесли в другой конфиг, который теперь придется так же листать, что поменялось? Файл где храниться описание джобы? Опять же на ваш взгляд зона ответственности других людей, вот бить надо таких как вы! Мой код работает это у вас косяк. А подумать о том как это и чем будет мониториться, а задел на автоматику? Почему вы подумали только о себе и о своей части, а интеграцией вашего костыля должны париться другие? Судя по описалову в статье Ваш АПИ и репорт что он выдает никуда не годиться. Хоть бы примеры привели тут как они выглядят.


        1. mayorovp
          09.01.2018 13:45

          Далее получается что конфиг крона, который понятен куче людей почти на интуитивном уровне перенесли в другой конфиг, который теперь придется так же листать, что поменялось?

          Я так понял, что поменялся основной читатель и писатель этого конфига — были опсы, стали девы.


  1. Shvonder
    09.01.2018 10:59

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


    1. goodnickoff Автор
      09.01.2018 11:14

      Все в ваших руках. Напишите обертку, которая поднимет демон или будет запускать JobRunnerв несколько потоков.
      Место хранения задач можете выбрать сами. В файле кофигурации, скрипте, базе данных. Это не область ответственности этой библиотеки.


  1. iXCray
    09.01.2018 11:42

    Правильно понимаю, что любой fatal error тихо положит планировщик со всеми задачами на неопределенном этапе выполнения?
    Контроля запуска дубликатов тоже не увидел.


    Насколько помню, такой велосипед (раз уж есть доступ до cron-a) реализовывался ранее всегда через демон-подобный вечный скрипт с pid.lock файлом для контроля запуска дублей, полной обработкой всех ошибок, чтобы жить как можно дольше или предсказуемо падать при первой возможности, а также реакцией на принудительное завершение через sig9 — в друпале, джумле, вордпрессе и пр.


    Нет четкого разделения ответственности: за дубликаты задач должны отвечать сами задачи, но в то же время ваш планировщик зачем-то хранит в памяти кучу вывода из этих самых задач. В таком вопросе обычно либо все, либо ничего — нужно четко определять границы обслуживания, иначе либо задача пишется, подразумевая использование именного вашего планировщика (отсутствует эффект drop-in replacement), либо допиливать каждый раз через обертки ваш интерфейс, что делает продукт менее интересным.


    1. goodnickoff Автор
      09.01.2018 11:54

      Если в вашем приложении появляется fatal error, то у меня для вас плохие новости.


      1. hamnsk
        09.01.2018 11:59
        -1

        а по существу не судьба ответить? или ваш планировщик от fatal error'ов защищен на все 100%?


        1. goodnickoff Автор
          09.01.2018 12:06
          +1

          По существу fatal error должен появляться в вашем приложении не чаще чем никогда. Но если у вас другое мнение, то register_shutdown_function вам в помощь. Обработка fatal error далеко не зона ответственности этой библиотеки.


          1. hamnsk
            09.01.2018 12:54
            +1

            вы опять мимо строк читаете, вам задали конкретные вопросы, вы их упорно игнорируете и отвечаете какую то муть, не связанную с вопросом. Мои приложения не ловят fatal error и не нуждаются в вашей библиотеке потому, что я не пишу на пыхе, и для меня например есть celery. Далее суть вашей библиотеки мы уже поняли, выше были мои вопросы к вам по существу, так как например я обслуживаю код написанный на пыхе, и там есть проблемы с шедулингом, которые решились через другие инструменты без изобретения костылей! На мои вопросы, которые интересуют меня как человека из эксплуатации вы упорно не хотите давать ответа. Так тогда смысл пиарить здесь этот велосипед? За попытку 5, за функционал и приодность неуд. Смысл минусовать посты, если не в состоянии дать внятного ответа на поставленные задачи.


            1. goodnickoff Автор
              09.01.2018 13:15

              Я не минусовал ваши посты.
              Просто на большую часть вопросов я ответил выше, поэтому не стал повторяться по всем пунктам.


              Сами задачи отвечают только за запуск и ничего более. На счет хранения в памяти кучи вывода я не совсем понял, что вы имеете ввиду. О какой куче речь?


          1. iXCray
            10.01.2018 04:39

            Зона ответственности планировщика — выполнять задания по плану гарантированно и предсказуемо (без неоднозначности).


            Вопрос: если одно из заданий "рушится" — а это ожидаемое с некоторой долей вероятности (в зависимости от заявленного качества ПО в соответствии с ISO 9000, раз уж мы тут про "по существу") — планировщик может гарантировать, что не упадет сам и выполнит следующее в очереди задание, независимо от "результатов" предыдущего?


            Если все-таки он рушится вместе с заданием, откуда он начнет выполнение заданий со следующим запуском: с начала или с момента аварийной остановки?


            Что случится при запуске двух и более копий планировщика?


            1. goodnickoff Автор
              10.01.2018 10:22

              У нас несколько разный взгляд на разработку ПО. Я полагаю, что fatal error и "обрушение" недопустимы, и закладывать их вероятность при разработке это дикость. Представьте строителей, которые закладывают вероятность обрушения потолка при строительстве: "а давайте его веревками к стропилам привяжем, вдруг обрушится" (хотя, кажется, теперь я знаю чем руководствуются разработчики Skype).


              Если говорить об ожидаемых авариях, исключительных ситуациях (Exceptions), то JobRunner их ловит, являясь последним форпостом на пути брошенного Exception. Но не пойманные исключения тем приложением, которое их бросило — тоже отдельный вопрос, который также не лучшим образом характеризует качество кода. Я долго сомневался стоит ли вовсе ловить исключения, брошенные чужим кодом.


              Что касается запуска двух копий, тут я с вами согласен. В некоторых обстоятельствах может возникнуть такая ситуация (хотя ее тоже можно обработать кодом, запускающим планировщик). Но у меня в планах реализовать параллельное выполнение нескольких JobRunner.


              1. iXCray
                10.01.2018 13:22

                закладывать их вероятность при разработке это дикость

                Это не дикость, а выбор паттерна поведения в случае непредвиденной реакции окружения ("обрушилась" база, подключение, недостаточно памяти, не был получен семафор и т.д.). В том числе выход из процедуры с ошибкой, завершение приложения с кодом ошибки и проч — такое встречается повсеместно, а в некоторых системах "обрушение" и вовсе приветствуется, как гарантированная остановка приложения — все зависит от целей, бизнес-процесса, принятых норм, а также соответствия уровню качества (доверия), который был выбран, исходя из целей по рентабельности кода, а также всей системы, частью которой эта Job является.


                "а давайте его веревками к стропилам привяжем, вдруг обрушится"

                Холодное и горячее резервирование систем вам чуждо, я так полагаю.


                Простейшее решение в виде запуска задачи в независимом/слабозависимом потоке легко решит вопрос и сделает логику планировщика предсказуемой в этой части.


                1. goodnickoff Автор
                  10.01.2018 13:50

                  Простейшее решение — указать callback для Job'а, который запустит ваш код в независимом потоке, на удаленном worker сервере или где вам угодно с необходимыми вам Fault tolerance, Graceful degradation, обработкой "падений", бэкапами и прочим.


                  Обработка ошибок стороннего кода и "падений" сторонних сервисов — не область ответственности этой библиотеки.


                  1. iXCray
                    10.01.2018 14:41

                    Я говорю не об обработке ошибок, а о том, чтобы ваш планировщик не падал из-за критической ошибки в запускаемой задаче, оставаясь независимым предсказуемым участником системы


                    1. goodnickoff Автор
                      10.01.2018 15:11

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


  1. zenn
    09.01.2018 18:51

    Посмотрел код библиотеки — написана очень качественно с точки зрения OOP модели, даже тесты есть… Попробовал найти применение конкретно в области планировки задач — и не смог, к сожалению в данный момент это лишь «календарь», который умеет разбирать синтаксис iCelendar. Присоединюсь к hamnsk — в моем понимании CronJobber как любой Task просто обязан иметь возможность контролировать состояние потока.
    Более того, я считаю что для библиотеки Cron менеджера просто недопустимы вот такие вещи:

    При запуске планировщика при помощи cron я советую сохранять время последнего запуска, и при следующем запуске передавать его в параметр $from прибавив одну секунду, так как точность cron'а не идеальна, и существует вероятность пропустить какие-либо задачи или выполнить их дважды.

    Как итог оставлю свое личное мнение — библиотека написана красиво и качественно, но к сожалению едва-ли применима как полноценный CronManager.


  1. easty
    09.01.2018 19:17

    Симпатично написано. Надо будет подумать, и попробовать написать обертку для yii. Спасибо.


  1. vlasenkofedor
    09.01.2018 22:53

    Ребята это, что троллинг по красоте написания?
    github.com/simshaun/recurr/blob/master/src/Recurr/Rule.php
    сплошной дубляж. Единственная красота, это что автор не поленился. А вот с кодом стоит поработать.


    1. goodnickoff Автор
      10.01.2018 09:04

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


  1. vlreshet
    10.01.2018 10:40

    Я так понимаю, стандартный формат даты crontaba не поддерживается? ИМХО, это большой минус. Допустим, есть проект в котором 50+ кронов, в обычном кронтабе. И для перевода на эту библиотеку прийдётся переписать все 50 форматов под ваш. Ну такое.


    1. goodnickoff Автор
      10.01.2018 10:49

      Вы подали мне хорошую идею. В ближайшее время допишу тул для перевода из формата cron
      в формат iCalendar.
      Но использование стандарта iCalendar было одной из основных целей написания этой библиотеки. Он гораздо более гибкий и используется повсеместно для подобных целей.


  1. kraso4niy
    10.01.2018 23:23

    Но использование стандарта iCalendar было одной из основных целей написания этой библиотеки. Он гораздо более гибкий и используется повсеместно для подобных целей.

    В статье так не написано. Русский язык тоже гибкий. И crontab тоже. Вашу задачу (много задач в crontab) теперь придется поддерживать, если вдруг надо поменять расписание запуска какого либо скрипта, нужно деплоить код, в который программист вася обязательно нагадит с обратной совместимостью: ), а если проект растет по настоящему, то 1им сервером не обойтись и нужно все это запустить на 3-10 серверах… а еще рестарт крона будет накапливать задержки, вообщем ждите беды и готовьтесь к худшему.
    А если не хотите беды и хотите пить кофе, то сделайте демона, и натравите на него супервизор.