├── LICENSE ├── composer.json └── src ├── Cron.php └── Cron ├── Action.php ├── ActionInterface.php ├── ActionTrait.php ├── RunOnStartUpAction.php └── Scheduler.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cees-Jan Kiewiet 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": "wyrihaximus/react-cron", 3 | "description": "⏱️ Cronlike scheduler running inside the ReactPHP Event Loop", 4 | "license": "MIT", 5 | "require": { 6 | "php": "^8.3", 7 | "dragonmantank/cron-expression": "^3.4.0", 8 | "evenement/evenement": "^3.0.2", 9 | "react/async": "^4.3", 10 | "react/event-loop": "^1.5", 11 | "react/promise": "^3.2", 12 | "wyrihaximus/constants": "^1.6", 13 | "wyrihaximus/react-mutex": "^3.2", 14 | "wyrihaximus/react-mutex-contracts": "^2.3" 15 | }, 16 | "require-dev": { 17 | "wyrihaximus/async-test-utilities": "^9.1.0", 18 | "wyrihaximus/makefiles": "^0.4.2" 19 | }, 20 | "conflict": { 21 | "azjezz/psl": "<2" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "WyriHaximus\\React\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "WyriHaximus\\Tests\\React\\Cron\\": "tests/" 31 | } 32 | }, 33 | "config": { 34 | "allow-plugins": { 35 | "composer/package-versions-deprecated": true, 36 | "dealerdirect/phpcodesniffer-composer-installer": true, 37 | "ergebnis/composer-normalize": true, 38 | "icanhazstring/composer-unused": true, 39 | "infection/extension-installer": true, 40 | "phpstan/extension-installer": true, 41 | "wyrihaximus/makefiles": true 42 | }, 43 | "platform": { 44 | "php": "8.3.13" 45 | }, 46 | "sort-packages": true 47 | }, 48 | "scripts": { 49 | "post-install-cmd": [ 50 | "composer normalize" 51 | ], 52 | "post-update-cmd": [ 53 | "composer normalize" 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Cron.php: -------------------------------------------------------------------------------- 1 | */ 27 | private array $actions; 28 | 29 | private Scheduler $scheduler; 30 | 31 | private function __construct(private MutexInterface $mutex, ActionInterface ...$actions) 32 | { 33 | $this->scheduler = new Scheduler(); 34 | $this->actions = $actions; 35 | 36 | foreach ($this->actions as $action) { 37 | if (! ($action instanceof RunOnStartUpAction)) { 38 | continue; 39 | } 40 | 41 | Loop::futureTick(async(fn () => $this->perform($action))); 42 | } 43 | 44 | $this->scheduler->schedule(function (): void { 45 | $this->tick(); 46 | }); 47 | } 48 | 49 | public static function create(ActionInterface ...$actions): self 50 | { 51 | return self::createWithMutex(new Memory(), ...$actions); 52 | } 53 | 54 | public static function createWithMutex(MutexInterface $mutex, ActionInterface ...$actions): self 55 | { 56 | return new self($mutex, ...$actions); 57 | } 58 | 59 | public function stop(): void 60 | { 61 | $this->scheduler->stop(); 62 | } 63 | 64 | private function tick(): void 65 | { 66 | foreach ($this->actions as $action) { 67 | if (! $action->isDue()) { 68 | continue; 69 | } 70 | 71 | Loop::futureTick(async(fn () => $this->perform($action))); 72 | } 73 | } 74 | 75 | private function perform(ActionInterface $action): void 76 | { 77 | try { 78 | /** @var ?LockInterface $lock */ 79 | $lock = await($this->mutex->acquire($action->key(), $action->mutexTtl())); 80 | if ($lock === null) { 81 | return; 82 | } 83 | 84 | $action->perform(); 85 | 86 | $this->mutex->release($lock); 87 | } catch (Throwable $throwable) { 88 | $this->emit('error', [$throwable, $action]); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Cron/Action.php: -------------------------------------------------------------------------------- 1 | expression = new CronExpression($expression); 19 | $this->performer = $performer; 20 | } 21 | 22 | public function key(): string 23 | { 24 | return $this->key; 25 | } 26 | 27 | public function mutexTtl(): float 28 | { 29 | return $this->mutexTtl; 30 | } 31 | 32 | public function isDue(): bool 33 | { 34 | return $this->expression->isDue(); 35 | } 36 | 37 | public function perform(): void 38 | { 39 | ($this->performer)(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Cron/RunOnStartUpAction.php: -------------------------------------------------------------------------------- 1 | align(); 35 | } 36 | 37 | public function schedule(callable $tick): void 38 | { 39 | // Push this new tick on the stack with the rest, running it in the next minute 40 | $this->ticks[] = $tick; 41 | } 42 | 43 | private function time(): float 44 | { 45 | return microtime(TRUE_); 46 | } 47 | 48 | private function hasDrifted(float $time): bool 49 | { 50 | return (int) date('s', (int) $time) > ZERO; 51 | } 52 | 53 | private function tick(): void 54 | { 55 | if ($this->active === self::INACTIVE) { 56 | return; 57 | } 58 | 59 | $startOfTick = $this->time(); 60 | 61 | foreach ($this->ticks as $tick) { 62 | $tick(); 63 | } 64 | 65 | if (! $this->hasDrifted($startOfTick)) { 66 | return; 67 | } 68 | 69 | $this->align(); 70 | } 71 | 72 | private function align(): void 73 | { 74 | if ($this->timer instanceof TimerInterface) { 75 | Loop::cancelTimer($this->timer); 76 | $this->timer = null; 77 | } 78 | 79 | $currentSecond = (int) date('s', (int) $this->time()); 80 | 81 | if ($currentSecond >= ONE && $currentSecond <= self::TIER_SLOW) { 82 | $this->timer = Loop::addTimer(self::TIER_SLOW - $currentSecond, function (): void { 83 | $this->timer = null; 84 | $this->align(); 85 | }); 86 | 87 | return; 88 | } 89 | 90 | if ($currentSecond > self::TIER_SLOW && $currentSecond <= self::TIER_MEDIUM) { 91 | $this->timer = Loop::addTimer(ONE, function (): void { 92 | $this->timer = null; 93 | $this->align(); 94 | }); 95 | 96 | return; 97 | } 98 | 99 | if ($currentSecond === self::TIER_FAST) { 100 | $this->timer = Loop::addTimer(0.001, function (): void { 101 | $this->timer = null; 102 | $this->align(); 103 | }); 104 | 105 | return; 106 | } 107 | 108 | $this->tick(); 109 | 110 | $this->timer = Loop::addPeriodicTimer(self::MINUTE_SECONDS, function (): void { 111 | $this->tick(); 112 | }); 113 | } 114 | 115 | public function stop(): void 116 | { 117 | $this->active = self::INACTIVE; 118 | 119 | if (! ($this->timer instanceof TimerInterface)) { 120 | return; 121 | } 122 | 123 | Loop::cancelTimer($this->timer); 124 | } 125 | } 126 | --------------------------------------------------------------------------------