├── src ├── Cron │ ├── Action.php │ ├── RunOnStartUpAction.php │ ├── ActionInterface.php │ ├── ActionTrait.php │ └── Scheduler.php └── Cron.php ├── example.php ├── LICENSE └── composer.json /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 | -------------------------------------------------------------------------------- /example.php: -------------------------------------------------------------------------------- 1 | */ 29 | private array $actions; 30 | 31 | private Scheduler $scheduler; 32 | 33 | private function __construct(private MutexInterface $mutex, ClockInterface $clock, ActionInterface ...$actions) 34 | { 35 | $this->scheduler = new Scheduler($clock); 36 | $this->actions = $actions; 37 | 38 | foreach ($this->actions as $action) { 39 | if (! ($action instanceof RunOnStartUpAction)) { 40 | continue; 41 | } 42 | 43 | Loop::futureTick(async(fn () => $this->perform($action))); 44 | } 45 | 46 | $this->scheduler->schedule(function (): void { 47 | $this->tick(); 48 | }); 49 | } 50 | 51 | public static function create(ActionInterface ...$actions): self 52 | { 53 | return self::createWithClockAndMutex(new Memory(), SystemClock::fromUTC(), ...$actions); 54 | } 55 | 56 | public static function createWithClock(ClockInterface $clock, ActionInterface ...$actions): self 57 | { 58 | return new self(new Memory(), $clock, ...$actions); 59 | } 60 | 61 | public static function createWithMutex(MutexInterface $mutex, ActionInterface ...$actions): self 62 | { 63 | return self::createWithClockAndMutex($mutex, SystemClock::fromUTC(), ...$actions); 64 | } 65 | 66 | public static function createWithClockAndMutex(MutexInterface $mutex, ClockInterface $clock, ActionInterface ...$actions): self 67 | { 68 | return new self($mutex, $clock, ...$actions); 69 | } 70 | 71 | public function stop(): void 72 | { 73 | $this->scheduler->stop(); 74 | } 75 | 76 | private function tick(): void 77 | { 78 | foreach ($this->actions as $action) { 79 | if (! $action->isDue()) { 80 | continue; 81 | } 82 | 83 | Loop::futureTick(async(fn () => $this->perform($action))); 84 | } 85 | } 86 | 87 | private function perform(ActionInterface $action): void 88 | { 89 | try { 90 | /** @var ?LockInterface $lock */ 91 | $lock = await($this->mutex->acquire($action->key(), $action->mutexTtl())); 92 | if ($lock === null) { 93 | return; 94 | } 95 | 96 | $action->perform(); 97 | 98 | $this->mutex->release($lock); 99 | } catch (Throwable $throwable) { 100 | $this->emit('error', [$throwable, $action]); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Cron/Scheduler.php: -------------------------------------------------------------------------------- 1 | */ 26 | private array $ticks = []; 27 | 28 | private TimerInterface|null $timer = null; 29 | private bool $active = self::ACTIVE; 30 | 31 | public function __construct( 32 | private readonly ClockInterface $clock, 33 | ) { 34 | $this->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 hasDrifted(DateTimeImmutable $time): bool 44 | { 45 | return (int) $time->format('s') > 0; 46 | } 47 | 48 | private function tick(): void 49 | { 50 | if ($this->active === self::INACTIVE) { 51 | return; 52 | } 53 | 54 | $startOfTick = $this->clock->now(); 55 | 56 | foreach ($this->ticks as $tick) { 57 | $tick(); 58 | } 59 | 60 | if (! $this->hasDrifted($startOfTick)) { 61 | return; 62 | } 63 | 64 | $this->align(); 65 | } 66 | 67 | private function align(): void 68 | { 69 | if ($this->timer instanceof TimerInterface) { 70 | Loop::cancelTimer($this->timer); 71 | $this->timer = null; 72 | } 73 | 74 | $currentSecond = (int) $this->clock->now()->format('s'); 75 | 76 | if ($currentSecond >= self::FIRST_SECOND_AFTER_OUR_TICK_WINDOW && $currentSecond <= self::TIER_SLOW) { 77 | $this->timer = Loop::addTimer(self::INTERVAL_SLOW_TO_MEDIUM, function (): void { 78 | $this->timer = null; 79 | $this->align(); 80 | }); 81 | 82 | return; 83 | } 84 | 85 | if ($currentSecond > self::TIER_SLOW && $currentSecond <= self::TIER_MEDIUM) { 86 | $this->timer = Loop::addTimer(self::INTERVAL_MEDIUM_TO_FAST, function (): void { 87 | $this->timer = null; 88 | $this->align(); 89 | }); 90 | 91 | return; 92 | } 93 | 94 | if ($currentSecond === self::TIER_FAST) { 95 | $this->timer = Loop::addTimer(self::INTERVAL_FAST, function (): void { 96 | $this->timer = null; 97 | $this->align(); 98 | }); 99 | 100 | return; 101 | } 102 | 103 | $this->tick(); 104 | 105 | $this->timer = Loop::addPeriodicTimer(self::MINUTE_SECONDS, function (): void { 106 | $this->tick(); 107 | }); 108 | } 109 | 110 | public function stop(): void 111 | { 112 | $this->active = self::INACTIVE; 113 | 114 | if (! ($this->timer instanceof TimerInterface)) { 115 | return; 116 | } 117 | 118 | Loop::cancelTimer($this->timer); 119 | } 120 | } 121 | --------------------------------------------------------------------------------