diff --git a/CHANGELOG.md b/CHANGELOG.md index 88ff9f4..ef61653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `Scheduler` - `runPromise()` - allows `scheduler:run` and `scheduler:work` commands to output job result as soon as it is finished + - `getScheduledJobs()` - added `repeatAfterSeconds` parameter into the returned array +- `SimpleScheduler` + - `addJob()` accepts parameter `repeatAfterSeconds` +- `JobInfo` + - `getSecond()`- returns for which second within a minute was job scheduled +- `JobManager` + - `getScheduledJob()` - added `repeatAfterSeconds` parameter into the returned array + - `getScheduledJobs()` - added `repeatAfterSeconds` parameter into the returned array +- `SimpleJobManager` + - `addJob()` accepts parameter `repeatAfterSeconds` +- `CallbackJobManager` + - `addJob()` accepts parameter `repeatAfterSeconds` ### Changed @@ -24,3 +36,5 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `getJobs()` -> `getJobSummaries()` - `JobExecutor` - `runJobs()` returns `Generator` instead of `RunSummary` +- `ProcessJobExecutor` + - constructor requires `JobManager` as first parameter diff --git a/composer.json b/composer.json index fab2868..b6daa04 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "require": { "php": ">=7.4.0 <8.3.0", "dragonmantank/cron-expression": "^3.3", - "orisai/clock": "^1.1.1", + "orisai/clock": "^1.2.0", "orisai/exceptions": "^1.1.0", "symfony/console": "^5.3.0|^6.0.0", "symfony/lock": "^5.3.0|^6.0.0", diff --git a/docs/README.md b/docs/README.md index d6b9f27..6352261 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,8 @@ Cron job scheduler - with locks, parallelism and more - [Why do you need it?](#why-do-you-need-it) - [Quick start](#quick-start) - [Execution time](#execution-time) + - [Cron expression - minutes and above](#cron-expression---minutes-and-above) + - [Seconds](#seconds) - [Events](#events) - [Handling errors](#handling-errors) - [Locks and job overlapping](#locks-and-job-overlapping) @@ -50,6 +52,7 @@ Orisai Scheduler solves all of these problems. On top of that you get: - [locking](#locks-and-job-overlapping) - each job should run only once at a time, without overlapping +- [per-second scheduling](#seconds) - run jobs multiple times in a minute - [before/after job events](#events) for accessing job status - [overview of all jobs](#list-command), including estimated time of next run - running jobs either [once](#run-command) or [periodically](#worker-command) during development @@ -90,7 +93,22 @@ Good to go! ## Execution time -Cron execution time is expressed via `CronExpression`, using crontab syntax +Execution time is determined by [cron expression](#cron-expression---minutes-and-above) which allows you to schedule +jobs from anywhere between once a year and once every minute and [seconds], allowing you tu run job several times in a +minute. + +In ideal situation, jobs are executed just in time, but it may not be always the case. Crontab can execute jobs several +seconds late, serial jobs execution may take way over a minute and long jobs may overlap. To prevent any issues, we +implement multiple measures: + +- jobs [repeated after seconds](#seconds) take in account crontab may run late and delay each execution accordingly to + minimize unwanted gaps between executions (e.g. if crontab starts 10 seconds late, all jobs also run 10 seconds late) +- [parallel execution](#parallelization-and-process-isolation) can be used instead of the serial +- [locks](#locks-and-job-overlapping) should be used to prevent overlapping of long-running jobs + +### Cron expression - minutes and above + +Main job execution time is expressed via `CronExpression`, using crontab syntax ```php use Cron\CronExpression; @@ -131,6 +149,37 @@ You can also use macro instead of an expression: - `@daily`, `@midnight` - Run once a day, midnight - `0 0 * * *` - `@hourly` - Run once an hour, first minute - `0 * * * *` +### Seconds + +Run a job every n seconds within a minute. + +```php +use Cron\CronExpression; + +$scheduler->addJob( + /* ... */, + new CronExpression('* * * * *'), + /* ... */, + 1, // every second, 60 times a minute +); +``` + +```php +use Cron\CronExpression; + +$scheduler->addJob( + /* ... */, + new CronExpression('* * * * *'), + /* ... */, + 30, // every 30 seconds, 2 times a minute +); +``` + +With default, synchronous job executor, all jobs scheduled for current second are executed and just after it is +finished, jobs for the next second are executed. With [parallel](#parallelization-and-process-isolation) executor it is +different - all jobs are executed as soon as it is their time. Therefore, it is strongly recommended to +use [locking](#locks-and-job-overlapping) to prevent overlapping. + ## Events Run callbacks before and after job to collect statistics, etc. @@ -178,6 +227,7 @@ $errorHandler = function(Throwable $throwable, JobInfo $info, JobResult $result) 'exception' => $throwable, 'name' => $info->getName(), 'expression' => $info->getExpression(), + 'second' => $info->getSecond(), 'start' => $info->getStart()->format(DateTimeInterface::ATOM), 'end' => $result->getEnd()->format(DateTimeInterface::ATOM), ]); @@ -248,11 +298,13 @@ have [proc_*](https://www.php.net/manual/en/ref.exec.php) functions enabled. Als used [run-job command](#run-job-command), so you need to have [console](#cli-commands) set up as well. ```php -use Orisai\Scheduler\SimpleScheduler; use Orisai\Scheduler\Executor\ProcessJobExecutor; +use Orisai\Scheduler\ManagedScheduler; +use Orisai\Scheduler\Manager\SimpleJobManager; -$executor = new ProcessJobExecutor(); -$scheduler = new SimpleScheduler(null, null, $executor); +$jobManager = new SimpleJobManager(); +$executor = new ProcessJobExecutor($jobManager); +$scheduler = new ManagedScheduler($jobManager, null, null, $executor); ``` If your executable script is not `bin/console` or if you are using multiple scheduler setups, specify the executable: @@ -260,7 +312,7 @@ If your executable script is not `bin/console` or if you are using multiple sche ```php use Orisai\Scheduler\Executor\ProcessJobExecutor; -$executor = new ProcessJobExecutor(); +$executor = new ProcessJobExecutor($jobManager); $executor->setExecutable('bin/console', 'scheduler:run-job'); ``` @@ -328,6 +380,7 @@ Info: $id = $info->getId(); // string|int $name = $info->getName(); // string $expression = $info->getExpression(); // string, e.g. '* * * * *' +$second = $info->getSecond(); $start = $info->getStart(); // DateTimeImmutable ``` @@ -435,7 +488,7 @@ Run single job, ignoring scheduled time ### List command -List all scheduled jobs (in `expression [id] name... next-due` format) +List all scheduled jobs (in `expression / second [id] name... next-due` format) `bin/console scheduler:list` diff --git a/src/Command/BaseRunCommand.php b/src/Command/BaseRunCommand.php index fa414a7..11caeee 100644 --- a/src/Command/BaseRunCommand.php +++ b/src/Command/BaseRunCommand.php @@ -90,6 +90,7 @@ protected function jobToArray(JobSummary $summary): array 'id' => $info->getId(), 'name' => $info->getName(), 'expression' => $info->getExpression(), + 'second' => $info->getSecond(), 'start' => $info->getStart()->format('U.v'), ], 'result' => [ diff --git a/src/Command/ListCommand.php b/src/Command/ListCommand.php index c4311b8..f8b5fda 100644 --- a/src/Command/ListCommand.php +++ b/src/Command/ListCommand.php @@ -18,12 +18,14 @@ use Symfony\Component\Console\Terminal; use function abs; use function array_splice; +use function floor; use function max; use function mb_strlen; use function preg_match; use function sprintf; use function str_pad; use function str_repeat; +use function strlen; use function strnatcmp; use function uasort; use const STR_PAD_LEFT; @@ -73,14 +75,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $terminalWidth = $this->getTerminalWidth(); $expressionSpacing = $this->getCronExpressionSpacing($jobs); + $repeatAfterSecondsSpacing = $this->getRepeatAfterSecondsSpacing($jobs); - foreach ($this->sortJobs($jobs, $nextOption) as $key => [$job, $expression]) { + foreach ($this->sortJobs($jobs, $nextOption) as $key => [$job, $expression, $repeatAfterSeconds]) { $expressionString = $this->formatCronExpression($expression, $expressionSpacing); + $repeatAfterSecondsString = $this->formatRepeatAfterSeconds( + $repeatAfterSeconds, + $repeatAfterSecondsSpacing, + ); $name = $job->getName(); $nextDueDateLabel = 'Next Due:'; - $nextDueDate = $this->getNextDueDateForEvent($expression); + $nextDueDate = $this->getNextDueDate($expression, $repeatAfterSeconds); $nextDueDate = $output->isVerbose() ? $nextDueDate->format('Y-m-d H:i:s P') : $this->getRelativeTime($nextDueDate); @@ -89,14 +96,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int '.', max( /* @infection-ignore-all */ - $terminalWidth - mb_strlen($expressionString . $key . $name . $nextDueDateLabel . $nextDueDate) - 9, + $terminalWidth - mb_strlen( + $expressionString . $repeatAfterSecondsString . $key . $name . $nextDueDateLabel . $nextDueDate, + ) - 9, 0, ), ); $output->writeln(sprintf( - ' %s [%s] %s%s %s %s', + ' %s%s [%s] %s%s %s %s', $expressionString, + $repeatAfterSecondsString, $key, $name, $dots, @@ -138,18 +148,18 @@ private function validateOptionNext(InputInterface $input) } /** - * @param array $jobs - * @param bool|int<1, max> $next - * @return array + * @param array}> $jobs + * @param bool|int<1, max> $next + * @return array}> */ private function sortJobs(array $jobs, $next): array { if ($next !== false) { /** @infection-ignore-all */ uasort($jobs, function ($a, $b): int { - $nextDueDateA = $this->getNextDueDateForEvent($a[1]) + $nextDueDateA = $this->getNextDueDate($a[1], $a[2]) ->setTimezone(new DateTimeZone('UTC')); - $nextDueDateB = $this->getNextDueDateForEvent($b[1]) + $nextDueDateB = $this->getNextDueDate($b[1], $b[2]) ->setTimezone(new DateTimeZone('UTC')); if ( @@ -182,11 +192,49 @@ private function sortJobs(array $jobs, $next): array return $jobs; } - private function getNextDueDateForEvent(CronExpression $expression): DateTimeImmutable + private function getNextDueDate(CronExpression $expression, int $repeatAfterSeconds): DateTimeImmutable + { + $now = $this->clock->now(); + $nextDueDate = DateTimeImmutable::createFromMutable( + $expression->getNextRunDate($now), + ); + + if ($repeatAfterSeconds === 0) { + return $nextDueDate; + } + + $previousDueDate = DateTimeImmutable::createFromMutable( + $expression->getPreviousRunDate($now, 0, true), + ); + + if (!$this->wasPreviousDueDateInCurrentMinute($now, $previousDueDate)) { + return $nextDueDate; + } + + $currentSecond = (int) $now->format('s'); + $runTimes = (int) floor($currentSecond / $repeatAfterSeconds); + $nextRunSecond = ($runTimes + 1) * $repeatAfterSeconds; + + // Don't abuse seconds overlap + if ($nextRunSecond > 59) { + return $nextDueDate; + } + + return $now->setTime( + (int) $now->format('H'), + (int) $now->format('i'), + $nextRunSecond, + ); + } + + private function wasPreviousDueDateInCurrentMinute(DateTimeImmutable $now, DateTimeImmutable $previousDueDate): bool { - return DateTimeImmutable::createFromMutable( - $expression->getNextRunDate($this->clock->now()), + $currentMinute = $now->setTime( + (int) $now->format('H'), + (int) $now->format('i'), ); + + return $currentMinute->getTimestamp() === $previousDueDate->getTimestamp(); } /** @@ -225,14 +273,14 @@ private function getRelativeTime(DateTimeImmutable $time): string } /** - * @param array $jobs + * @param array}> $jobs * * @infection-ignore-all */ private function getCronExpressionSpacing(array $jobs): int { $max = 0; - foreach ($jobs as [$job, $expression]) { + foreach ($jobs as [$job, $expression, $repeatAfterSeconds]) { $length = mb_strlen($expression->getExpression()); if ($length > $max) { $max = $length; @@ -247,6 +295,41 @@ private function formatCronExpression(CronExpression $expression, int $spacing): return str_pad($expression->getExpression(), $spacing, ' ', STR_PAD_LEFT); } + /** + * @param array}|null> $jobs + * + * @infection-ignore-all + */ + private function getRepeatAfterSecondsSpacing(array $jobs): int + { + $max = 0; + foreach ($jobs as [$job, $expression, $repeatAfterSeconds]) { + if ($repeatAfterSeconds === 0) { + continue; + } + + $length = strlen((string) $repeatAfterSeconds); + if ($length > $max) { + $max = $length; + } + } + + if ($max !== 0) { + $max += 3; + } + + return $max; + } + + private function formatRepeatAfterSeconds(int $repeatAfterSeconds, int $spacing): string + { + if ($repeatAfterSeconds === 0) { + return str_pad('', $spacing); + } + + return str_pad(" / $repeatAfterSeconds", $spacing); + } + private function getTerminalWidth(): int { return (new Terminal())->getWidth(); diff --git a/src/Command/RunJobCommand.php b/src/Command/RunJobCommand.php index 8285e21..7eb6187 100644 --- a/src/Command/RunJobCommand.php +++ b/src/Command/RunJobCommand.php @@ -5,10 +5,12 @@ use Orisai\Scheduler\Scheduler; use Orisai\Scheduler\Status\JobResultState; use Orisai\Scheduler\Status\JobSummary; +use Orisai\Scheduler\Status\RunParameters; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use function json_decode; use function json_encode; use const JSON_PRETTY_PRINT; use const JSON_THROW_ON_ERROR; @@ -44,14 +46,19 @@ protected function configure(): void 'Don\'t force job to run and respect due time instead', ); $this->addOption('json', null, InputOption::VALUE_NONE, 'Output in json format'); + $this->addOption('parameters', null, InputOption::VALUE_REQUIRED, '[Internal]'); } protected function execute(InputInterface $input, OutputInterface $output): int { $json = $input->getOption('json'); + $params = $input->getOption('parameters'); $summary = $this->scheduler->runJob( $input->getArgument('id'), !$input->getOption('no-force'), + $params === null + ? null + : RunParameters::fromArray(json_decode($params, true, 512, JSON_THROW_ON_ERROR)), ); if ($summary === null) { diff --git a/src/Executor/BasicJobExecutor.php b/src/Executor/BasicJobExecutor.php index d25d02c..b08ce8c 100644 --- a/src/Executor/BasicJobExecutor.php +++ b/src/Executor/BasicJobExecutor.php @@ -6,14 +6,16 @@ use Cron\CronExpression; use DateTimeImmutable; use Generator; +use Orisai\Clock\Clock; use Orisai\Scheduler\Exception\RunFailure; use Orisai\Scheduler\Job\Job; use Orisai\Scheduler\Manager\JobManager; use Orisai\Scheduler\Status\JobSummary; use Orisai\Scheduler\Status\RunSummary; -use Psr\Clock\ClockInterface; use Throwable; +use function array_keys; use function assert; +use function max; /** * @internal @@ -21,17 +23,17 @@ final class BasicJobExecutor implements JobExecutor { - private ClockInterface $clock; + private Clock $clock; private JobManager $jobManager; - /** @var Closure(string|int, Job, CronExpression): array{JobSummary, Throwable|null} */ + /** @var Closure(string|int, Job, CronExpression, int<0, max>): array{JobSummary, Throwable|null} */ private Closure $runCb; /** - * @param Closure(string|int, Job, CronExpression): array{JobSummary, Throwable|null} $runCb + * @param Closure(string|int, Job, CronExpression, int<0, max>): array{JobSummary, Throwable|null} $runCb */ - public function __construct(ClockInterface $clock, JobManager $jobManager, Closure $runCb) + public function __construct(Clock $clock, JobManager $jobManager, Closure $runCb) { $this->clock = $clock; $this->jobManager = $jobManager; @@ -40,29 +42,95 @@ public function __construct(ClockInterface $clock, JobManager $jobManager, Closu public function runJobs(array $ids, DateTimeImmutable $runStart): Generator { + if ($ids === []) { + return new RunSummary($runStart, $runStart, []); + } + + $scheduledJobsBySecond = $this->getScheduledJobsBySecond($ids); + $lastSecond = max(array_keys($scheduledJobsBySecond)); + $jobSummaries = []; - $suppressed = []; - foreach ($ids as $id) { - $scheduledJob = $this->jobManager->getScheduledJob($id); - assert($scheduledJob !== null); - [$job, $expression] = $scheduledJob; + $suppressedExceptions = []; + for ($second = 0; $second <= $lastSecond; $second++) { + $secondInitiatedAt = $this->clock->now(); - [$jobSummary, $throwable] = ($this->runCb)($id, $job, $expression); + foreach ($scheduledJobsBySecond[$second] ?? [] as [$id, $job, $expression]) { + [$jobSummary, $throwable] = ($this->runCb)($id, $job, $expression, $second); - yield $jobSummaries[] = $jobSummary; + yield $jobSummaries[] = $jobSummary; - if ($throwable !== null) { - $suppressed[] = $throwable; + if ($throwable !== null) { + $suppressedExceptions[] = $throwable; + } } + + $this->sleepTillNextSecond($second, $lastSecond, $secondInitiatedAt); } $summary = new RunSummary($runStart, $this->clock->now(), $jobSummaries); - if ($suppressed !== []) { - throw RunFailure::create($summary, $suppressed); + if ($suppressedExceptions !== []) { + throw RunFailure::create($summary, $suppressedExceptions); } return $summary; } + /** + * @param non-empty-list $ids + * @return non-empty-array> + */ + private function getScheduledJobsBySecond(array $ids): array + { + $scheduledJobsBySecond = []; + foreach ($ids as $id) { + $scheduledJob = $this->jobManager->getScheduledJob($id); + assert($scheduledJob !== null); + [$job, $expression, $repeatAfterSeconds] = $scheduledJob; + + if ($repeatAfterSeconds === 0) { + $scheduledJobsBySecond[0][] = [$id, $job, $expression]; + } else { + for ($second = 0; $second <= 59; $second += $repeatAfterSeconds) { + $scheduledJobsBySecond[$second][] = [$id, $job, $expression]; + } + } + } + + // $ids are not empty and for cycle is always run at least once + assert($scheduledJobsBySecond !== []); + + return $scheduledJobsBySecond; + } + + /** + * More accurate than (float) $dateTime->format('U.u') + */ + private function getMicroTimestamp(DateTimeImmutable $dateTime): float + { + $seconds = (float) $dateTime->format('U'); + $microseconds = (float) $dateTime->format('u') / 1e6; + + return $seconds + $microseconds; + } + + private function sleepTillNextSecond(int $second, int $lastSecond, DateTimeImmutable $secondInitiatedAt): void + { + $sleepTime = $this->getTimeTillNextSecond($second, $lastSecond, $secondInitiatedAt); + $this->clock->sleep(0, 0, (int) ($sleepTime * 1e6)); + } + + private function getTimeTillNextSecond(int $second, int $lastSecond, DateTimeImmutable $secondInitiatedAt): float + { + if ($second === $lastSecond) { + return 0; + } + + $startOfSecond = $this->getMicroTimestamp($secondInitiatedAt); + $endOfSecond = $this->getMicroTimestamp($this->clock->now()); + $timeElapsed = $endOfSecond - $startOfSecond; + + return 1 - $timeElapsed; + } + } diff --git a/src/Executor/ProcessJobExecutor.php b/src/Executor/ProcessJobExecutor.php index 2804ae5..2d3d18f 100644 --- a/src/Executor/ProcessJobExecutor.php +++ b/src/Executor/ProcessJobExecutor.php @@ -6,23 +6,25 @@ use DateTimeImmutable; use Generator; use JsonException; +use Orisai\Clock\Adapter\ClockAdapterFactory; +use Orisai\Clock\Clock; use Orisai\Clock\SystemClock; use Orisai\Scheduler\Exception\JobProcessFailure; use Orisai\Scheduler\Exception\RunFailure; +use Orisai\Scheduler\Job\Job; +use Orisai\Scheduler\Manager\JobManager; use Orisai\Scheduler\Status\JobInfo; use Orisai\Scheduler\Status\JobResult; use Orisai\Scheduler\Status\JobResultState; use Orisai\Scheduler\Status\JobSummary; +use Orisai\Scheduler\Status\RunParameters; use Orisai\Scheduler\Status\RunSummary; use Psr\Clock\ClockInterface; use Symfony\Component\Process\Process; -use function array_map; use function assert; -use function escapeshellarg; -use function implode; use function is_array; use function json_decode; -use function usleep; +use function json_encode; use const JSON_THROW_ON_ERROR; use const PHP_BINARY; @@ -32,15 +34,18 @@ final class ProcessJobExecutor implements JobExecutor { - private ClockInterface $clock; + private JobManager $jobManager; + + private Clock $clock; private string $script = 'bin/console'; private string $command = 'scheduler:run-job'; - public function __construct(?ClockInterface $clock = null) + public function __construct(JobManager $jobManager, ?ClockInterface $clock = null) { - $this->clock = $clock ?? new SystemClock(); + $this->jobManager = $jobManager; + $this->clock = ClockAdapterFactory::create($clock ?? new SystemClock()); } public function setExecutable(string $script, string $command = 'scheduler:run-job'): void @@ -51,65 +56,138 @@ public function setExecutable(string $script, string $command = 'scheduler:run-j public function runJobs(array $ids, DateTimeImmutable $runStart): Generator { - $executions = []; - foreach ($ids as $id) { - $command = implode(' ', array_map(static fn (string $arg) => escapeshellarg($arg), [ - PHP_BINARY, - $this->script, - $this->command, - $id, - '--json', - ])); - - $executions[$id] = $execution = Process::fromShellCommandline($command); - $execution->start(); + if ($ids === []) { + return new RunSummary($runStart, $runStart, []); } + $scheduledJobsBySecond = $this->getScheduledJobsBySecond($ids); + + $jobExecutions = []; $jobSummaries = []; - $suppressed = []; - while ($executions !== []) { - foreach ($executions as $key => $execution) { - if ($execution->isRunning()) { - continue; + $suppressedExceptions = []; + + $lastExecutedSecond = -1; + while ($jobExecutions !== [] || $scheduledJobsBySecond !== []) { + // If we have scheduled jobs and are at right second, execute them + if ($scheduledJobsBySecond !== []) { + $shouldRunSecond = $this->clock->now()->getTimestamp() - $runStart->getTimestamp(); + + while ($lastExecutedSecond < $shouldRunSecond) { + $currentSecond = $lastExecutedSecond + 1; + if (isset($scheduledJobsBySecond[$currentSecond])) { + $jobExecutions = $this->startJobs( + $scheduledJobsBySecond[$currentSecond], + $jobExecutions, + new RunParameters($currentSecond), + ); + unset($scheduledJobsBySecond[$currentSecond]); + } + + $lastExecutedSecond = $currentSecond; } + } + + // Check running jobs + foreach ($jobExecutions as $i => $execution) { + if (!$execution->isRunning()) { + unset($jobExecutions[$i]); + + $output = $execution->getOutput() . $execution->getErrorOutput(); - unset($executions[$key]); - - $output = $execution->getOutput() . $execution->getErrorOutput(); - - try { - $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); - assert(is_array($decoded)); - - yield $jobSummaries[] = new JobSummary( - new JobInfo( - $decoded['info']['id'], - $decoded['info']['name'], - $decoded['info']['expression'], - DateTimeImmutable::createFromFormat('U.v', $decoded['info']['start']), - ), - new JobResult( - new CronExpression($decoded['info']['expression']), - DateTimeImmutable::createFromFormat('U.v', $decoded['result']['end']), - JobResultState::from($decoded['result']['state']), - ), - ); - } catch (JsonException $e) { - $suppressed[] = JobProcessFailure::create() - ->withMessage("Job subprocess failed with following output:\n$output"); + try { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + assert(is_array($decoded)); + + yield $jobSummaries[] = $this->createSummary($decoded); + } catch (JsonException $e) { + $suppressedExceptions[] = JobProcessFailure::create() + ->withMessage("Job subprocess failed with following output:\n$output"); + } } } - usleep(1_000); + // Nothing to do, wait + $this->clock->sleep(0, 1); } $summary = new RunSummary($runStart, $this->clock->now(), $jobSummaries); - if ($suppressed !== []) { - throw RunFailure::create($summary, $suppressed); + if ($suppressedExceptions !== []) { + throw RunFailure::create($summary, $suppressedExceptions); } return $summary; } + /** + * @param non-empty-list $ids + * @return non-empty-array> + */ + private function getScheduledJobsBySecond(array $ids): array + { + $scheduledJobsBySecond = []; + foreach ($ids as $id) { + $scheduledJob = $this->jobManager->getScheduledJob($id); + assert($scheduledJob !== null); + [$job, $expression, $repeatAfterSeconds] = $scheduledJob; + + if ($repeatAfterSeconds === 0) { + $scheduledJobsBySecond[0][] = [$id, $job, $expression]; + } else { + for ($second = 0; $second <= 59; $second += $repeatAfterSeconds) { + $scheduledJobsBySecond[$second][] = [$id, $job, $expression]; + } + } + } + + // $ids are not empty and for cycle is always run at least once + assert($scheduledJobsBySecond !== []); + + return $scheduledJobsBySecond; + } + + /** + * @param list $scheduledJobs + * @param array $jobExecutions + * @return array + */ + private function startJobs(array $scheduledJobs, array $jobExecutions, RunParameters $parameters): array + { + foreach ($scheduledJobs as [$id, $job, $expression]) { + $jobExecutions[] = $execution = new Process([ + PHP_BINARY, + $this->script, + $this->command, + $id, + '--json', + '--parameters', + json_encode($parameters->toArray(), JSON_THROW_ON_ERROR), + ]); + $execution->start(); + } + + return $jobExecutions; + } + + /** + * @param array $raw + */ + private function createSummary(array $raw): JobSummary + { + return new JobSummary( + new JobInfo( + $raw['info']['id'], + $raw['info']['name'], + $raw['info']['expression'], + $raw['info']['second'], + DateTimeImmutable::createFromFormat('U.v', $raw['info']['start']), + ), + new JobResult( + new CronExpression($raw['info']['expression']), + DateTimeImmutable::createFromFormat('U.v', $raw['result']['end']), + JobResultState::from($raw['result']['state']), + ), + ); + } + } diff --git a/src/ManagedScheduler.php b/src/ManagedScheduler.php index b56dcfa..604efb6 100644 --- a/src/ManagedScheduler.php +++ b/src/ManagedScheduler.php @@ -5,6 +5,8 @@ use Closure; use Cron\CronExpression; use Generator; +use Orisai\Clock\Adapter\ClockAdapterFactory; +use Orisai\Clock\Clock; use Orisai\Clock\SystemClock; use Orisai\Exceptions\Logic\InvalidArgument; use Orisai\Exceptions\Message; @@ -18,6 +20,7 @@ use Orisai\Scheduler\Status\JobResult; use Orisai\Scheduler\Status\JobResultState; use Orisai\Scheduler\Status\JobSummary; +use Orisai\Scheduler\Status\RunParameters; use Orisai\Scheduler\Status\RunSummary; use Psr\Clock\ClockInterface; use Symfony\Component\Lock\LockFactory; @@ -37,7 +40,7 @@ class ManagedScheduler implements Scheduler private JobExecutor $executor; - private ClockInterface $clock; + private Clock $clock; /** @var list */ private array $beforeJob = []; @@ -59,12 +62,17 @@ public function __construct( $this->jobManager = $jobManager; $this->errorHandler = $errorHandler; $this->lockFactory = $lockFactory ?? new LockFactory(new InMemoryStore()); - $this->clock = $clock ?? new SystemClock(); + $this->clock = ClockAdapterFactory::create($clock ?? new SystemClock()); $this->executor = $executor ?? new BasicJobExecutor( $this->clock, $this->jobManager, - fn ($id, Job $job, CronExpression $expression): array => $this->runInternal($id, $job, $expression), + fn ($id, Job $job, CronExpression $expression, int $second): array => $this->runInternal( + $id, + $job, + $expression, + $second, + ), ); } @@ -73,9 +81,10 @@ public function getScheduledJobs(): array return $this->jobManager->getScheduledJobs(); } - public function runJob($id, bool $force = true): ?JobSummary + public function runJob($id, bool $force = true, ?RunParameters $parameters = null): ?JobSummary { $scheduledJob = $this->jobManager->getScheduledJob($id); + $parameters ??= new RunParameters(0); if ($scheduledJob === null) { $message = Message::create() @@ -92,11 +101,12 @@ public function runJob($id, bool $force = true): ?JobSummary [$job, $expression] = $scheduledJob; + // Intentionally ignores repeat after seconds if (!$force && !$expression->isDue($this->clock->now())) { return null; } - [$summary, $throwable] = $this->runInternal($id, $job, $expression); + [$summary, $throwable] = $this->runInternal($id, $job, $expression, $parameters->getSecond()); if ($throwable !== null) { throw JobFailure::create($summary, $throwable); @@ -129,14 +139,16 @@ public function run(): RunSummary /** * @param string|int $id + * @param int<0, max> $second * @return array{JobSummary, Throwable|null} */ - private function runInternal($id, Job $job, CronExpression $expression): array + private function runInternal($id, Job $job, CronExpression $expression, int $second): array { $info = new JobInfo( $id, $job->getName(), $expression->getExpression(), + $second, $this->clock->now(), ); diff --git a/src/Manager/CallbackJobManager.php b/src/Manager/CallbackJobManager.php index 902a4f0..9dc0f21 100644 --- a/src/Manager/CallbackJobManager.php +++ b/src/Manager/CallbackJobManager.php @@ -15,17 +15,28 @@ final class CallbackJobManager implements JobManager /** @var array */ private array $expressions = []; + /** @var array> */ + private array $repeat = []; + /** * @param Closure(): Job $jobCtor + * @param int<0, 30> $repeatAfterSeconds */ - public function addJob(Closure $jobCtor, CronExpression $expression, ?string $id = null): void + public function addJob( + Closure $jobCtor, + CronExpression $expression, + ?string $id = null, + int $repeatAfterSeconds = 0 + ): void { if ($id === null) { $this->jobs[] = $jobCtor; $this->expressions[] = $expression; + $this->repeat[] = $repeatAfterSeconds; } else { $this->jobs[$id] = $jobCtor; $this->expressions[$id] = $expression; + $this->repeat[$id] = $repeatAfterSeconds; } } @@ -40,20 +51,22 @@ public function getScheduledJob($id): ?array return [ $job(), $this->expressions[$id], + $this->repeat[$id], ]; } public function getScheduledJobs(): array { - $pairs = []; + $scheduledJobs = []; foreach ($this->jobs as $id => $job) { - $pairs[$id] = [ + $scheduledJobs[$id] = [ $job(), $this->expressions[$id], + $this->repeat[$id], ]; } - return $pairs; + return $scheduledJobs; } public function getExpressions(): array diff --git a/src/Manager/JobManager.php b/src/Manager/JobManager.php index 9976f0a..7553da2 100644 --- a/src/Manager/JobManager.php +++ b/src/Manager/JobManager.php @@ -10,12 +10,12 @@ interface JobManager /** * @param int|string $id - * @return array{Job, CronExpression}|null + * @return array{Job, CronExpression, int<0, 30>}|null */ public function getScheduledJob($id): ?array; /** - * @return array + * @return array}> */ public function getScheduledJobs(): array; diff --git a/src/Manager/SimpleJobManager.php b/src/Manager/SimpleJobManager.php index ed862b2..387bdbf 100644 --- a/src/Manager/SimpleJobManager.php +++ b/src/Manager/SimpleJobManager.php @@ -14,14 +14,22 @@ final class SimpleJobManager implements JobManager /** @var array */ private array $expressions = []; - public function addJob(Job $job, CronExpression $expression, ?string $id = null): void + /** @var array> */ + private array $repeat = []; + + /** + * @param int<0, 30> $repeatAfterSeconds + */ + public function addJob(Job $job, CronExpression $expression, ?string $id = null, int $repeatAfterSeconds = 0): void { if ($id === null) { $this->jobs[] = $job; $this->expressions[] = $expression; + $this->repeat[] = $repeatAfterSeconds; } else { $this->jobs[$id] = $job; $this->expressions[$id] = $expression; + $this->repeat[$id] = $repeatAfterSeconds; } } @@ -36,20 +44,22 @@ public function getScheduledJob($id): ?array return [ $job, $this->expressions[$id], + $this->repeat[$id], ]; } public function getScheduledJobs(): array { - $pairs = []; + $scheduledJobs = []; foreach ($this->jobs as $id => $job) { - $pairs[$id] = [ + $scheduledJobs[$id] = [ $job, $this->expressions[$id], + $this->repeat[$id], ]; } - return $pairs; + return $scheduledJobs; } public function getExpressions(): array diff --git a/src/Scheduler.php b/src/Scheduler.php index 8e13d6d..0228ae0 100644 --- a/src/Scheduler.php +++ b/src/Scheduler.php @@ -8,13 +8,14 @@ use Orisai\Scheduler\Exception\RunFailure; use Orisai\Scheduler\Job\Job; use Orisai\Scheduler\Status\JobSummary; +use Orisai\Scheduler\Status\RunParameters; use Orisai\Scheduler\Status\RunSummary; interface Scheduler { /** - * @return array + * @return array}> */ public function getScheduledJobs(): array; @@ -35,6 +36,6 @@ public function run(): RunSummary; * @phpstan-return ($force is true ? JobSummary : JobSummary|null) * @throws JobFailure When job failed and no error handler was set */ - public function runJob($id, bool $force = true): ?JobSummary; + public function runJob($id, bool $force = true, ?RunParameters $parameters = null): ?JobSummary; } diff --git a/src/SimpleScheduler.php b/src/SimpleScheduler.php index ec0bfc5..d6705f1 100644 --- a/src/SimpleScheduler.php +++ b/src/SimpleScheduler.php @@ -33,9 +33,12 @@ public function __construct( ); } - public function addJob(Job $job, CronExpression $expression, ?string $id = null): void + /** + * @param int<0, 30> $repeatAfterSeconds + */ + public function addJob(Job $job, CronExpression $expression, ?string $id = null, int $repeatAfterSeconds = 0): void { - $this->jobManager->addJob($job, $expression, $id); + $this->jobManager->addJob($job, $expression, $id, $repeatAfterSeconds); } } diff --git a/src/Status/JobInfo.php b/src/Status/JobInfo.php index 9e383f4..f0ef21c 100644 --- a/src/Status/JobInfo.php +++ b/src/Status/JobInfo.php @@ -14,16 +14,21 @@ final class JobInfo private string $expression; + /** @var int<0, max> */ + private int $second; + private DateTimeImmutable $start; /** * @param string|int $id + * @param int<0, max> $second */ - public function __construct($id, string $name, string $expression, DateTimeImmutable $start) + public function __construct($id, string $name, string $expression, int $second, DateTimeImmutable $start) { $this->id = $id; $this->name = $name; $this->expression = $expression; + $this->second = $second; $this->start = $start; } @@ -45,6 +50,14 @@ public function getExpression(): string return $this->expression; } + /** + * @return int<0, max> + */ + public function getSecond(): int + { + return $this->second; + } + public function getStart(): DateTimeImmutable { return $this->start; diff --git a/src/Status/RunParameters.php b/src/Status/RunParameters.php new file mode 100644 index 0000000..ff6a0db --- /dev/null +++ b/src/Status/RunParameters.php @@ -0,0 +1,48 @@ + */ + private int $second; + + /** + * @param int<0, max> $second + */ + public function __construct(int $second) + { + $this->second = $second; + } + + /** + * @param array $raw + */ + public static function fromArray(array $raw): self + { + return new self($raw['second']); + } + + /** + * @return int<0, max> + */ + public function getSecond(): int + { + return $this->second; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'second' => $this->second, + ]; + } + +} diff --git a/tests/Unit/Command/ListCommandTest.php b/tests/Unit/Command/ListCommandTest.php index a1b44a1..bd4b8ff 100644 --- a/tests/Unit/Command/ListCommandTest.php +++ b/tests/Unit/Command/ListCommandTest.php @@ -127,6 +127,67 @@ public function testList(): void */30 7-15 * * 1-5 [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()....... Next Due: 1970-01-01 07:00:00 +01:00 30 * 12 10 * [3] tests/Doubles/CallbackList.php:32......................... Next Due: 1970-10-12 00:30:00 +01:00 +MSG, + implode( + PHP_EOL, + array_map( + static fn (string $s): string => rtrim($s), + explode(PHP_EOL, $tester->getDisplay()), + ), + ), + ); + self::assertSame($command::SUCCESS, $code); + } + + public function testListWithSeconds(): void + { + $clock = new FrozenClock(1, new DateTimeZone('Europe/Prague')); + $scheduler = new SimpleScheduler(null, null, null, $clock); + + $cbs = new CallbackList(); + $scheduler->addJob( + new CallbackJob(Closure::fromCallable([$cbs, 'job1'])), + new CronExpression('* * * * *'), + ); + $scheduler->addJob( + new CallbackJob(Closure::fromCallable([$cbs, 'job1'])), + new CronExpression('*/30 7-15 * * 1-5'), + ); + $scheduler->addJob( + new CallbackJob(Closure::fromCallable([$cbs, 'job1'])), + new CronExpression('*/30 7-15 * * 1-5'), + null, + 1, + ); + $scheduler->addJob( + new CallbackJob(Closure::fromCallable($cbs)), + new CronExpression('* * * 4 *'), + null, + 5, + ); + $scheduler->addJob( + new CallbackJob($cbs->getClosure()), + new CronExpression('30 * 12 10 *'), + null, + 30, + ); + + $command = new ListCommand($scheduler, $clock); + $tester = new CommandTester($command); + + putenv('COLUMNS=120'); + $code = $tester->execute([], [ + 'verbosity' => OutputInterface::VERBOSITY_VERBOSE, + ]); + + self::assertSame( + <<<'MSG' + * * * 4 * / 5 [3] Tests\Orisai\Scheduler\Doubles\CallbackList::__invoke() Next Due: 1970-04-01 00:00:00 +01:00 + * * * * * [0] Tests\Orisai\Scheduler\Doubles\CallbackList::job1().. Next Due: 1970-01-01 01:01:00 +01:00 + */30 7-15 * * 1-5 [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1().. Next Due: 1970-01-01 07:00:00 +01:00 + */30 7-15 * * 1-5 / 1 [2] Tests\Orisai\Scheduler\Doubles\CallbackList::job1().. Next Due: 1970-01-01 07:00:00 +01:00 + 30 * 12 10 * / 30 [4] tests/Doubles/CallbackList.php:32.................... Next Due: 1970-10-12 00:30:00 +01:00 + MSG, implode( PHP_EOL, @@ -146,12 +207,13 @@ public function testNext(): void $cbs = new CallbackList(); $job = new CallbackJob(Closure::fromCallable([$cbs, 'job1'])); - $scheduler->addJob($job, new CronExpression('* * * 4 *')); - $scheduler->addJob($job, new CronExpression('* * * 4 *')); $scheduler->addJob($job, new CronExpression('* * * 2 *')); - $scheduler->addJob($job, new CronExpression('* * * 7 *')); - $scheduler->addJob($job, new CronExpression('* * * 1 *')); - $scheduler->addJob($job, new CronExpression('* * * 6 *')); + $scheduler->addJob($job, new CronExpression('* * 3 * *')); + $scheduler->addJob($job, new CronExpression('* 3 * * *')); + $scheduler->addJob($job, new CronExpression('2 * * * *')); + $scheduler->addJob($job, new CronExpression('* * * * *')); + $scheduler->addJob($job, new CronExpression('* * * * *'), null, 1); + $scheduler->addJob($job, new CronExpression('* * * * *')); $command = new ListCommand($scheduler, $clock); $tester = new CommandTester($command); @@ -163,12 +225,13 @@ public function testNext(): void self::assertSame( <<<'MSG' - * * * 1 * [4] Tests\Orisai\Scheduler\Doubles\CallbackList::job1().......... Next Due: 59 seconds - * * * 2 * [2] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()............. Next Due: 1 month - * * * 4 * [0] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()............ Next Due: 2 months - * * * 4 * [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()............ Next Due: 2 months - * * * 6 * [5] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()............ Next Due: 5 months - * * * 7 * [3] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()............ Next Due: 6 months + * * * * * / 1 [5] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()........ Next Due: 1 second + * * * * * [4] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()...... Next Due: 59 seconds + * * * * * [6] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()...... Next Due: 59 seconds + 2 * * * * [3] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()........ Next Due: 1 minute + * 3 * * * [2] Tests\Orisai\Scheduler\Doubles\CallbackList::job1().......... Next Due: 1 hour + * * 3 * * [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()........... Next Due: 1 day + * * * 2 * [0] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()......... Next Due: 1 month MSG, implode( @@ -182,13 +245,48 @@ public function testNext(): void self::assertSame($command::SUCCESS, $code); $code = $tester->execute([ - '--next' => '2', + '--next' => '4', + ]); + + self::assertSame( + <<<'MSG' + * * * * * / 1 [0] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()........ Next Due: 1 second + * * * * * [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()...... Next Due: 59 seconds + * * * * * [2] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()...... Next Due: 59 seconds + 2 * * * * [3] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()........ Next Due: 1 minute + +MSG, + implode( + PHP_EOL, + array_map( + static fn (string $s): string => rtrim($s), + explode(PHP_EOL, $tester->getDisplay()), + ), + ), + ); + self::assertSame($command::SUCCESS, $code); + } + + public function testNextOverlap(): void + { + $clock = new FrozenClock(59, new DateTimeZone('Europe/Prague')); + $scheduler = new SimpleScheduler(null, null, null, $clock); + + $cbs = new CallbackList(); + $job = new CallbackJob(Closure::fromCallable([$cbs, 'job1'])); + $scheduler->addJob($job, new CronExpression('* * * * *'), null, 1); + + $command = new ListCommand($scheduler, $clock); + $tester = new CommandTester($command); + + putenv('COLUMNS=100'); + $code = $tester->execute([], [ + 'verbosity' => OutputInterface::VERBOSITY_VERBOSE, ]); self::assertSame( <<<'MSG' - * * * 1 * [0] Tests\Orisai\Scheduler\Doubles\CallbackList::job1().......... Next Due: 59 seconds - * * * 2 * [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1()............. Next Due: 1 month + * * * * * / 1 [0] Tests\Orisai\Scheduler\Doubles\CallbackList::job1() Next Due: 1970-01-01 01:01:00 +01:00 MSG, implode( diff --git a/tests/Unit/Command/RunCommandTest.php b/tests/Unit/Command/RunCommandTest.php index 16a9104..5d4d432 100644 --- a/tests/Unit/Command/RunCommandTest.php +++ b/tests/Unit/Command/RunCommandTest.php @@ -104,6 +104,7 @@ public function testSuccess(): void "id": 0, "name": "Tests\\Orisai\\Scheduler\\Doubles\\CallbackList::job1()", "expression": "* * * * *", + "second": 0, "start": "1.000" }, "result": { @@ -116,6 +117,7 @@ public function testSuccess(): void "id": 1, "name": "Tests\\Orisai\\Scheduler\\Doubles\\CallbackList::job2()", "expression": "* * * * *", + "second": 0, "start": "1.000" }, "result": { @@ -176,6 +178,7 @@ public function testFailure(): void "id": 0, "name": "Tests\\Orisai\\Scheduler\\Doubles\\CallbackList::job1()", "expression": "* * * * *", + "second": 0, "start": "1.000" }, "result": { @@ -188,6 +191,7 @@ public function testFailure(): void "id": 1, "name": "Tests\\Orisai\\Scheduler\\Doubles\\CallbackList::exceptionJob()", "expression": "* * * * *", + "second": 0, "start": "1.000" }, "result": { @@ -225,8 +229,8 @@ public function testSkip(): void $tester = new CommandTester($command); putenv('COLUMNS=80'); - $code = $tester->execute([]); + $code = $tester->execute([]); self::assertSame( <<<'MSG' 1970-01-01 01:00:01 Running [0] job1................................... 0ms SKIP @@ -245,8 +249,8 @@ public function testProcessExecutor(): void $tester = new CommandTester($command); putenv('COLUMNS=80'); - $code = $tester->execute([]); + $code = $tester->execute([]); $displayLines = explode(PHP_EOL, $tester->getDisplay()); sort($displayLines); @@ -256,6 +260,7 @@ public function testProcessExecutor(): void '1970-01-01 00:00:01 Running [0] Tests\Orisai\Scheduler\Doubles\CallbackList::exceptionJob() 0ms FAIL', '1970-01-01 00:00:01 Running [1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1() 0ms DONE', '1970-01-01 00:00:01 Running [job1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1() 0ms DONE', + '1970-01-01 00:00:01 Running [job1] Tests\Orisai\Scheduler\Doubles\CallbackList::job1() 0ms DONE', ], $displayLines, ); diff --git a/tests/Unit/Command/RunJobCommandTest.php b/tests/Unit/Command/RunJobCommandTest.php index db8e2d4..3c60c7e 100644 --- a/tests/Unit/Command/RunJobCommandTest.php +++ b/tests/Unit/Command/RunJobCommandTest.php @@ -10,6 +10,7 @@ use Orisai\Scheduler\Command\RunJobCommand; use Orisai\Scheduler\Job\CallbackJob; use Orisai\Scheduler\SimpleScheduler; +use Orisai\Scheduler\Status\RunParameters; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\Store\InMemoryStore; @@ -19,9 +20,11 @@ use function array_map; use function explode; use function implode; +use function json_encode; use function preg_replace; use function putenv; use function rtrim; +use const JSON_THROW_ON_ERROR; use const PHP_EOL; /** @@ -222,7 +225,7 @@ public function testNoForce(): void ); self::assertSame($command::SUCCESS, $code); - $clock->move(60); + $clock->sleep(60); $code = $tester->execute([ 'id' => 0, '--no-force' => true, @@ -280,10 +283,11 @@ public function testJson(): void ); self::assertSame($command::SUCCESS, $code); - $clock->move(60); + $clock->sleep(60); $code = $tester->execute([ 'id' => 0, '--json' => true, + '--parameters' => json_encode((new RunParameters(30))->toArray(), JSON_THROW_ON_ERROR), ]); self::assertSame( @@ -293,6 +297,7 @@ public function testJson(): void "id": 0, "name": "Tests\\Orisai\\Scheduler\\Doubles\\CallbackList::job1()", "expression": "1 * * * *", + "second": 30, "start": "61.000" }, "result": { diff --git a/tests/Unit/Command/WorkerCommandTest.php b/tests/Unit/Command/WorkerCommandTest.php index 8c5364e..5142ab8 100644 --- a/tests/Unit/Command/WorkerCommandTest.php +++ b/tests/Unit/Command/WorkerCommandTest.php @@ -23,7 +23,7 @@ public function testNoRuns(): void $clock = new FrozenClock(1_020, new DateTimeZone('Europe/Prague')); $command = new WorkerCommand($clock); - $command->enableTestMode(0, static fn () => $clock->move(60)); + $command->enableTestMode(0, static fn () => $clock->sleep(60)); $tester = new CommandTester($command); putenv('COLUMNS=80'); @@ -50,7 +50,7 @@ public function testSingleRun(): void $clock = new FrozenClock(1_020, new DateTimeZone('Europe/Prague')); $command = new WorkerCommand($clock); - $command->enableTestMode(1, static fn () => $clock->move(60)); + $command->enableTestMode(1, static fn () => $clock->sleep(60)); $tester = new CommandTester($command); putenv('COLUMNS=80'); @@ -68,7 +68,7 @@ public function testExecutableSetter(): void $command = new WorkerCommand($clock); $command->setExecutable('tests/Unit/Command/worker-binary.php'); - $command->enableTestMode(1, static fn () => $clock->move(60)); + $command->enableTestMode(1, static fn () => $clock->sleep(60)); $tester = new CommandTester($command); putenv('COLUMNS=80'); @@ -83,7 +83,7 @@ public function testMultipleRuns(): void $clock = new FrozenClock(1_020, new DateTimeZone('Europe/Prague')); $command = new WorkerCommand($clock); - $command->enableTestMode(2, static fn () => $clock->move(60)); + $command->enableTestMode(2, static fn () => $clock->sleep(60)); $tester = new CommandTester($command); putenv('COLUMNS=80'); @@ -100,7 +100,7 @@ public function testNoJobs(): void $clock = new FrozenClock(1_020, new DateTimeZone('Europe/Prague')); $command = new WorkerCommand($clock); - $command->enableTestMode(2, static fn () => $clock->move(60)); + $command->enableTestMode(2, static fn () => $clock->sleep(60)); $tester = new CommandTester($command); putenv('COLUMNS=80'); @@ -129,7 +129,7 @@ public function testDefaultExecutable(): void $clock = new FrozenClock(1_020, new DateTimeZone('Europe/Prague')); $command = new WorkerCommand($clock); - $command->enableTestMode(2, static fn () => $clock->move(60)); + $command->enableTestMode(2, static fn () => $clock->sleep(60)); $tester = new CommandTester($command); putenv('COLUMNS=80'); diff --git a/tests/Unit/Exception/JobFailureTest.php b/tests/Unit/Exception/JobFailureTest.php index fcd9437..3fbe7df 100644 --- a/tests/Unit/Exception/JobFailureTest.php +++ b/tests/Unit/Exception/JobFailureTest.php @@ -17,7 +17,7 @@ final class JobFailureTest extends TestCase public function test(): void { - $info = new JobInfo('id', 'name', '* * * * *', new DateTimeImmutable()); + $info = new JobInfo('id', 'name', '* * * * *', 0, new DateTimeImmutable()); $result = new JobResult( new CronExpression('* * * * *'), new DateTimeImmutable(), diff --git a/tests/Unit/Manager/CallbackJobManagerTest.php b/tests/Unit/Manager/CallbackJobManagerTest.php index 67016ff..9801864 100644 --- a/tests/Unit/Manager/CallbackJobManagerTest.php +++ b/tests/Unit/Manager/CallbackJobManagerTest.php @@ -28,7 +28,7 @@ public function test(): void $job2 = clone $job1; $expression2 = clone $expression1; - $manager->addJob(static fn (): Job => $job2, $expression2, 'id'); + $manager->addJob(static fn (): Job => $job2, $expression2, 'id', 1); self::assertSame( [ @@ -39,13 +39,13 @@ public function test(): void ); self::assertSame( [ - 0 => [$job1, $expression1], - 'id' => [$job2, $expression2], + 0 => [$job1, $expression1, 0], + 'id' => [$job2, $expression2, 1], ], $manager->getScheduledJobs(), ); - self::assertSame([$job1, $expression1], $manager->getScheduledJob(0)); - self::assertSame([$job2, $expression2], $manager->getScheduledJob('id')); + self::assertSame([$job1, $expression1, 0], $manager->getScheduledJob(0)); + self::assertSame([$job2, $expression2, 1], $manager->getScheduledJob('id')); self::assertNull($manager->getScheduledJob(42)); } diff --git a/tests/Unit/Manager/SimpleJobManagerTest.php b/tests/Unit/Manager/SimpleJobManagerTest.php index 718fd5d..6fcde59 100644 --- a/tests/Unit/Manager/SimpleJobManagerTest.php +++ b/tests/Unit/Manager/SimpleJobManagerTest.php @@ -27,7 +27,7 @@ public function test(): void $job2 = clone $job1; $expression2 = clone $expression1; - $manager->addJob($job2, $expression2, 'id'); + $manager->addJob($job2, $expression2, 'id', 1); self::assertSame( [ @@ -38,13 +38,13 @@ public function test(): void ); self::assertSame( [ - 0 => [$job1, $expression1], - 'id' => [$job2, $expression2], + 0 => [$job1, $expression1, 0], + 'id' => [$job2, $expression2, 1], ], $manager->getScheduledJobs(), ); - self::assertSame([$job1, $expression1], $manager->getScheduledJob(0)); - self::assertSame([$job2, $expression2], $manager->getScheduledJob('id')); + self::assertSame([$job1, $expression1, 0], $manager->getScheduledJob(0)); + self::assertSame([$job2, $expression2, 1], $manager->getScheduledJob('id')); self::assertNull($manager->getScheduledJob(42)); } diff --git a/tests/Unit/SchedulerProcessSetup.php b/tests/Unit/SchedulerProcessSetup.php index 1eae84c..9454d00 100644 --- a/tests/Unit/SchedulerProcessSetup.php +++ b/tests/Unit/SchedulerProcessSetup.php @@ -7,8 +7,9 @@ use Orisai\Clock\FrozenClock; use Orisai\Scheduler\Executor\ProcessJobExecutor; use Orisai\Scheduler\Job\CallbackJob; +use Orisai\Scheduler\ManagedScheduler; +use Orisai\Scheduler\Manager\SimpleJobManager; use Orisai\Scheduler\Scheduler; -use Orisai\Scheduler\SimpleScheduler; use Orisai\Scheduler\Status\JobInfo; use Orisai\Scheduler\Status\JobResult; use Tests\Orisai\Scheduler\Doubles\CallbackList; @@ -23,12 +24,12 @@ public static function createWithErrorHandler(): Scheduler // Noop }; - return self::create($errorHandler, 'tests/Unit/scheduler-process-binary-with-error-handler.php'); + return self::create($errorHandler, __DIR__ . '/scheduler-process-binary-with-error-handler.php'); } public static function createWithoutErrorHandler(): Scheduler { - return self::create(null, 'tests/Unit/scheduler-process-binary-without-error-handler.php'); + return self::create(null, __DIR__ . '/scheduler-process-binary-without-error-handler.php'); } public static function createWithDefaultExecutable(): Scheduler @@ -36,40 +37,50 @@ public static function createWithDefaultExecutable(): Scheduler return self::create(); } + public static function createEmpty(): Scheduler + { + $jobManager = new SimpleJobManager(); + $clock = new FrozenClock(1); + $executor = new ProcessJobExecutor($jobManager, $clock); + $executor->setExecutable(__DIR__ . '/scheduler-process-binary-empty.php'); + + return new ManagedScheduler($jobManager, null, null, $executor, $clock); + } + /** * @param Closure(Throwable, JobInfo, JobResult): (void)|null $errorHandler */ private static function create(?Closure $errorHandler = null, ?string $script = null): Scheduler { - $executor = new ProcessJobExecutor(); - - if ($script !== null) { - $executor->setExecutable($script); - } - - $clock = new FrozenClock(1); - $scheduler = new SimpleScheduler($errorHandler, null, $executor, $clock); $cbs = new CallbackList(); - - $scheduler->addJob( + $jobManager = new SimpleJobManager(); + $jobManager->addJob( new CallbackJob(Closure::fromCallable([$cbs, 'job1'])), new CronExpression('* * * * *'), 'job1', + 30, ); - $scheduler->addJob( + $jobManager->addJob( new CallbackJob(Closure::fromCallable([$cbs, 'exceptionJob'])), new CronExpression('* * * * *'), ); - $scheduler->addJob( + $jobManager->addJob( new CallbackJob(Closure::fromCallable([$cbs, 'job1'])), new CronExpression('0 * * * *'), ); - $scheduler->addJob( + $jobManager->addJob( new CallbackJob(Closure::fromCallable([$cbs, 'job1'])), new CronExpression('1 * * * *'), ); - return $scheduler; + $clock = new FrozenClock(1); + $executor = new ProcessJobExecutor($jobManager, $clock); + + if ($script !== null) { + $executor->setExecutable($script); + } + + return new ManagedScheduler($jobManager, $errorHandler, null, $executor, $clock); } } diff --git a/tests/Unit/SimpleSchedulerTest.php b/tests/Unit/SimpleSchedulerTest.php index 6d6b7fc..95dfc17 100644 --- a/tests/Unit/SimpleSchedulerTest.php +++ b/tests/Unit/SimpleSchedulerTest.php @@ -5,6 +5,7 @@ use Closure; use Cron\CronExpression; use DateTimeImmutable; +use Generator; use Orisai\Clock\FrozenClock; use Orisai\Exceptions\Logic\InvalidArgument; use Orisai\Scheduler\Exception\JobFailure; @@ -15,6 +16,7 @@ use Orisai\Scheduler\Status\JobResult; use Orisai\Scheduler\Status\JobResultState; use Orisai\Scheduler\Status\JobSummary; +use Orisai\Scheduler\Status\RunParameters; use Orisai\Scheduler\Status\RunSummary; use PHPUnit\Framework\TestCase; use Symfony\Component\Lock\Store\InMemoryStore; @@ -41,7 +43,7 @@ static function () use (&$i): void { $scheduler->addJob($job, $expression); self::assertSame([ - [$job, $expression], + [$job, $expression, 0], ], $scheduler->getScheduledJobs()); $scheduler->run(); @@ -72,7 +74,7 @@ static function () use (&$i): void { $scheduler->addJob($job, $expression, $key); self::assertSame([ - $key => [$job, $expression], + $key => [$job, $expression, 0], ], $scheduler->getScheduledJobs()); $scheduler->runJob($key); @@ -208,8 +210,8 @@ public function testEvents(): void self::assertEquals( [ - new JobInfo(0, 'Tests\Orisai\Scheduler\Doubles\CallbackList::exceptionJob()', '* * * * *', $now), - new JobInfo(1, 'Tests\Orisai\Scheduler\Doubles\CallbackList::job1()', '* * * * *', $now), + new JobInfo(0, 'Tests\Orisai\Scheduler\Doubles\CallbackList::exceptionJob()', '* * * * *', 0, $now), + new JobInfo(1, 'Tests\Orisai\Scheduler\Doubles\CallbackList::job1()', '* * * * *', 0, $now), ], $beforeCollected, ); @@ -217,11 +219,11 @@ public function testEvents(): void self::assertEquals( [ [ - new JobInfo(0, 'Tests\Orisai\Scheduler\Doubles\CallbackList::exceptionJob()', '* * * * *', $now), + new JobInfo(0, 'Tests\Orisai\Scheduler\Doubles\CallbackList::exceptionJob()', '* * * * *', 0, $now), new JobResult(new CronExpression('* * * * *'), $now, JobResultState::fail()), ], [ - new JobInfo(1, 'Tests\Orisai\Scheduler\Doubles\CallbackList::job1()', '* * * * *', $now), + new JobInfo(1, 'Tests\Orisai\Scheduler\Doubles\CallbackList::job1()', '* * * * *', 0, $now), new JobResult(new CronExpression('* * * * *'), $now, JobResultState::done()), ], ], @@ -241,16 +243,15 @@ public function testTimeMovement(): void $jobLine = __LINE__ + 2; $job = new CallbackJob( - static function (): void { - // Noop + static function () use ($clock): void { + $clock->sleep(5); }, ); $scheduler->addJob($job, new CronExpression('* * * * *')); $beforeCollected = []; - $beforeCb = static function (JobInfo $info) use (&$beforeCollected, $clock): void { + $beforeCb = static function (JobInfo $info) use (&$beforeCollected): void { $beforeCollected[] = $info; - $clock->move(1); }; $scheduler->addBeforeJobCallback($beforeCb); @@ -268,6 +269,7 @@ static function (): void { 0, "tests/Unit/SimpleSchedulerTest.php:$jobLine", '* * * * *', + 0, DateTimeImmutable::createFromFormat('U', '1'), ), ], @@ -280,11 +282,12 @@ static function (): void { 0, "tests/Unit/SimpleSchedulerTest.php:$jobLine", '* * * * *', + 0, DateTimeImmutable::createFromFormat('U', '1'), ), new JobResult( new CronExpression('* * * * *'), - DateTimeImmutable::createFromFormat('U', '2'), + DateTimeImmutable::createFromFormat('U', '6'), JobResultState::done(), ), ], @@ -329,7 +332,7 @@ static function (): void { self::assertNotNull($scheduler->runJob(2)); $expressions = []; - $clock->move(60); + $clock->sleep(60); $scheduler->run(); self::assertSame( [ @@ -343,7 +346,7 @@ static function (): void { self::assertNotNull($scheduler->runJob(2, false)); $expressions = []; - $clock->move(60); + $clock->sleep(60); $scheduler->run(); self::assertSame( [ @@ -363,7 +366,7 @@ public function testLongRunningJobDoesNotPreventNextJobToStart(): void $job1 = new CallbackJob( static function () use ($clock): void { - $clock->move(60); // Moves time to next minute, next time will job be not ran + $clock->sleep(60); // Moves time to next minute, next time will job be not ran }, ); $scheduler->addJob($job1, new CronExpression('0 * * * *')); @@ -398,7 +401,7 @@ public function testRunSummary(): void $scheduler->addJob( new CustomNameJob( new CallbackJob(static function () use ($clock): void { - $clock->move(60); + $clock->sleep(60); }), 'job1', ), @@ -419,6 +422,7 @@ public function testRunSummary(): void 0, 'Tests\Orisai\Scheduler\Doubles\CallbackList::job1()', '* * * * *', + 0, $before, ), new JobResult(new CronExpression('* * * * *'), $before, JobResultState::done()), @@ -428,6 +432,7 @@ public function testRunSummary(): void 1, 'job1', '* * * * *', + 0, $before, ), new JobResult(new CronExpression('* * * * *'), $after, JobResultState::done()), @@ -438,7 +443,12 @@ public function testRunSummary(): void ); } - public function testJobSummary(): void + /** + * @param int<0, max> $second + * + * @dataProvider provideJobSummary + */ + public function testJobSummary(?RunParameters $parameters, int $second): void { $clock = new FrozenClock(1); $scheduler = new SimpleScheduler(null, null, null, $clock); @@ -447,7 +457,7 @@ public function testJobSummary(): void $job = new CallbackJob(Closure::fromCallable([$cbs, 'job1'])); $scheduler->addJob($job, new CronExpression('* * * * *')); - $summary = $scheduler->runJob(0); + $summary = $scheduler->runJob(0, true, $parameters); self::assertInstanceOf(JobSummary::class, $summary); $now = $clock->now(); @@ -456,6 +466,7 @@ public function testJobSummary(): void 0, 'Tests\Orisai\Scheduler\Doubles\CallbackList::job1()', '* * * * *', + $second, $now, ), $summary->getInfo(), @@ -466,6 +477,13 @@ public function testJobSummary(): void ); } + public function provideJobSummary(): Generator + { + yield [null, 0]; + yield [new RunParameters(10), 10]; + yield [new RunParameters(30), 30]; + } + public function testLockAlreadyAcquired(): void { $lockFactory = new TestLockFactory(new InMemoryStore(), false); @@ -504,11 +522,11 @@ static function () use (&$i2): void { self::assertEquals( [ new JobSummary( - new JobInfo(0, 'job1', '* * * * *', $clock->now()), + new JobInfo(0, 'job1', '* * * * *', 0, $clock->now()), new JobResult(new CronExpression('* * * * *'), $clock->now(), JobResultState::skip()), ), new JobSummary( - new JobInfo(1, 'job2', '* * * * *', $clock->now()), + new JobInfo(1, 'job2', '* * * * *', 0, $clock->now()), new JobResult(new CronExpression('* * * * *'), $clock->now(), JobResultState::done()), ), ], @@ -537,11 +555,11 @@ static function () use (&$i2): void { self::assertEquals( [ new JobSummary( - new JobInfo(0, 'job1', '* * * * *', $clock->now()), + new JobInfo(0, 'job1', '* * * * *', 0, $clock->now()), new JobResult(new CronExpression('* * * * *'), $clock->now(), JobResultState::done()), ), new JobSummary( - new JobInfo(1, 'job2', '* * * * *', $clock->now()), + new JobInfo(1, 'job2', '* * * * *', 0, $clock->now()), new JobResult(new CronExpression('* * * * *'), $clock->now(), JobResultState::done()), ), ], @@ -675,12 +693,68 @@ static function () use (&$i): void { self::assertSame(3, $i); } + public function testRepeat(): void + { + $clock = new FrozenClock(1); + $scheduler = new SimpleScheduler(null, null, null, $clock); + + $i1 = 0; + $job1 = new CallbackJob( + static function () use (&$i1): void { + $i1++; + }, + ); + $scheduler->addJob( + $job1, + new CronExpression('* * * * *'), + null, + 30, + ); + + $summary = $scheduler->run(); + self::assertSame(2, $i1); + self::assertCount(2, $summary->getJobSummaries()); + self::assertSame(31, $clock->now()->getTimestamp()); + + $i2 = 0; + $job2 = new CallbackJob( + static function () use (&$i2): void { + $i2++; + }, + ); + $scheduler->addJob( + $job2, + new CronExpression('* * * * *'), + null, + 1, + ); + + $summary = $scheduler->run(); + self::assertSame(4, $i1); + self::assertSame(60, $i2); + self::assertCount(62, $summary->getJobSummaries()); + self::assertSame(90, $clock->now()->getTimestamp()); + } + + public function testProcessNoJobs(): void + { + $clock = new FrozenClock(1); + $scheduler = SchedulerProcessSetup::createEmpty(); + + self::assertSame([], $scheduler->getScheduledJobs()); + + self::assertEquals( + new RunSummary($clock->now(), $clock->now(), []), + $scheduler->run(), + ); + } + public function testProcessExecutorWithErrorHandler(): void { $scheduler = SchedulerProcessSetup::createWithErrorHandler(); $summary = $scheduler->run(); - self::assertCount(3, $summary->getJobSummaries()); + self::assertCount(4, $summary->getJobSummaries()); } public function testProcessExecutorWithoutErrorHandler(): void diff --git a/tests/Unit/Status/JobInfoTest.php b/tests/Unit/Status/JobInfoTest.php index c393e73..ada2a69 100644 --- a/tests/Unit/Status/JobInfoTest.php +++ b/tests/Unit/Status/JobInfoTest.php @@ -14,12 +14,14 @@ public function test(): void $id = 'id'; $name = 'name'; $expression = '* * * * *'; + $second = 0; $start = new DateTimeImmutable(); - $info = new JobInfo($id, $name, $expression, $start); + $info = new JobInfo($id, $name, $expression, $second, $start); self::assertSame($id, $info->getId()); self::assertSame($name, $info->getName()); self::assertSame($expression, $info->getExpression()); + self::assertSame($second, $info->getSecond()); self::assertSame($start, $info->getStart()); } diff --git a/tests/Unit/Status/JobSummaryTest.php b/tests/Unit/Status/JobSummaryTest.php index 9d3f1ff..851df5f 100644 --- a/tests/Unit/Status/JobSummaryTest.php +++ b/tests/Unit/Status/JobSummaryTest.php @@ -15,7 +15,7 @@ final class JobSummaryTest extends TestCase public function test(): void { - $info = new JobInfo('id', 'name', '* * * * *', new DateTimeImmutable()); + $info = new JobInfo('id', 'name', '* * * * *', 0, new DateTimeImmutable()); $result = new JobResult( new CronExpression('* * * * *'), new DateTimeImmutable(), diff --git a/tests/Unit/Status/RunParametersTest.php b/tests/Unit/Status/RunParametersTest.php new file mode 100644 index 0000000..e792a86 --- /dev/null +++ b/tests/Unit/Status/RunParametersTest.php @@ -0,0 +1,20 @@ +getSecond()); + self::assertEquals($parameters, RunParameters::fromArray($parameters->toArray())); + } + +} diff --git a/tests/Unit/Status/RunSummaryTest.php b/tests/Unit/Status/RunSummaryTest.php index a7418ec..e252034 100644 --- a/tests/Unit/Status/RunSummaryTest.php +++ b/tests/Unit/Status/RunSummaryTest.php @@ -20,11 +20,11 @@ public function test(): void $end = new DateTimeImmutable(); $jobSummaries = [ new JobSummary( - new JobInfo('id', '1', '* * * * *', new DateTimeImmutable()), + new JobInfo('id', '1', '* * * * *', 0, new DateTimeImmutable()), new JobResult(new CronExpression('* * * * *'), new DateTimeImmutable(), JobResultState::done()), ), new JobSummary( - new JobInfo('id', '2', '1 * * * *', new DateTimeImmutable()), + new JobInfo('id', '2', '1 * * * *', 10, new DateTimeImmutable()), new JobResult(new CronExpression('1 * * * *'), new DateTimeImmutable(), JobResultState::done()), ), ]; diff --git a/tests/Unit/scheduler-process-binary-empty.php b/tests/Unit/scheduler-process-binary-empty.php new file mode 100644 index 0000000..f137a0f --- /dev/null +++ b/tests/Unit/scheduler-process-binary-empty.php @@ -0,0 +1,17 @@ +addCommands([$command]); + +$application->run(); diff --git a/tools/phpstan.baseline.neon b/tools/phpstan.baseline.neon index 2cc3d25..657ff08 100644 --- a/tools/phpstan.baseline.neon +++ b/tools/phpstan.baseline.neon @@ -30,23 +30,13 @@ parameters: count: 1 path: ../src/Command/WorkerCommand.php - - - message: "#^Foreach overwrites \\$execution with its value variable\\.$#" - count: 1 - path: ../src/Executor/ProcessJobExecutor.php - - - - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(int\\|string\\)\\: mixed\\)\\|null, Closure\\(string\\)\\: string given\\.$#" - count: 1 - path: ../src/Executor/ProcessJobExecutor.php - - message: "#^Parameter \\#2 \\$end of class Orisai\\\\Scheduler\\\\Status\\\\JobResult constructor expects DateTimeImmutable, DateTimeImmutable\\|false given\\.$#" count: 1 path: ../src/Executor/ProcessJobExecutor.php - - message: "#^Parameter \\#4 \\$start of class Orisai\\\\Scheduler\\\\Status\\\\JobInfo constructor expects DateTimeImmutable, DateTimeImmutable\\|false given\\.$#" + message: "#^Parameter \\#5 \\$start of class Orisai\\\\Scheduler\\\\Status\\\\JobInfo constructor expects DateTimeImmutable, DateTimeImmutable\\|false given\\.$#" count: 1 path: ../src/Executor/ProcessJobExecutor.php @@ -125,6 +115,11 @@ parameters: count: 1 path: ../tests/Unit/SimpleSchedulerTest.php + - + message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertSame\\(\\) with 4 and 2 will always evaluate to false\\.$#" + count: 1 + path: ../tests/Unit/SimpleSchedulerTest.php + - message: "#^Call to static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertSame\\(\\) with array\\{'\\* \\* \\* \\* \\*', '1 \\* \\* \\* \\*'\\} and array\\{\\} will always evaluate to false\\.$#" count: 1 @@ -151,7 +146,7 @@ parameters: path: ../tests/Unit/SimpleSchedulerTest.php - - message: "#^Parameter \\#4 \\$start of class Orisai\\\\Scheduler\\\\Status\\\\JobInfo constructor expects DateTimeImmutable, DateTimeImmutable\\|false given\\.$#" + message: "#^Parameter \\#5 \\$start of class Orisai\\\\Scheduler\\\\Status\\\\JobInfo constructor expects DateTimeImmutable, DateTimeImmutable\\|false given\\.$#" count: 2 path: ../tests/Unit/SimpleSchedulerTest.php