├── .gitattributes ├── publish └── crontab.php ├── src ├── Event │ ├── AfterExecute.php │ ├── BeforeExecute.php │ ├── CrontabDispatcherStarted.php │ ├── Event.php │ └── FailToExecute.php ├── LoggerInterface.php ├── Exception │ └── InvalidArgumentException.php ├── PipeMessage.php ├── Strategy │ ├── StrategyInterface.php │ ├── AbstractStrategy.php │ ├── CoroutineStrategy.php │ ├── ProcessStrategy.php │ ├── TaskWorkerStrategy.php │ ├── WorkerStrategy.php │ └── Executor.php ├── Mutex │ ├── ServerNodeInterface.php │ ├── ServerMutex.php │ ├── TaskMutex.php │ ├── RedisTaskMutex.php │ └── RedisServerMutex.php ├── Scheduler.php ├── ConfigProvider.php ├── Schedule.php ├── Command │ └── RunCommand.php ├── CrontabManager.php ├── Listener │ ├── OnPipeMessageListener.php │ └── CrontabRegisterListener.php ├── Annotation │ └── Crontab.php ├── Process │ └── CrontabDispatcherProcess.php ├── Parser.php ├── Crontab.php └── ManagesFrequencies.php ├── LICENSE └── composer.json /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | /.github export-ignore 3 | /types export-ignore 4 | -------------------------------------------------------------------------------- /publish/crontab.php: -------------------------------------------------------------------------------- 1 | true, 14 | 'crontab' => [ 15 | ], 16 | ]; 17 | -------------------------------------------------------------------------------- /src/Event/AfterExecute.php: -------------------------------------------------------------------------------- 1 | throwable; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Strategy/CoroutineStrategy.php: -------------------------------------------------------------------------------- 1 | container->get(Executor::class); 24 | $executor->execute($crontab); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Strategy/ProcessStrategy.php: -------------------------------------------------------------------------------- 1 | currentWorkerId; 22 | $maxWorkerId = $server->setting['worker_num'] + $server->setting['task_worker_num'] - 1; 23 | if ($this->currentWorkerId > $maxWorkerId) { 24 | $this->currentWorkerId = 0; 25 | } 26 | return $this->currentWorkerId; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Mutex/TaskMutex.php: -------------------------------------------------------------------------------- 1 | currentWorkerId; 22 | $minWorkerId = (int) $server->setting['worker_num']; 23 | $maxWorkerId = $minWorkerId + $server->setting['task_worker_num'] - 1; 24 | if ($this->currentWorkerId < $minWorkerId || $this->currentWorkerId > $maxWorkerId) { 25 | $this->currentWorkerId = $minWorkerId; 26 | } 27 | return $this->currentWorkerId; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Hyperf 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 | -------------------------------------------------------------------------------- /src/Scheduler.php: -------------------------------------------------------------------------------- 1 | schedules = new SplQueue(); 38 | } 39 | 40 | public function schedule(): SplQueue 41 | { 42 | foreach ($this->getSchedules() as $schedule) { 43 | $this->schedules->enqueue($schedule); 44 | } 45 | return $this->schedules; 46 | } 47 | 48 | protected function getSchedules(): array 49 | { 50 | return $this->crontabManager->parse(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | [ 31 | RunCommand::class, 32 | ], 33 | 'dependencies' => [ 34 | StrategyInterface::class => WorkerStrategy::class, 35 | ServerMutex::class => RedisServerMutex::class, 36 | TaskMutex::class => RedisTaskMutex::class, 37 | ], 38 | 'listeners' => [ 39 | CrontabRegisterListener::class, 40 | OnPipeMessageListener::class, 41 | ], 42 | 'publish' => [ 43 | [ 44 | 'id' => 'config', 45 | 'description' => 'The config for crontab.', 46 | 'source' => __DIR__ . '/../publish/crontab.php', 47 | 'destination' => BASE_PATH . '/config/autoload/crontab.php', 48 | ], 49 | ], 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Schedule.php: -------------------------------------------------------------------------------- 1 | $command], $arguments); 38 | 39 | if (! isset($arguments['--disable-event-dispatcher'])) { 40 | $arguments['--disable-event-dispatcher'] = true; 41 | } 42 | 43 | return tap(new Crontab(), fn ($crontab) => self::$crontabs[] = $crontab) 44 | ->setType('command') 45 | ->setCallback($arguments); 46 | } 47 | 48 | public static function call(mixed $callable): Crontab 49 | { 50 | $type = $callable instanceof Closure ? 'closure' : 'callback'; 51 | 52 | return tap(new Crontab(), fn ($crontab) => self::$crontabs[] = $crontab) 53 | ->setType($type) 54 | ->setCallback($callable); 55 | } 56 | 57 | /** 58 | * @return Crontab[] 59 | */ 60 | public static function getCrontabs(): array 61 | { 62 | return self::$crontabs; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Command/RunCommand.php: -------------------------------------------------------------------------------- 1 | container->get(ConfigInterface::class); 34 | $scheduler = $this->container->get(Scheduler::class); 35 | $executor = $this->container->get(Executor::class); 36 | 37 | if (! $config->get('crontab.enable', false)) { 38 | throw new InvalidArgumentException('Crontab is already disabled, please enable it first.'); 39 | } 40 | 41 | $this->eventDispatcher?->dispatch(new CrontabDispatcherStarted()); 42 | 43 | $this->line('Triggering Crontab', 'info'); 44 | 45 | /** @var Crontab[] $crontabs */ 46 | $crontabs = $scheduler->schedule(); 47 | 48 | foreach ($crontabs as $crontab) { 49 | $executor->execute($crontab); 50 | } 51 | 52 | foreach ($crontabs as $crontab) { 53 | $crontab->wait(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperf/crontab", 3 | "description": "A crontab component for Hyperf.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "php", 7 | "swoole", 8 | "hyperf", 9 | "crontab" 10 | ], 11 | "homepage": "https://hyperf.io", 12 | "support": { 13 | "issues": "https://github.com/hyperf/hyperf/issues", 14 | "source": "https://github.com/hyperf/hyperf", 15 | "docs": "https://hyperf.wiki", 16 | "pull-request": "https://github.com/hyperf/hyperf/pulls" 17 | }, 18 | "require": { 19 | "php": ">=8.1", 20 | "hyperf/collection": "~3.1.0", 21 | "hyperf/conditionable": "~3.1.0", 22 | "hyperf/context": "~3.1.0", 23 | "hyperf/contract": "~3.1.0", 24 | "hyperf/coordinator": "~3.1.0", 25 | "hyperf/coroutine": "~3.1.0", 26 | "hyperf/engine": "^2.0", 27 | "hyperf/framework": "~3.1.0", 28 | "hyperf/stringable": "~3.1.0", 29 | "hyperf/support": "~3.1.0", 30 | "hyperf/tappable": "~3.1.0", 31 | "nesbot/carbon": "^2.0" 32 | }, 33 | "suggest": { 34 | "hyperf/command": "Required to use command trigger.", 35 | "hyperf/process": "Auto register the Crontab process for server." 36 | }, 37 | "autoload": { 38 | "psr-4": { 39 | "Hyperf\\Crontab\\": "src/" 40 | } 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "HyperfTest\\Crontab\\": "tests/" 45 | } 46 | }, 47 | "config": { 48 | "sort-packages": true 49 | }, 50 | "extra": { 51 | "branch-alias": { 52 | "dev-master": "3.1-dev" 53 | }, 54 | "hyperf": { 55 | "config": "Hyperf\\Crontab\\ConfigProvider" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/CrontabManager.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected array $crontabs = []; 21 | 22 | public function __construct(protected Parser $parser) 23 | { 24 | } 25 | 26 | public function register(Crontab $crontab): bool 27 | { 28 | if (! $this->isValidCrontab($crontab) || ! $crontab->isEnable()) { 29 | return false; 30 | } 31 | $this->crontabs[$crontab->getName()] = $crontab; 32 | return true; 33 | } 34 | 35 | /** 36 | * @return Crontab[] 37 | */ 38 | public function parse(): array 39 | { 40 | $result = []; 41 | $crontabs = $this->getCrontabs(); 42 | $last = time(); 43 | foreach ($crontabs as $key => $crontab) { 44 | if (! $crontab instanceof Crontab) { 45 | unset($this->crontabs[$key]); 46 | continue; 47 | } 48 | $time = $this->parser->parse($crontab->getRule(), $last, $crontab->getTimezone()); 49 | if ($time) { 50 | foreach ($time as $t) { 51 | $result[] = (clone $crontab)->setExecuteTime($t); 52 | } 53 | } 54 | } 55 | return $result; 56 | } 57 | 58 | /** 59 | * @return array 60 | */ 61 | public function getCrontabs(): array 62 | { 63 | return $this->crontabs; 64 | } 65 | 66 | public function isValidCrontab(Crontab $crontab): bool 67 | { 68 | return $crontab->getName() && $crontab->getRule() && $crontab->getCallback() && $this->parser->isValid($crontab->getRule()); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Mutex/RedisTaskMutex.php: -------------------------------------------------------------------------------- 1 | timer = new Timer(); 26 | } 27 | 28 | /** 29 | * Attempt to obtain a task mutex for the given crontab. 30 | */ 31 | public function create(Crontab $crontab): bool 32 | { 33 | $redis = $this->redisFactory->get($crontab->getMutexPool()); 34 | $mutexName = $this->getMutexName($crontab); 35 | $attempted = (bool) $redis->set($mutexName, $crontab->getName(), ['NX', 'EX' => $crontab->getMutexExpires()]); 36 | $attempted && $this->timer->tick(1, function () use ($mutexName, $redis) { 37 | if ($redis->expire($mutexName, $redis->ttl($mutexName) + 1) === false) { 38 | return Timer::STOP; 39 | } 40 | }); 41 | return $attempted; 42 | } 43 | 44 | /** 45 | * Determine if a task mutex exists for the given crontab. 46 | */ 47 | public function exists(Crontab $crontab): bool 48 | { 49 | return (bool) $this->redisFactory->get($crontab->getMutexPool())->exists( 50 | $this->getMutexName($crontab) 51 | ); 52 | } 53 | 54 | /** 55 | * Clear the task mutex for the given crontab. 56 | */ 57 | public function remove(Crontab $crontab) 58 | { 59 | $this->redisFactory->get($crontab->getMutexPool())->del( 60 | $this->getMutexName($crontab) 61 | ); 62 | } 63 | 64 | protected function getMutexName(Crontab $crontab) 65 | { 66 | return 'framework' . DIRECTORY_SEPARATOR . 'crontab-' . sha1($crontab->getName() . $crontab->getRule()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Strategy/WorkerStrategy.php: -------------------------------------------------------------------------------- 1 | serverFactory = $container->get(ServerFactory::class); 32 | 33 | parent::__construct($container); 34 | } 35 | 36 | public function dispatch(Crontab $crontab): void 37 | { 38 | $logger = match (true) { 39 | $this->container->has(LoggerInterface::class) => $this->container->get(LoggerInterface::class), 40 | $this->container->has(StdoutLoggerInterface::class) => $this->container->get(StdoutLoggerInterface::class), 41 | default => null, 42 | }; 43 | $server = $this->serverFactory->getServer()->getServer(); 44 | 45 | if (! $server instanceof Server) { 46 | $logger?->warning('Cannot dispatch crontab, use CoroutineStrategy if run in coroutine style server.'); 47 | return; 48 | } 49 | if ($crontab->getType() === 'closure') { 50 | $logger?->warning('Closure type crontab is only supported in CoroutineStrategy.'); 51 | return; 52 | } 53 | 54 | $workerId = $this->getNextWorkerId($server); 55 | $server->sendMessage(new PipeMessage( 56 | 'callback', 57 | [Executor::class, 'execute'], 58 | $crontab 59 | ), $workerId); 60 | } 61 | 62 | protected function getNextWorkerId(Server $server): int 63 | { 64 | ++$this->currentWorkerId; 65 | $maxWorkerId = $server->setting['worker_num'] - 1; 66 | if ($this->currentWorkerId > $maxWorkerId) { 67 | $this->currentWorkerId = 0; 68 | } 69 | return $this->currentWorkerId; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Listener/OnPipeMessageListener.php: -------------------------------------------------------------------------------- 1 | has(StdoutLoggerInterface::class)) { 31 | $this->logger = $container->get(StdoutLoggerInterface::class); 32 | } 33 | } 34 | 35 | /** 36 | * @return string[] returns the events that you want to listen 37 | */ 38 | public function listen(): array 39 | { 40 | return [ 41 | OnPipeMessage::class, 42 | ]; 43 | } 44 | 45 | /** 46 | * Handle the Event when the event is triggered, all listeners will 47 | * complete before the event is returned to the EventDispatcher. 48 | */ 49 | public function process(object $event): void 50 | { 51 | if ($event instanceof OnPipeMessage && $event->data instanceof PipeMessage) { 52 | $data = $event->data; 53 | try { 54 | switch ($data->type) { 55 | case 'callback': 56 | $this->handleCallable($data); 57 | break; 58 | } 59 | } catch (Throwable $throwable) { 60 | $this->logger?->error($throwable->getMessage()); 61 | } 62 | } 63 | } 64 | 65 | private function handleCallable(PipeMessage $data): void 66 | { 67 | $instance = $this->container->get($data->callable[0]); 68 | $method = $data->callable[1] ?? null; 69 | if (! $instance || ! $method || ! method_exists($instance, $method)) { 70 | return; 71 | } 72 | $crontab = $data->data ?? null; 73 | $crontab instanceof Crontab && $instance->{$method}($crontab); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Mutex/RedisServerMutex.php: -------------------------------------------------------------------------------- 1 | macAddress = $this->getMacAddress(); 36 | $this->timer = new Timer(); 37 | } 38 | 39 | /** 40 | * Attempt to obtain a server mutex for the given crontab. 41 | */ 42 | public function attempt(Crontab $crontab): bool 43 | { 44 | if ($this->macAddress === null) { 45 | return false; 46 | } 47 | 48 | $redis = $this->redisFactory->get($crontab->getMutexPool()); 49 | $mutexName = $this->getMutexName($crontab); 50 | 51 | $result = $redis->set($mutexName, $this->macAddress, ['NX', 'EX' => $crontab->getMutexExpires()]); 52 | 53 | if ($result) { 54 | $this->timer->tick(1, function () use ($mutexName, $redis) { 55 | if ($redis->expire($mutexName, $redis->ttl($mutexName) + 1) === false) { 56 | return Timer::STOP; 57 | } 58 | }); 59 | 60 | Coroutine::create(function () use ($redis, $mutexName) { 61 | CoordinatorManager::until(Constants::WORKER_EXIT)->yield(); 62 | $redis->del($mutexName); 63 | }); 64 | return true; 65 | } 66 | 67 | return $redis->get($mutexName) === $this->macAddress; 68 | } 69 | 70 | /** 71 | * Get the server mutex for the given crontab. 72 | */ 73 | public function get(Crontab $crontab): string 74 | { 75 | return (string) $this->redisFactory->get($crontab->getMutexPool())->get( 76 | $this->getMutexName($crontab) 77 | ); 78 | } 79 | 80 | protected function getMutexName(Crontab $crontab) 81 | { 82 | return 'hyperf' . DIRECTORY_SEPARATOR . 'crontab-' . sha1($crontab->getName() . $crontab->getRule()) . '-sv'; 83 | } 84 | 85 | protected function getMacAddress(): ?string 86 | { 87 | if ($node = $this->getServerNode()) { 88 | return $node->getName(); 89 | } 90 | 91 | $macAddresses = swoole_get_local_mac(); 92 | 93 | foreach (Arr::wrap($macAddresses) as $name => $address) { 94 | if ($address && $address !== '00:00:00:00:00:00') { 95 | return $name . ':' . str_replace(':', '', $address); 96 | } 97 | } 98 | 99 | return null; 100 | } 101 | 102 | protected function getServerNode(): ?ServerNodeInterface 103 | { 104 | if (ApplicationContext::hasContainer() && ApplicationContext::getContainer()->has(ServerNodeInterface::class)) { 105 | return ApplicationContext::getContainer()->get(ServerNodeInterface::class); 106 | } 107 | 108 | return null; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Annotation/Crontab.php: -------------------------------------------------------------------------------- 1 | rule)) { 41 | $this->rule = str_replace('\\', '', $this->rule); 42 | } 43 | } 44 | 45 | public function collectMethod(string $className, ?string $target): void 46 | { 47 | if ($target === null) { 48 | return; 49 | } 50 | 51 | if (! $this->name) { 52 | $this->name = $className . '::' . $target; 53 | } 54 | 55 | if (! $this->callback) { 56 | $this->callback = [$className, $target]; 57 | } elseif (is_string($this->callback)) { 58 | $this->callback = [$className, $this->callback]; 59 | } 60 | 61 | parent::collectMethod($className, $target); 62 | } 63 | 64 | public function collectClass(string $className): void 65 | { 66 | $this->parseName($className); 67 | $this->parseCallback($className); 68 | $this->parseEnable($className); 69 | 70 | parent::collectClass($className); 71 | } 72 | 73 | protected function parseName(string $className): void 74 | { 75 | if (! $this->name) { 76 | $this->name = $className; 77 | } 78 | } 79 | 80 | protected function parseCallback(string $className): void 81 | { 82 | if (! $this->callback) { 83 | $reflectionClass = ReflectionManager::reflectClass($className); 84 | $reflectionMethods = $reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC); 85 | $availableMethodCount = 0; 86 | $firstAvailableMethod = null; 87 | $hasInvokeMagicMethod = false; 88 | foreach ($reflectionMethods as $reflectionMethod) { 89 | if (! Str::startsWith($reflectionMethod->getName(), ['__'])) { 90 | ++$availableMethodCount; 91 | ! $firstAvailableMethod && $firstAvailableMethod = $reflectionMethod; 92 | } elseif ($reflectionMethod->getName() === '__invoke') { 93 | $hasInvokeMagicMethod = true; 94 | } 95 | } 96 | if ($availableMethodCount === 1) { 97 | $this->callback = [$className, $firstAvailableMethod->getName()]; 98 | } elseif ($hasInvokeMagicMethod) { 99 | $this->callback = [$className, '__invoke']; 100 | } else { 101 | throw new InvalidArgumentException('Missing argument $callback of @Crontab annotation.'); 102 | } 103 | } elseif (is_string($this->callback)) { 104 | $this->callback = [$className, $this->callback]; 105 | } 106 | } 107 | 108 | protected function parseEnable(string $className): void 109 | { 110 | if ($this->enable === 'true') { 111 | $this->enable = true; 112 | return; 113 | } 114 | 115 | if ($this->enable === 'false') { 116 | $this->enable = false; 117 | return; 118 | } 119 | 120 | if (is_string($this->enable)) { 121 | $this->enable = [$className, $this->enable]; 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Process/CrontabDispatcherProcess.php: -------------------------------------------------------------------------------- 1 | config = $container->get(ConfigInterface::class); 52 | $this->scheduler = $container->get(Scheduler::class); 53 | $this->strategy = $container->get(StrategyInterface::class); 54 | $this->logger = match (true) { 55 | $container->has(LoggerInterface::class) => $container->get(LoggerInterface::class), 56 | $container->has(StdoutLoggerInterface::class) => $container->get(StdoutLoggerInterface::class), 57 | default => null, 58 | }; 59 | } 60 | 61 | public function bind($server): void 62 | { 63 | $this->server = $server; 64 | parent::bind($server); 65 | } 66 | 67 | public function isEnable($server): bool 68 | { 69 | return (bool) $this->config->get('crontab.enable', false); 70 | } 71 | 72 | public function handle(): void 73 | { 74 | $this->event?->dispatch(new CrontabDispatcherStarted()); 75 | while (ProcessManager::isRunning()) { 76 | if ($this->sleep()) { 77 | break; 78 | } 79 | if ($this->ensureToNextMinuteTimestamp()) { 80 | break; 81 | } 82 | $crontabs = $this->scheduler->schedule(); 83 | while (! $crontabs->isEmpty()) { 84 | $crontab = $crontabs->dequeue(); 85 | $this->strategy->dispatch($crontab); 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Get the interval of the current second to the next minute. 92 | */ 93 | public function getInterval(int $currentSecond, float $ms): float 94 | { 95 | $sleep = 60 - $currentSecond - $ms; 96 | return round($sleep, 3); 97 | } 98 | 99 | /** 100 | * @return bool whether the server shutdown 101 | */ 102 | private function sleep(): bool 103 | { 104 | [$ms, $now] = explode(' ', microtime()); 105 | $current = date('s', (int) $now); 106 | 107 | $sleep = $this->getInterval((int) $current, (float) $ms); 108 | $this->logger?->debug('Current microtime: ' . $now . ' ' . $ms . '. Crontab dispatcher sleep ' . $sleep . 's.'); 109 | 110 | if ($sleep > 0) { 111 | if (CoordinatorManager::until(Constants::WORKER_EXIT)->yield($sleep)) { 112 | return true; 113 | } 114 | } 115 | 116 | return false; 117 | } 118 | 119 | private function ensureToNextMinuteTimestamp(): bool 120 | { 121 | $minuteTimestamp = (int) (time() / 60); 122 | if ($this->minuteTimestamp !== 0 && $minuteTimestamp === $this->minuteTimestamp) { 123 | $this->logger?->debug('Crontab tasks will be executed at the same minute, but the framework found it, so you don\'t care it.'); 124 | if (CoordinatorManager::until(Constants::WORKER_EXIT)->yield(0.1)) { 125 | return true; 126 | } 127 | 128 | return $this->ensureToNextMinuteTimestamp(); 129 | } 130 | 131 | $this->minuteTimestamp = $minuteTimestamp; 132 | return false; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | isValid($crontabString)) { 43 | throw new InvalidArgumentException('Invalid cron string: ' . $crontabString); 44 | } 45 | $startTime = $this->parseStartTime($startTime); 46 | $date = $this->parseDate($crontabString); 47 | $result = []; 48 | $currentDateTime = new DateTime(); 49 | $currentDateTime->setTimestamp($startTime); 50 | 51 | if (isset($timezone)) { 52 | $timezone = is_string($timezone) ? new DateTimeZone($timezone) : $timezone; 53 | $currentDateTime->setTimezone($timezone); 54 | } 55 | 56 | if (in_array((int) $currentDateTime->format('i'), $date['minutes']) 57 | && in_array((int) $currentDateTime->format('G'), $date['hours']) 58 | && in_array((int) $currentDateTime->format('j'), $date['day']) 59 | && in_array((int) $currentDateTime->format('w'), $date['week']) 60 | && in_array((int) $currentDateTime->format('n'), $date['month']) 61 | ) { 62 | foreach ($date['second'] as $second) { 63 | $result[] = Carbon::createFromTimestamp($startTime + $second, $timezone); 64 | } 65 | } 66 | return $result; 67 | } 68 | 69 | public function isValid(string $crontabString): bool 70 | { 71 | if (! preg_match('#^((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)$#i', trim($crontabString))) { 72 | if (! preg_match('#^((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)\s+((\*(/[0-9]+)?)|[0-9\-,/]+)$#i', trim($crontabString))) { 73 | return false; 74 | } 75 | } 76 | return true; 77 | } 78 | 79 | /** 80 | * Parse each segment of crontab string. 81 | */ 82 | protected function parseSegment(string $string, int $min, int $max, ?int $start = null) 83 | { 84 | if ($start === null || $start < $min) { 85 | $start = $min; 86 | } 87 | $result = []; 88 | if ($string === '*') { 89 | for ($i = $start; $i <= $max; ++$i) { 90 | $result[] = $i; 91 | } 92 | } elseif (str_contains($string, ',')) { 93 | $exploded = explode(',', $string); 94 | foreach ($exploded as $value) { 95 | if (str_contains($value, '/') || str_contains($string, '-')) { 96 | $result = array_merge($result, $this->parseSegment($value, $min, $max, $start)); 97 | continue; 98 | } 99 | 100 | if (trim($value) === '' || ! $this->between((int) $value, max($min, $start), $max)) { 101 | continue; 102 | } 103 | $result[] = (int) $value; 104 | } 105 | } elseif (str_contains($string, '/')) { 106 | $exploded = explode('/', $string); 107 | if (str_contains($exploded[0], '-')) { 108 | [$nMin, $nMax] = explode('-', $exploded[0]); 109 | $nMin > $min && $min = (int) $nMin; 110 | $nMax < $max && $max = (int) $nMax; 111 | } 112 | // If the value of start is larger than the value of min, the value of start should equal with the value of min. 113 | $start < $min && $start = $min; 114 | for ($i = $start; $i <= $max;) { 115 | $result[] = $i; 116 | $i += (int) $exploded[1]; 117 | } 118 | } elseif (str_contains($string, '-')) { 119 | $result = array_merge($result, $this->parseSegment($string . '/1', $min, $max, $start)); 120 | } elseif ($this->between((int) $string, max($min, $start), $max)) { 121 | $result[] = (int) $string; 122 | } 123 | return $result; 124 | } 125 | 126 | /** 127 | * Determine if the $value is between in $min and $max ? 128 | */ 129 | private function between(int $value, int $min, int $max): bool 130 | { 131 | return $value >= $min && $value <= $max; 132 | } 133 | 134 | /** 135 | * @param null|Carbon|int $startTime 136 | */ 137 | private function parseStartTime($startTime): int 138 | { 139 | if ($startTime instanceof Carbon) { 140 | $startTime = $startTime->getTimestamp(); 141 | } elseif ($startTime === null) { 142 | $startTime = time(); 143 | } 144 | if (! is_numeric($startTime)) { 145 | throw new InvalidArgumentException("\$startTime have to be a valid unix timestamp ({$startTime} given)"); 146 | } 147 | return (int) $startTime; 148 | } 149 | 150 | private function parseDate(string $crontabString): array 151 | { 152 | $cron = preg_split('/\s+/i', trim($crontabString)); 153 | return [ 154 | 'week' => $this->parseSegment(array_pop($cron), 0, 6), 155 | 'month' => $this->parseSegment(array_pop($cron), 1, 12), 156 | 'day' => $this->parseSegment(array_pop($cron), 1, 31), 157 | 'hours' => $this->parseSegment(array_pop($cron), 0, 23), 158 | 'minutes' => $this->parseSegment(array_pop($cron), 0, 59), 159 | 'second' => $this->parseSegment(array_pop($cron) ?? '0', 0, 59), 160 | ]; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Listener/CrontabRegisterListener.php: -------------------------------------------------------------------------------- 1 | crontabManager = $this->container->get(CrontabManager::class); 60 | $this->logger = match (true) { 61 | $this->container->has(LoggerInterface::class) => $this->container->get(LoggerInterface::class), 62 | $this->container->has(StdoutLoggerInterface::class) => $this->container->get(StdoutLoggerInterface::class), 63 | default => null, 64 | }; 65 | $this->config = $this->container->get(ConfigInterface::class); 66 | 67 | if (! $this->config->get('crontab.enable', false)) { 68 | return; 69 | } 70 | 71 | $crontabs = $this->parseCrontabs(); 72 | $environment = (string) $this->config->get('app_env', ''); 73 | 74 | foreach ($crontabs as $crontab) { 75 | if (! $crontab instanceof Crontab) { 76 | continue; 77 | } 78 | 79 | if (! $crontab->isEnable()) { 80 | $this->logger?->warning(sprintf('Crontab %s is disabled.', $crontab->getName())); 81 | continue; 82 | } 83 | 84 | if (! $crontab->runsInEnvironment($environment)) { 85 | $this->logger?->warning(sprintf('Crontab %s is disabled in %s environment.', $crontab->getName(), $environment)); 86 | continue; 87 | } 88 | 89 | if (! $this->crontabManager->isValidCrontab($crontab)) { 90 | $this->logger?->warning(sprintf('Crontab %s is invalid.', $crontab->getName())); 91 | continue; 92 | } 93 | 94 | if ($this->crontabManager->register($crontab)) { 95 | $this->logger?->debug(sprintf('Crontab %s have been registered.', $crontab->getName())); 96 | } 97 | } 98 | } 99 | 100 | private function parseCrontabs(): array 101 | { 102 | $configCrontabs = $this->config->get('crontab.crontab', []); 103 | $annotationCrontabs = AnnotationCollector::getClassesByAnnotation(CrontabAnnotation::class); 104 | $methodCrontabs = $this->getCrontabsFromMethod(); 105 | 106 | Schedule::load(); 107 | $pendingCrontabs = Schedule::getCrontabs(); 108 | 109 | $crontabs = []; 110 | 111 | foreach (array_merge($configCrontabs, $annotationCrontabs, $methodCrontabs, $pendingCrontabs) as $crontab) { 112 | if ($crontab instanceof CrontabAnnotation) { 113 | $crontab = $this->buildCrontabByAnnotation($crontab); 114 | } 115 | if ($crontab instanceof Crontab) { 116 | $crontabs[$crontab->getName()] = $crontab; 117 | } 118 | } 119 | 120 | return array_values($crontabs); 121 | } 122 | 123 | private function getCrontabsFromMethod(): array 124 | { 125 | $result = AnnotationCollector::getMethodsByAnnotation(CrontabAnnotation::class); 126 | $crontabs = []; 127 | foreach ($result as $item) { 128 | $crontabs[] = $item['annotation']; 129 | } 130 | return $crontabs; 131 | } 132 | 133 | private function buildCrontabByAnnotation(CrontabAnnotation $annotation): Crontab 134 | { 135 | $crontab = new Crontab(); 136 | isset($annotation->name) && $crontab->setName($annotation->name); 137 | isset($annotation->type) && $crontab->setType($annotation->type); 138 | isset($annotation->rule) && $crontab->setRule($annotation->rule); 139 | isset($annotation->singleton) && $crontab->setSingleton($annotation->singleton); 140 | isset($annotation->mutexPool) && $crontab->setMutexPool($annotation->mutexPool); 141 | isset($annotation->mutexExpires) && $crontab->setMutexExpires($annotation->mutexExpires); 142 | isset($annotation->onOneServer) && $crontab->setOnOneServer($annotation->onOneServer); 143 | isset($annotation->callback) && $crontab->setCallback($annotation->callback); 144 | isset($annotation->memo) && $crontab->setMemo($annotation->memo); 145 | isset($annotation->enable) && $crontab->setEnable($this->resolveCrontabEnableMethod($annotation->enable)); 146 | isset($annotation->timezone) && $crontab->setTimezone($annotation->timezone); 147 | isset($annotation->environments) && $crontab->setEnvironments($annotation->environments); 148 | isset($annotation->options) && $crontab->setOptions($annotation->options); 149 | 150 | return $crontab; 151 | } 152 | 153 | private function resolveCrontabEnableMethod(array|bool $enable): bool 154 | { 155 | if (is_bool($enable)) { 156 | return $enable; 157 | } 158 | 159 | $className = reset($enable); 160 | $method = end($enable); 161 | 162 | try { 163 | $reflectionClass = ReflectionManager::reflectClass($className); 164 | $reflectionMethod = $reflectionClass->getMethod($method); 165 | 166 | if ($reflectionMethod->isPublic()) { 167 | if ($reflectionMethod->isStatic()) { 168 | return $className::$method(); 169 | } 170 | 171 | $container = ApplicationContext::getContainer(); 172 | if ($container->has($className)) { 173 | return $container->get($className)->{$method}(); 174 | } 175 | } 176 | 177 | $this->logger?->info('Crontab enable method is not public, skip register.'); 178 | } catch (ReflectionException $e) { 179 | $this->logger?->error('Resolve crontab enable failed, skip register.' . $e); 180 | } 181 | 182 | return false; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Crontab.php: -------------------------------------------------------------------------------- 1 | running = new Channel(1); 58 | } 59 | 60 | public function __serialize(): array 61 | { 62 | return [ 63 | "\x00*\x00name" => $this->name, 64 | "\x00*\x00type" => $this->type, 65 | "\x00*\x00rule" => $this->rule, 66 | "\x00*\x00singleton" => $this->singleton, 67 | "\x00*\x00mutexPool" => $this->mutexPool, 68 | "\x00*\x00mutexExpires" => $this->mutexExpires, 69 | "\x00*\x00onOneServer" => $this->onOneServer, 70 | "\x00*\x00callback" => $this->callback, 71 | "\x00*\x00memo" => $this->memo, 72 | "\x00*\x00executeTime" => $this->executeTime, 73 | "\x00*\x00enable" => $this->enable, 74 | "\x00*\x00timezone" => $this->timezone, 75 | "\x00*\x00environments" => $this->environments, 76 | "\x00*\x00options" => $this->options, 77 | ]; 78 | } 79 | 80 | public function __unserialize(array $data): void 81 | { 82 | $this->name = $data["\x00*\x00name"] ?? $this->name; 83 | $this->type = $data["\x00*\x00type"] ?? $this->type; 84 | $this->rule = $data["\x00*\x00rule"] ?? $this->rule; 85 | $this->singleton = $data["\x00*\x00singleton"] ?? $this->singleton; 86 | $this->mutexPool = $data["\x00*\x00mutexPool"] ?? $this->mutexPool; 87 | $this->mutexExpires = $data["\x00*\x00mutexExpires"] ?? $this->mutexExpires; 88 | $this->onOneServer = $data["\x00*\x00onOneServer"] ?? $this->onOneServer; 89 | $this->callback = $data["\x00*\x00callback"] ?? $this->callback; 90 | $this->memo = $data["\x00*\x00memo"] ?? $this->memo; 91 | $this->executeTime = $data["\x00*\x00executeTime"] ?? $this->executeTime; 92 | $this->enable = $data["\x00*\x00enable"] ?? $this->enable; 93 | $this->running = new Channel(1); 94 | $this->timezone = $data["\x00*\x00timezone"] ?? $this->timezone; 95 | $this->environments = $data["\x00*\x00environments"] ?? $this->environments; 96 | $this->options = $data["\x00*\x00options"] ?? $this->options; 97 | } 98 | 99 | public function getName(): ?string 100 | { 101 | return $this->name; 102 | } 103 | 104 | public function setName(?string $name): static 105 | { 106 | $this->name = $name; 107 | return $this; 108 | } 109 | 110 | public function getRule(): ?string 111 | { 112 | return $this->rule; 113 | } 114 | 115 | public function setRule(?string $rule): static 116 | { 117 | $this->rule = $rule; 118 | return $this; 119 | } 120 | 121 | public function isSingleton(): bool 122 | { 123 | return $this->singleton; 124 | } 125 | 126 | public function setSingleton(bool $singleton): static 127 | { 128 | $this->singleton = $singleton; 129 | return $this; 130 | } 131 | 132 | public function getMutexPool(): string 133 | { 134 | return $this->mutexPool; 135 | } 136 | 137 | public function setMutexPool(string $mutexPool): static 138 | { 139 | $this->mutexPool = $mutexPool; 140 | return $this; 141 | } 142 | 143 | public function getMutexExpires(): int 144 | { 145 | return $this->mutexExpires; 146 | } 147 | 148 | public function setMutexExpires(int $mutexExpires): static 149 | { 150 | $this->mutexExpires = $mutexExpires; 151 | return $this; 152 | } 153 | 154 | public function isOnOneServer(): bool 155 | { 156 | return $this->onOneServer; 157 | } 158 | 159 | public function setOnOneServer(bool $onOneServer): static 160 | { 161 | $this->onOneServer = $onOneServer; 162 | return $this; 163 | } 164 | 165 | public function getCallback(): mixed 166 | { 167 | return $this->callback; 168 | } 169 | 170 | public function setCallback(mixed $callback): static 171 | { 172 | $this->callback = $callback; 173 | return $this; 174 | } 175 | 176 | public function getMemo(): ?string 177 | { 178 | return $this->memo; 179 | } 180 | 181 | public function setMemo(?string $memo): static 182 | { 183 | $this->memo = $memo; 184 | return $this; 185 | } 186 | 187 | public function getType(): string 188 | { 189 | return $this->type; 190 | } 191 | 192 | public function setType(string $type): static 193 | { 194 | $this->type = $type; 195 | return $this; 196 | } 197 | 198 | public function getExecuteTime(): ?Carbon 199 | { 200 | return $this->executeTime; 201 | } 202 | 203 | public function setExecuteTime(Carbon $executeTime): static 204 | { 205 | $this->executeTime = $executeTime; 206 | return $this; 207 | } 208 | 209 | public function isEnable(): bool 210 | { 211 | return $this->enable; 212 | } 213 | 214 | public function setEnable(bool $enable): static 215 | { 216 | $this->enable = $enable; 217 | return $this; 218 | } 219 | 220 | public function getTimezone(): null|DateTimeZone|string 221 | { 222 | return $this->timezone; 223 | } 224 | 225 | public function setTimezone(DateTimeZone|string $timezone): static 226 | { 227 | $this->timezone = $timezone; 228 | return $this; 229 | } 230 | 231 | /** 232 | * Limit the environments the command should run in. 233 | * 234 | * @param array|mixed $environments 235 | * @return $this 236 | */ 237 | public function setEnvironments($environments): static 238 | { 239 | $this->environments = is_array($environments) ? $environments : func_get_args(); 240 | 241 | return $this; 242 | } 243 | 244 | public function getEnvironments(): array 245 | { 246 | return $this->environments; 247 | } 248 | 249 | public function setOptions(array $options): static 250 | { 251 | $this->options = $options; 252 | return $this; 253 | } 254 | 255 | public function getOptions(): array 256 | { 257 | return $this->options; 258 | } 259 | 260 | public function runsInEnvironment(string $environment): bool 261 | { 262 | return empty($this->environments) || in_array($environment, $this->environments, true); 263 | } 264 | 265 | public function complete(): void 266 | { 267 | $this->running?->close(); 268 | } 269 | 270 | public function close(): void 271 | { 272 | $this->running?->close(); 273 | } 274 | 275 | public function wait(): void 276 | { 277 | $this->running?->pop(); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/Strategy/Executor.php: -------------------------------------------------------------------------------- 1 | logger = match (true) { 54 | $container->has(LoggerInterface::class) => $container->get(LoggerInterface::class), 55 | $container->has(StdoutLoggerInterface::class) => $container->get(StdoutLoggerInterface::class), 56 | default => null, 57 | }; 58 | if ($container->has(EventDispatcherInterface::class)) { 59 | $this->dispatcher = $container->get(EventDispatcherInterface::class); 60 | } 61 | $this->timer = new Timer($this->logger); 62 | } 63 | 64 | public function execute(Crontab $crontab) 65 | { 66 | try { 67 | $diff = Carbon::now()->diffInRealSeconds($crontab->getExecuteTime(), false); 68 | $runnable = null; 69 | 70 | switch ($crontab->getType()) { 71 | case 'closure': 72 | $runnable = $crontab->getCallback(); 73 | break; 74 | case 'callback': 75 | [$class, $method] = $crontab->getCallback(); 76 | $parameters = $crontab->getCallback()[2] ?? null; 77 | if ($class && $method && class_exists($class) && method_exists($class, $method)) { 78 | $runnable = function () use ($class, $method, $parameters) { 79 | $instance = make($class); 80 | if ($parameters && is_array($parameters)) { 81 | $instance->{$method}(...$parameters); 82 | } else { 83 | $instance->{$method}(); 84 | } 85 | }; 86 | } 87 | break; 88 | case 'command': 89 | $input = make(ArrayInput::class, [$crontab->getCallback()]); 90 | $output = make(NullOutput::class); 91 | /** @var Application */ 92 | $application = $this->container->get(ApplicationInterface::class); 93 | $application->setAutoExit(false); 94 | $application->setCatchExceptions(false); 95 | $runnable = function () use ($application, $input, $output) { 96 | if ($application->run($input, $output) !== 0) { 97 | throw new RuntimeException('Crontab task failed to execute.'); 98 | } 99 | }; 100 | break; 101 | case 'eval': 102 | $runnable = fn () => eval($crontab->getCallback()); 103 | break; 104 | default: 105 | throw new InvalidArgumentException(sprintf('Crontab task type [%s] is invalid.', $crontab->getType())); 106 | } 107 | 108 | $runnable = function ($isClosing) use ($crontab, $runnable) { 109 | if ($isClosing) { 110 | $crontab->close(); 111 | $this->logResult($crontab, false); 112 | return; 113 | } 114 | try { 115 | $runnable = $this->catchToExecute($crontab, $runnable); 116 | $this->decorateRunnable($crontab, $runnable)(); 117 | } finally { 118 | $crontab->complete(); 119 | } 120 | }; 121 | $this->timer->after(max($diff, 0), $runnable); 122 | } catch (Throwable $exception) { 123 | $crontab->close(); 124 | throw $exception; 125 | } 126 | } 127 | 128 | protected function runInSingleton(Crontab $crontab, Closure $runnable): Closure 129 | { 130 | return function () use ($crontab, $runnable) { 131 | $taskMutex = $this->getTaskMutex(); 132 | 133 | if ($taskMutex->exists($crontab) || ! $taskMutex->create($crontab)) { 134 | $this->logger?->info(sprintf('Crontab task [%s] skipped execution at %s caused by task mutex.', $crontab->getName(), date('Y-m-d H:i:s'))); 135 | return; 136 | } 137 | 138 | try { 139 | $runnable(); 140 | } finally { 141 | $taskMutex->remove($crontab); 142 | } 143 | }; 144 | } 145 | 146 | protected function getTaskMutex(): TaskMutex 147 | { 148 | return $this->taskMutex ??= $this->container->get(TaskMutex::class); 149 | } 150 | 151 | protected function runOnOneServer(Crontab $crontab, Closure $runnable): Closure 152 | { 153 | return function () use ($crontab, $runnable) { 154 | $taskMutex = $this->getServerMutex(); 155 | 156 | if (! $taskMutex->attempt($crontab)) { 157 | $this->logger?->info(sprintf('Crontab task [%s] skipped execution at %s caused by server mutex.', $crontab->getName(), date('Y-m-d H:i:s'))); 158 | return; 159 | } 160 | 161 | $runnable(); 162 | }; 163 | } 164 | 165 | protected function getServerMutex(): ServerMutex 166 | { 167 | return $this->serverMutex ??= $this->container->get(ServerMutex::class); 168 | } 169 | 170 | protected function decorateRunnable(Crontab $crontab, Closure $runnable): Closure 171 | { 172 | if ($crontab->isSingleton()) { 173 | $runnable = $this->runInSingleton($crontab, $runnable); 174 | } 175 | 176 | if ($crontab->isOnOneServer()) { 177 | $runnable = $this->runOnOneServer($crontab, $runnable); 178 | } 179 | 180 | return $runnable; 181 | } 182 | 183 | protected function catchToExecute(Crontab $crontab, ?Closure $runnable): Closure 184 | { 185 | return function () use ($crontab, $runnable) { 186 | try { 187 | $this->dispatcher?->dispatch(new BeforeExecute($crontab)); 188 | $result = true; 189 | if (! $runnable) { 190 | throw new InvalidArgumentException('The crontab task is invalid.'); 191 | } 192 | $runnable(); 193 | $this->dispatcher?->dispatch(new AfterExecute($crontab)); 194 | } catch (Throwable $throwable) { 195 | $result = false; 196 | $this->dispatcher?->dispatch(new FailToExecute($crontab, $throwable)); 197 | } finally { 198 | $this->logResult($crontab, $result, $throwable ?? null); 199 | } 200 | }; 201 | } 202 | 203 | protected function logResult(Crontab $crontab, bool $isSuccess, ?Throwable $throwable = null) 204 | { 205 | if ($isSuccess) { 206 | $this->logger?->info(sprintf('Crontab task [%s] executed successfully at %s.', $crontab->getName(), date('Y-m-d H:i:s'))); 207 | } else { 208 | $this->logger?->error(sprintf('Crontab task [%s] failed execution at %s.', $crontab->getName(), date('Y-m-d H:i:s'))); 209 | $throwable && $this->logger?->error((string) $throwable); 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/ManagesFrequencies.php: -------------------------------------------------------------------------------- 1 | setRule($expression); 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Schedule the event to run every second. 35 | * 36 | * @return $this 37 | */ 38 | public function everySecond(): static 39 | { 40 | $this->setCrontabInSeconds(); 41 | 42 | return $this->spliceIntoPosition(1, '*'); 43 | } 44 | 45 | /** 46 | * Schedule the event to run every two seconds. 47 | * 48 | * @return $this 49 | */ 50 | public function everyTwoSeconds(): static 51 | { 52 | $this->setCrontabInSeconds(); 53 | 54 | return $this->spliceIntoPosition(1, '*/2'); 55 | } 56 | 57 | /** 58 | * Schedule the event to run every five seconds. 59 | * 60 | * @return $this 61 | */ 62 | public function everyFiveSeconds(): static 63 | { 64 | $this->setCrontabInSeconds(); 65 | 66 | return $this->spliceIntoPosition(1, '*/5'); 67 | } 68 | 69 | /** 70 | * Schedule the event to run every ten seconds. 71 | * 72 | * @return $this 73 | */ 74 | public function everyTenSeconds(): static 75 | { 76 | $this->setCrontabInSeconds(); 77 | 78 | return $this->spliceIntoPosition(1, '*/10'); 79 | } 80 | 81 | /** 82 | * Schedule the event to run every fifteen seconds. 83 | * 84 | * @return $this 85 | */ 86 | public function everyFifteenSeconds(): static 87 | { 88 | $this->setCrontabInSeconds(); 89 | 90 | return $this->spliceIntoPosition(1, '*/15'); 91 | } 92 | 93 | /** 94 | * Schedule the event to run every twenty seconds. 95 | * 96 | * @return $this 97 | */ 98 | public function everyTwentySeconds(): static 99 | { 100 | $this->setCrontabInSeconds(); 101 | 102 | return $this->spliceIntoPosition(1, '*/20'); 103 | } 104 | 105 | /** 106 | * Schedule the event to run every thirty seconds. 107 | * 108 | * @return $this 109 | */ 110 | public function everyThirtySeconds(): static 111 | { 112 | $this->setCrontabInSeconds(); 113 | 114 | return $this->spliceIntoPosition(1, '*/30'); 115 | } 116 | 117 | /** 118 | * Schedule the event to run every minute. 119 | * 120 | * @return $this 121 | */ 122 | public function everyMinute(): static 123 | { 124 | return $this->spliceIntoPosition(1, '*'); 125 | } 126 | 127 | /** 128 | * Schedule the event to run every two minutes. 129 | * 130 | * @return $this 131 | */ 132 | public function everyTwoMinutes(): static 133 | { 134 | return $this->spliceIntoPosition(1, '*/2'); 135 | } 136 | 137 | /** 138 | * Schedule the event to run every three minutes. 139 | * 140 | * @return $this 141 | */ 142 | public function everyThreeMinutes(): static 143 | { 144 | return $this->spliceIntoPosition(1, '*/3'); 145 | } 146 | 147 | /** 148 | * Schedule the event to run every four minutes. 149 | * 150 | * @return $this 151 | */ 152 | public function everyFourMinutes(): static 153 | { 154 | return $this->spliceIntoPosition(1, '*/4'); 155 | } 156 | 157 | /** 158 | * Schedule the event to run every five minutes. 159 | * 160 | * @return $this 161 | */ 162 | public function everyFiveMinutes(): static 163 | { 164 | return $this->spliceIntoPosition(1, '*/5'); 165 | } 166 | 167 | /** 168 | * Schedule the event to run every ten minutes. 169 | * 170 | * @return $this 171 | */ 172 | public function everyTenMinutes(): static 173 | { 174 | return $this->spliceIntoPosition(1, '*/10'); 175 | } 176 | 177 | /** 178 | * Schedule the event to run every fifteen minutes. 179 | * 180 | * @return $this 181 | */ 182 | public function everyFifteenMinutes(): static 183 | { 184 | return $this->spliceIntoPosition(1, '*/15'); 185 | } 186 | 187 | /** 188 | * Schedule the event to run every thirty minutes. 189 | * 190 | * @return $this 191 | */ 192 | public function everyThirtyMinutes(): static 193 | { 194 | return $this->spliceIntoPosition(1, '0,30'); 195 | } 196 | 197 | /** 198 | * Schedule the event to run hourly. 199 | * 200 | * @return $this 201 | */ 202 | public function hourly(): static 203 | { 204 | return $this->spliceIntoPosition(1, 0); 205 | } 206 | 207 | /** 208 | * Schedule the event to run hourly at a given offset in the hour. 209 | * 210 | * @param array|int $offset 211 | * @return $this 212 | */ 213 | public function hourlyAt($offset): static 214 | { 215 | return $this->hourBasedSchedule($offset, '*'); 216 | } 217 | 218 | /** 219 | * Schedule the event to run every two hours. 220 | * 221 | * @param array|int|string $offset 222 | * @return $this 223 | */ 224 | public function everyOddHour($offset = 0): static 225 | { 226 | return $this->hourBasedSchedule($offset, '1-23/2'); 227 | } 228 | 229 | /** 230 | * Schedule the event to run every two hours. 231 | * 232 | * @param array|int|string $offset 233 | * @return $this 234 | */ 235 | public function everyTwoHours($offset = 0): static 236 | { 237 | return $this->hourBasedSchedule($offset, '*/2'); 238 | } 239 | 240 | /** 241 | * Schedule the event to run every three hours. 242 | * 243 | * @param array|int|string $offset 244 | * @return $this 245 | */ 246 | public function everyThreeHours($offset = 0): static 247 | { 248 | return $this->hourBasedSchedule($offset, '*/3'); 249 | } 250 | 251 | /** 252 | * Schedule the event to run every four hours. 253 | * 254 | * @param array|int|string $offset 255 | * @return $this 256 | */ 257 | public function everyFourHours($offset = 0): static 258 | { 259 | return $this->hourBasedSchedule($offset, '*/4'); 260 | } 261 | 262 | /** 263 | * Schedule the event to run every six hours. 264 | * 265 | * @param array|int|string $offset 266 | * @return $this 267 | */ 268 | public function everySixHours($offset = 0): static 269 | { 270 | return $this->hourBasedSchedule($offset, '*/6'); 271 | } 272 | 273 | /** 274 | * Schedule the event to run daily. 275 | * 276 | * @return $this 277 | */ 278 | public function daily(): static 279 | { 280 | return $this->hourBasedSchedule(0, 0); 281 | } 282 | 283 | /** 284 | * Schedule the command at a given time. 285 | * 286 | * @return $this 287 | */ 288 | public function at(string $time): static 289 | { 290 | return $this->dailyAt($time); 291 | } 292 | 293 | /** 294 | * Schedule the event to run daily at a given time (10:00, 19:30, etc). 295 | * 296 | * @return $this 297 | */ 298 | public function dailyAt(string $time): static 299 | { 300 | $segments = explode(':', $time); 301 | 302 | return $this->spliceIntoPosition(2, (int) $segments[0]) 303 | ->spliceIntoPosition(1, count($segments) === 2 ? (int) $segments[1] : '0'); 304 | } 305 | 306 | /** 307 | * Schedule the event to run twice daily. 308 | * 309 | * @return $this 310 | */ 311 | public function twiceDaily(int $first = 1, int $second = 13): static 312 | { 313 | return $this->twiceDailyAt($first, $second, 0); 314 | } 315 | 316 | /** 317 | * Schedule the event to run twice daily at a given offset. 318 | * 319 | * @return $this 320 | */ 321 | public function twiceDailyAt(int $first = 1, int $second = 13, int $offset = 0): static 322 | { 323 | $hours = $first . ',' . $second; 324 | 325 | return $this->hourBasedSchedule($offset, $hours); 326 | } 327 | 328 | /** 329 | * Schedule the event to run only on weekdays. 330 | * 331 | * @return $this 332 | */ 333 | public function weekdays(): static 334 | { 335 | return $this->days(Scheduler::MONDAY . '-' . Scheduler::FRIDAY); 336 | } 337 | 338 | /** 339 | * Schedule the event to run only on weekends. 340 | * 341 | * @return $this 342 | */ 343 | public function weekends(): static 344 | { 345 | return $this->days(Scheduler::SATURDAY . ',' . Scheduler::SUNDAY); 346 | } 347 | 348 | /** 349 | * Schedule the event to run only on Mondays. 350 | * 351 | * @return $this 352 | */ 353 | public function mondays(): static 354 | { 355 | return $this->days(Scheduler::MONDAY); 356 | } 357 | 358 | /** 359 | * Schedule the event to run only on Tuesdays. 360 | * 361 | * @return $this 362 | */ 363 | public function tuesdays(): static 364 | { 365 | return $this->days(Scheduler::TUESDAY); 366 | } 367 | 368 | /** 369 | * Schedule the event to run only on Wednesdays. 370 | * 371 | * @return $this 372 | */ 373 | public function wednesdays(): static 374 | { 375 | return $this->days(Scheduler::WEDNESDAY); 376 | } 377 | 378 | /** 379 | * Schedule the event to run only on Thursdays. 380 | * 381 | * @return $this 382 | */ 383 | public function thursdays(): static 384 | { 385 | return $this->days(Scheduler::THURSDAY); 386 | } 387 | 388 | /** 389 | * Schedule the event to run only on Fridays. 390 | * 391 | * @return $this 392 | */ 393 | public function fridays(): static 394 | { 395 | return $this->days(Scheduler::FRIDAY); 396 | } 397 | 398 | /** 399 | * Schedule the event to run only on Saturdays. 400 | * 401 | * @return $this 402 | */ 403 | public function saturdays(): static 404 | { 405 | return $this->days(Scheduler::SATURDAY); 406 | } 407 | 408 | /** 409 | * Schedule the event to run only on Sundays. 410 | * 411 | * @return $this 412 | */ 413 | public function sundays(): static 414 | { 415 | return $this->days(Scheduler::SUNDAY); 416 | } 417 | 418 | /** 419 | * Schedule the event to run weekly. 420 | * 421 | * @return $this 422 | */ 423 | public function weekly(): static 424 | { 425 | return $this->spliceIntoPosition(1, 0) 426 | ->spliceIntoPosition(2, 0) 427 | ->spliceIntoPosition(5, 0); 428 | } 429 | 430 | /** 431 | * Schedule the event to run weekly on a given day and time. 432 | * 433 | * @param array|mixed $dayOfWeek 434 | * @return $this 435 | */ 436 | public function weeklyOn($dayOfWeek, string $time = '0:0'): static 437 | { 438 | $this->dailyAt($time); 439 | 440 | return $this->days($dayOfWeek); 441 | } 442 | 443 | /** 444 | * Schedule the event to run monthly. 445 | * 446 | * @return $this 447 | */ 448 | public function monthly(): static 449 | { 450 | return $this->spliceIntoPosition(1, 0) 451 | ->spliceIntoPosition(2, 0) 452 | ->spliceIntoPosition(3, 1); 453 | } 454 | 455 | /** 456 | * Schedule the event to run monthly on a given day and time. 457 | * 458 | * @return $this 459 | */ 460 | public function monthlyOn(int $dayOfMonth = 1, string $time = '0:0'): static 461 | { 462 | $this->dailyAt($time); 463 | 464 | return $this->spliceIntoPosition(3, $dayOfMonth); 465 | } 466 | 467 | /** 468 | * Schedule the event to run twice monthly at a given time. 469 | * 470 | * @return $this 471 | */ 472 | public function twiceMonthly(int $first = 1, int $second = 16, string $time = '0:0'): static 473 | { 474 | $daysOfMonth = $first . ',' . $second; 475 | 476 | $this->dailyAt($time); 477 | 478 | return $this->spliceIntoPosition(3, $daysOfMonth); 479 | } 480 | 481 | /** 482 | * Schedule the event to run on the last day of the month. 483 | * 484 | * @return $this 485 | */ 486 | public function lastDayOfMonth(string $time = '0:0'): static 487 | { 488 | $this->dailyAt($time); 489 | 490 | return $this->spliceIntoPosition(3, Carbon::now()->endOfMonth()->day); 491 | } 492 | 493 | /** 494 | * Schedule the event to run quarterly. 495 | * 496 | * @return $this 497 | */ 498 | public function quarterly(): static 499 | { 500 | return $this->spliceIntoPosition(1, 0) 501 | ->spliceIntoPosition(2, 0) 502 | ->spliceIntoPosition(3, 1) 503 | ->spliceIntoPosition(4, '1-12/3'); 504 | } 505 | 506 | /** 507 | * Schedule the event to run yearly. 508 | * 509 | * @return $this 510 | */ 511 | public function yearly(): static 512 | { 513 | return $this->spliceIntoPosition(1, 0) 514 | ->spliceIntoPosition(2, 0) 515 | ->spliceIntoPosition(3, 1) 516 | ->spliceIntoPosition(4, 1); 517 | } 518 | 519 | /** 520 | * Schedule the event to run yearly on a given month, day, and time. 521 | * 522 | * @return $this 523 | */ 524 | public function yearlyOn(int $month = 1, int|string $dayOfMonth = 1, string $time = '0:0'): static 525 | { 526 | $this->dailyAt($time); 527 | 528 | return $this->spliceIntoPosition(3, $dayOfMonth) 529 | ->spliceIntoPosition(4, $month); 530 | } 531 | 532 | /** 533 | * Set the days of the week the command should run on. 534 | * 535 | * @param array|mixed $days 536 | * @return $this 537 | */ 538 | public function days($days): static 539 | { 540 | $days = is_array($days) ? $days : func_get_args(); 541 | 542 | return $this->spliceIntoPosition(5, implode(',', $days)); 543 | } 544 | 545 | /** 546 | * Set the timezone the date should be evaluated on. 547 | * 548 | * @param DateTimeZone|string $timezone 549 | * @return $this 550 | */ 551 | public function timezone($timezone): static 552 | { 553 | $this->timezone = $timezone; 554 | 555 | return $this; 556 | } 557 | 558 | /** 559 | * Set the cron rule to second level. 560 | * 561 | * @return $this 562 | */ 563 | protected function setCrontabInSeconds(): static 564 | { 565 | $this->setRule('* * * * * *'); 566 | 567 | return $this; 568 | } 569 | 570 | /** 571 | * Schedule the event to run at the given minutes and hours. 572 | * 573 | * @param array|int|string $minutes 574 | * @param array|int|string $hours 575 | * @return $this 576 | */ 577 | protected function hourBasedSchedule($minutes, $hours): static 578 | { 579 | $minutes = is_array($minutes) ? implode(',', $minutes) : $minutes; 580 | 581 | $hours = is_array($hours) ? implode(',', $hours) : $hours; 582 | 583 | return $this->spliceIntoPosition(1, $minutes) 584 | ->spliceIntoPosition(2, $hours); 585 | } 586 | 587 | /** 588 | * Splice the given value into the given position of the expression. 589 | * 590 | * @return $this 591 | */ 592 | protected function spliceIntoPosition(int $position, int|string $value): static 593 | { 594 | $segments = preg_split('/\s+/', $this->rule ?: '* * * * *'); 595 | 596 | $segments[$position - 1] = $value; 597 | 598 | return $this->cron(implode(' ', $segments)); 599 | } 600 | 601 | /** 602 | * Schedule the event to run between start and end time. 603 | */ 604 | private function inTimeInterval(string $startTime, string $endTime): Closure 605 | { 606 | [$now, $startTime, $endTime] = [ 607 | Carbon::now($this->timezone), 608 | Carbon::parse($startTime, $this->timezone), 609 | Carbon::parse($endTime, $this->timezone), 610 | ]; 611 | 612 | if ($endTime->lessThan($startTime)) { 613 | if ($startTime->greaterThan($now)) { 614 | $startTime->subDay(); 615 | } else { 616 | $endTime->addDay(); 617 | } 618 | } 619 | 620 | return function () use ($now, $startTime, $endTime) { 621 | return $now->between($startTime, $endTime); 622 | }; 623 | } 624 | } 625 | --------------------------------------------------------------------------------