├── .codecov.yml ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── doc ├── cli-commands.md ├── define-schedule.md ├── define-tasks.md ├── extending.md ├── images │ ├── schedule-list-with-issues.png │ ├── schedule-list.png │ ├── schedule-run-error.png │ └── schedule-run.png └── run-schedule.md ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml.dist └── src ├── Attribute └── AsScheduledTask.php ├── Command ├── ScheduleListCommand.php └── ScheduleRunCommand.php ├── DependencyInjection ├── Compiler │ ├── ScheduleBuilderKernelPass.php │ └── ScheduledServiceBuilderPass.php ├── Configuration.php └── ZenstruckScheduleExtension.php ├── Event ├── AfterScheduleEvent.php ├── AfterTaskEvent.php ├── BeforeScheduleEvent.php ├── BeforeTaskEvent.php ├── BuildScheduleEvent.php ├── ScheduleEvent.php └── TaskEvent.php ├── EventListener ├── ScheduleBuilderSubscriber.php ├── ScheduleConsoleOutputSubscriber.php ├── ScheduleExtensionSubscriber.php ├── ScheduleLoggerSubscriber.php ├── ScheduleTimezoneSubscriber.php ├── SelfSchedulingCommandSubscriber.php └── TaskConfigurationSubscriber.php ├── Resources └── config │ ├── http.xml │ ├── mailer.xml │ ├── messenger.xml │ ├── notifier.xml │ ├── process.xml │ ├── services.xml │ ├── single_server.xml │ ├── timezone.xml │ └── without_overlapping.xml ├── Schedule.php ├── Schedule ├── Builder │ └── ScheduledServiceBuilder.php ├── CronExpression.php ├── Exception │ ├── MissingDependency.php │ ├── SkipSchedule.php │ └── SkipTask.php ├── Extension │ ├── BetweenTimeExtension.php │ ├── CallbackExtension.php │ ├── EmailExtension.php │ ├── EnvironmentExtension.php │ ├── ExtensionHandler.php │ ├── ExtensionHandlerRegistry.php │ ├── Handler │ │ ├── BetweenTimeHandler.php │ │ ├── CallbackHandler.php │ │ ├── EmailHandler.php │ │ ├── EnvironmentHandler.php │ │ ├── NotifierHandler.php │ │ ├── PingHandler.php │ │ ├── SingleServerHandler.php │ │ └── WithoutOverlappingHandler.php │ ├── LockingExtension.php │ ├── NotifierExtension.php │ ├── PingExtension.php │ ├── SingleServerExtension.php │ └── WithoutOverlappingExtension.php ├── HasExtensions.php ├── HasMissingDependencyMessage.php ├── RunContext.php ├── ScheduleBuilder.php ├── ScheduleRunContext.php ├── ScheduleRunner.php ├── SelfSchedulingCommand.php ├── Task.php ├── Task │ ├── CallbackTask.php │ ├── CommandTask.php │ ├── CompoundTask.php │ ├── MessageTask.php │ ├── PingTask.php │ ├── ProcessTask.php │ ├── Result.php │ ├── Runner │ │ ├── CallbackTaskRunner.php │ │ ├── CommandTaskRunner.php │ │ ├── MessageTaskRunner.php │ │ ├── PingTaskRunner.php │ │ ├── ProcessTaskRunner.php │ │ └── ShellVerbosityResetter.php │ ├── TaskRunContext.php │ └── TaskRunner.php └── TaskOutput.php └── ZenstruckScheduleBundle.php /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /build/ 5 | /.php-cs-fixer.cache 6 | /.phpunit.result.cache 7 | /var/ 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | 70 | 71 | ## [v1.4.0](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.4.0) 72 | 73 | June 8th, 2022 - [v1.3.0...v1.4.0](https://github.com/zenstruck/schedule-bundle/compare/v1.3.0...v1.4.0) 74 | 75 | * 191e113 [minor] support Symfony 6.1 (#63) by @kbond 76 | 77 | ## [v1.3.0](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.3.0) 78 | 79 | April 14th, 2022 - [v1.2.1...v1.3.0](https://github.com/zenstruck/schedule-bundle/compare/v1.2.1...v1.3.0) 80 | 81 | * 9c224b6 [minor] improve converting callbacks to strings (#62) by @kbond 82 | * 0273756 [feature] add `AsScheduledTask` for self-schedule commands/services (#62) by @kbond 83 | * 613bbec [minor] dep upgrade (#61) by @kbond 84 | * 00b7d8f [minor] remove scrutinizer (#61) by @kbond 85 | * 2f7cd2f [feature] add additional cron hash aliases (#61) by @kbond 86 | * c910a93 [doc] Update define-schedule.md (#56) by @Lenny4 87 | * 0dfcf62 [minor] add static code analysis with phpstan (#55) by @kbond 88 | * e021707 [minor] run php-cs-fixer on lowest supported php version (#54) by @kbond 89 | 90 | ## [v1.2.1](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.2.1) 91 | 92 | November 13th, 2021 - [v1.2.0...v1.2.1](https://github.com/zenstruck/schedule-bundle/compare/v1.2.0...v1.2.1) 93 | 94 | * c04c992 [minor] add php 8.1 support by @kbond 95 | * 33b432b [ci] use reusable actions (#53) by @kbond 96 | * a0d76a8 [minor] add symfony 6 to ci matrix (#52) by @kbond 97 | 98 | ## [v1.2.0](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.2.0) 99 | 100 | September 13th, 2021 - [v1.1.2...v1.2.0](https://github.com/zenstruck/schedule-bundle/compare/v1.1.2...v1.2.0) 101 | 102 | * a65343c [minor] allow Symfony 6 (#51) by @kbond 103 | * 7649b18 [minor][SMALL BC BREAK] schedule:run now has no output for no tasks (#49) by @kbond 104 | * 49ade72 [minor] disable codecov pr annotations (#50) by @kbond 105 | * 219878d [minor] add Symfony 5.3 to CI matrix (#46) by @kbond 106 | * d4d9fe9 [minor] update php-cs-fixer to v3 by @kbond 107 | * 87120a4 [minor] set deprectation fail threshold by @kbond 108 | 109 | ## [v1.1.2](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.1.2) 110 | 111 | April 14th, 2021 - [v1.1.1...v1.1.2](https://github.com/zenstruck/schedule-bundle/compare/v1.1.1...v1.1.2) 112 | 113 | * 07fbffd [minor] capture stdout output in failed process task result (#44) by @kbond 114 | * 70a8160 [minor] lock php-cs-fixer version in ci (bug) by @kbond 115 | * 90bfea4 [bug] in CommandTaskRunner, reset SHELL_VERBOSITY to pre-run state (#42) by @kbond 116 | 117 | ## [v1.1.1](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.1.1) 118 | 119 | February 26th, 2021 - [v1.1.0...v1.1.1](https://github.com/zenstruck/schedule-bundle/compare/v1.1.0...v1.1.1) 120 | 121 | * 8d0500f [minor] adjust deps to make it clear it isn't usable in Symfony 3.4 (#38) by @kbond 122 | * 7d9cb91 [doc] Add missing namespace in the README's example (#35) by @justRau 123 | * ae849c7 [minor] fix cs by @kbond 124 | * 60bee6c [minor] replace removed phpunit method (#34) by @kbond 125 | * e5e2e04 [minor] add codecov badge (#33) by @kbond 126 | * 6fa2eea [minor] switch to codecov for code coverage (#32) by @kbond 127 | * 4b3b027 [minor] further streamline gh actions (#31) by @kbond 128 | * b775669 [minor] Streamline GitHub CI by using ramsey/composer-install (#30) by @kbond 129 | * 40677dd [minor] add Symfony 5.2 to ci matrix by @kbond 130 | 131 | ## [v1.1.0](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.1.0) 132 | 133 | November 15th, 2020 - [v1.0.1...v1.1.0](https://github.com/zenstruck/schedule-bundle/compare/v1.0.1...v1.1.0) 134 | 135 | * ff3dcd2 [minor] support php8 (#25) by @kbond 136 | * 68826f7 [minor] ci adjustments (#29) by @kbond 137 | * a862b00 [minor] ci adjustments (#29) by @kbond 138 | * 59fbaeb [feature][experimental] add MessageTask to schedule messenger messages (#28) by @kbond 139 | * cd6bba4 [minor] cs fix (#27) by @kbond 140 | * 2f788a6 [minor] Schedule::addPing()/CompoundTask::addPing() convenience methods (#27) by @kbond 141 | * 6002b61 [minor] test on Symfony 5.1 (#26) by @kbond 142 | * d54f71f [minor] switch flex branch to main (#26) by @kbond 143 | * 6445da4 [doc] replace Envoyer with "Oh Dear" as example cron monitoring service by @kbond 144 | 145 | ## [v1.0.1](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.0.1) 146 | 147 | September 27th, 2020 - [v1.0.0...v1.0.1](https://github.com/zenstruck/schedule-bundle/compare/v1.0.0...v1.0.1) 148 | 149 | * f77865d [bug] Ensure kernel definition class is not null (#22) by @encreinformatique 150 | * 4646d22 [bug] fix test checking for changed exception message (#23) by @kbond 151 | * f7b3d21 [bug] remove minimum-stability from composer.json (#23) by @kbond 152 | * f260289 [minor] self-update php-cs-fixer in action by @kbond 153 | * 073385e [minor] adjust PingTaskRunnerTest tests (#20) by @kbond 154 | * 7e47d8e [minor] add MockLogger to just check the message (not level) (#20) by @kbond 155 | * 926e98f [doc] document "disable on deployment" strategy (fixes #10) (#17) by @kbond 156 | * bc6bccd [doc] prefix "bin/console" with php (fixes #14) (#16) by @kbond 157 | * 05a9c99 [doc] add additional readme badges by @kbond 158 | * 0c06d89 [minor] switch to `Symfony\Component\Mime\Address::create()` (#11) by @kbond 159 | * 21b252b [minor] adjust CI badge by @kbond 160 | * a7cce5e [minor] fixcs by @kbond 161 | 162 | ## [v1.0.0](https://github.com/zenstruck/schedule-bundle/releases/tag/v1.0.0) 163 | 164 | May 13th, 2020 - _[Initial Release](https://github.com/zenstruck/schedule-bundle/commits/v1.0.0)_ 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kevin Bond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/schedule-bundle", 3 | "description": "Schedule Cron jobs (commands/callbacks/bash scripts) within your Symfony application.", 4 | "homepage": "https://github.com/zenstruck/schedule-bundle", 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "keywords": ["schedule", "cron"], 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.0", 16 | "dragonmantank/cron-expression": "^2.3|^3.0", 17 | "symfony/console": "^5.4|^6.0|^7.0", 18 | "symfony/dependency-injection": "^5.4|^6.0|^7.0", 19 | "symfony/event-dispatcher": "^5.4|^6.0|^7.0", 20 | "symfony/http-kernel": "^5.4|^6.0|^7.0" 21 | }, 22 | "require-dev": { 23 | "lorisleiva/cron-translator": "^0.1.0|^0.3.1|^0.4.0", 24 | "matthiasnoback/symfony-dependency-injection-test": "^4.1|^5.0", 25 | "phpstan/phpstan": "^1.4", 26 | "phpunit/phpunit": "^9.5", 27 | "psr/log": "^1.1", 28 | "symfony/framework-bundle": "^5.4|^6.0|^7.0", 29 | "symfony/http-client": "^5.4|^6.0|^7.0", 30 | "symfony/lock": "^5.4|^6.0|^7.0", 31 | "symfony/mailer": "^5.4|^6.0|^7.0", 32 | "symfony/messenger": "^5.4|^6.0|^7.0", 33 | "symfony/notifier": "^5.4|^6.0|^7.0", 34 | "symfony/phpunit-bridge": "^6.2|^7.0", 35 | "symfony/process": "^5.4|^6.0|^7.0" 36 | }, 37 | "suggest": { 38 | "lorisleiva/cron-translator": "Displays human readable cron expression in schedule:list", 39 | "symfony/http-client": "Allows usage of ping* extensions", 40 | "symfony/lock": "Allows usage of withoutOverlapping and onSingleServer extensions", 41 | "symfony/mailer": "Allows usage of email* extensions", 42 | "symfony/notifier": "Allows usage of notify* extensions", 43 | "symfony/process": "Allows usage of ProcessTask" 44 | }, 45 | "config": { 46 | "preferred-install": "dist", 47 | "sort-packages": true 48 | }, 49 | "autoload": { 50 | "psr-4": { "Zenstruck\\ScheduleBundle\\": "src/" } 51 | }, 52 | "autoload-dev": { 53 | "psr-4": { "Zenstruck\\ScheduleBundle\\Tests\\": "tests/" } 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true 57 | } 58 | -------------------------------------------------------------------------------- /doc/extending.md: -------------------------------------------------------------------------------- 1 | # Extending 2 | 3 | ## Custom Tasks 4 | 5 | You can define your own task types. Tasks consist of a *task* object that extends 6 | [`Task`](../src/Schedule/Task.php) and a *runner* that implements 7 | [`TaskRunner`](../src/Schedule/Task/TaskRunner.php). The runner is responsible 8 | for running the command and returning a [`Result`](../src/Schedule/Task/Result.php). 9 | 10 | The runner must be a service with the `schedule.task_runner` tag (this is *autoconfigurable*). 11 | Runners must implement the `supports()` method which should return true when passed the task 12 | it handles. 13 | 14 | As an example, let's create a Task that sends a *Message* to your *MessageBus* (`symfony/messenger` 15 | required). 16 | 17 | **NOTE**: There is now a [MessageTask in core](define-tasks.md#messagetask). 18 | 19 | First, let's create the task: 20 | 21 | ```php 22 | // src/Schedule/MessageTask.php 23 | 24 | use Zenstruck\ScheduleBundle\Schedule\Task; 25 | 26 | class MessageTask extends Task 27 | { 28 | private $message; 29 | 30 | public function __construct(object $message) 31 | { 32 | $this->message = $message; 33 | 34 | // be sure to call the parent constructor with a default description 35 | parent::__construct(get_class($message)); 36 | } 37 | 38 | public function getMessage(): object 39 | { 40 | return $this->message; 41 | } 42 | } 43 | ``` 44 | 45 | Next, let's create the *runner*: 46 | 47 | ```php 48 | // src/Schedule/MessageTaskRunner.php 49 | 50 | use Symfony\Component\Messenger\MessageBusInterface; 51 | use Zenstruck\ScheduleBundle\Schedule\Task; 52 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 53 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 54 | 55 | class MessageTaskRunner implements TaskRunner 56 | { 57 | private $bus; 58 | 59 | public function __construct(MessageBusInterface $bus) 60 | { 61 | $this->bus = $bus; 62 | } 63 | 64 | /** 65 | * @param MessageTask $task 66 | */ 67 | public function __invoke(Task $task): Result 68 | { 69 | $this->bus->dispatch($task->getMessage()); 70 | 71 | return Result::successful($task); 72 | } 73 | 74 | public function supports(Task $task) : bool 75 | { 76 | return $task instanceof MessageTask; 77 | } 78 | } 79 | ``` 80 | 81 | Finally, use this task in your schedule: 82 | 83 | ```php 84 | use App\Message\DoSomething; 85 | use App\Schedule\MessageTask; 86 | 87 | /* @var $schedule \Zenstruck\ScheduleBundle\Schedule */ 88 | 89 | $schedule->add(new MessageTask(new DoSomething())) 90 | ->daily() 91 | ->at('13:30') 92 | ; 93 | ``` 94 | 95 | ## Custom Extensions 96 | 97 | The primary way of hooking into schedule/task events is with extensions. Extensions 98 | can be added to both tasks and the schedule as a whole. Extensions are plain objects 99 | and require a *handler* that extends 100 | [`ExtensionHandler`](../src/Schedule/Extension/ExtensionHandler.php). 101 | 102 | The handler must be a service with the `schedule.extension_handler` tag (this is 103 | *autoconfigurable*). Extension handlers must implement the `supports()` method which 104 | should return true when passed the extension it handles. 105 | 106 | If your extension is applicable to the schedule, you can auto-add it by registering 107 | it as a service and adding the `schedule.extension` tag (*autoconfiguration* is **not** 108 | available). 109 | 110 | Making your extension stringable by implementing `__toString` shows this value in the 111 | [`schedule:list`](cli-commands.md#schedulelist) command. 112 | 113 | Below are some examples of custom extensions: 114 | 115 | ### Example: Skip Schedule if in maintenance mode 116 | 117 | Say your application has the concept of maintenance mode. You want to prevent the 118 | schedule from running in maintenance mode. 119 | 120 | This example assumes your `Kernel` has an `isInMaintenanceMode()` method. 121 | 122 | The *extension*: 123 | 124 | ```php 125 | // src/Schedule/Extension/NotInMaintenanceMode.php 126 | 127 | class NotInMaintenanceMode 128 | { 129 | public function __toString(): string 130 | { 131 | return 'Do not run in maintenance mode.'; 132 | } 133 | } 134 | ``` 135 | 136 | The *handler* service: 137 | 138 | ```php 139 | // src/Schedule/Extension/NotInMaintenanceModeHandler.php 140 | 141 | use App\Kernel; 142 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 143 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule; 144 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 145 | 146 | class NotInMaintenanceModeHandler extends ExtensionHandler 147 | { 148 | private $kernel; 149 | 150 | public function __construct(Kernel $kernel) 151 | { 152 | $this->kernel = $kernel; 153 | } 154 | 155 | public function supports(object $extension) : bool 156 | { 157 | return $extension instanceof NotInMaintenanceMode; 158 | } 159 | 160 | /** 161 | * @param NotInMaintenanceMode $extension 162 | */ 163 | public function filterSchedule(ScheduleRunContext $context, object $extension): void 164 | { 165 | if ($this->kernel->isInMaintenanceMode()) { 166 | throw new SkipSchedule('Does not run in maintenance mode.'); 167 | } 168 | } 169 | } 170 | ``` 171 | 172 | The easiest way to add this extension to your schedule is to register the *extension* 173 | (`App\Schedule\Extension\NotInMaintenanceMode`) as a service and tag it with 174 | `schedule.extension`. 175 | 176 | Alternatively, you can add it to the schedule in PHP: 177 | 178 | ```php 179 | use App\Schedule\Extension\NotInMaintenanceMode; 180 | 181 | /* @var $schedule \Zenstruck\ScheduleBundle\Schedule */ 182 | 183 | $schedule->addExtension(new NotInMaintenanceMode()); 184 | ``` 185 | 186 | **NOTE:** This is an example to show creating/registering a custom extension. In 187 | a real world application, all that would be needed to accomplish the above 188 | example would be the following in your `Kernel`: 189 | 190 | ```php 191 | // src/Kernel.php 192 | 193 | use Symfony\Component\HttpKernel\Kernel as BaseKernel; 194 | use Zenstruck\ScheduleBundle\Schedule; 195 | use Zenstruck\ScheduleBundle\Schedule\ScheduleBuilder; 196 | 197 | class Kernel extends BaseKernel implements ScheduleBuilder 198 | { 199 | public function isInMaintenanceMode(): bool 200 | { 201 | // return true if in maintenance mode... 202 | } 203 | 204 | public function buildSchedule(Schedule $schedule): void 205 | { 206 | $schedule->skip('Does not run in maintenance mode.', $this->isInMaintenanceMode()); 207 | } 208 | 209 | // ... 210 | } 211 | ``` 212 | 213 | ## Events 214 | 215 | The following Symfony events are available: 216 | 217 | | Event | Description | 218 | | ------------------------------------------------------------- | -------------------------------- | 219 | | [`BeforeScheduleEvent`](../src/Event/BeforeScheduleEvent.php) | Runs before the schedule runs | 220 | | [`AfterScheduleEvent`](../src/Event/AfterScheduleEvent.php) | Runs after the schedule runs | 221 | | [`BeforeTaskEvent`](../src/Event/BeforeTaskEvent.php) | Runs before a due task runs | 222 | | [`AfterTaskEvent`](../src/Event/AfterTaskEvent.php) | Runs after a due task runs | 223 | | [`BuildScheduleEvent`](../src/Event/BuildScheduleEvent.php) | Define/manipulate tasks/schedule | 224 | 225 | ### Example: Add "withoutOverlapping" to all defined tasks 226 | 227 | Let's configure all our tasks to have the [withoutOverlapping](define-tasks.md#prevent-overlap) 228 | extension added. 229 | 230 | The *subscriber* service: 231 | 232 | ```php 233 | // src/EventSubscriber/ScheduleWithoutOverlappingSubscriber.php 234 | 235 | namespace App\EventSubscriber; 236 | 237 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 238 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 239 | 240 | class ScheduleWithoutOverlappingSubscriber implements EventSubscriberInterface 241 | { 242 | public static function getSubscribedEvents(): array 243 | { 244 | return [ 245 | BuildScheduleEvent::class => [ 246 | 'onBuildSchedule', 247 | /* 248 | * The actual building of the schedule happens at priority "0". 249 | * We set to a lower priority to ensure all tasks have been defined. 250 | */ 251 | -100, 252 | ], 253 | ]; 254 | } 255 | 256 | public function onBuildSchedule(BuildScheduleEvent $event): void 257 | { 258 | foreach ($event->getSchedule()->all() as $task) { 259 | $task->withoutOverlapping(); 260 | } 261 | } 262 | } 263 | ``` 264 | 265 | **NOTE:** If *autoconfiguration* is not enabled, add the `kernel.event_subscriber` 266 | tag to the service. 267 | -------------------------------------------------------------------------------- /doc/images/schedule-list-with-issues.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenstruck/schedule-bundle/c82f4d3e96566260796975a90a8d8075fca681c9/doc/images/schedule-list-with-issues.png -------------------------------------------------------------------------------- /doc/images/schedule-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenstruck/schedule-bundle/c82f4d3e96566260796975a90a8d8075fca681c9/doc/images/schedule-list.png -------------------------------------------------------------------------------- /doc/images/schedule-run-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenstruck/schedule-bundle/c82f4d3e96566260796975a90a8d8075fca681c9/doc/images/schedule-run-error.png -------------------------------------------------------------------------------- /doc/images/schedule-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenstruck/schedule-bundle/c82f4d3e96566260796975a90a8d8075fca681c9/doc/images/schedule-run.png -------------------------------------------------------------------------------- /doc/run-schedule.md: -------------------------------------------------------------------------------- 1 | # Running the Schedule 2 | 3 | To run tasks when they are due, the schedule should be *run* **every minute** 4 | on your production server(s) indefinitely. 5 | 6 | *The schedule doesn't have to be run every minute but if it isn't, jobs 7 | scheduled in between the frequency you choose will never run. If you are 8 | careful when choosing task frequencies, this might not be an issue. If not 9 | running every minute, it must be run at predictable times like every hour, 10 | exactly on the hour (ie 08:00, not 08:01).* 11 | 12 | If multiple tasks are due at the same time, they are run synchronously in the 13 | order they were defined. If you define tasks in multiple places 14 | ([Configuration](define-schedule.md#bundle-configuration), 15 | [Builder Service](define-schedule.md#schedulebuilder-service), 16 | [Kernel](define-schedule.md#your-kernel), 17 | [Self-Scheduling Commands](define-schedule.md#self-scheduling-commands)) only 18 | the order of tasks defined in each place is guaranteed. 19 | 20 | Shipped with this bundle is a [`schedule:run`](cli-commands.md#schedulerun) 21 | console command. Running this command determines the due tasks (if any) for 22 | the current time and runs them. 23 | 24 | ## Cron Job on Server 25 | 26 | The most common way to run the schedule is a Cron job that runs the 27 | [`schedule:run`](cli-commands.md#schedulerun) every minute. The following 28 | should be added to your production server's 29 | [crontab](http://man7.org/linux/man-pages/man5/crontab.5.html): 30 | 31 | ``` 32 | * * * * * cd /path-to-your-project && php bin/console schedule:run >> /dev/null 2>&1 33 | ``` 34 | 35 | ## Symfony Cloud 36 | 37 | The [Symfony Cloud](https://symfony.com/cloud/) platform has the ability to 38 | configure Cron jobs. Add the following configuration to run your schedule every 39 | minute: 40 | 41 | ```yaml 42 | # .symfony.cloud.yaml 43 | 44 | cron: 45 | spec: * * * * * 46 | cmd: bin/console schedule:run 47 | 48 | # ... 49 | ``` 50 | 51 | *[View the full Cron Jobs Documentation](https://symfony.com/doc/master/cloud/cookbooks/crons.html)* 52 | 53 | ## Alternatives 54 | 55 | If you don't have the ability to run Cron jobs on your server there may be 56 | other ways to run the schedule. 57 | 58 | The schedule can alternatively be run in your code. Behind the scenes, the 59 | `schedule:run` command invokes the [`ScheduleRunner`](../src/Schedule/ScheduleRunner.php) 60 | service which does all the work. The return value of `ScheduleRunner::__invoke()` is a 61 | [`ScheduleRunContext`](../src/Schedule/ScheduleRunContext.php) object. 62 | 63 | The following is a list of alternative scheduling options (*please add your own solutions 64 | via PR*): 65 | 66 | ### Webhook 67 | 68 | Perhaps you have a service that can *ping* an endpoint (`/run-schedule`) defined in 69 | your app every minute (AWS Lamda can be configured to do this). This endpoint 70 | can run the schedule: 71 | 72 | ```php 73 | // src/Controller/RunScheduleController.php 74 | 75 | use Symfony\Component\HttpFoundation\Response; 76 | use Symfony\Component\Routing\Annotation\Route; 77 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunner; 78 | 79 | /** 80 | * @Route("/run-schedule") 81 | */ 82 | class RunScheduleController 83 | { 84 | public function __invoke(ScheduleRunner $scheduleRunner): Response 85 | { 86 | $result = $scheduleRunner(); 87 | 88 | return new Response('', $result->isSuccessful() ? 200 : 500); 89 | } 90 | } 91 | ``` 92 | 93 | ## Force Run 94 | 95 | The [`schedule:run`](cli-commands.md#schedulerun) command optionally takes 96 | a list of [Task ID's](define-tasks.md#task-id). This will force run these 97 | tasks (and no others) even if they are not currently due. This can be useful 98 | for re-running tasks that [fail](#dealing-with-failures). The task ID is show 99 | in emails/logs and listed in [`schedule:list --detail`](cli-commands.md#schedulelist). 100 | 101 | ## Dealing with Failures 102 | 103 | It is probable that at some point, a scheduled task will fail. Because the 104 | schedule runs in the background, administrators need to be made aware failures. 105 | 106 | *If multiple tasks are due at the same time, one failure will not prevent the 107 | other due tasks from running.* 108 | 109 | A failing task may or may not be the result of an exception. For instance, a 110 | [CommandTask](define-tasks.md#commandtask) that ran with an exit code of `1` 111 | is considered failed but may not be from the result of an exception (the 112 | command could have returned `1`). 113 | 114 | The following are different methods of being alerted to failures: 115 | 116 | ### Logs 117 | 118 | All schedule/task events are logged (if using monolog, on the `schedule` channel). 119 | Errors and Exceptions are logged with the `ERROR` and `CRITICAL` levels respectively. 120 | The log's context contains useful information like duration, memory usage, task output 121 | and the exception (if failed). 122 | 123 | The following is an example log file (some context excluded): 124 | 125 | ``` 126 | [2020-01-20 13:17:13] schedule.INFO: Running 4 due tasks. {"total":22,"due":4} 127 | [2020-01-20 13:17:13] schedule.INFO: Running "CommandTask": my:command 128 | [2020-01-20 13:17:13] schedule.INFO: Successfully ran "CommandTask": my:command 129 | [2020-01-20 13:17:13] schedule.INFO: Running "ProcessTask": fdere -dsdfsd 130 | [2020-01-20 13:17:13] schedule.ERROR: Failure when running "ProcessTask": fdere -dsdfsd 131 | [2020-01-20 13:17:13] schedule.INFO: Running "CallbackTask": some callback 132 | [2020-01-20 13:17:13] schedule.CRITICAL: Exception thrown when running "CallbackTask": some callback 133 | [2020-01-20 13:24:11] schedule.INFO: Running "CommandTask": another:command 134 | [2020-01-20 13:24:11] schedule.INFO: Skipped "CommandTask": another:command {"reason":"the reason for skip..."} 135 | [2020-01-20 13:24:11] schedule.ERROR: 3/4 tasks ran {"total":4,"successful":1,"skipped":1,"failures":2,"duration":"< 1 sec","memory":"10.0 MiB"} 136 | ``` 137 | 138 | Services like [Papertrail](https://papertrailapp.com) can be [configured to alert 139 | administrators](https://help.papertrailapp.com/kb/how-it-works/alerts/) when a filter 140 | (ie `schedule.ERROR OR schedule.CRITICAL`) is matched. 141 | 142 | ### Email on Schedule Failure 143 | 144 | Administrators can be notified via email when tasks fail. This can be configured 145 | [per task](define-tasks.md#email-output) or 146 | [for the entire schedule](define-schedule.md#email-on-failure). 147 | 148 | ### `schedule:run` exit code/output 149 | 150 | The [`schedule:run`](cli-commands.md#schedulerun) command will have an exit code of 151 | `1` if one or more tasks fail. The command's output also contains detailed output. 152 | The crontab entry [shown above](#cron-job-on-server) ignores the exit code and 153 | dumps the command's output to `/dev/null` but this could be changed to log the 154 | output and/or alert an administrator. 155 | 156 | ### Alert with Symfony Cloud 157 | 158 | When defining the `schedule:run` cron job with [Symfony Cloud](#symfony-cloud), you can 159 | [prefix the command with `croncape` to be alerted via email](https://symfony.com/doc/master/cloud/cookbooks/crons.html#command-to-run) 160 | when something goes wrong: 161 | 162 | ```yaml 163 | # .symfony.cloud.yaml 164 | 165 | cron: 166 | spec: * * * * * 167 | cmd: croncape bin/console schedule:run 168 | 169 | # ... 170 | ``` 171 | 172 | ### Custom Schedule Extension 173 | 174 | You can create a [custom schedule extension](extending.md#custom-extensions) with a 175 | `onScheduleFailure` hook to add your own failure logic. 176 | 177 | ### AfterSchedule Event 178 | 179 | You can [create an event subscriber](extending.md#events) that listens to the 180 | [`AfterScheduleEvent`](../src/Event/AfterScheduleEvent.php), check if the schedule 181 | failed, and run your own failure logic. 182 | 183 | ## Ensuring the Schedule is Running 184 | 185 | It is important to be assured your schedule is always running. The best method 186 | is to use a Cron health monitoring tool like [Oh Dear](https://ohdear.app/), 187 | [Cronitor](https://cronitor.io/) or [Healthchecks](https://healthchecks.io/). 188 | These services give you a unique URL endpoint to *ping*. If the endpoint doesn't 189 | receive a ping after a specified amount of time, an administrator is notified. 190 | 191 | You can [configure your schedule to ping](define-schedule.md#ping-webhook) after 192 | running (assumes your endpoint is `https://my-health-monitor.com/endpoint`): 193 | 194 | ```yaml 195 | # config/packages/zenstruck_schedule.yaml 196 | 197 | zenstruck_schedule: 198 | schedule_extensions: 199 | ping_after: https://my-health-monitor.com/endpoint 200 | ``` 201 | 202 | This will ping the endpoint after the schedule runs (every minute). If this is too 203 | frequent, you can configure a *[PingTask](define-tasks.md#pingtask)* to ping the 204 | endpoint at a different frequency: 205 | 206 | ```yaml 207 | zenstruck_schedule: 208 | tasks: 209 | - task: ping:https://my-health-monitor/endpoint 210 | description: Health check 211 | frequency: '@hourly' 212 | ``` 213 | 214 | In this case, a notification from one of these services means your schedule isn't 215 | running. 216 | 217 | ## Disable Schedule during Deploy 218 | 219 | Depending on your deployment strategy, it might be desirable to ensure the schedule does not 220 | run when deploying. One way to do this is to have your deployment script create a `.deploying` 221 | file somewhere on the webserver at the start of a deploy, then remove when complete. You can 222 | check for this file when building your schedule and skip if it exists: 223 | 224 | ```php 225 | // src/Kernel.php 226 | 227 | use Symfony\Component\HttpKernel\Kernel as BaseKernel; 228 | use Zenstruck\ScheduleBundle\Schedule; 229 | use Zenstruck\ScheduleBundle\Schedule\ScheduleBuilder; 230 | 231 | class Kernel extends BaseKernel implements ScheduleBuilder 232 | { 233 | public function isDeploying(): bool 234 | { 235 | return file_exists('/path/to/file/.deploying'); 236 | } 237 | 238 | public function buildSchedule(Schedule $schedule): void 239 | { 240 | $schedule->skip('App is deploying...', $this->isDeploying()); 241 | } 242 | 243 | // ... 244 | } 245 | ``` 246 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 8 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests/ 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/Attribute/AsScheduledTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Attribute; 13 | 14 | /** 15 | * Schedule an invokable service or console command. 16 | * 17 | * @author Kevin Bond 18 | */ 19 | #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] 20 | final class AsScheduledTask 21 | { 22 | public function __construct( 23 | /** 24 | * Cron expression or alias. 25 | */ 26 | public string $frequency, 27 | 28 | /** 29 | * Task description. 30 | */ 31 | public ?string $description = null, 32 | 33 | /** 34 | * The invokable service method to be called when run (must 35 | * have no required parameters). 36 | * 37 | * Only applicable to "invokable services". 38 | */ 39 | public string $method = '__invoke', 40 | 41 | /** 42 | * The command arguments (ie "-v --no-interaction"). 43 | * 44 | * Only applicable to "console commands". 45 | */ 46 | public ?string $arguments = null, 47 | ) { 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Command/ScheduleRunCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Command; 13 | 14 | use Symfony\Component\Console\Attribute\AsCommand; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Input\InputArgument; 17 | use Symfony\Component\Console\Input\InputInterface; 18 | use Symfony\Component\Console\Output\OutputInterface; 19 | use Symfony\Component\Console\Style\SymfonyStyle; 20 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 21 | use Zenstruck\ScheduleBundle\EventListener\ScheduleConsoleOutputSubscriber; 22 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunner; 23 | 24 | /** 25 | * @author Kevin Bond 26 | */ 27 | #[AsCommand( 28 | name: 'schedule:run', 29 | description: 'Runs scheduled tasks that are due', 30 | )] 31 | final class ScheduleRunCommand extends Command 32 | { 33 | /** @var ScheduleRunner */ 34 | private $scheduleRunner; 35 | 36 | /** @var EventDispatcherInterface */ 37 | private $dispatcher; 38 | 39 | public function __construct(ScheduleRunner $scheduleRunner, EventDispatcherInterface $dispatcher) 40 | { 41 | $this->scheduleRunner = $scheduleRunner; 42 | $this->dispatcher = $dispatcher; 43 | 44 | parent::__construct(); 45 | } 46 | 47 | protected function configure(): void 48 | { 49 | $this 50 | ->addArgument('id', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, '(optional) Task ID\'s to "force" run') 51 | ->setHelp(<<> /dev/null 2>&1 62 | EOF 63 | ) 64 | ; 65 | } 66 | 67 | protected function execute(InputInterface $input, OutputInterface $output): int 68 | { 69 | $io = new SymfonyStyle($input, $output); 70 | 71 | $this->dispatcher->addSubscriber(new ScheduleConsoleOutputSubscriber($io)); 72 | 73 | return ($this->scheduleRunner)(...$input->getArgument('id'))->isSuccessful() ? 0 : 1; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ScheduleBuilderKernelPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\DependencyInjection\Compiler; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Zenstruck\ScheduleBundle\Schedule\ScheduleBuilder; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class ScheduleBuilderKernelPass implements CompilerPassInterface 22 | { 23 | public function process(ContainerBuilder $container): void 24 | { 25 | if (!$container->hasDefinition('kernel')) { 26 | return; 27 | } 28 | 29 | $kernel = $container->getDefinition('kernel'); 30 | 31 | if (null === $class = $kernel->getClass()) { 32 | return; 33 | } 34 | 35 | /** @var class-string $class */ 36 | if ((new \ReflectionClass($class))->implementsInterface(ScheduleBuilder::class)) { 37 | $kernel->addTag('schedule.builder'); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/DependencyInjection/Compiler/ScheduledServiceBuilderPass.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\DependencyInjection\Compiler; 13 | 14 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Reference; 17 | use Zenstruck\ScheduleBundle\Schedule\Builder\ScheduledServiceBuilder; 18 | 19 | /** 20 | * @author Kevin Bond 21 | * 22 | * @internal 23 | */ 24 | final class ScheduledServiceBuilderPass implements CompilerPassInterface 25 | { 26 | public function process(ContainerBuilder $container): void 27 | { 28 | $builder = $container->getDefinition('zenstruck_schedule.service_builder'); 29 | 30 | foreach ($container->findTaggedServiceIds('schedule.service') as $id => $tags) { 31 | foreach ($tags as $attributes) { 32 | ScheduledServiceBuilder::validate($container->getDefinition($id)->getClass(), $attributes); 33 | 34 | $builder->addMethodCall('add', [new Reference($id), $attributes]); 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/DependencyInjection/ZenstruckScheduleExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\DependencyInjection; 13 | 14 | use Symfony\Component\Config\FileLocator; 15 | use Symfony\Component\DependencyInjection\ContainerBuilder; 16 | use Symfony\Component\DependencyInjection\Definition; 17 | use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; 18 | use Symfony\Component\DependencyInjection\Reference; 19 | use Symfony\Component\HttpClient\HttpClient; 20 | use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension; 21 | use Symfony\Component\Lock\LockFactory; 22 | use Symfony\Component\Process\Process; 23 | use Zenstruck\ScheduleBundle\Attribute\AsScheduledTask; 24 | use Zenstruck\ScheduleBundle\EventListener\ScheduleTimezoneSubscriber; 25 | use Zenstruck\ScheduleBundle\EventListener\TaskConfigurationSubscriber; 26 | use Zenstruck\ScheduleBundle\Schedule\Extension\EmailExtension; 27 | use Zenstruck\ScheduleBundle\Schedule\Extension\EnvironmentExtension; 28 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 29 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\EmailHandler; 30 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\NotifierHandler; 31 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\PingHandler; 32 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\SingleServerHandler; 33 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\WithoutOverlappingHandler; 34 | use Zenstruck\ScheduleBundle\Schedule\Extension\NotifierExtension; 35 | use Zenstruck\ScheduleBundle\Schedule\Extension\PingExtension; 36 | use Zenstruck\ScheduleBundle\Schedule\Extension\SingleServerExtension; 37 | use Zenstruck\ScheduleBundle\Schedule\ScheduleBuilder; 38 | use Zenstruck\ScheduleBundle\Schedule\SelfSchedulingCommand; 39 | use Zenstruck\ScheduleBundle\Schedule\Task\Runner\MessageTaskRunner; 40 | use Zenstruck\ScheduleBundle\Schedule\Task\Runner\PingTaskRunner; 41 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 42 | 43 | /** 44 | * @author Kevin Bond 45 | */ 46 | final class ZenstruckScheduleExtension extends ConfigurableExtension 47 | { 48 | protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void 49 | { 50 | $container->registerForAutoconfiguration(ScheduleBuilder::class) 51 | ->addTag('schedule.builder') 52 | ; 53 | 54 | $container->registerForAutoconfiguration(TaskRunner::class) 55 | ->addTag('schedule.task_runner') 56 | ; 57 | 58 | $container->registerForAutoconfiguration(SelfSchedulingCommand::class) 59 | ->addTag('schedule.self_scheduling_command') 60 | ; 61 | 62 | $container->registerForAutoconfiguration(ExtensionHandler::class) 63 | ->addTag('schedule.extension_handler') 64 | ; 65 | 66 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 67 | $loader->load('services.xml'); 68 | 69 | if (\class_exists(Process::class)) { 70 | $loader->load('process.xml'); 71 | } 72 | 73 | $container 74 | ->getDefinition(TaskConfigurationSubscriber::class) 75 | ->setArgument(0, $mergedConfig['tasks']) 76 | ; 77 | 78 | if ($mergedConfig['without_overlapping_lock_factory'] || \class_exists(LockFactory::class)) { 79 | $loader->load('without_overlapping.xml'); 80 | } 81 | 82 | if ($mergedConfig['without_overlapping_lock_factory']) { 83 | $container 84 | ->getDefinition(WithoutOverlappingHandler::class) 85 | ->setArgument(0, new Reference($mergedConfig['without_overlapping_lock_factory'])) 86 | ; 87 | } 88 | 89 | if ($mergedConfig['single_server_lock_factory']) { 90 | $loader->load('single_server.xml'); 91 | 92 | $container 93 | ->getDefinition(SingleServerHandler::class) 94 | ->setArgument(0, new Reference($mergedConfig['single_server_lock_factory'])) 95 | ; 96 | } 97 | 98 | if ($mergedConfig['http_client'] || \class_exists(HttpClient::class)) { 99 | $loader->load('http.xml'); 100 | } 101 | 102 | if ($mergedConfig['http_client']) { 103 | $container 104 | ->getDefinition(PingHandler::class) 105 | ->setArgument(0, new Reference($mergedConfig['http_client'])) 106 | ; 107 | 108 | $container 109 | ->getDefinition(PingTaskRunner::class) 110 | ->setArgument(0, new Reference($mergedConfig['http_client'])) 111 | ; 112 | } 113 | 114 | if ($mergedConfig['timezone']) { 115 | $loader->load('timezone.xml'); 116 | $container 117 | ->getDefinition(ScheduleTimezoneSubscriber::class) 118 | ->setArgument(0, $mergedConfig['timezone']) 119 | ; 120 | } 121 | 122 | if ($mergedConfig['messenger']['enabled']) { 123 | $loader->load('messenger.xml'); 124 | 125 | $container 126 | ->getDefinition(MessageTaskRunner::class) 127 | ->setArgument(0, new Reference($mergedConfig['messenger']['message_bus'])) 128 | ; 129 | } 130 | 131 | if ($mergedConfig['mailer']['enabled']) { 132 | $loader->load('mailer.xml'); 133 | 134 | $container 135 | ->getDefinition(EmailHandler::class) 136 | ->setArguments([ 137 | new Reference($mergedConfig['mailer']['service']), 138 | $mergedConfig['mailer']['default_from'], 139 | $mergedConfig['mailer']['default_to'], 140 | $mergedConfig['mailer']['subject_prefix'], 141 | ]) 142 | ; 143 | } 144 | 145 | if ($mergedConfig['notifier']['enabled']) { 146 | $loader->load('notifier.xml'); 147 | 148 | $container 149 | ->getDefinition(NotifierHandler::class) 150 | ->setArguments([ 151 | new Reference($mergedConfig['notifier']['service']), 152 | $mergedConfig['notifier']['default_channel'], 153 | $mergedConfig['notifier']['default_email'], 154 | $mergedConfig['notifier']['default_phone'], 155 | $mergedConfig['notifier']['subject_prefix'], 156 | ]) 157 | ; 158 | } 159 | 160 | $this->registerScheduleExtensions($mergedConfig, $container); 161 | 162 | if (\method_exists($container, 'registerAttributeForAutoconfiguration')) { 163 | $container->registerAttributeForAutoconfiguration( 164 | AsScheduledTask::class, 165 | static function(Definition $definition, AsScheduledTask $attribute) { 166 | $definition->addTag('schedule.service', \get_object_vars($attribute)); 167 | }, 168 | ); 169 | } 170 | } 171 | 172 | private function registerScheduleExtensions(array $config, ContainerBuilder $container): void 173 | { 174 | /** @var Definition[] $definitions */ 175 | $definitions = []; 176 | $idPrefix = 'zenstruck_schedule.extension.'; 177 | 178 | if (!empty($config['schedule_extensions']['environments'])) { 179 | $definitions[$idPrefix.'environments'] = new Definition( 180 | EnvironmentExtension::class, 181 | [$config['schedule_extensions']['environments']], 182 | ); 183 | } 184 | 185 | if ($config['schedule_extensions']['on_single_server']['enabled']) { 186 | $definitions[$idPrefix.'on_single_server'] = new Definition( 187 | SingleServerExtension::class, 188 | [$config['schedule_extensions']['on_single_server']['ttl']], 189 | ); 190 | } 191 | 192 | if ($config['schedule_extensions']['email_on_failure']['enabled']) { 193 | $definition = new Definition(EmailExtension::class); 194 | $definition->setFactory([EmailExtension::class, 'scheduleFailure']); 195 | $definition->setArguments([ 196 | $config['schedule_extensions']['email_on_failure']['to'], 197 | $config['schedule_extensions']['email_on_failure']['subject'], 198 | ]); 199 | 200 | $definitions[$idPrefix.'email_on_failure'] = $definition; 201 | } 202 | 203 | if ($config['schedule_extensions']['notify_on_failure']['enabled']) { 204 | $definition = new Definition(NotifierExtension::class); 205 | $definition->setFactory([NotifierExtension::class, 'scheduleFailure']); 206 | $definition->setArguments([ 207 | $config['schedule_extensions']['notify_on_failure']['channel'], 208 | $config['schedule_extensions']['notify_on_failure']['email'], 209 | $config['schedule_extensions']['notify_on_failure']['phone'], 210 | $config['schedule_extensions']['notify_on_failure']['subject'], 211 | ]); 212 | 213 | $definitions[$idPrefix.'notify_on_failure'] = $definition; 214 | } 215 | 216 | $pingMap = [ 217 | 'ping_before' => 'scheduleBefore', 218 | 'ping_after' => 'scheduleAfter', 219 | 'ping_on_success' => 'scheduleSuccess', 220 | 'ping_on_failure' => 'scheduleFailure', 221 | ]; 222 | 223 | foreach ($pingMap as $key => $method) { 224 | if ($config['schedule_extensions'][$key]['enabled']) { 225 | $definition = new Definition(PingExtension::class); 226 | $definition->setFactory([PingExtension::class, $method]); 227 | $definition->setArguments([ 228 | $config['schedule_extensions'][$key]['url'], 229 | $config['schedule_extensions'][$key]['method'], 230 | $config['schedule_extensions'][$key]['options'], 231 | ]); 232 | 233 | $definitions[$idPrefix.$key] = $definition; 234 | } 235 | } 236 | 237 | foreach ($definitions as $definition) { 238 | $definition->addTag('schedule.extension'); 239 | } 240 | 241 | $container->addDefinitions($definitions); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/Event/AfterScheduleEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class AfterScheduleEvent extends ScheduleEvent 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Event/AfterTaskEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class AfterTaskEvent extends TaskEvent 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Event/BeforeScheduleEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class BeforeScheduleEvent extends ScheduleEvent 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Event/BeforeTaskEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class BeforeTaskEvent extends TaskEvent 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /src/Event/BuildScheduleEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | use Symfony\Contracts\EventDispatcher\Event; 15 | use Zenstruck\ScheduleBundle\Schedule; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class BuildScheduleEvent extends Event 21 | { 22 | /** @var Schedule */ 23 | private $schedule; 24 | 25 | public function __construct(Schedule $schedule) 26 | { 27 | $this->schedule = $schedule; 28 | } 29 | 30 | public function getSchedule(): Schedule 31 | { 32 | return $this->schedule; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Event/ScheduleEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | use Symfony\Contracts\EventDispatcher\Event; 15 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | abstract class ScheduleEvent extends Event 21 | { 22 | /** @var ScheduleRunContext */ 23 | private $runContext; 24 | 25 | final public function __construct(ScheduleRunContext $runContext) 26 | { 27 | $this->runContext = $runContext; 28 | } 29 | 30 | final public function runContext(): ScheduleRunContext 31 | { 32 | return $this->runContext; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Event/TaskEvent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Event; 13 | 14 | use Symfony\Contracts\EventDispatcher\Event; 15 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | abstract class TaskEvent extends Event 21 | { 22 | /** @var TaskRunContext */ 23 | private $runContext; 24 | 25 | final public function __construct(TaskRunContext $runContext) 26 | { 27 | $this->runContext = $runContext; 28 | } 29 | 30 | final public function runContext(): TaskRunContext 31 | { 32 | return $this->runContext; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/EventListener/ScheduleBuilderSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 16 | use Zenstruck\ScheduleBundle\Schedule\ScheduleBuilder; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class ScheduleBuilderSubscriber implements EventSubscriberInterface 22 | { 23 | /** @var iterable */ 24 | private $builders; 25 | 26 | /** 27 | * @param iterable $builders 28 | */ 29 | public function __construct(iterable $builders) 30 | { 31 | $this->builders = $builders; 32 | } 33 | 34 | public static function getSubscribedEvents(): array 35 | { 36 | return [BuildScheduleEvent::class => 'build']; 37 | } 38 | 39 | public function build(BuildScheduleEvent $event): void 40 | { 41 | foreach ($this->builders as $builder) { 42 | $builder->buildSchedule($event->getSchedule()); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/EventListener/ScheduleConsoleOutputSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Symfony\Component\Console\Style\OutputStyle; 15 | use Symfony\Component\Console\Style\SymfonyStyle; 16 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 17 | use Zenstruck\ScheduleBundle\Event\AfterScheduleEvent; 18 | use Zenstruck\ScheduleBundle\Event\AfterTaskEvent; 19 | use Zenstruck\ScheduleBundle\Event\BeforeScheduleEvent; 20 | use Zenstruck\ScheduleBundle\Event\BeforeTaskEvent; 21 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 22 | 23 | /** 24 | * @author Kevin Bond 25 | */ 26 | final class ScheduleConsoleOutputSubscriber implements EventSubscriberInterface 27 | { 28 | /** @var OutputStyle */ 29 | private $io; 30 | 31 | public function __construct(OutputStyle $io) 32 | { 33 | $this->io = $io; 34 | } 35 | 36 | public static function getSubscribedEvents(): array 37 | { 38 | return [ 39 | BeforeScheduleEvent::class => 'beforeSchedule', 40 | AfterScheduleEvent::class => 'afterSchedule', 41 | BeforeTaskEvent::class => 'beforeTask', 42 | AfterTaskEvent::class => 'afterTask', 43 | ]; 44 | } 45 | 46 | public function afterSchedule(AfterScheduleEvent $event): void 47 | { 48 | $context = $event->runContext(); 49 | 50 | if ($context->isSkipped()) { 51 | $this->io->note($context->getSkipReason()); 52 | 53 | return; 54 | } 55 | 56 | $total = \count($context->getResults()); 57 | $successful = \count($context->getSuccessful()); 58 | $failures = \count($context->getFailures()); 59 | $skipped = \count($context->getSkipped()); 60 | $run = \count($context->getRun()); 61 | $messages = ["{$run}/{$total} tasks ran"]; 62 | 63 | if (0 === $total) { 64 | return; 65 | } 66 | 67 | if ($successful > 0) { 68 | $messages[] = "{$successful} succeeded"; 69 | } 70 | 71 | if ($skipped > 0) { 72 | $messages[] = "{$skipped} skipped"; 73 | } 74 | 75 | if ($failures > 0) { 76 | $messages[] = "{$failures} failed"; 77 | } 78 | 79 | $messages = \implode(', ', $messages).'.'; 80 | $messages .= " (Duration: {$context->getFormattedDuration()}, Memory: {$context->getFormattedMemory()})"; 81 | 82 | $this->io->{$context->isSuccessful() ? 'success' : 'error'}($messages); 83 | } 84 | 85 | public function beforeSchedule(BeforeScheduleEvent $event): void 86 | { 87 | $context = $event->runContext(); 88 | 89 | $allTaskCount = \count($context->getSchedule()->all()); 90 | $dueTaskCount = \count($context->dueTasks()); 91 | 92 | if ($dueTaskCount > 0) { 93 | $this->io->{$this->io instanceof SymfonyStyle ? 'comment' : 'text'}(\sprintf( 94 | '%sRunning %s %stask%s. (%s total tasks)', 95 | $context->isForceRun() ? 'Force ' : '', 96 | $dueTaskCount, 97 | $context->isForceRun() ? '' : 'due ', 98 | $dueTaskCount > 1 ? 's' : '', 99 | $allTaskCount, 100 | )); 101 | } 102 | 103 | if ($this->io->isDebug()) { 104 | $this->io->note(\sprintf('No tasks due to run. (%s total tasks)', $allTaskCount)); 105 | } 106 | } 107 | 108 | public function beforeTask(BeforeTaskEvent $event): void 109 | { 110 | $context = $event->runContext(); 111 | $task = $context->getTask(); 112 | 113 | $this->io->text(\sprintf( 114 | '%sRunning %s: %s', 115 | $context->getScheduleRunContext()->isForceRun() ? 'Force ' : '', 116 | $task->getType(), 117 | $task->getDescription(), 118 | )); 119 | } 120 | 121 | public function afterTask(AfterTaskEvent $event): void 122 | { 123 | $context = $event->runContext(); 124 | 125 | if ($this->io->isVerbose() && $output = $context->getResult()->getOutput()) { 126 | $this->io->text('---begin output---'); 127 | $this->io->write($output); 128 | $this->io->text('---end output---'); 129 | } 130 | 131 | $this->io->text(\sprintf('%s (Duration: %s, Memory: %s)', 132 | $this->afterTaskMessage($context->getResult()), 133 | $context->getFormattedDuration(), 134 | $context->getFormattedMemory(), 135 | )); 136 | $this->io->newLine(); 137 | } 138 | 139 | private function afterTaskMessage(Result $result): string 140 | { 141 | if ($result->isException()) { 142 | return "Exception: {$result->getDescription()}"; 143 | } 144 | 145 | if ($result->isFailure()) { 146 | return "Failure: {$result->getDescription()}"; 147 | } 148 | 149 | if ($result->isSkipped()) { 150 | return "Skipped: {$result->getDescription()}"; 151 | } 152 | 153 | return 'Success.'; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/EventListener/ScheduleExtensionSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class ScheduleExtensionSubscriber implements EventSubscriberInterface 21 | { 22 | /** @var iterable */ 23 | private $extensions; 24 | 25 | /** 26 | * @param iterable $extensions 27 | */ 28 | public function __construct(iterable $extensions) 29 | { 30 | $this->extensions = $extensions; 31 | } 32 | 33 | public static function getSubscribedEvents(): array 34 | { 35 | return [BuildScheduleEvent::class => 'addExtensions']; 36 | } 37 | 38 | public function addExtensions(BuildScheduleEvent $event): void 39 | { 40 | foreach ($this->extensions as $extension) { 41 | $event->getSchedule()->addExtension($extension); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/EventListener/ScheduleLoggerSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Psr\Log\LogLevel; 16 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 17 | use Zenstruck\ScheduleBundle\Event\AfterScheduleEvent; 18 | use Zenstruck\ScheduleBundle\Event\AfterTaskEvent; 19 | use Zenstruck\ScheduleBundle\Event\BeforeScheduleEvent; 20 | use Zenstruck\ScheduleBundle\Event\BeforeTaskEvent; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class ScheduleLoggerSubscriber implements EventSubscriberInterface 26 | { 27 | /** @var LoggerInterface */ 28 | private $logger; 29 | 30 | public function __construct(LoggerInterface $logger) 31 | { 32 | $this->logger = $logger; 33 | } 34 | 35 | public static function getSubscribedEvents(): array 36 | { 37 | return [ 38 | BeforeScheduleEvent::class => 'beforeSchedule', 39 | AfterScheduleEvent::class => 'afterSchedule', 40 | BeforeTaskEvent::class => 'beforeTask', 41 | AfterTaskEvent::class => 'afterTask', 42 | ]; 43 | } 44 | 45 | public function beforeSchedule(BeforeScheduleEvent $event): void 46 | { 47 | $context = $event->runContext(); 48 | 49 | $allTaskCount = \count($context->getSchedule()->all()); 50 | $dueTaskCount = \count($context->dueTasks()); 51 | 52 | if (0 === $dueTaskCount) { 53 | $this->logger->debug('No tasks due to run.', ['total' => $allTaskCount]); 54 | 55 | return; 56 | } 57 | 58 | $message = \sprintf('%s %d %stask%s.', 59 | $context->isForceRun() ? 'Force running' : 'Running', 60 | $dueTaskCount, 61 | $context->isForceRun() ? '' : 'due ', 62 | $dueTaskCount > 1 ? 's' : '', 63 | ); 64 | 65 | $this->logger->info($message, [ 66 | 'total' => $allTaskCount, 67 | 'due' => $dueTaskCount, 68 | ]); 69 | } 70 | 71 | public function afterSchedule(AfterScheduleEvent $event): void 72 | { 73 | $context = $event->runContext(); 74 | 75 | if ($context->isSkipped()) { 76 | $this->logger->info($context->getSkipReason()); 77 | 78 | return; 79 | } 80 | 81 | $total = \count($context->getResults()); 82 | $successful = \count($context->getSuccessful()); 83 | $failures = \count($context->getFailures()); 84 | $skipped = \count($context->getSkipped()); 85 | $run = \count($context->getRun()); 86 | $level = $context->isSuccessful() ? LogLevel::INFO : LogLevel::ERROR; 87 | 88 | if (0 === $total) { 89 | return; 90 | } 91 | 92 | $this->logger->log($level, "{$run}/{$total} tasks ran", [ 93 | 'total' => $total, 94 | 'successful' => $successful, 95 | 'skipped' => $skipped, 96 | 'failures' => $failures, 97 | 'duration' => $context->getFormattedDuration(), 98 | 'memory' => $context->getFormattedMemory(), 99 | 'forced' => $context->isForceRun(), 100 | ]); 101 | } 102 | 103 | public function beforeTask(BeforeTaskEvent $event): void 104 | { 105 | $context = $event->runContext(); 106 | $task = $context->getTask(); 107 | 108 | $this->logger->info(\sprintf('%s "%s"', 109 | $context->getScheduleRunContext()->isForceRun() ? 'Force running' : 'Running', 110 | $task, 111 | ), ['id' => $task->getId()]); 112 | } 113 | 114 | public function afterTask(AfterTaskEvent $event): void 115 | { 116 | $context = $event->runContext(); 117 | 118 | $result = $context->getResult(); 119 | $task = $result->getTask(); 120 | $logContext = ['id' => $task->getId()]; 121 | 122 | if ($result->isSkipped()) { 123 | $this->logger->info("Skipped \"{$task}\" ({$result->getDescription()})", $logContext); 124 | 125 | return; 126 | } 127 | 128 | $logContext['result'] = $result->getDescription(); 129 | $logContext['duration'] = $context->getFormattedDuration(); 130 | $logContext['memory'] = $context->getFormattedMemory(); 131 | $logContext['forced'] = $context->getScheduleRunContext()->isForceRun(); 132 | 133 | if ($result->isSuccessful()) { 134 | $this->logger->info("Successfully ran \"{$task}\"", $logContext); 135 | 136 | return; 137 | } 138 | 139 | if ($result->getOutput()) { 140 | $logContext['output'] = $result->getOutput(); 141 | } 142 | 143 | if (!$result->isException()) { 144 | $this->logger->error("Failure when running \"{$task}\"", $logContext); 145 | 146 | return; 147 | } 148 | 149 | $logContext['exception'] = $result->getException(); 150 | 151 | $this->logger->critical("Exception thrown when running \"{$task}\"", $logContext); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/EventListener/ScheduleTimezoneSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class ScheduleTimezoneSubscriber implements EventSubscriberInterface 21 | { 22 | /** @var string */ 23 | private $timezone; 24 | 25 | public function __construct(string $timezone) 26 | { 27 | $this->timezone = $timezone; 28 | } 29 | 30 | public static function getSubscribedEvents(): array 31 | { 32 | return [BuildScheduleEvent::class => 'setTimezone']; 33 | } 34 | 35 | public function setTimezone(BuildScheduleEvent $event): void 36 | { 37 | $event->getSchedule()->timezone($this->timezone); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/EventListener/SelfSchedulingCommandSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 16 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 17 | use Zenstruck\ScheduleBundle\Schedule\SelfSchedulingCommand; 18 | use Zenstruck\ScheduleBundle\Schedule\Task\CommandTask; 19 | 20 | /** 21 | * @author Kevin Bond 22 | */ 23 | final class SelfSchedulingCommandSubscriber implements EventSubscriberInterface 24 | { 25 | /** @var iterable */ 26 | private $commands; 27 | 28 | /** 29 | * @param iterable $commands 30 | */ 31 | public function __construct(iterable $commands) 32 | { 33 | $this->commands = $commands; 34 | } 35 | 36 | public static function getSubscribedEvents(): array 37 | { 38 | return [BuildScheduleEvent::class => 'build']; 39 | } 40 | 41 | public function build(BuildScheduleEvent $event): void 42 | { 43 | foreach ($this->commands as $command) { 44 | if (!$command instanceof Command) { 45 | throw new \InvalidArgumentException(\sprintf('"%s" is not a console command. "%s" can only be used on commands.', $command::class, SelfSchedulingCommand::class)); 46 | } 47 | 48 | $task = new CommandTask((string) $command->getName()); 49 | 50 | $command->schedule($task); 51 | 52 | $event->getSchedule()->add($task); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/EventListener/TaskConfigurationSubscriber.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\EventListener; 13 | 14 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 15 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 16 | use Zenstruck\ScheduleBundle\Schedule; 17 | use Zenstruck\ScheduleBundle\Schedule\Task; 18 | use Zenstruck\ScheduleBundle\Schedule\Task\CommandTask; 19 | use Zenstruck\ScheduleBundle\Schedule\Task\CompoundTask; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\PingTask; 21 | use Zenstruck\ScheduleBundle\Schedule\Task\ProcessTask; 22 | 23 | /** 24 | * @author Kevin Bond 25 | */ 26 | final class TaskConfigurationSubscriber implements EventSubscriberInterface 27 | { 28 | private const PROCESS_TASK_PREFIX = 'bash:'; 29 | private const PING_TASK_PREFIX = 'ping:'; 30 | 31 | /** @var array */ 32 | private $config; 33 | 34 | public function __construct(array $config) 35 | { 36 | $this->config = $config; 37 | } 38 | 39 | public static function getSubscribedEvents(): array 40 | { 41 | return [BuildScheduleEvent::class => 'configureTasks']; 42 | } 43 | 44 | public function configureTasks(BuildScheduleEvent $event): void 45 | { 46 | foreach ($this->config as $taskConfig) { 47 | $this->addTask($event->getSchedule(), $taskConfig); 48 | } 49 | } 50 | 51 | private function addTask(Schedule $schedule, array $config): void 52 | { 53 | $task = $this->createTask($config['task']); 54 | 55 | $task->cron($config['frequency']); 56 | 57 | if ($config['description']) { 58 | $task->description($config['description']); 59 | } 60 | 61 | if ($config['timezone']) { 62 | $task->timezone($config['timezone']); 63 | } 64 | 65 | if ($config['without_overlapping']['enabled']) { 66 | $task->withoutOverlapping($config['without_overlapping']['ttl']); 67 | } 68 | 69 | if ($config['only_between']['enabled']) { 70 | $task->onlyBetween($config['only_between']['start'], $config['only_between']['end']); 71 | } 72 | 73 | if ($config['unless_between']['enabled']) { 74 | $task->unlessBetween($config['unless_between']['start'], $config['unless_between']['end']); 75 | } 76 | 77 | if ($config['ping_before']['enabled']) { 78 | $task->pingBefore($config['ping_before']['url'], $config['ping_before']['method'], $config['ping_before']['options']); 79 | } 80 | 81 | if ($config['ping_after']['enabled']) { 82 | $task->pingAfter($config['ping_after']['url'], $config['ping_after']['method'], $config['ping_after']['options']); 83 | } 84 | 85 | if ($config['ping_on_success']['enabled']) { 86 | $task->pingOnSuccess($config['ping_on_success']['url'], $config['ping_on_success']['method'], $config['ping_on_success']['options']); 87 | } 88 | 89 | if ($config['ping_on_failure']['enabled']) { 90 | $task->pingOnFailure($config['ping_on_failure']['url'], $config['ping_on_failure']['method'], $config['ping_on_failure']['options']); 91 | } 92 | 93 | if ($config['email_after']['enabled']) { 94 | $task->emailAfter($config['email_after']['to'], $config['email_after']['subject']); 95 | } 96 | 97 | if ($config['email_on_failure']['enabled']) { 98 | $task->emailOnFailure($config['email_on_failure']['to'], $config['email_on_failure']['subject']); 99 | } 100 | 101 | $schedule->add($task); 102 | } 103 | 104 | private function createTask(array $commands): Task 105 | { 106 | if (1 === \count($commands)) { 107 | return self::createSingleTask(\array_values($commands)[0]); 108 | } 109 | 110 | $task = new CompoundTask(); 111 | 112 | foreach ($commands as $description => $command) { 113 | $subTask = self::createSingleTask($command); 114 | 115 | if (!\is_numeric($description)) { 116 | $subTask->description($description); 117 | } 118 | 119 | $task->add($subTask); 120 | } 121 | 122 | return $task; 123 | } 124 | 125 | private static function createSingleTask(string $command): Task 126 | { 127 | if (0 === \mb_strpos($command, self::PROCESS_TASK_PREFIX)) { 128 | return new ProcessTask(self::removePrefix($command, self::PROCESS_TASK_PREFIX)); 129 | } 130 | 131 | if (0 === \mb_strpos($command, self::PING_TASK_PREFIX)) { 132 | return new PingTask(self::removePrefix($command, self::PING_TASK_PREFIX)); 133 | } 134 | 135 | return new CommandTask($command); 136 | } 137 | 138 | private static function removePrefix(string $value, string $prefix): string 139 | { 140 | return \trim(\mb_substr($value, \mb_strlen($prefix))); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Resources/config/http.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Resources/config/mailer.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Resources/config/messenger.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Resources/config/notifier.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Resources/config/process.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | %kernel.environment% 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/Resources/config/single_server.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/config/timezone.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Resources/config/without_overlapping.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Schedule/Builder/ScheduledServiceBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Builder; 13 | 14 | use Symfony\Component\Console\Command\Command; 15 | use Zenstruck\ScheduleBundle\Attribute\AsScheduledTask; 16 | use Zenstruck\ScheduleBundle\Schedule; 17 | use Zenstruck\ScheduleBundle\Schedule\ScheduleBuilder; 18 | use Zenstruck\ScheduleBundle\Schedule\Task; 19 | use Zenstruck\ScheduleBundle\Schedule\Task\CallbackTask; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\CommandTask; 21 | 22 | /** 23 | * @author Kevin Bond 24 | * 25 | * @internal 26 | */ 27 | final class ScheduledServiceBuilder implements ScheduleBuilder 28 | { 29 | /** @var array}> */ 30 | private $services = []; 31 | 32 | /** 33 | * @param array $attributes 34 | */ 35 | public function add(object $service, array $attributes): void 36 | { 37 | $this->services[] = [$service, $attributes]; 38 | } 39 | 40 | public function buildSchedule(Schedule $schedule): void 41 | { 42 | foreach ($this->services as [$service, $attributes]) { 43 | $task = $this->createTask($service, $attributes) 44 | ->cron($attributes['frequency']) 45 | ; 46 | 47 | if ($description = $attributes['description'] ?? null) { 48 | $task->description($description); 49 | } 50 | 51 | $schedule->add($task); 52 | } 53 | } 54 | 55 | /** 56 | * @param array $attributes 57 | */ 58 | public static function validate(?string $class, array $attributes): void 59 | { 60 | if (!\class_exists($class = (string) $class)) { 61 | throw new \LogicException('Class does not exist.'); 62 | } 63 | 64 | if (!isset($attributes['frequency'])) { 65 | throw new \LogicException('Missing frequency tag attribute.'); 66 | } 67 | 68 | if (\is_a($class, Command::class, true)) { 69 | return; 70 | } 71 | 72 | if (!isset($attributes['method'])) { 73 | throw new \LogicException('Missing method tag attribute.'); 74 | } 75 | 76 | if ($attributes['arguments'] ?? null) { 77 | throw new \LogicException(\sprintf('%s::$arguments used on %s is not usable for non-%s services.', AsScheduledTask::class, $class, Command::class)); 78 | } 79 | 80 | try { 81 | $method = new \ReflectionMethod($class, $attributes['method']); 82 | } catch (\ReflectionException $e) { 83 | throw new \LogicException(\sprintf('%s::%s() method is required to use with the %s attribute.', $class, $attributes['method'], AsScheduledTask::class)); 84 | } 85 | 86 | if ($method->isStatic() || !$method->isPublic()) { 87 | throw new \LogicException(\sprintf('Method %s::%s() must non-static and public to use with the %s attribute.', $class, $attributes['method'], AsScheduledTask::class)); 88 | } 89 | 90 | if ($method->getNumberOfRequiredParameters()) { 91 | throw new \LogicException(\sprintf('Method %s::%s() must not have any required parameters to use with the %s attribute.', $class, $attributes['method'], AsScheduledTask::class)); 92 | } 93 | } 94 | 95 | /** 96 | * @param array $attributes 97 | */ 98 | private function createTask(object $service, array $attributes): Task 99 | { 100 | if ($service instanceof Command) { 101 | return new CommandTask((string) $service->getName(), (string) ($attributes['arguments'] ?? null)); 102 | } 103 | 104 | $callback = [$service, $attributes['method']]; 105 | 106 | \assert(\is_callable($callback)); 107 | 108 | return new CallbackTask($callback); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Schedule/CronExpression.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | use Cron\CronExpression as CronSchedule; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class CronExpression 20 | { 21 | public const MINUTE = 0; 22 | public const HOUR = 1; 23 | public const DOM = 2; 24 | public const MONTH = 3; 25 | public const DOW = 4; 26 | 27 | public const ALIASES = [ 28 | '@hourly', 29 | '@daily', 30 | '@weekly', 31 | '@monthly', 32 | '@yearly', 33 | '@annually', 34 | ]; 35 | 36 | private const HASH_ALIAS_MAP = [ 37 | '#hourly' => '# * * * *', 38 | '#daily' => '# # * * *', 39 | '#weekly' => '# # * * #', 40 | '#weekly@midnight' => '# #(0-2) * * #', 41 | '#monthly' => '# # # * *', 42 | '#monthly@midnight' => '# #(0-2) # * *', 43 | '#annually' => '# # # # *', 44 | '#annually@midnight' => '# #(0-2) # # *', 45 | '#yearly' => '# # # # *', 46 | '#yearly@midnight' => '# #(0-2) # # *', 47 | '#midnight' => '# #(0-2) * * *', 48 | ]; 49 | 50 | private const RANGES = [ 51 | self::MINUTE => [0, 59], 52 | self::HOUR => [0, 23], 53 | self::DOM => [1, 28], 54 | self::MONTH => [1, 12], 55 | self::DOW => [0, 6], 56 | ]; 57 | 58 | /** @var string */ 59 | private $value; 60 | 61 | /** @var string[] */ 62 | private $parts; 63 | 64 | /** @var string */ 65 | private $context; 66 | 67 | /** @var string */ 68 | private $parsedValue; 69 | 70 | public function __construct(string $value, string $context) 71 | { 72 | $this->value = $value; 73 | $this->context = $context; 74 | 75 | if (\in_array($value, self::ALIASES, true)) { 76 | return; 77 | } 78 | 79 | $value = self::HASH_ALIAS_MAP[$value] ?? $value; 80 | $parts = \explode(' ', $value); 81 | 82 | if (5 !== \count($parts)) { 83 | throw new \InvalidArgumentException("\"{$value}\" is an invalid cron expression."); 84 | } 85 | 86 | $this->parts = $parts; 87 | } 88 | 89 | public function __toString(): string 90 | { 91 | return $this->getParsedValue(); 92 | } 93 | 94 | public function getRawValue(): string 95 | { 96 | return $this->value; 97 | } 98 | 99 | public function getParsedValue(): string 100 | { 101 | if (!$this->parts) { 102 | return $this->getRawValue(); 103 | } 104 | 105 | return $this->parsedValue ?: $this->parsedValue = \implode(' ', [ 106 | $this->parsePart(self::MINUTE), 107 | $this->parsePart(self::HOUR), 108 | $this->parsePart(self::DOM), 109 | $this->parsePart(self::MONTH), 110 | $this->parsePart(self::DOW), 111 | ]); 112 | } 113 | 114 | public function isHashed(): bool 115 | { 116 | return $this->getRawValue() !== $this->getParsedValue(); 117 | } 118 | 119 | public function getNextRun(?string $timezone = null): \DateTimeInterface 120 | { 121 | return CronSchedule::factory($this->getParsedValue())->getNextRunDate('now', 0, false, $timezone); 122 | } 123 | 124 | public function isDue(\DateTimeInterface $timestamp, ?string $timezone = null): bool 125 | { 126 | return CronSchedule::factory($this->getParsedValue())->isDue($timestamp, $timezone); 127 | } 128 | 129 | private function parsePart(int $position): string 130 | { 131 | $value = $this->parts[$position]; 132 | 133 | if (\preg_match('#^\#(\((\d+)-(\d+)\))?$#', $value, $matches)) { 134 | $value = $this->hashField( 135 | $matches[2] ?? self::RANGES[$position][0], 136 | $matches[3] ?? self::RANGES[$position][1], 137 | ); 138 | } 139 | 140 | return $value; 141 | } 142 | 143 | private function hashField(int $start, int $end): string 144 | { 145 | $possibleValues = \range($start, $end); 146 | 147 | return (string) $possibleValues[(int) \fmod(\hexdec(\mb_substr(\md5($this->context), 0, 10)), \count($possibleValues))]; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Schedule/Exception/MissingDependency.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Exception; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 15 | use Zenstruck\ScheduleBundle\Schedule\Task; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class MissingDependency extends \LogicException 21 | { 22 | public function __construct(string $message) 23 | { 24 | parent::__construct($message); 25 | } 26 | 27 | public static function noTaskRunner(Task $task): self 28 | { 29 | if ($task instanceof HasMissingDependencyMessage) { 30 | return new self($task::getMissingDependencyMessage()); 31 | } 32 | 33 | return new self(\sprintf('No task runner registered for "%s".', $task)); 34 | } 35 | 36 | public static function noExtensionHandler(object $extension): self 37 | { 38 | if ($extension instanceof HasMissingDependencyMessage) { 39 | return new self($extension::getMissingDependencyMessage()); 40 | } 41 | 42 | if (\method_exists($extension, '__toString')) { 43 | return new self(\sprintf('No extension handler registered for "%s: %s".', $extension::class, $extension)); 44 | } 45 | 46 | return new self(\sprintf('No extension handler registered for "%s".', $extension::class)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Schedule/Exception/SkipSchedule.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Exception; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class SkipSchedule extends \DomainException 18 | { 19 | public function __construct(string $reason) 20 | { 21 | parent::__construct($reason); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Schedule/Exception/SkipTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Exception; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Task; 15 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class SkipTask extends \DomainException 21 | { 22 | public function __construct(string $reason) 23 | { 24 | parent::__construct($reason); 25 | } 26 | 27 | public function createResult(Task $task): Result 28 | { 29 | return Result::skipped($task, $this->getMessage()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Schedule/Extension/BetweenTimeExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipTask; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class BetweenTimeExtension 20 | { 21 | /** @var string */ 22 | private $startTime; 23 | 24 | /** @var string */ 25 | private $endTime; 26 | 27 | /** @var bool */ 28 | private $within; 29 | 30 | /** @var bool */ 31 | private $inclusive; 32 | 33 | private function __construct(string $startTime, string $endTime, bool $within, bool $inclusive) 34 | { 35 | $this->startTime = self::normalizeTime($startTime); 36 | $this->endTime = self::normalizeTime($endTime); 37 | $this->within = $within; 38 | $this->inclusive = $inclusive; 39 | } 40 | 41 | public function __toString(): string 42 | { 43 | if ($this->within) { 44 | return "Only run between {$this->startTime} and {$this->endTime}"; 45 | } 46 | 47 | return "Only run if not between {$this->startTime} and {$this->endTime}"; 48 | } 49 | 50 | public function filter(?\DateTimeZone $timezone): void 51 | { 52 | $isBetween = $this->isBetween($timezone); 53 | 54 | if ($this->within && !$isBetween) { 55 | throw new SkipTask("Only runs between {$this->startTime} and {$this->endTime}"); 56 | } 57 | 58 | if (!$this->within && $isBetween) { 59 | throw new SkipTask("Only runs if not between {$this->startTime} and {$this->endTime}"); 60 | } 61 | } 62 | 63 | public static function whenWithin(string $startTime, string $endTime, bool $inclusive = true): self 64 | { 65 | return new self($startTime, $endTime, true, $inclusive); 66 | } 67 | 68 | public static function unlessWithin(string $startTime, string $endTime, bool $inclusive = true): self 69 | { 70 | return new self($startTime, $endTime, false, $inclusive); 71 | } 72 | 73 | private function isBetween(?\DateTimeZone $timezone): bool 74 | { 75 | [$now, $startTime, $endTime] = [ 76 | new \DateTime(\date('Y-m-d H:i:00'), $timezone), 77 | self::parseTime($this->startTime, $timezone), 78 | self::parseTime($this->endTime, $timezone), 79 | ]; 80 | 81 | if ($endTime < $startTime) { 82 | // account for overnight 83 | $endTime = $endTime->add(new \DateInterval('P1D')); 84 | } 85 | 86 | if ($this->inclusive) { 87 | return $now >= $startTime && $now <= $endTime; 88 | } 89 | 90 | return $now > $startTime && $now < $endTime; 91 | } 92 | 93 | private static function normalizeTime(string $time): string 94 | { 95 | return false === \mb_strpos($time, ':') ? "{$time}:00" : $time; 96 | } 97 | 98 | private static function parseTime(string $time, ?\DateTimeZone $timezone): \DateTime 99 | { 100 | [$hour, $minute] = \explode(':', $time, 2); 101 | 102 | return (new \DateTime('today', $timezone)) 103 | ->add(new \DateInterval("PT{$hour}H{$minute}M")) 104 | ; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Schedule/Extension/CallbackExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule; 15 | use Zenstruck\ScheduleBundle\Schedule\Task; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class CallbackExtension 21 | { 22 | /** @var callable */ 23 | private $callback; 24 | 25 | private function __construct( 26 | private string $hook, 27 | callable $callback, 28 | private ?string $description = null) 29 | { 30 | $this->callback = $callback; 31 | } 32 | 33 | public function __toString(): string 34 | { 35 | return \sprintf('%s callback: %s', $this->hook, $this->description ?? self::createDescriptionFromCallback($this->callback)); 36 | } 37 | 38 | public function getHook(): string 39 | { 40 | return $this->hook; 41 | } 42 | 43 | public function getCallback(): callable 44 | { 45 | return $this->callback; 46 | } 47 | 48 | public function getDescription(): ?string 49 | { 50 | return $this->description; 51 | } 52 | 53 | public static function taskFilter(callable $callback, ?string $description = null): self 54 | { 55 | return new self(Task::FILTER, $callback, $description); 56 | } 57 | 58 | public static function taskBefore(callable $callback, ?string $description = null): self 59 | { 60 | return new self(Task::BEFORE, $callback, $description); 61 | } 62 | 63 | public static function taskAfter(callable $callback, ?string $description = null): self 64 | { 65 | return new self(Task::AFTER, $callback, $description); 66 | } 67 | 68 | public static function taskSuccess(callable $callback, ?string $description = null): self 69 | { 70 | return new self(Task::SUCCESS, $callback, $description); 71 | } 72 | 73 | public static function taskFailure(callable $callback, ?string $description = null): self 74 | { 75 | return new self(Task::FAILURE, $callback, $description); 76 | } 77 | 78 | public static function scheduleFilter(callable $callback, ?string $description = null): self 79 | { 80 | return new self(Schedule::FILTER, $callback, $description); 81 | } 82 | 83 | public static function scheduleBefore(callable $callback, ?string $description = null): self 84 | { 85 | return new self(Schedule::BEFORE, $callback, $description); 86 | } 87 | 88 | public static function scheduleAfter(callable $callback, ?string $description = null): self 89 | { 90 | return new self(Schedule::AFTER, $callback, $description); 91 | } 92 | 93 | public static function scheduleSuccess(callable $callback, ?string $description = null): self 94 | { 95 | return new self(Schedule::SUCCESS, $callback, $description); 96 | } 97 | 98 | public static function scheduleFailure(callable $callback, ?string $description = null): self 99 | { 100 | return new self(Schedule::FAILURE, $callback, $description); 101 | } 102 | 103 | public static function createDescriptionFromCallback(callable $callback): string 104 | { 105 | if (\is_array($callback)) { 106 | return \sprintf('%s::%s()', \is_object($callback[0]) ? \get_class($callback[0]) : $callback[0], $callback[1]); 107 | } 108 | 109 | if (\is_object($callback) && !$callback instanceof \Closure && \method_exists($callback, '__invoke')) { 110 | return \sprintf('%s::__invoke()', $callback::class); 111 | } 112 | 113 | $ref = new \ReflectionFunction(\Closure::fromCallable($callback)); 114 | 115 | if ($class = $ref->getClosureScopeClass()) { 116 | return "{$class->getName()}:{$ref->getStartLine()}"; 117 | } 118 | 119 | return $ref->getName().'()'; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Schedule/Extension/EmailExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Symfony\Component\Mime\Address; 15 | use Symfony\Component\Mime\Email; 16 | use Zenstruck\ScheduleBundle\Schedule; 17 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 18 | use Zenstruck\ScheduleBundle\Schedule\Task; 19 | 20 | /** 21 | * @author Kevin Bond 22 | */ 23 | final class EmailExtension implements HasMissingDependencyMessage 24 | { 25 | /** @var string */ 26 | private $hook; 27 | 28 | /** @var Email */ 29 | private $email; 30 | 31 | /** 32 | * @param string|Address|string[]|Address[]|null $to 33 | */ 34 | private function __construct(string $hook, $to = null, ?string $subject = null, ?callable $callback = null) 35 | { 36 | $this->hook = $hook; 37 | 38 | $email = new Email(); 39 | 40 | if (null !== $to) { 41 | $email->to(...(array) $to); 42 | } 43 | 44 | if ($subject) { 45 | $email->subject($subject); 46 | } 47 | 48 | if ($callback) { 49 | $callback($email); 50 | } 51 | 52 | $this->email = $email; 53 | } 54 | 55 | public function __toString(): string 56 | { 57 | $to = $this->email->getTo(); 58 | 59 | if (empty($to)) { 60 | return "{$this->hook}, email output"; 61 | } 62 | 63 | $to = \array_map(fn(Address $address) => $address->toString(), $to); 64 | $to = \implode('; ', $to); 65 | 66 | return "{$this->hook}, email output to \"{$to}\""; 67 | } 68 | 69 | /** 70 | * @param string|Address|string[]|Address[]|null $to 71 | */ 72 | public static function taskAfter($to = null, ?string $subject = null, ?callable $callback = null): self 73 | { 74 | return new self(Task::AFTER, $to, $subject, $callback); 75 | } 76 | 77 | /** 78 | * @param string|Address|string[]|Address[]|null $to 79 | */ 80 | public static function taskFailure($to = null, ?string $subject = null, ?callable $callback = null): self 81 | { 82 | return new self(Task::FAILURE, $to, $subject, $callback); 83 | } 84 | 85 | /** 86 | * @param string|Address|string[]|Address[]|null $to 87 | */ 88 | public static function scheduleFailure($to = null, ?string $subject = null, ?callable $callback = null): self 89 | { 90 | return new self(Schedule::FAILURE, $to, $subject, $callback); 91 | } 92 | 93 | public function getEmail(): Email 94 | { 95 | return $this->email; 96 | } 97 | 98 | public function isHook(string $expectedHook): bool 99 | { 100 | return $expectedHook === $this->hook; 101 | } 102 | 103 | public static function getMissingDependencyMessage(): string 104 | { 105 | return 'To use the email extension you must configure a mailer (config path: "zenstruck_schedule.mailer").'; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Schedule/Extension/EnvironmentExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | final class EnvironmentExtension 18 | { 19 | /** @var string[] */ 20 | private $runEnvironments; 21 | 22 | /** 23 | * @param string[] $runEnvironments 24 | */ 25 | public function __construct(array $runEnvironments) 26 | { 27 | if (empty($runEnvironments)) { 28 | throw new \InvalidArgumentException('At least one environment must be configured.'); 29 | } 30 | 31 | $this->runEnvironments = $runEnvironments; 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return \sprintf('Only run in [%s] environment%s', \implode(', ', $this->runEnvironments), \count($this->runEnvironments) > 1 ? 's' : ''); 37 | } 38 | 39 | /** 40 | * @return string[] 41 | */ 42 | public function getRunEnvironments(): array 43 | { 44 | return $this->runEnvironments; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Schedule/Extension/ExtensionHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule; 15 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipTask; 16 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 17 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | abstract class ExtensionHandler 23 | { 24 | /** 25 | * Skip entire schedule if \Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule 26 | * exception is thrown. 27 | * 28 | * @throws SkipSchedule 29 | */ 30 | public function filterSchedule(ScheduleRunContext $context, object $extension): void 31 | { 32 | // noop 33 | } 34 | 35 | /** 36 | * Executes before the schedule runs. 37 | */ 38 | public function beforeSchedule(ScheduleRunContext $context, object $extension): void 39 | { 40 | // noop 41 | } 42 | 43 | /** 44 | * Executes after the schedule runs. 45 | */ 46 | public function afterSchedule(ScheduleRunContext $context, object $extension): void 47 | { 48 | // noop 49 | } 50 | 51 | /** 52 | * Executes if the schedule ran with no failures. 53 | */ 54 | public function onScheduleSuccess(ScheduleRunContext $context, object $extension): void 55 | { 56 | // noop 57 | } 58 | 59 | /** 60 | * Executes if the schedule ran with failures. 61 | */ 62 | public function onScheduleFailure(ScheduleRunContext $context, object $extension): void 63 | { 64 | // noop 65 | } 66 | 67 | /** 68 | * Skip task if \Zenstruck\ScheduleBundle\Schedule\Exception\SkipTask exception 69 | * is thrown. 70 | * 71 | * @throws SkipTask 72 | */ 73 | public function filterTask(TaskRunContext $context, object $extension): void 74 | { 75 | // noop 76 | } 77 | 78 | /** 79 | * Executes before the task runs (not if skipped). 80 | */ 81 | public function beforeTask(TaskRunContext $context, object $extension): void 82 | { 83 | // noop 84 | } 85 | 86 | /** 87 | * Executes after the task runs (not if skipped). 88 | */ 89 | public function afterTask(TaskRunContext $context, object $extension): void 90 | { 91 | // noop 92 | } 93 | 94 | /** 95 | * Executes if the task ran successfully (not if skipped). 96 | */ 97 | public function onTaskSuccess(TaskRunContext $context, object $extension): void 98 | { 99 | // noop 100 | } 101 | 102 | /** 103 | * Executes if the task failed (not if skipped). 104 | */ 105 | public function onTaskFailure(TaskRunContext $context, object $extension): void 106 | { 107 | // noop 108 | } 109 | 110 | abstract public function supports(object $extension): bool; 111 | } 112 | -------------------------------------------------------------------------------- /src/Schedule/Extension/ExtensionHandlerRegistry.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Exception\MissingDependency; 15 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\BetweenTimeHandler; 16 | use Zenstruck\ScheduleBundle\Schedule\Extension\Handler\CallbackHandler; 17 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 18 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 19 | 20 | /** 21 | * @author Kevin Bond 22 | */ 23 | final class ExtensionHandlerRegistry 24 | { 25 | /** @var iterable */ 26 | private $handlers; 27 | 28 | /** @var array */ 29 | private $handlerCache; 30 | 31 | /** 32 | * @param iterable $handlers 33 | */ 34 | public function __construct(iterable $handlers) 35 | { 36 | $this->handlers = $handlers; 37 | $this->handlerCache = [ 38 | CallbackExtension::class => new CallbackHandler(), 39 | BetweenTimeExtension::class => new BetweenTimeHandler(), 40 | ]; 41 | } 42 | 43 | public function handlerFor(object $extension): ExtensionHandler 44 | { 45 | $class = $extension::class; 46 | 47 | if (isset($this->handlerCache[$class])) { 48 | return $this->handlerCache[$class]; 49 | } 50 | 51 | foreach ($this->handlers as $handler) { 52 | if ($handler->supports($extension)) { 53 | return $this->handlerCache[$class] = $handler; 54 | } 55 | } 56 | 57 | throw MissingDependency::noExtensionHandler($extension); 58 | } 59 | 60 | public function beforeSchedule(ScheduleRunContext $context): void 61 | { 62 | foreach ($context->getSchedule()->getExtensions() as $extension) { 63 | $this->handlerFor($extension)->filterSchedule($context, $extension); 64 | } 65 | 66 | foreach ($context->getSchedule()->getExtensions() as $extension) { 67 | $this->handlerFor($extension)->beforeSchedule($context, $extension); 68 | } 69 | } 70 | 71 | public function afterSchedule(ScheduleRunContext $context): void 72 | { 73 | foreach ($context->getSchedule()->getExtensions() as $extension) { 74 | $this->handlerFor($extension)->afterSchedule($context, $extension); 75 | } 76 | 77 | if ($context->isSuccessful()) { 78 | foreach ($context->getSchedule()->getExtensions() as $extension) { 79 | $this->handlerFor($extension)->onScheduleSuccess($context, $extension); 80 | } 81 | } 82 | 83 | if ($context->isFailure()) { 84 | foreach ($context->getSchedule()->getExtensions() as $extension) { 85 | $this->handlerFor($extension)->onScheduleFailure($context, $extension); 86 | } 87 | } 88 | } 89 | 90 | public function beforeTask(TaskRunContext $context): void 91 | { 92 | foreach ($context->getTask()->getExtensions() as $extension) { 93 | $this->handlerFor($extension)->filterTask($context, $extension); 94 | } 95 | 96 | foreach ($context->getTask()->getExtensions() as $extension) { 97 | $this->handlerFor($extension)->beforeTask($context, $extension); 98 | } 99 | } 100 | 101 | public function afterTask(TaskRunContext $context): void 102 | { 103 | if (!$context->hasRun()) { 104 | return; 105 | } 106 | 107 | foreach ($context->getTask()->getExtensions() as $extension) { 108 | $this->handlerFor($extension)->afterTask($context, $extension); 109 | } 110 | 111 | if ($context->isSuccessful()) { 112 | foreach ($context->getTask()->getExtensions() as $extension) { 113 | $this->handlerFor($extension)->onTaskSuccess($context, $extension); 114 | } 115 | } 116 | 117 | if ($context->isFailure()) { 118 | foreach ($context->getTask()->getExtensions() as $extension) { 119 | $this->handlerFor($extension)->onTaskFailure($context, $extension); 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/BetweenTimeHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Extension\BetweenTimeExtension; 15 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 16 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class BetweenTimeHandler extends ExtensionHandler 22 | { 23 | /** 24 | * @param BetweenTimeExtension $extension 25 | */ 26 | public function filterTask(TaskRunContext $context, object $extension): void 27 | { 28 | $extension->filter($context->getTask()->getTimezone()); 29 | } 30 | 31 | public function supports(object $extension): bool 32 | { 33 | return $extension instanceof BetweenTimeExtension; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/CallbackHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule; 15 | use Zenstruck\ScheduleBundle\Schedule\Extension\CallbackExtension; 16 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 17 | use Zenstruck\ScheduleBundle\Schedule\RunContext; 18 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 19 | use Zenstruck\ScheduleBundle\Schedule\Task; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class CallbackHandler extends ExtensionHandler 26 | { 27 | /** 28 | * @param CallbackExtension $extension 29 | */ 30 | public function filterSchedule(ScheduleRunContext $context, object $extension): void 31 | { 32 | $this->runIf($extension, Schedule::FILTER, $context); 33 | } 34 | 35 | /** 36 | * @param CallbackExtension $extension 37 | */ 38 | public function beforeSchedule(ScheduleRunContext $context, object $extension): void 39 | { 40 | $this->runIf($extension, Schedule::BEFORE, $context); 41 | } 42 | 43 | /** 44 | * @param CallbackExtension $extension 45 | */ 46 | public function afterSchedule(ScheduleRunContext $context, object $extension): void 47 | { 48 | $this->runIf($extension, Schedule::AFTER, $context); 49 | } 50 | 51 | /** 52 | * @param CallbackExtension $extension 53 | */ 54 | public function onScheduleSuccess(ScheduleRunContext $context, object $extension): void 55 | { 56 | $this->runIf($extension, Schedule::SUCCESS, $context); 57 | } 58 | 59 | /** 60 | * @param CallbackExtension $extension 61 | */ 62 | public function onScheduleFailure(ScheduleRunContext $context, object $extension): void 63 | { 64 | $this->runIf($extension, Schedule::FAILURE, $context); 65 | } 66 | 67 | /** 68 | * @param CallbackExtension $extension 69 | */ 70 | public function filterTask(TaskRunContext $context, object $extension): void 71 | { 72 | $this->runIf($extension, Task::FILTER, $context); 73 | } 74 | 75 | /** 76 | * @param CallbackExtension $extension 77 | */ 78 | public function beforeTask(TaskRunContext $context, object $extension): void 79 | { 80 | $this->runIf($extension, Task::BEFORE, $context); 81 | } 82 | 83 | /** 84 | * @param CallbackExtension $extension 85 | */ 86 | public function afterTask(TaskRunContext $context, object $extension): void 87 | { 88 | $this->runIf($extension, Task::AFTER, $context); 89 | } 90 | 91 | /** 92 | * @param CallbackExtension $extension 93 | */ 94 | public function onTaskSuccess(TaskRunContext $context, object $extension): void 95 | { 96 | $this->runIf($extension, Task::SUCCESS, $context); 97 | } 98 | 99 | /** 100 | * @param CallbackExtension $extension 101 | */ 102 | public function onTaskFailure(TaskRunContext $context, object $extension): void 103 | { 104 | $this->runIf($extension, Task::FAILURE, $context); 105 | } 106 | 107 | public function supports(object $extension): bool 108 | { 109 | return $extension instanceof CallbackExtension; 110 | } 111 | 112 | private function runIf(CallbackExtension $extension, string $expectedHook, RunContext $context): void 113 | { 114 | if ($expectedHook === $extension->getHook()) { 115 | $extension->getCallback()($context); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/EmailHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Symfony\Component\Mailer\MailerInterface; 15 | use Symfony\Component\Mime\Address; 16 | use Symfony\Component\Mime\Email; 17 | use Zenstruck\ScheduleBundle\Schedule; 18 | use Zenstruck\ScheduleBundle\Schedule\Exception\MissingDependency; 19 | use Zenstruck\ScheduleBundle\Schedule\Extension\EmailExtension; 20 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 21 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 22 | use Zenstruck\ScheduleBundle\Schedule\Task; 23 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 24 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 25 | 26 | /** 27 | * @author Kevin Bond 28 | */ 29 | final class EmailHandler extends ExtensionHandler 30 | { 31 | use Schedule\TaskOutput; 32 | 33 | /** @var MailerInterface */ 34 | private $mailer; 35 | 36 | /** @var string|null */ 37 | private $defaultFrom; 38 | 39 | /** @var string|null */ 40 | private $defaultTo; 41 | 42 | /** @var string|null */ 43 | private $subjectPrefix; 44 | 45 | public function __construct(MailerInterface $mailer, ?string $defaultFrom = null, ?string $defaultTo = null, ?string $subjectPrefix = null) 46 | { 47 | $this->mailer = $mailer; 48 | $this->defaultFrom = $defaultFrom; 49 | $this->defaultTo = $defaultTo; 50 | $this->subjectPrefix = $subjectPrefix; 51 | } 52 | 53 | /** 54 | * @param EmailExtension $extension 55 | */ 56 | public function onScheduleFailure(ScheduleRunContext $context, object $extension): void 57 | { 58 | if ($extension->isHook(Schedule::FAILURE)) { 59 | $this->sendScheduleEmail($context, $extension); 60 | } 61 | } 62 | 63 | /** 64 | * @param EmailExtension $extension 65 | */ 66 | public function afterTask(TaskRunContext $context, object $extension): void 67 | { 68 | if ($extension->isHook(Task::AFTER)) { 69 | $this->sendTaskEmail($extension, $context->getResult(), $context->getScheduleRunContext()); 70 | } 71 | } 72 | 73 | /** 74 | * @param EmailExtension $extension 75 | */ 76 | public function onTaskFailure(TaskRunContext $context, object $extension): void 77 | { 78 | if ($extension->isHook(Task::FAILURE)) { 79 | $this->sendTaskEmail($extension, $context->getResult(), $context->getScheduleRunContext()); 80 | } 81 | } 82 | 83 | public function supports(object $extension): bool 84 | { 85 | return $extension instanceof EmailExtension; 86 | } 87 | 88 | private function sendScheduleEmail(ScheduleRunContext $context, EmailExtension $extension): void 89 | { 90 | $email = $this->emailHeaderFor($extension); 91 | $failureCount = \count($context->getFailures()); 92 | $summary = \sprintf('%d task%s failed', $failureCount, $failureCount > 1 ? 's' : ''); 93 | $text = $summary; 94 | 95 | $email->priority(Email::PRIORITY_HIGHEST); 96 | $this->prefixSubject($email, "[Schedule Failure] {$summary}"); 97 | 98 | foreach ($context->getFailures() as $i => $failure) { 99 | $task = $failure->getTask(); 100 | $text .= \sprintf("\n\n# (Failure %d/%d) %s\n\n", $i + 1, $failureCount, $task); 101 | $text .= $this->getTaskOutput($failure, $context); 102 | 103 | if ($i < $failureCount - 1) { 104 | $text .= "\n\n---"; 105 | } 106 | } 107 | 108 | $email->text($text); 109 | 110 | $this->mailer->send($email); 111 | } 112 | 113 | private function sendTaskEmail(EmailExtension $extension, Result $result, ScheduleRunContext $context): void 114 | { 115 | $email = $this->emailHeaderFor($extension); 116 | 117 | $this->prefixSubject($email, \sprintf('[Scheduled Task %s] %s', 118 | $result->isFailure() ? 'Failed' : 'Succeeded', 119 | $result->getTask(), 120 | )); 121 | 122 | if ($result->isFailure()) { 123 | $email->priority(Email::PRIORITY_HIGHEST); 124 | } 125 | 126 | $email->text($this->getTaskOutput($result, $context)); 127 | 128 | $this->mailer->send($email); 129 | } 130 | 131 | private function emailHeaderFor(EmailExtension $extension): Email 132 | { 133 | $email = $extension->getEmail(); 134 | 135 | if (null !== $this->defaultFrom && empty($email->getFrom())) { 136 | $email->from(Address::create($this->defaultFrom)); 137 | } 138 | 139 | if (null !== $this->defaultTo && empty($email->getTo())) { 140 | $email->to(Address::create($this->defaultTo)); 141 | } 142 | 143 | if (empty($email->getTo())) { 144 | throw new MissingDependency('There is no "To" configured for the email. Either set it when adding the extension or in your configuration (config path: "zenstruck_schedule.mailer.default_to").'); 145 | } 146 | 147 | return $email; 148 | } 149 | 150 | private function prefixSubject(Email $email, string $defaultSubject): void 151 | { 152 | $subject = $email->getSubject() ?: $defaultSubject; 153 | 154 | $email->subject($this->subjectPrefix.$subject); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/EnvironmentHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule; 15 | use Zenstruck\ScheduleBundle\Schedule\Extension\EnvironmentExtension; 16 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 17 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class EnvironmentHandler extends ExtensionHandler 23 | { 24 | /** @var string */ 25 | private $currentEnvironment; 26 | 27 | public function __construct(string $currentEnvironment) 28 | { 29 | $this->currentEnvironment = $currentEnvironment; 30 | } 31 | 32 | /** 33 | * @param EnvironmentExtension $extension 34 | */ 35 | public function filterSchedule(ScheduleRunContext $context, object $extension): void 36 | { 37 | if (\in_array($this->currentEnvironment, $extension->getRunEnvironments(), true)) { 38 | return; // currently in configured environment 39 | } 40 | 41 | throw new SkipSchedule(\sprintf('Schedule configured not to run in [%s] environment (only [%s]).', $this->currentEnvironment, \implode(', ', $extension->getRunEnvironments()))); 42 | } 43 | 44 | public function supports(object $extension): bool 45 | { 46 | return $extension instanceof EnvironmentExtension; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/NotifierHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Symfony\Component\Notifier\Notification\Notification; 15 | use Symfony\Component\Notifier\NotifierInterface; 16 | use Symfony\Component\Notifier\Recipient\NoRecipient; 17 | use Symfony\Component\Notifier\Recipient\Recipient; 18 | use Symfony\Component\Notifier\Recipient\RecipientInterface; 19 | use Zenstruck\ScheduleBundle\Schedule; 20 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 21 | use Zenstruck\ScheduleBundle\Schedule\Extension\NotifierExtension; 22 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 23 | use Zenstruck\ScheduleBundle\Schedule\Task; 24 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 25 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 26 | use Zenstruck\ScheduleBundle\Schedule\TaskOutput; 27 | 28 | /** 29 | * @author Pierre du Plessis 30 | */ 31 | final class NotifierHandler extends ExtensionHandler 32 | { 33 | use TaskOutput; 34 | 35 | /** @var NotifierInterface */ 36 | private $notifier; 37 | 38 | /** @var string */ 39 | private $defaultEmail; 40 | 41 | /** @var string */ 42 | private $defaultPhone; 43 | 44 | /** @var string|null */ 45 | private $subjectPrefix; 46 | 47 | /** @var array */ 48 | private $defaultChannel; 49 | 50 | /** 51 | * @param string|string[] $defaultChannel 52 | */ 53 | public function __construct(NotifierInterface $notifier, $defaultChannel = null, ?string $defaultEmail = null, ?string $defaultPhone = null, ?string $subjectPrefix = null) 54 | { 55 | $this->notifier = $notifier; 56 | $this->defaultEmail = $defaultEmail ?? ''; 57 | $this->defaultPhone = $defaultPhone ?? ''; 58 | $this->subjectPrefix = $subjectPrefix; 59 | $this->defaultChannel = (array) $defaultChannel; 60 | } 61 | 62 | /** 63 | * @param NotifierExtension $extension 64 | */ 65 | public function onScheduleFailure(ScheduleRunContext $context, object $extension): void 66 | { 67 | if ($extension->isHook(Schedule::FAILURE)) { 68 | $this->sendScheduleNotification($context, $extension); 69 | } 70 | } 71 | 72 | /** 73 | * @param NotifierExtension $extension 74 | */ 75 | public function afterTask(TaskRunContext $context, object $extension): void 76 | { 77 | if ($extension->isHook(Task::AFTER)) { 78 | $this->sendTaskNotification($extension, $context->getResult(), $context->getScheduleRunContext()); 79 | } 80 | } 81 | 82 | /** 83 | * @param NotifierExtension $extension 84 | */ 85 | public function onTaskFailure(TaskRunContext $context, object $extension): void 86 | { 87 | if ($extension->isHook(Task::FAILURE)) { 88 | $this->sendTaskNotification($extension, $context->getResult(), $context->getScheduleRunContext()); 89 | } 90 | } 91 | 92 | public function supports(object $extension): bool 93 | { 94 | return $extension instanceof NotifierExtension; 95 | } 96 | 97 | private function sendScheduleNotification(ScheduleRunContext $context, NotifierExtension $extension): void 98 | { 99 | $notification = $extension->getNotification(); 100 | $failureCount = \count($context->getFailures()); 101 | $summary = \sprintf('%d task%s failed', $failureCount, $failureCount > 1 ? 's' : ''); 102 | $text = $summary; 103 | 104 | $notification->importance(Notification::IMPORTANCE_HIGH); 105 | $this->prefixSubject($notification, "[Schedule Failure] {$summary}"); 106 | 107 | foreach ($context->getFailures() as $i => $failure) { 108 | $task = $failure->getTask(); 109 | $text .= \sprintf("\n\n# (Failure %d/%d) %s\n\n", $i + 1, $failureCount, $task); 110 | $text .= $this->getTaskOutput($failure, $context, false); 111 | 112 | if ($i < $failureCount - 1) { 113 | $text .= "\n\n---"; 114 | } 115 | } 116 | 117 | $notification->content($text); 118 | 119 | if ([] === $notification->getChannels($this->getRecipient($extension))) { 120 | $notification->channels($this->defaultChannel); 121 | } 122 | 123 | $this->notifier->send($notification, $this->getRecipient($extension)); 124 | } 125 | 126 | private function sendTaskNotification(NotifierExtension $extension, Result $result, ScheduleRunContext $context): void 127 | { 128 | $notification = $extension->getNotification(); 129 | 130 | $this->prefixSubject($notification, \sprintf('[Scheduled Task %s] %s', 131 | $result->isFailure() ? 'Failed' : 'Succeeded', 132 | $result->getTask(), 133 | )); 134 | 135 | if ($result->isFailure()) { 136 | $notification->importance(Notification::IMPORTANCE_HIGH); 137 | } 138 | 139 | $notification->content($this->getTaskOutput($result, $context, false)); 140 | 141 | if ([] === $notification->getChannels($this->getRecipient($extension))) { 142 | if (empty($this->defaultChannel)) { 143 | throw new \LogicException('There is no "Channel" configured for the notification. Either set it when adding the extension or in your configuration (config path: "zenstruck_schedule.notifier.default_channel").'); 144 | } 145 | 146 | $notification->channels($this->defaultChannel); 147 | } 148 | 149 | $this->notifier->send($notification, $this->getRecipient($extension)); 150 | } 151 | 152 | private function prefixSubject(Notification $notification, string $defaultSubject): void 153 | { 154 | $subject = $notification->getSubject() ?: $defaultSubject; 155 | 156 | $notification->subject($this->subjectPrefix.$subject); 157 | } 158 | 159 | private function getRecipient(NotifierExtension $extension): RecipientInterface 160 | { 161 | $recipient = $extension->getRecipient(); 162 | 163 | if ($recipient instanceof NoRecipient && ('' !== $this->defaultEmail || '' !== $this->defaultPhone)) { 164 | $recipient = new Recipient($this->defaultEmail, $this->defaultPhone); 165 | } 166 | 167 | return $recipient; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/PingHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Symfony\Component\HttpClient\HttpClient; 15 | use Symfony\Contracts\HttpClient\HttpClientInterface; 16 | use Zenstruck\ScheduleBundle\Schedule; 17 | use Zenstruck\ScheduleBundle\Schedule\Exception\MissingDependency; 18 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 19 | use Zenstruck\ScheduleBundle\Schedule\Extension\PingExtension; 20 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 21 | use Zenstruck\ScheduleBundle\Schedule\Task; 22 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 23 | 24 | /** 25 | * @author Kevin Bond 26 | */ 27 | final class PingHandler extends ExtensionHandler 28 | { 29 | /** @var HttpClientInterface */ 30 | private $httpClient; 31 | 32 | public function __construct(?HttpClientInterface $httpClient = null) 33 | { 34 | if (null === $httpClient && !\class_exists(HttpClient::class)) { 35 | throw new MissingDependency(PingExtension::getMissingDependencyMessage()); 36 | } 37 | 38 | $this->httpClient = $httpClient ?: HttpClient::create(); 39 | } 40 | 41 | /** 42 | * @param PingExtension $extension 43 | */ 44 | public function beforeSchedule(ScheduleRunContext $context, object $extension): void 45 | { 46 | $this->pingIf($extension, Schedule::BEFORE); 47 | } 48 | 49 | /** 50 | * @param PingExtension $extension 51 | */ 52 | public function afterSchedule(ScheduleRunContext $context, object $extension): void 53 | { 54 | $this->pingIf($extension, Schedule::AFTER); 55 | } 56 | 57 | /** 58 | * @param PingExtension $extension 59 | */ 60 | public function onScheduleSuccess(ScheduleRunContext $context, object $extension): void 61 | { 62 | $this->pingIf($extension, Schedule::SUCCESS); 63 | } 64 | 65 | /** 66 | * @param PingExtension $extension 67 | */ 68 | public function onScheduleFailure(ScheduleRunContext $context, object $extension): void 69 | { 70 | $this->pingIf($extension, Schedule::FAILURE); 71 | } 72 | 73 | /** 74 | * @param PingExtension $extension 75 | */ 76 | public function beforeTask(TaskRunContext $context, object $extension): void 77 | { 78 | $this->pingIf($extension, Task::BEFORE); 79 | } 80 | 81 | /** 82 | * @param PingExtension $extension 83 | */ 84 | public function afterTask(TaskRunContext $context, object $extension): void 85 | { 86 | $this->pingIf($extension, Task::AFTER); 87 | } 88 | 89 | /** 90 | * @param PingExtension $extension 91 | */ 92 | public function onTaskSuccess(TaskRunContext $context, object $extension): void 93 | { 94 | $this->pingIf($extension, Task::SUCCESS); 95 | } 96 | 97 | /** 98 | * @param PingExtension $extension 99 | */ 100 | public function onTaskFailure(TaskRunContext $context, object $extension): void 101 | { 102 | $this->pingIf($extension, Task::FAILURE); 103 | } 104 | 105 | public function supports(object $extension): bool 106 | { 107 | return $extension instanceof PingExtension; 108 | } 109 | 110 | private function pingIf(PingExtension $extension, string $expectedHook): void 111 | { 112 | if ($expectedHook === $extension->getHook()) { 113 | $this->httpClient->request($extension->getMethod(), $extension->getUrl(), $extension->getOptions())->getStatusCode(); 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/SingleServerHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Symfony\Component\Lock\LockFactory; 15 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule; 16 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipTask; 17 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 18 | use Zenstruck\ScheduleBundle\Schedule\Extension\SingleServerExtension; 19 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class SingleServerHandler extends ExtensionHandler 26 | { 27 | /** @var LockFactory */ 28 | private $lockFactory; 29 | 30 | public function __construct(LockFactory $lockFactory) 31 | { 32 | $this->lockFactory = $lockFactory; 33 | } 34 | 35 | /** 36 | * @param SingleServerExtension $extension 37 | */ 38 | public function filterSchedule(ScheduleRunContext $context, object $extension): void 39 | { 40 | if (!$extension->acquireLock($this->lockFactory, self::createMutex($context->getSchedule()->getId(), $context->getStartTime()))) { 41 | throw new SkipSchedule('Schedule running on another server.'); 42 | } 43 | } 44 | 45 | /** 46 | * @param SingleServerExtension $extension 47 | */ 48 | public function filterTask(TaskRunContext $context, object $extension): void 49 | { 50 | if (!$extension->acquireLock($this->lockFactory, self::createMutex($context->getTask()->getId(), $context->getScheduleRunContext()->getStartTime()))) { 51 | throw new SkipTask('Task running on another server.'); 52 | } 53 | } 54 | 55 | public function supports(object $extension): bool 56 | { 57 | return $extension instanceof SingleServerExtension; 58 | } 59 | 60 | private static function createMutex(string $id, \DateTimeInterface $timestamp): string 61 | { 62 | return $id.$timestamp->format('Hi'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Schedule/Extension/Handler/WithoutOverlappingHandler.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension\Handler; 13 | 14 | use Symfony\Component\Lock\LockFactory; 15 | use Symfony\Component\Lock\PersistingStoreInterface; 16 | use Symfony\Component\Lock\Store\FlockStore; 17 | use Symfony\Component\Lock\Store\SemaphoreStore; 18 | use Zenstruck\ScheduleBundle\Schedule\Exception\MissingDependency; 19 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipTask; 20 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandler; 21 | use Zenstruck\ScheduleBundle\Schedule\Extension\WithoutOverlappingExtension; 22 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 23 | 24 | /** 25 | * @author Kevin Bond 26 | */ 27 | final class WithoutOverlappingHandler extends ExtensionHandler 28 | { 29 | /** @var LockFactory */ 30 | private $lockFactory; 31 | 32 | public function __construct(?LockFactory $lockFactory = null) 33 | { 34 | if (null === $lockFactory && !\class_exists(LockFactory::class)) { 35 | throw new MissingDependency(WithoutOverlappingExtension::getMissingDependencyMessage()); 36 | } 37 | 38 | $this->lockFactory = $lockFactory ?: new LockFactory(self::createLocalStore()); 39 | } 40 | 41 | /** 42 | * @param WithoutOverlappingExtension $extension 43 | */ 44 | public function filterTask(TaskRunContext $context, object $extension): void 45 | { 46 | if (!$extension->acquireLock($this->lockFactory, $context->getTask()->getId())) { 47 | throw new SkipTask('Task running in another process.'); 48 | } 49 | } 50 | 51 | /** 52 | * @param WithoutOverlappingExtension $extension 53 | */ 54 | public function afterTask(TaskRunContext $context, object $extension): void 55 | { 56 | $extension->releaseLock(); 57 | } 58 | 59 | public function supports(object $extension): bool 60 | { 61 | return $extension instanceof WithoutOverlappingExtension; 62 | } 63 | 64 | private static function createLocalStore(): PersistingStoreInterface 65 | { 66 | if (SemaphoreStore::isSupported()) { 67 | return new SemaphoreStore(); 68 | } 69 | 70 | return new FlockStore(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Schedule/Extension/LockingExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Symfony\Component\Lock\LockFactory; 15 | use Symfony\Component\Lock\LockInterface; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | abstract class LockingExtension 21 | { 22 | /** @var int */ 23 | private $ttl; 24 | 25 | /** @var LockInterface|null */ 26 | private $lock; 27 | 28 | public function __construct(int $ttl) 29 | { 30 | $this->ttl = $ttl; 31 | } 32 | 33 | final public function acquireLock(LockFactory $lockFactory, string $mutex): bool 34 | { 35 | if (null !== $this->lock) { 36 | throw new \LogicException('A lock is already in place.'); 37 | } 38 | 39 | $this->lock = $lockFactory->createLock('symfony-schedule-'.$mutex, $this->ttl); 40 | 41 | if ($this->lock->acquire()) { 42 | return true; 43 | } 44 | 45 | $this->lock = null; 46 | 47 | return false; 48 | } 49 | 50 | final public function releaseLock(): void 51 | { 52 | if ($this->lock) { 53 | $this->lock->release(); 54 | 55 | $this->lock = null; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Schedule/Extension/NotifierExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Symfony\Component\Notifier\Notification\Notification; 15 | use Symfony\Component\Notifier\Recipient\NoRecipient; 16 | use Symfony\Component\Notifier\Recipient\Recipient; 17 | use Symfony\Component\Notifier\Recipient\RecipientInterface; 18 | use Zenstruck\ScheduleBundle\Schedule; 19 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 20 | use Zenstruck\ScheduleBundle\Schedule\Task; 21 | 22 | /** 23 | * @author Pierre du Plessis 24 | */ 25 | final class NotifierExtension implements HasMissingDependencyMessage 26 | { 27 | /** @var string */ 28 | private $hook; 29 | 30 | /** @var Notification */ 31 | private $notification; 32 | 33 | /** @var string|null */ 34 | private $email; 35 | 36 | /** @var string|null */ 37 | private $phone; 38 | 39 | /** 40 | * @param string|string[]|null $channel 41 | */ 42 | private function __construct(string $hook, $channel = null, ?string $email = null, ?string $phone = null, ?string $subject = null, ?callable $callback = null) 43 | { 44 | $this->hook = $hook; 45 | 46 | $notification = new Notification($subject ?? '', (array) $channel); 47 | 48 | if ($callback) { 49 | $notification = ($callback($notification) ?? $notification); 50 | } 51 | 52 | $this->notification = $notification; 53 | $this->email = $email; 54 | $this->phone = $phone; 55 | } 56 | 57 | public function __toString(): string 58 | { 59 | $channels = $this->notification->getChannels($this->getRecipient()); 60 | 61 | if (empty($channels)) { 62 | return "{$this->hook}, notification output"; 63 | } 64 | 65 | $channels = \implode('; ', $channels); 66 | 67 | return "{$this->hook}, notification output to \"{$channels}\""; 68 | } 69 | 70 | /** 71 | * @param string|string[]|null $channel 72 | */ 73 | public static function taskAfter($channel = null, ?string $email = null, ?string $phone = null, ?string $subject = null, ?callable $callback = null): self 74 | { 75 | return new self(Task::AFTER, $channel, $email, $phone, $subject, $callback); 76 | } 77 | 78 | /** 79 | * @param string|string[]|null $channel 80 | */ 81 | public static function taskFailure($channel = null, ?string $email = null, ?string $phone = null, ?string $subject = null, ?callable $callback = null): self 82 | { 83 | return new self(Task::FAILURE, $channel, $email, $phone, $subject, $callback); 84 | } 85 | 86 | /** 87 | * @param string|string[]|null $channel 88 | */ 89 | public static function scheduleFailure($channel = null, ?string $email = null, ?string $phone = null, ?string $subject = null, ?callable $callback = null): self 90 | { 91 | return new self(Schedule::FAILURE, $channel, $email, $phone, $subject, $callback); 92 | } 93 | 94 | public function getNotification(): Notification 95 | { 96 | return $this->notification; 97 | } 98 | 99 | public function getRecipient(): RecipientInterface 100 | { 101 | if (empty($this->email) && empty($this->phone)) { 102 | return new NoRecipient(); 103 | } 104 | 105 | return new Recipient($this->email ?? '', $this->phone ?? ''); 106 | } 107 | 108 | public function isHook(string $expectedHook): bool 109 | { 110 | return $expectedHook === $this->hook; 111 | } 112 | 113 | public static function getMissingDependencyMessage(): string 114 | { 115 | return 'To use the notifier extension you must configure a notifier (config path: "zenstruck_schedule.notifier").'; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Schedule/Extension/PingExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule; 15 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 16 | use Zenstruck\ScheduleBundle\Schedule\Task; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class PingExtension implements HasMissingDependencyMessage 22 | { 23 | /** @var string */ 24 | private $hook; 25 | 26 | /** @var string */ 27 | private $url; 28 | 29 | /** @var string */ 30 | private $method; 31 | 32 | /** @var array */ 33 | private $options; 34 | 35 | /** 36 | * @param array $options See HttpClientInterface::OPTIONS_DEFAULTS 37 | */ 38 | private function __construct(string $hook, string $url, string $method = 'GET', array $options = []) 39 | { 40 | $this->hook = $hook; 41 | $this->url = $url; 42 | $this->method = $method; 43 | $this->options = $options; 44 | } 45 | 46 | public function __toString(): string 47 | { 48 | return "{$this->hook}, ping \"{$this->url}\""; 49 | } 50 | 51 | public function getHook(): string 52 | { 53 | return $this->hook; 54 | } 55 | 56 | public function getUrl(): string 57 | { 58 | return $this->url; 59 | } 60 | 61 | public function getMethod(): string 62 | { 63 | return $this->method; 64 | } 65 | 66 | public function getOptions(): array 67 | { 68 | return $this->options; 69 | } 70 | 71 | public static function getMissingDependencyMessage(): string 72 | { 73 | return 'Symfony HttpClient is required to use the ping extension. Install with "composer require symfony/http-client".'; 74 | } 75 | 76 | public static function taskBefore(string $url, string $method = 'GET', array $options = []): self 77 | { 78 | return new self(Task::BEFORE, $url, $method, $options); 79 | } 80 | 81 | public static function taskAfter(string $url, string $method = 'GET', array $options = []): self 82 | { 83 | return new self(Task::AFTER, $url, $method, $options); 84 | } 85 | 86 | public static function taskSuccess(string $url, string $method = 'GET', array $options = []): self 87 | { 88 | return new self(Task::SUCCESS, $url, $method, $options); 89 | } 90 | 91 | public static function taskFailure(string $url, string $method = 'GET', array $options = []): self 92 | { 93 | return new self(Task::FAILURE, $url, $method, $options); 94 | } 95 | 96 | public static function scheduleBefore(string $url, string $method = 'GET', array $options = []): self 97 | { 98 | return new self(Schedule::BEFORE, $url, $method, $options); 99 | } 100 | 101 | public static function scheduleAfter(string $url, string $method = 'GET', array $options = []): self 102 | { 103 | return new self(Schedule::AFTER, $url, $method, $options); 104 | } 105 | 106 | public static function scheduleSuccess(string $url, string $method = 'GET', array $options = []): self 107 | { 108 | return new self(Schedule::SUCCESS, $url, $method, $options); 109 | } 110 | 111 | public static function scheduleFailure(string $url, string $method = 'GET', array $options = []): self 112 | { 113 | return new self(Schedule::FAILURE, $url, $method, $options); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Schedule/Extension/SingleServerExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class SingleServerExtension extends LockingExtension implements HasMissingDependencyMessage 20 | { 21 | public const DEFAULT_TTL = 3600; 22 | 23 | /** 24 | * @param int $ttl Maximum expected lock duration in seconds 25 | */ 26 | public function __construct(int $ttl = self::DEFAULT_TTL) 27 | { 28 | parent::__construct($ttl); 29 | } 30 | 31 | public function __toString(): string 32 | { 33 | return 'Run on single server'; 34 | } 35 | 36 | public static function getMissingDependencyMessage(): string 37 | { 38 | return 'To use "onSingleServer" you must configure a lock factory (config path: "zenstruck_schedule.single_server_lock_factory").'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Schedule/Extension/WithoutOverlappingExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Extension; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class WithoutOverlappingExtension extends LockingExtension implements HasMissingDependencyMessage 20 | { 21 | public const DEFAULT_TTL = 86400; 22 | 23 | /** 24 | * @param int $ttl Maximum expected lock duration in seconds 25 | */ 26 | public function __construct(int $ttl = self::DEFAULT_TTL) 27 | { 28 | parent::__construct($ttl); 29 | } 30 | 31 | public function __toString(): string 32 | { 33 | return 'Without overlapping'; 34 | } 35 | 36 | public static function getMissingDependencyMessage(): string 37 | { 38 | return 'Symfony Lock is required to use the without overlapping extension. Install with "composer require symfony/lock".'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Schedule/HasExtensions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | trait HasExtensions 18 | { 19 | /** @var object[] */ 20 | private $extensions = []; 21 | 22 | final public function addExtension(object $extension): self 23 | { 24 | $this->extensions[] = $extension; 25 | 26 | return $this; 27 | } 28 | 29 | /** 30 | * @return object[] 31 | */ 32 | final public function getExtensions(): array 33 | { 34 | return $this->extensions; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Schedule/HasMissingDependencyMessage.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | /** 15 | * @author Kevin Bond 16 | */ 17 | interface HasMissingDependencyMessage 18 | { 19 | public static function getMissingDependencyMessage(): string; 20 | } 21 | -------------------------------------------------------------------------------- /src/Schedule/RunContext.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | use Symfony\Component\Console\Helper\Helper; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | abstract class RunContext 20 | { 21 | private \DateTimeImmutable $startTime; 22 | private ?int $duration = null; 23 | private ?int $memory = null; 24 | 25 | public function __construct() 26 | { 27 | $this->startTime = new \DateTimeImmutable('now'); 28 | } 29 | 30 | abstract public function __toString(): string; 31 | 32 | final public function getStartTime(): \DateTimeImmutable 33 | { 34 | return $this->startTime; 35 | } 36 | 37 | final public function hasRun(): bool 38 | { 39 | return null !== $this->memory; 40 | } 41 | 42 | final public function getDuration(): int 43 | { 44 | $this->ensureHasRun(); 45 | 46 | return $this->duration; 47 | } 48 | 49 | final public function getFormattedDuration(): string 50 | { 51 | return Helper::formatTime($this->getDuration()); 52 | } 53 | 54 | final public function getMemory(): int 55 | { 56 | $this->ensureHasRun(); 57 | 58 | return $this->memory; 59 | } 60 | 61 | final public function getFormattedMemory(): string 62 | { 63 | return Helper::formatMemory($this->getMemory()); 64 | } 65 | 66 | final protected function markAsRun(int $memory): void 67 | { 68 | $this->duration = \time() - $this->startTime->getTimestamp(); 69 | $this->memory = $memory; 70 | } 71 | 72 | /** 73 | * @throws \LogicException if has not yet run 74 | */ 75 | final protected function ensureHasRun(): void 76 | { 77 | if (!$this->hasRun()) { 78 | throw new \LogicException("\"{$this}\" has not yet run."); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Schedule/ScheduleBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | interface ScheduleBuilder 20 | { 21 | public function buildSchedule(Schedule $schedule): void; 22 | } 23 | -------------------------------------------------------------------------------- /src/Schedule/ScheduleRunContext.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule; 15 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule; 16 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 17 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class ScheduleRunContext extends RunContext 23 | { 24 | /** @var Schedule */ 25 | private $schedule; 26 | 27 | /** @var Task[] */ 28 | private $dueTasks; 29 | 30 | /** @var bool */ 31 | private $force; 32 | 33 | /** @var TaskRunContext[] */ 34 | private $taskRunContexts; 35 | 36 | /** @var string */ 37 | private $skipReason; 38 | 39 | /** @var Result[]|null */ 40 | private $results; 41 | 42 | /** @var Result[]|null */ 43 | private $successful; 44 | 45 | /** @var Result[]|null */ 46 | private $failures; 47 | 48 | /** @var Result[]|null */ 49 | private $skipped; 50 | 51 | /** @var Result[]|null */ 52 | private $run; 53 | 54 | public function __construct(Schedule $schedule, Task ...$forcedTasks) 55 | { 56 | parent::__construct(); 57 | 58 | $this->schedule = $schedule; 59 | $this->dueTasks = empty($forcedTasks) ? $schedule->due($this->getStartTime()) : $forcedTasks; 60 | $this->force = !empty($forcedTasks); 61 | } 62 | 63 | public function __toString(): string 64 | { 65 | return 'The Schedule'; 66 | } 67 | 68 | public function getSchedule(): Schedule 69 | { 70 | return $this->schedule; 71 | } 72 | 73 | /** 74 | * @return Task[] 75 | */ 76 | public function dueTasks(): array 77 | { 78 | return $this->dueTasks; 79 | } 80 | 81 | public function isForceRun(): bool 82 | { 83 | return $this->force; 84 | } 85 | 86 | public function setTaskRunContexts(TaskRunContext ...$contexts): void 87 | { 88 | $contextCount = \count($contexts); 89 | $dueCount = \count($this->dueTasks()); 90 | 91 | if ($contextCount !== $dueCount) { 92 | throw new \LogicException("The number of results ({$contextCount}) does not match the number of due tasks ({$dueCount})."); 93 | } 94 | 95 | $this->markAsRun(\memory_get_peak_usage(true)); 96 | 97 | $this->taskRunContexts = $contexts; 98 | } 99 | 100 | public function skip(SkipSchedule $exception): void 101 | { 102 | $this->skipReason = $exception->getMessage(); 103 | } 104 | 105 | public function getSkipReason(): ?string 106 | { 107 | return $this->skipReason; 108 | } 109 | 110 | /** 111 | * @return TaskRunContext[] 112 | * 113 | * @throws \LogicException if has not yet run 114 | */ 115 | public function getTaskRunContexts(): array 116 | { 117 | $this->ensureHasRun(); 118 | 119 | return $this->taskRunContexts; 120 | } 121 | 122 | /** 123 | * @return Result[] 124 | * 125 | * @throws \LogicException if has not yet run 126 | */ 127 | public function getResults(): array 128 | { 129 | if (null !== $this->results) { 130 | return $this->results; 131 | } 132 | 133 | $this->results = []; 134 | 135 | foreach ($this->getTaskRunContexts() as $context) { 136 | $this->results[] = $context->getResult(); 137 | } 138 | 139 | return $this->results; 140 | } 141 | 142 | /** 143 | * @throws \LogicException if has not yet run and has not been marked as skipped 144 | */ 145 | public function isSuccessful(): bool 146 | { 147 | return $this->isSkipped() || 0 === \count($this->getFailures()); 148 | } 149 | 150 | /** 151 | * @throws \LogicException if has not yet run 152 | */ 153 | public function isFailure(): bool 154 | { 155 | return !$this->isSuccessful(); 156 | } 157 | 158 | public function isSkipped(): bool 159 | { 160 | return null !== $this->skipReason; 161 | } 162 | 163 | /** 164 | * @return Result[] 165 | * 166 | * @throws \LogicException if has not yet run 167 | */ 168 | public function getSuccessful(): array 169 | { 170 | if (null !== $this->successful) { 171 | return $this->successful; 172 | } 173 | 174 | $this->successful = []; 175 | 176 | foreach ($this->getResults() as $result) { 177 | if ($result->isSuccessful()) { 178 | $this->successful[] = $result; 179 | } 180 | } 181 | 182 | return $this->successful; 183 | } 184 | 185 | /** 186 | * @return Result[] 187 | * 188 | * @throws \LogicException if has not yet run 189 | */ 190 | public function getFailures(): array 191 | { 192 | if (null !== $this->failures) { 193 | return $this->failures; 194 | } 195 | 196 | $this->failures = []; 197 | 198 | foreach ($this->getResults() as $result) { 199 | if ($result->isFailure()) { 200 | $this->failures[] = $result; 201 | } 202 | } 203 | 204 | return $this->failures; 205 | } 206 | 207 | /** 208 | * @return Result[] 209 | * 210 | * @throws \LogicException if has not yet run 211 | */ 212 | public function getSkipped(): array 213 | { 214 | if (null !== $this->skipped) { 215 | return $this->skipped; 216 | } 217 | 218 | $this->skipped = []; 219 | 220 | foreach ($this->getResults() as $result) { 221 | if ($result->isSkipped()) { 222 | $this->skipped[] = $result; 223 | } 224 | } 225 | 226 | return $this->skipped; 227 | } 228 | 229 | /** 230 | * @return Result[] 231 | * 232 | * @throws \LogicException if has not yet run 233 | */ 234 | public function getRun(): array 235 | { 236 | if (null !== $this->run) { 237 | return $this->run; 238 | } 239 | 240 | $this->run = []; 241 | 242 | foreach ($this->getResults() as $result) { 243 | if ($result->hasRun()) { 244 | $this->run[] = $result; 245 | } 246 | } 247 | 248 | return $this->run; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/Schedule/ScheduleRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 15 | use Zenstruck\ScheduleBundle\Event\AfterScheduleEvent; 16 | use Zenstruck\ScheduleBundle\Event\AfterTaskEvent; 17 | use Zenstruck\ScheduleBundle\Event\BeforeScheduleEvent; 18 | use Zenstruck\ScheduleBundle\Event\BeforeTaskEvent; 19 | use Zenstruck\ScheduleBundle\Event\BuildScheduleEvent; 20 | use Zenstruck\ScheduleBundle\Schedule; 21 | use Zenstruck\ScheduleBundle\Schedule\Exception\MissingDependency; 22 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipSchedule; 23 | use Zenstruck\ScheduleBundle\Schedule\Exception\SkipTask; 24 | use Zenstruck\ScheduleBundle\Schedule\Extension\ExtensionHandlerRegistry; 25 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 26 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunContext; 27 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 28 | 29 | /** 30 | * @author Kevin Bond 31 | */ 32 | final class ScheduleRunner 33 | { 34 | /** @var iterable */ 35 | private $taskRunners; 36 | 37 | /** @var ExtensionHandlerRegistry */ 38 | private $extensions; 39 | 40 | /** @var EventDispatcherInterface */ 41 | private $dispatcher; 42 | 43 | /** 44 | * @param iterable $taskRunners 45 | */ 46 | public function __construct(iterable $taskRunners, ExtensionHandlerRegistry $handlerRegistry, EventDispatcherInterface $dispatcher) 47 | { 48 | $this->taskRunners = $taskRunners; 49 | $this->extensions = $handlerRegistry; 50 | $this->dispatcher = $dispatcher; 51 | } 52 | 53 | /** 54 | * @param string ...$taskIds Task ID's to force run 55 | */ 56 | public function __invoke(string ...$taskIds): ScheduleRunContext 57 | { 58 | $scheduleRunContext = $this->createRunContext($taskIds); 59 | 60 | try { 61 | $this->dispatcher->dispatch(new BeforeScheduleEvent($scheduleRunContext)); 62 | $this->extensions->beforeSchedule($scheduleRunContext); 63 | } catch (SkipSchedule $e) { 64 | $scheduleRunContext->skip($e); 65 | 66 | $this->dispatcher->dispatch(new AfterScheduleEvent($scheduleRunContext)); 67 | 68 | return $scheduleRunContext; 69 | } 70 | 71 | $taskRunContexts = []; 72 | 73 | foreach ($scheduleRunContext->dueTasks() as $task) { 74 | $taskRunContext = new TaskRunContext($scheduleRunContext, $task); 75 | 76 | $this->dispatcher->dispatch(new BeforeTaskEvent($taskRunContext)); 77 | 78 | $taskRunContext->setResult($this->runTask($taskRunContext)); 79 | 80 | $this->postRun($taskRunContext); 81 | 82 | $this->dispatcher->dispatch(new AfterTaskEvent($taskRunContext)); 83 | 84 | $taskRunContexts[] = $taskRunContext; 85 | } 86 | 87 | $scheduleRunContext->setTaskRunContexts(...$taskRunContexts); 88 | 89 | $this->extensions->afterSchedule($scheduleRunContext); 90 | $this->dispatcher->dispatch(new AfterScheduleEvent($scheduleRunContext)); 91 | 92 | return $scheduleRunContext; 93 | } 94 | 95 | public function buildSchedule(): Schedule 96 | { 97 | $this->dispatcher->dispatch(new BuildScheduleEvent($schedule = new Schedule())); 98 | 99 | return $schedule; 100 | } 101 | 102 | public function runnerFor(Task $task): TaskRunner 103 | { 104 | foreach ($this->taskRunners as $runner) { 105 | if ($runner->supports($task)) { 106 | return $runner; 107 | } 108 | } 109 | 110 | throw MissingDependency::noTaskRunner($task); 111 | } 112 | 113 | private function runTask(TaskRunContext $context): Result 114 | { 115 | $task = $context->getTask(); 116 | 117 | try { 118 | $this->extensions->beforeTask($context); 119 | 120 | return $this->runnerFor($task)($task); 121 | } catch (SkipTask $e) { 122 | return $e->createResult($task); 123 | } catch (\Throwable $e) { 124 | return Result::exception($task, $e); 125 | } 126 | } 127 | 128 | private function postRun(TaskRunContext $context): void 129 | { 130 | try { 131 | $this->extensions->afterTask($context); 132 | } catch (\Throwable $e) { 133 | $context->setResult(Result::exception($context->getTask(), $e)); 134 | } 135 | } 136 | 137 | /** 138 | * @param string[] $taskIds 139 | */ 140 | private function createRunContext(array $taskIds): ScheduleRunContext 141 | { 142 | $schedule = $this->buildSchedule(); 143 | 144 | $tasks = \array_map(fn(string $id) => $schedule->getTask($id), $taskIds); 145 | 146 | return new ScheduleRunContext($schedule, ...$tasks); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Schedule/SelfSchedulingCommand.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Task\CommandTask; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | interface SelfSchedulingCommand 20 | { 21 | public function schedule(CommandTask $task): void; 22 | } 23 | -------------------------------------------------------------------------------- /src/Schedule/Task/CallbackTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Extension\CallbackExtension; 15 | use Zenstruck\ScheduleBundle\Schedule\Task; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class CallbackTask extends Task 21 | { 22 | /** @var callable */ 23 | private $callback; 24 | 25 | /** 26 | * @param callable $callback Return value is considered "output" 27 | */ 28 | public function __construct(callable $callback, ?string $description = null) 29 | { 30 | parent::__construct($description ?? '(callable) '.CallbackExtension::createDescriptionFromCallback($callback)); 31 | 32 | $this->callback = $callback; 33 | } 34 | 35 | public function getCallback(): callable 36 | { 37 | return $this->callback; 38 | } 39 | 40 | public function getContext(): array 41 | { 42 | return ['Callable' => CallbackExtension::createDescriptionFromCallback($this->callback)]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Schedule/Task/CommandTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Symfony\Component\Console\Application; 15 | use Symfony\Component\Console\Command\Command; 16 | use Symfony\Component\Console\Command\LazyCommand; 17 | use Symfony\Component\Console\Exception\CommandNotFoundException; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Input\StringInput; 20 | use Zenstruck\ScheduleBundle\Schedule\Task; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class CommandTask extends Task 26 | { 27 | /** @var string */ 28 | private $name; 29 | 30 | /** @var string */ 31 | private $arguments = ''; 32 | 33 | /** 34 | * @param string $name Command class or name (my:command) 35 | */ 36 | public function __construct(string $name, string ...$arguments) 37 | { 38 | $parts = \explode(' ', $name, 2); 39 | $name = $parts[0]; 40 | 41 | if (2 === \count($parts)) { 42 | $arguments = \array_merge([$parts[1]], $arguments); 43 | } 44 | 45 | $this->name = $name; 46 | 47 | if (!empty($arguments)) { 48 | $this->arguments(...$arguments); 49 | } 50 | 51 | parent::__construct($this->name); 52 | } 53 | 54 | public function arguments(string $argument, string ...$arguments): self 55 | { 56 | $this->arguments = \implode(' ', \array_merge([$argument], $arguments)); 57 | 58 | return $this; 59 | } 60 | 61 | public function getArguments(): string 62 | { 63 | return (string) $this->arguments; 64 | } 65 | 66 | public function getContext(): array 67 | { 68 | return [ 69 | 'Command Name' => $this->name, 70 | 'Command Arguments' => $this->getArguments() ?: '(none)', 71 | ]; 72 | } 73 | 74 | public function createCommandInput(Application $application): InputInterface 75 | { 76 | return new StringInput(\implode(' ', \array_filter([ 77 | $this->createCommand($application)->getName(), 78 | $this->getArguments(), 79 | ]))); 80 | } 81 | 82 | public function createCommand(Application $application): Command 83 | { 84 | $registeredCommands = $application->all(); 85 | 86 | if (isset($registeredCommands[$this->name])) { 87 | return $registeredCommands[$this->name]; 88 | } 89 | 90 | foreach ($registeredCommands as $command) { 91 | $className = $command::class; 92 | if ($command instanceof LazyCommand) { 93 | $className = \get_class($command->getCommand()); 94 | } 95 | if ($this->name === $className) { 96 | return $command; 97 | } 98 | } 99 | 100 | throw new CommandNotFoundException("Command \"{$this->name}\" not registered."); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Schedule/Task/CompoundTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Symfony\Component\Process\Process; 16 | use Zenstruck\ScheduleBundle\Schedule\Task; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @implements \IteratorAggregate 22 | */ 23 | final class CompoundTask extends Task implements \IteratorAggregate 24 | { 25 | /** @var Task[] */ 26 | private $tasks = []; 27 | 28 | public function __construct() 29 | { 30 | parent::__construct('compound task'); 31 | } 32 | 33 | public function add(Task $task): self 34 | { 35 | if ($task instanceof self) { 36 | throw new \LogicException('Cannot nest compound tasks.'); 37 | } 38 | 39 | $this->tasks[] = $task; 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * @see CommandTask::__construct() 46 | * 47 | * @param string|null $description optional description 48 | */ 49 | public function addCommand(string $name, array $arguments = [], ?string $description = null): self 50 | { 51 | return $this->addWithDescription(new CommandTask($name, ...$arguments), $description); 52 | } 53 | 54 | /** 55 | * @see CallbackTask::__construct() 56 | * 57 | * @param string|null $description optional description 58 | */ 59 | public function addCallback(callable $callback, ?string $description = null): self 60 | { 61 | return $this->addWithDescription(new CallbackTask($callback), $description); 62 | } 63 | 64 | /** 65 | * @see ProcessTask::__construct() 66 | * 67 | * @param string|Process $process 68 | * @param string|null $description optional description 69 | */ 70 | public function addProcess($process, ?string $description = null): self 71 | { 72 | return $this->addWithDescription(new ProcessTask($process), $description); 73 | } 74 | 75 | /** 76 | * @see PingTask::__construct() 77 | * 78 | * @param string|null $description optional description 79 | */ 80 | public function addPing(string $url, string $method = 'GET', array $options = [], ?string $description = null): self 81 | { 82 | return $this->addWithDescription(new PingTask($url, $method, $options), $description); 83 | } 84 | 85 | /** 86 | * @see MessageTask::__construct() 87 | * 88 | * @param object|Envelope $message 89 | * @param string|null $description optional description 90 | */ 91 | public function addMessage(object $message, array $stamps = [], ?string $description = null): self 92 | { 93 | return $this->addWithDescription(new MessageTask($message, $stamps), $description); 94 | } 95 | 96 | /** 97 | * @return \Traversable 98 | */ 99 | public function getIterator(): \Traversable 100 | { 101 | foreach ($this->tasks as $task) { 102 | $task->cron($this->getExpression()); 103 | 104 | if ($this->getTimezone()) { 105 | $task->timezone($this->getTimezone()); 106 | } 107 | 108 | foreach ($this->getExtensions() as $extension) { 109 | $task->addExtension($extension); 110 | } 111 | 112 | yield $task; 113 | } 114 | } 115 | 116 | private function addWithDescription(Task $task, ?string $description = null): self 117 | { 118 | if ($description) { 119 | $task->description($description); 120 | } 121 | 122 | return $this->add($task); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Schedule/Task/MessageTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Symfony\Component\Messenger\Stamp\StampInterface; 16 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 17 | use Zenstruck\ScheduleBundle\Schedule\Task; 18 | 19 | /** 20 | * @experimental This is experimental and may experience BC breaks 21 | * 22 | * @author Kevin Bond 23 | */ 24 | final class MessageTask extends Task implements HasMissingDependencyMessage 25 | { 26 | /** @var object|Envelope */ 27 | private $message; 28 | 29 | /** @var StampInterface[] */ 30 | private $stamps; 31 | 32 | /** 33 | * @param object|Envelope $message 34 | * @param StampInterface[] $stamps 35 | */ 36 | public function __construct(object $message, array $stamps = []) 37 | { 38 | $this->message = $message; 39 | $this->stamps = $stamps; 40 | 41 | parent::__construct($this->messageClass()); 42 | } 43 | 44 | /** 45 | * @return object|Envelope 46 | */ 47 | public function getMessage(): object 48 | { 49 | return $this->message; 50 | } 51 | 52 | /** 53 | * @return StampInterface[] 54 | */ 55 | public function getStamps(): array 56 | { 57 | return $this->stamps; 58 | } 59 | 60 | public function getContext(): array 61 | { 62 | $stamps = \array_merge( 63 | $this->message instanceof Envelope ? \array_keys($this->message->all()) : [], 64 | \array_map(static fn(StampInterface $stamp) => $stamp::class, $this->stamps), 65 | ); 66 | $stamps = \array_map( 67 | static function(string $stamp) { 68 | /** @var class-string $stamp */ 69 | return (new \ReflectionClass($stamp))->getShortName(); 70 | }, 71 | $stamps, 72 | ); 73 | $stamps = \implode(', ', \array_unique($stamps)); 74 | 75 | return [ 76 | 'Message' => $this->messageClass(), 77 | 'Stamps' => $stamps ?: '(none)', 78 | ]; 79 | } 80 | 81 | public static function getMissingDependencyMessage(): string 82 | { 83 | return 'To use the message task you must install symfony/messenger (composer require symfony/messenger) and enable (config path: "zenstruck_schedule.messenger").'; 84 | } 85 | 86 | private function messageClass(): string 87 | { 88 | return $this->message instanceof Envelope ? \get_class($this->message->getMessage()) : \get_class($this->message); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Schedule/Task/PingTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 15 | use Zenstruck\ScheduleBundle\Schedule\Task; 16 | 17 | /** 18 | * @author Kevin Bond 19 | */ 20 | final class PingTask extends Task implements HasMissingDependencyMessage 21 | { 22 | /** @var string */ 23 | private $url; 24 | 25 | /** @var string */ 26 | private $method; 27 | 28 | /** @var array */ 29 | private $options; 30 | 31 | /** 32 | * @param array $options See HttpClientInterface::OPTIONS_DEFAULTS 33 | */ 34 | public function __construct(string $url, string $method = 'GET', array $options = []) 35 | { 36 | $this->url = $url; 37 | $this->method = $method; 38 | $this->options = $options; 39 | 40 | parent::__construct("Ping {$url}"); 41 | } 42 | 43 | public function getContext(): array 44 | { 45 | return [ 46 | 'Url' => $this->url, 47 | 'Method' => $this->method, 48 | 'Options' => \json_encode($this->options), 49 | ]; 50 | } 51 | 52 | public function getUrl(): string 53 | { 54 | return $this->url; 55 | } 56 | 57 | public function getMethod(): string 58 | { 59 | return $this->method; 60 | } 61 | 62 | public function getOptions(): array 63 | { 64 | return $this->options; 65 | } 66 | 67 | public static function getMissingDependencyMessage(): string 68 | { 69 | return \sprintf('Symfony HttpClient is required to use "%s". Install with "composer require symfony/http-client".', self::class); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Schedule/Task/ProcessTask.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Symfony\Component\Process\Process; 15 | use Zenstruck\ScheduleBundle\Schedule\HasMissingDependencyMessage; 16 | use Zenstruck\ScheduleBundle\Schedule\Task; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class ProcessTask extends Task implements HasMissingDependencyMessage 22 | { 23 | /** @var Process */ 24 | private $process; 25 | 26 | /** 27 | * @param string|Process $process 28 | */ 29 | public function __construct($process) 30 | { 31 | if (!$process instanceof Process) { 32 | $process = Process::fromShellCommandline($process); 33 | } 34 | 35 | $this->process = $process; 36 | 37 | parent::__construct($process->getCommandLine()); 38 | } 39 | 40 | public function getProcess(): Process 41 | { 42 | return $this->process; 43 | } 44 | 45 | public function getContext(): array 46 | { 47 | return [ 48 | 'Command Line' => $this->process->getCommandLine(), 49 | 'Command Timeout' => (string) $this->process->getTimeout(), 50 | ]; 51 | } 52 | 53 | public static function getMissingDependencyMessage(): string 54 | { 55 | return \sprintf('"symfony/process" is required to use "%s". Install with "composer require symfony/process".', self::class); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Schedule/Task/Result.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Task; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | final class Result 20 | { 21 | public const SUCCESSFUL = 'successful'; 22 | public const FAILED = 'failed'; 23 | public const SKIPPED = 'skipped'; 24 | 25 | /** @var Task */ 26 | private $task; 27 | 28 | /** @var string */ 29 | private $type; 30 | 31 | /** @var string */ 32 | private $description; 33 | 34 | /** @var string|null */ 35 | private $output; 36 | 37 | /** @var \Throwable|null */ 38 | private $exception; 39 | 40 | private function __construct(Task $task, string $type, string $description) 41 | { 42 | $this->task = $task; 43 | $this->type = $type; 44 | $this->description = $description; 45 | } 46 | 47 | public function __toString(): string 48 | { 49 | return $this->getDescription(); 50 | } 51 | 52 | public static function successful(Task $task, ?string $output = null, string $description = 'Successful'): self 53 | { 54 | $result = new self($task, self::SUCCESSFUL, $description); 55 | $result->output = $output; 56 | 57 | return $result; 58 | } 59 | 60 | public static function failure(Task $task, string $description, ?string $output = null): self 61 | { 62 | $result = new self($task, self::FAILED, $description); 63 | $result->output = $output; 64 | 65 | return $result; 66 | } 67 | 68 | public static function exception(Task $task, \Throwable $exception, ?string $output = null, ?string $description = null): self 69 | { 70 | $description = $description ?: \sprintf('%s: %s', (new \ReflectionClass($exception))->getShortName(), $exception->getMessage()); 71 | 72 | $result = self::failure($task, $description, $output); 73 | $result->exception = $exception; 74 | 75 | return $result; 76 | } 77 | 78 | public static function skipped(Task $task, string $description): self 79 | { 80 | return new self($task, self::SKIPPED, $description); 81 | } 82 | 83 | public function getTask(): Task 84 | { 85 | return $this->task; 86 | } 87 | 88 | public function getType(): string 89 | { 90 | return $this->type; 91 | } 92 | 93 | public function getDescription(): string 94 | { 95 | return $this->description; 96 | } 97 | 98 | public function getOutput(): ?string 99 | { 100 | return $this->output; 101 | } 102 | 103 | public function getException(): ?\Throwable 104 | { 105 | return $this->exception; 106 | } 107 | 108 | public function isSuccessful(): bool 109 | { 110 | return self::SUCCESSFUL === $this->getType(); 111 | } 112 | 113 | public function isFailure(): bool 114 | { 115 | return self::FAILED === $this->getType(); 116 | } 117 | 118 | public function isException(): bool 119 | { 120 | return $this->isFailure() && $this->exception instanceof \Throwable; 121 | } 122 | 123 | public function isSkipped(): bool 124 | { 125 | return self::SKIPPED === $this->getType(); 126 | } 127 | 128 | public function hasRun(): bool 129 | { 130 | return self::SKIPPED !== $this->getType(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Schedule/Task/Runner/CallbackTaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task\Runner; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Task; 15 | use Zenstruck\ScheduleBundle\Schedule\Task\CallbackTask; 16 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 17 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class CallbackTaskRunner implements TaskRunner 23 | { 24 | /** 25 | * @param CallbackTask $task 26 | */ 27 | public function __invoke(Task $task): Result 28 | { 29 | $output = $task->getCallback()(); 30 | 31 | return Result::successful($task, self::stringify($output)); 32 | } 33 | 34 | public function supports(Task $task): bool 35 | { 36 | return $task instanceof CallbackTask; 37 | } 38 | 39 | /** 40 | * @param mixed $value 41 | */ 42 | private static function stringify($value): ?string 43 | { 44 | if (null === $value) { 45 | return null; 46 | } 47 | 48 | if (\is_scalar($value)) { 49 | return (string) $value; 50 | } 51 | 52 | if (\is_object($value) && \method_exists($value, '__toString')) { 53 | return $value; 54 | } 55 | 56 | if (\is_object($value)) { 57 | return '[object] '.$value::class; 58 | } 59 | 60 | return '('.\gettype($value).')'; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Schedule/Task/Runner/CommandTaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task\Runner; 13 | 14 | use Symfony\Component\Console\Application; 15 | use Symfony\Component\Console\Output\BufferedOutput; 16 | use Symfony\Component\Process\Process; 17 | use Zenstruck\ScheduleBundle\Schedule\Task; 18 | use Zenstruck\ScheduleBundle\Schedule\Task\CommandTask; 19 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class CommandTaskRunner implements TaskRunner 26 | { 27 | /** @var Application */ 28 | private $application; 29 | 30 | public function __construct(Application $application) 31 | { 32 | $this->application = $application; 33 | } 34 | 35 | /** 36 | * @param CommandTask $task 37 | */ 38 | public function __invoke(Task $task): Result 39 | { 40 | $shellVerbosityResetter = new ShellVerbosityResetter(); 41 | $output = new BufferedOutput(); 42 | $this->application->setCatchExceptions(false); 43 | $this->application->setAutoExit(false); 44 | 45 | try { 46 | $exitCode = $this->application->run($task->createCommandInput($this->application), $output); 47 | } catch (\Throwable $e) { 48 | return Result::exception($task, $e, $output->fetch()); 49 | } finally { 50 | $shellVerbosityResetter->reset(); 51 | } 52 | 53 | if (0 === $exitCode) { 54 | return Result::successful($task, $output->fetch()); 55 | } 56 | 57 | return Result::failure($task, "Exit {$exitCode}: {$this->getFailureMessage($exitCode)}", $output->fetch()); 58 | } 59 | 60 | public function supports(Task $task): bool 61 | { 62 | return $task instanceof CommandTask; 63 | } 64 | 65 | private function getFailureMessage(int $exitCode): string 66 | { 67 | if (\class_exists(Process::class) && isset(Process::$exitCodes[$exitCode])) { 68 | return Process::$exitCodes[$exitCode]; 69 | } 70 | 71 | return 'Unknown error'; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Schedule/Task/Runner/MessageTaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task\Runner; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Symfony\Component\Messenger\MessageBusInterface; 16 | use Symfony\Component\Messenger\Stamp\HandledStamp; 17 | use Symfony\Component\Messenger\Stamp\SentStamp; 18 | use Zenstruck\ScheduleBundle\Schedule\Task; 19 | use Zenstruck\ScheduleBundle\Schedule\Task\MessageTask; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 21 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 22 | 23 | /** 24 | * @experimental This is experimental and may experience BC breaks 25 | * 26 | * @author Kevin Bond 27 | */ 28 | final class MessageTaskRunner implements TaskRunner 29 | { 30 | /** @var MessageBusInterface */ 31 | private $bus; 32 | 33 | public function __construct(MessageBusInterface $bus) 34 | { 35 | $this->bus = $bus; 36 | } 37 | 38 | /** 39 | * @param MessageTask $task 40 | */ 41 | public function __invoke(Task $task): Result 42 | { 43 | $envelope = $this->bus->dispatch($task->getMessage(), $task->getStamps()); 44 | $output = $this->handlerOutput($envelope); 45 | 46 | if (empty($output)) { 47 | return Result::failure($task, 'Message not handled or sent to transport.'); 48 | } 49 | 50 | return Result::successful($task, \implode("\n", $output)); 51 | } 52 | 53 | public function supports(Task $task): bool 54 | { 55 | return $task instanceof MessageTask; 56 | } 57 | 58 | /** 59 | * @return string[] 60 | */ 61 | private function handlerOutput(Envelope $envelope): array 62 | { 63 | $output = []; 64 | 65 | foreach ($envelope->all(HandledStamp::class) as $stamp) { 66 | /** @var HandledStamp $stamp */ 67 | $output[] = \sprintf('Handled by: "%s", return: %s', $stamp->getHandlerName(), $this->handledStampReturn($stamp)); 68 | } 69 | 70 | foreach ($envelope->all(SentStamp::class) as $stamp) { 71 | /** @var SentStamp $stamp */ 72 | $output[] = \sprintf('Sent to: "%s"', $stamp->getSenderClass()); 73 | } 74 | 75 | return $output; 76 | } 77 | 78 | private function handledStampReturn(HandledStamp $stamp): string 79 | { 80 | $result = $stamp->getResult(); 81 | 82 | switch (true) { 83 | case null === $result: 84 | return '(none)'; 85 | 86 | case \is_scalar($result): 87 | return \sprintf('(%s) "%s"', \get_debug_type($result), $result); 88 | } 89 | 90 | return \sprintf('(%s)', \get_debug_type($result)); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Schedule/Task/Runner/PingTaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task\Runner; 13 | 14 | use Symfony\Component\HttpClient\HttpClient; 15 | use Symfony\Contracts\HttpClient\HttpClientInterface; 16 | use Zenstruck\ScheduleBundle\Schedule\Exception\MissingDependency; 17 | use Zenstruck\ScheduleBundle\Schedule\Task; 18 | use Zenstruck\ScheduleBundle\Schedule\Task\PingTask; 19 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 20 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | final class PingTaskRunner implements TaskRunner 26 | { 27 | /** @var HttpClientInterface */ 28 | private $httpClient; 29 | 30 | public function __construct(?HttpClientInterface $httpClient = null) 31 | { 32 | if (null === $httpClient && !\class_exists(HttpClient::class)) { 33 | throw new MissingDependency(PingTask::getMissingDependencyMessage()); 34 | } 35 | 36 | $this->httpClient = $httpClient ?: HttpClient::create(); 37 | } 38 | 39 | /** 40 | * @param PingTask $task 41 | */ 42 | public function __invoke(Task $task): Result 43 | { 44 | $response = $this->httpClient->request($task->getMethod(), $task->getUrl(), $task->getOptions()); 45 | $content = $response->getContent(); 46 | $output = \array_merge($response->getInfo('response_headers'), ['', $content]); 47 | 48 | return Result::successful($task, \implode("\n", $output)); 49 | } 50 | 51 | public function supports(Task $task): bool 52 | { 53 | return $task instanceof PingTask; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Schedule/Task/Runner/ProcessTaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task\Runner; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Task; 15 | use Zenstruck\ScheduleBundle\Schedule\Task\ProcessTask; 16 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 17 | use Zenstruck\ScheduleBundle\Schedule\Task\TaskRunner; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class ProcessTaskRunner implements TaskRunner 23 | { 24 | /** 25 | * @param ProcessTask $task 26 | */ 27 | public function __invoke(Task $task): Result 28 | { 29 | $process = clone $task->getProcess(); 30 | 31 | $process->run(); 32 | 33 | if ($process->isSuccessful()) { 34 | return Result::successful($task, $process->getOutput()); 35 | } 36 | 37 | return Result::failure( 38 | $task, 39 | "Exit {$process->getExitCode()}: {$process->getExitCodeText()}", 40 | $process->getOutput().$process->getErrorOutput(), 41 | ); 42 | } 43 | 44 | public function supports(Task $task): bool 45 | { 46 | return $task instanceof ProcessTask; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Schedule/Task/Runner/ShellVerbosityResetter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task\Runner; 13 | 14 | /** 15 | * @internal 16 | * 17 | * @author Kevin Bond 18 | */ 19 | final class ShellVerbosityResetter 20 | { 21 | /** @var false|string */ 22 | private $var; 23 | 24 | /** @var false|string */ 25 | private $env; 26 | 27 | /** @var false|string */ 28 | private $server; 29 | 30 | public function __construct() 31 | { 32 | $var = \getenv('SHELL_VERBOSITY'); 33 | 34 | $this->var = \is_string($var) ? $var : false; 35 | $this->env = $_ENV['SHELL_VERBOSITY'] ?? false; 36 | $this->server = $_SERVER['SHELL_VERBOSITY'] ?? false; 37 | } 38 | 39 | public function reset(): void 40 | { 41 | $this->resetVar(); 42 | $this->resetEnv(); 43 | $this->resetServer(); 44 | } 45 | 46 | private function resetVar(): void 47 | { 48 | if (!\function_exists('putenv')) { 49 | return; 50 | } 51 | 52 | if (false === $this->var) { 53 | // unset as it wasn't set to begin with 54 | @\putenv('SHELL_VERBOSITY'); 55 | 56 | return; 57 | } 58 | 59 | @\putenv("SHELL_VERBOSITY={$this->var}"); 60 | } 61 | 62 | private function resetEnv(): void 63 | { 64 | if (false === $this->env) { 65 | // unset as it wasn't set to begin with 66 | unset($_ENV['SHELL_VERBOSITY']); 67 | 68 | return; 69 | } 70 | 71 | $_ENV['SHELL_VERBOSITY'] = $this->env; 72 | } 73 | 74 | private function resetServer(): void 75 | { 76 | if (false === $this->server) { 77 | // unset as it wasn't set to begin with 78 | unset($_SERVER['SHELL_VERBOSITY']); 79 | 80 | return; 81 | } 82 | 83 | $_SERVER['SHELL_VERBOSITY'] = $this->server; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Schedule/Task/TaskRunContext.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\RunContext; 15 | use Zenstruck\ScheduleBundle\Schedule\ScheduleRunContext; 16 | use Zenstruck\ScheduleBundle\Schedule\Task; 17 | 18 | /** 19 | * @author Kevin Bond 20 | */ 21 | final class TaskRunContext extends RunContext 22 | { 23 | /** @var ScheduleRunContext */ 24 | private $scheduleRunContext; 25 | 26 | /** @var Task */ 27 | private $task; 28 | 29 | /** @var Result */ 30 | private $result; 31 | 32 | public function __construct(ScheduleRunContext $scheduleRunContext, Task $task) 33 | { 34 | $this->scheduleRunContext = $scheduleRunContext; 35 | $this->task = $task; 36 | 37 | parent::__construct(); 38 | } 39 | 40 | public function __toString(): string 41 | { 42 | return (string) $this->getTask(); 43 | } 44 | 45 | public function getScheduleRunContext(): ScheduleRunContext 46 | { 47 | return $this->scheduleRunContext; 48 | } 49 | 50 | public function getTask(): Task 51 | { 52 | return $this->task; 53 | } 54 | 55 | /** 56 | * @throws \LogicException if has not yet run 57 | */ 58 | public function getResult(): Result 59 | { 60 | $this->ensureHasRun(); 61 | 62 | return $this->result; 63 | } 64 | 65 | public function setResult(Result $result): void 66 | { 67 | $resultTask = $result->getTask(); 68 | 69 | if ($resultTask->getId() !== $this->getTask()->getId()) { 70 | throw new \LogicException("The result's task ({$resultTask}) does not match the context's task ({$this->getTask()})."); 71 | } 72 | 73 | $this->markAsRun(\memory_get_usage(true)); 74 | 75 | $this->result = $result; 76 | } 77 | 78 | /** 79 | * @throws \LogicException if has not yet run 80 | */ 81 | public function isSuccessful(): bool 82 | { 83 | return $this->getResult()->isSuccessful(); 84 | } 85 | 86 | /** 87 | * @throws \LogicException if has not yet run 88 | */ 89 | public function isFailure(): bool 90 | { 91 | return $this->getResult()->isFailure(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Schedule/Task/TaskRunner.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle\Schedule\Task; 13 | 14 | use Zenstruck\ScheduleBundle\Schedule\Task; 15 | 16 | /** 17 | * @author Kevin Bond 18 | */ 19 | interface TaskRunner 20 | { 21 | public function __invoke(Task $task): Result; 22 | 23 | public function supports(Task $task): bool; 24 | } 25 | -------------------------------------------------------------------------------- /src/Schedule/TaskOutput.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace Zenstruck\ScheduleBundle\Schedule; 15 | 16 | use Zenstruck\ScheduleBundle\Schedule\Task\Result; 17 | 18 | trait TaskOutput 19 | { 20 | private function getTaskOutput(Result $result, ScheduleRunContext $context, bool $includeException = true): string 21 | { 22 | $output = ''; 23 | 24 | if ($context->isForceRun()) { 25 | $output = "!! This task was force run !!\n\n"; 26 | } 27 | 28 | $output .= \sprintf("Result: \"%s\"\n\nTask ID: %s", $result, $result->getTask()->getId()); 29 | 30 | if ($result->getOutput()) { 31 | $output .= "\n\n## Task Output:\n\n{$result->getOutput()}"; 32 | } 33 | 34 | if ($result->isException() && $includeException) { 35 | $output .= "\n\n## Exception:\n\n{$result->getException()}"; 36 | } 37 | 38 | return $output; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ZenstruckScheduleBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\ScheduleBundle; 13 | 14 | use Symfony\Component\DependencyInjection\ContainerBuilder; 15 | use Symfony\Component\HttpKernel\Bundle\Bundle; 16 | use Zenstruck\ScheduleBundle\DependencyInjection\Compiler\ScheduleBuilderKernelPass; 17 | use Zenstruck\ScheduleBundle\DependencyInjection\Compiler\ScheduledServiceBuilderPass; 18 | 19 | /** 20 | * @author Kevin Bond 21 | */ 22 | final class ZenstruckScheduleBundle extends Bundle 23 | { 24 | public function build(ContainerBuilder $container): void 25 | { 26 | parent::build($container); 27 | 28 | $container->addCompilerPass(new ScheduleBuilderKernelPass()); 29 | $container->addCompilerPass(new ScheduledServiceBuilderPass()); 30 | } 31 | } 32 | --------------------------------------------------------------------------------