|
| 1 | +Scheduler |
| 2 | +========= |
| 3 | + |
| 4 | +.. versionadded:: 6.3 |
| 5 | + |
| 6 | + The Scheduler component was introduced in Symfony 6.3 |
| 7 | + |
| 8 | +The scheduler component manages task scheduling within your PHP application, like |
| 9 | +running a task each night at 3 AM, every two weeks except for holidays or any |
| 10 | +other custom schedule you might need. |
| 11 | + |
| 12 | +This component is useful to schedule tasks like maintenance (database cleanup, |
| 13 | +cache clearing, etc.), background processing (queue handling, data synchronization, |
| 14 | +etc.), periodic data updates, scheduled notifications (emails, alerts), and more. |
| 15 | + |
| 16 | +This document focuses on using the Scheduler component in the context of a full |
| 17 | +stack Symfony application. |
| 18 | + |
| 19 | +Installation |
| 20 | +------------ |
| 21 | + |
| 22 | +In applications using :ref:`Symfony Flex <symfony-flex>`, run this command to |
| 23 | +install the scheduler component: |
| 24 | + |
| 25 | +.. code-block:: terminal |
| 26 | +
|
| 27 | + $ composer require symfony/scheduler |
| 28 | +
|
| 29 | +Symfony Scheduler Basics |
| 30 | +------------------------ |
| 31 | + |
| 32 | +The main benefit of using this component is that automation is managed by your |
| 33 | +application, which gives you a lot of flexibility that is not possible with cron |
| 34 | +jobs (e.g. dynamic schedules based on certain conditions). |
| 35 | + |
| 36 | +At its core, the Scheduler component allows you to create a task (called a message) |
| 37 | +that is executed by a service and repeated on some schedule. It has some similarities |
| 38 | +with the :doc:`Symfony Messenger </components/messenger>` component (such as message, |
| 39 | +handler, bus, transport, etc.), but the main difference is that Messenger can't |
| 40 | +deal with repetitive tasks at regular intervals. |
| 41 | + |
| 42 | +Consider the following example of an application that sends some reports to |
| 43 | +customers on a scheduled basis. First, create a Scheduler message that represents |
| 44 | +the task of creating a report:: |
| 45 | + |
| 46 | + // src/Scheduler/Message/SendDailySalesReports.php |
| 47 | + namespace App\Scheduler\Message; |
| 48 | + |
| 49 | + class SendDailySalesReports |
| 50 | + { |
| 51 | + public function __construct(private string $id) {} |
| 52 | + |
| 53 | + public function getId(): int |
| 54 | + { |
| 55 | + return $this->id; |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | +Next, create the handler that processes that kind of message:: |
| 60 | + |
| 61 | + // src/Scheduler/Handler/SendDailySalesReportsHandler.php |
| 62 | + namespace App\Scheduler\Handler; |
| 63 | + |
| 64 | + #[AsMessageHandler] |
| 65 | + class SendDailySalesReportsHandler |
| 66 | + { |
| 67 | + public function __invoke(SendDailySalesReports $message) |
| 68 | + { |
| 69 | + // ... do some work to send the report to the customers |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | +Instead of sending these messages immediately (as in the Messenger component), |
| 74 | +the goal is to create them based on a predefined frequency. This is possible |
| 75 | +thanks to :class:`Symfony\\Component\\Scheduler\\Messenger\\SchedulerTransport`, |
| 76 | +a special transport for Scheduler messages. |
| 77 | + |
| 78 | +The transport generates, autonomously, various messages according to the assigned |
| 79 | +frequencies. The following images illustrate the differences between the |
| 80 | +processing of messages in Messenger and Scheduler components: |
| 81 | + |
| 82 | +In Messenger: |
| 83 | + |
| 84 | +.. image:: /_images/components/messenger/basic_cycle.png |
| 85 | + :alt: Symfony Messenger basic cycle |
| 86 | + |
| 87 | +In Scheduler: |
| 88 | + |
| 89 | +.. image:: /_images/components/scheduler/scheduler_cycle.png |
| 90 | + :alt: Symfony Scheduler basic cycle |
| 91 | + |
| 92 | +Another important difference is that messages in the Scheduler component are |
| 93 | +recurring. They are represented via the :class:`Symfony\\Component\\Scheduler\\RecurringMessage` |
| 94 | +class. |
| 95 | + |
| 96 | +Attaching Recurring Messages to a Schedule |
| 97 | +------------------------------------------ |
| 98 | + |
| 99 | +The configuration of the message frequency is stored in a class that implements |
| 100 | +:class:`Symfony\\Component\\Scheduler\\ScheduleProviderInterface`. This provider |
| 101 | +uses the method :method:`Symfony\\Component\\Scheduler\\ScheduleProviderInterface::getSchedule` |
| 102 | +to return a schedule containing the different recurring messages. |
| 103 | + |
| 104 | +The :class:`Symfony\\Component\\Scheduler\\Attribute\\AsSchedule` attribute, |
| 105 | +which by default references the schedule named ``default``, allows you to register |
| 106 | +on a particular schedule:: |
| 107 | + |
| 108 | + // src/Scheduler/MyScheduleProvider.php |
| 109 | + namespace App\Scheduler; |
| 110 | + |
| 111 | + #[AsSchedule] |
| 112 | + class SaleTaskProvider implements ScheduleProviderInterface |
| 113 | + { |
| 114 | + public function getSchedule(): Schedule |
| 115 | + { |
| 116 | + // ... |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | +.. tip:: |
| 121 | + |
| 122 | + By default, the schedule name is ``default`` and the transport name follows |
| 123 | + the syntax: ``scheduler_nameofyourschedule`` (e.g. ``scheduler_default``). |
| 124 | + |
| 125 | +.. tip:: |
| 126 | + |
| 127 | + `Memoizing`_ your schedule is a good practice to prevent unnecessary reconstruction |
| 128 | + if the ``getSchedule()`` method is checked by another service. |
| 129 | + |
| 130 | +Scheduling Recurring Messages |
| 131 | +----------------------------- |
| 132 | + |
| 133 | +A ``RecurringMessage`` is a message associated with a trigger, which configures |
| 134 | +the frequency of the message. Symfony provides different types of triggers: |
| 135 | + |
| 136 | +Cron Expression Triggers |
| 137 | +~~~~~~~~~~~~~~~~~~~~~~~~ |
| 138 | + |
| 139 | +It uses the same syntax as the `cron command-line utility`_:: |
| 140 | + |
| 141 | + RecurringMessage::cron('* * * * *', new Message()); |
| 142 | + |
| 143 | +Before using it, you must install the following dependency: |
| 144 | + |
| 145 | +.. code-block:: terminal |
| 146 | +
|
| 147 | + composer require dragonmantank/cron-expression |
| 148 | +
|
| 149 | +.. tip:: |
| 150 | + |
| 151 | + Check out the `crontab.guru website`_ if you need help to construct/understand |
| 152 | + cron expressions. |
| 153 | + |
| 154 | +.. versionadded:: 6.4 |
| 155 | + |
| 156 | + Since version 6.4, it is now possible to add and define a timezone as a 3rd argument |
| 157 | + |
| 158 | +Periodical Triggers |
| 159 | +~~~~~~~~~~~~~~~~~~~ |
| 160 | + |
| 161 | +These triggers allows to configure the frequency using different data types |
| 162 | +(``string``, ``integer``, ``DateInterval``). They also support the `relative formats`_ |
| 163 | +defined by PHP datetime functions:: |
| 164 | + |
| 165 | + RecurringMessage::every('10 seconds', new Message()); |
| 166 | + RecurringMessage::every('3 weeks', new Message()); |
| 167 | + RecurringMessage::every('first Monday of next month', new Message()); |
| 168 | + |
| 169 | + $from = new \DateTimeImmutable('13:47', new \DateTimeZone('Europe/Paris')); |
| 170 | + $until = '2023-06-12'; |
| 171 | + RecurringMessage::every('first Monday of next month', new Message(), $from, $until); |
| 172 | + |
| 173 | +Custom Triggers |
| 174 | +~~~~~~~~~~~~~~~ |
| 175 | + |
| 176 | +Custom triggers allow to configure any frequency dynamically. They are created |
| 177 | +as services that implement :class:`Symfony\\Component\\Scheduler\\TriggerInterface`. |
| 178 | + |
| 179 | +For example, if you want to send customer reports daily except for holiday periods:: |
| 180 | + |
| 181 | + // src/Scheduler/Trigger/NewUserWelcomeEmailHandler.php |
| 182 | + namespace App\Scheduler\Trigger; |
| 183 | + |
| 184 | + class ExcludeHolidaysTrigger implements TriggerInterface |
| 185 | + { |
| 186 | + public function __construct(private TriggerInterface $inner) |
| 187 | + { |
| 188 | + } |
| 189 | + |
| 190 | + public function __toString(): string |
| 191 | + { |
| 192 | + return $this->inner.' (except holidays)'; |
| 193 | + } |
| 194 | + |
| 195 | + public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable |
| 196 | + { |
| 197 | + if (!$nextRun = $this->inner->getNextRunDate($run)) { |
| 198 | + return null; |
| 199 | + } |
| 200 | + |
| 201 | + // loop until you get the next run date that is not a holiday |
| 202 | + while (!$this->isHoliday($nextRun) { |
| 203 | + $nextRun = $this->inner->getNextRunDate($nextRun); |
| 204 | + } |
| 205 | + |
| 206 | + return $nextRun; |
| 207 | + } |
| 208 | + |
| 209 | + private function isHoliday(\DateTimeImmutable $timestamp): bool |
| 210 | + { |
| 211 | + // add some logic to determine if the given $timestamp is a holiday |
| 212 | + // return true if holiday, false otherwise |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | +Then, define your recurring message:: |
| 217 | + |
| 218 | + RecurringMessage::trigger( |
| 219 | + new ExcludeHolidaysTrigger( |
| 220 | + CronExpressionTrigger::fromSpec('@daily'), |
| 221 | + ), |
| 222 | + new SendDailySalesReports('...'), |
| 223 | + ); |
| 224 | + |
| 225 | +Finally, the recurring messages must be attached to a schedule:: |
| 226 | + |
| 227 | + // src/Scheduler/MyScheduleProvider.php |
| 228 | + namespace App\Scheduler; |
| 229 | + |
| 230 | + #[AsSchedule('uptoyou')] |
| 231 | + class SaleTaskProvider implements ScheduleProviderInterface |
| 232 | + { |
| 233 | + public function getSchedule(): Schedule |
| 234 | + { |
| 235 | + return $this->schedule ??= (new Schedule()) |
| 236 | + ->with( |
| 237 | + RecurringMessage::trigger( |
| 238 | + new ExcludeHolidaysTrigger( |
| 239 | + CronExpressionTrigger::fromSpec('@daily'), |
| 240 | + ), |
| 241 | + new SendDailySalesReports() |
| 242 | + ), |
| 243 | + RecurringMessage::cron('3 8 * * 1', new CleanUpOldSalesReport()) |
| 244 | + ); |
| 245 | + } |
| 246 | + } |
| 247 | + |
| 248 | +Consuming Messages (Running the Worker) |
| 249 | +--------------------------------------- |
| 250 | + |
| 251 | +After defining and attaching your recurring messages to a schedule, you'll need |
| 252 | +a mechanism to generate and consume the messages according to their defined frequencies. |
| 253 | +To do that, the Scheduler component uses the ``messenger:consume`` command from |
| 254 | +the Messenger component: |
| 255 | + |
| 256 | +.. code-block:: terminal |
| 257 | +
|
| 258 | + $ php bin/console messenger:consume scheduler_nameofyourschedule |
| 259 | +
|
| 260 | + # use -vv if you need details about what's happening |
| 261 | + $ php bin/console messenger:consume scheduler_nameofyourschedule -vv |
| 262 | +
|
| 263 | +.. image:: /_images/components/scheduler/generate_consume.png |
| 264 | + :alt: Symfony Scheduler - generate and consume |
| 265 | + |
| 266 | +.. versionadded:: 6.4 |
| 267 | + |
| 268 | + Since version 6.4, you can define your messages via a ``callback`` via the |
| 269 | + :class:`Symfony\\Component\\Scheduler\\Trigger\\CallbackMessageProvider`. |
| 270 | + |
| 271 | +Debugging the Schedule |
| 272 | +---------------------- |
| 273 | + |
| 274 | +The ``debug:scheduler`` command provides a list of schedules along with their |
| 275 | +recurring messages. You can narrow down the list to a specific schedule: |
| 276 | + |
| 277 | +.. code-block:: terminal |
| 278 | +
|
| 279 | + $ php bin/console debug:scheduler |
| 280 | +
|
| 281 | + Scheduler |
| 282 | + ========= |
| 283 | +
|
| 284 | + default |
| 285 | + ------- |
| 286 | +
|
| 287 | + ------------------- ------------------------- ---------------------- |
| 288 | + Trigger Provider Next Run |
| 289 | + ------------------- ------------------------- ---------------------- |
| 290 | + every 2 days App\Messenger\Foo(0:17..) Sun, 03 Dec 2023 ... |
| 291 | + 15 4 */3 * * App\Messenger\Foo(0:17..) Mon, 18 Dec 2023 ... |
| 292 | + -------------------- -------------------------- --------------------- |
| 293 | +
|
| 294 | +.. versionadded:: 6.4 |
| 295 | + |
| 296 | + Since version 6.4, you can even specify a date to determine the next run date |
| 297 | + using the ``--date`` option. Additionally, you have the option to display |
| 298 | + terminated recurring messages using the ``--all`` option. |
| 299 | + |
| 300 | +Efficient management with Symfony Scheduler |
| 301 | +------------------------------------------- |
| 302 | + |
| 303 | +If a worker becomes idle, the recurring messages won't be generated (because they |
| 304 | +are created on-the-fly by the scheduler transport). |
| 305 | + |
| 306 | +That's why the scheduler allows to remember the last execution date of a message |
| 307 | +via the ``stateful`` option (and the :doc:`Cache component </components/cache>`). |
| 308 | +This way, when it wakes up again, it looks at all the dates and can catch up on |
| 309 | +what it missed:: |
| 310 | + |
| 311 | + // src/Scheduler/MyScheduleProvider.php |
| 312 | + namespace App\Scheduler; |
| 313 | + |
| 314 | + #[AsSchedule('uptoyou')] |
| 315 | + class SaleTaskProvider implements ScheduleProviderInterface |
| 316 | + { |
| 317 | + public function getSchedule(): Schedule |
| 318 | + { |
| 319 | + $this->removeOldReports = RecurringMessage::cron('3 8 * * 1', new CleanUpOldSalesReport()); |
| 320 | + |
| 321 | + return $this->schedule ??= (new Schedule()) |
| 322 | + ->with( |
| 323 | + // ... |
| 324 | + ); |
| 325 | + ->stateful($this->cache) |
| 326 | + } |
| 327 | + } |
| 328 | + |
| 329 | +To scale your schedules more effectively, you can use multiple workers. In such |
| 330 | +cases, a good practice is to add a :doc:`lock </components/lock>` to prevent the |
| 331 | +same task more than once:: |
| 332 | + |
| 333 | + // src/Scheduler/MyScheduleProvider.php |
| 334 | + namespace App\Scheduler; |
| 335 | + |
| 336 | + #[AsSchedule('uptoyou')] |
| 337 | + class SaleTaskProvider implements ScheduleProviderInterface |
| 338 | + { |
| 339 | + public function getSchedule(): Schedule |
| 340 | + { |
| 341 | + $this->removeOldReports = RecurringMessage::cron('3 8 * * 1', new CleanUpOldSalesReport()); |
| 342 | + |
| 343 | + return $this->schedule ??= (new Schedule()) |
| 344 | + ->with( |
| 345 | + // ... |
| 346 | + ); |
| 347 | + ->lock($this->lockFactory->createLock('my-lock') |
| 348 | + } |
| 349 | + } |
| 350 | + |
| 351 | +.. tip:: |
| 352 | + |
| 353 | + The processing time of a message matters. If it takes a long time, all subsequent |
| 354 | + message processing may be delayed. So, it's a good practice to anticipate this |
| 355 | + and plan for frequencies greater than the processing time of a message. |
| 356 | + |
| 357 | +Additionally, for better scaling of your schedules, you have the option to wrap |
| 358 | +your message in a :class:`Symfony\\Component\\Messenger\\Message\\RedispatchMessage`. |
| 359 | +This allows you to specify a transport on which your message will be redispatched |
| 360 | +before being further redispatched to its corresponding handler:: |
| 361 | + |
| 362 | + // src/Scheduler/MyScheduleProvider.php |
| 363 | + namespace App\Scheduler; |
| 364 | + |
| 365 | + #[AsSchedule('uptoyou')] |
| 366 | + class SaleTaskProvider implements ScheduleProviderInterface |
| 367 | + { |
| 368 | + public function getSchedule(): Schedule |
| 369 | + { |
| 370 | + return $this->schedule ??= (new Schedule()) |
| 371 | + ->with(RecurringMessage::every('5 seconds'), new RedispatchMessage(new Message(), 'async')) |
| 372 | + ); |
| 373 | + } |
| 374 | + } |
| 375 | + |
| 376 | +.. _`Memoizing`: https://en.wikipedia.org/wiki/Memoization |
| 377 | +.. _`cron command-line utility`: https://en.wikipedia.org/wiki/Cron |
| 378 | +.. _`crontab.guru website`: https://crontab.guru/ |
| 379 | +.. _`relative formats`: https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative |
0 commit comments