From a0d00a117c5ed37a77432e6e54c95cb4099632f9 Mon Sep 17 00:00:00 2001 From: Vladimir Tsykun Date: Sun, 12 Jan 2020 17:31:34 +0300 Subject: [PATCH] Base cron bundle implementation --- LICENSE | 20 +++ README.md | 123 ++++++++++++++++++ composer.json | 40 ++++++ src/Command/CronCommand.php | 58 +++++++++ src/Command/CronExecuteCommand.php | 46 +++++++ src/DependencyInjection/Configuration.php | 29 +++++ .../OkvpnCronExtension.php | 28 ++++ src/Loader/ArrayScheduleLoader.php | 27 ++++ src/Loader/ScheduleFactory.php | 56 ++++++++ src/Loader/ScheduleFactoryInterface.php | 18 +++ src/Loader/ScheduleLoader.php | 28 ++++ src/Loader/ScheduleLoaderInterface.php | 15 +++ src/Middleware/AsyncProcessEngine.php | 59 +++++++++ src/Middleware/CommandRunnerEngine.php | 47 +++++++ src/Middleware/CronMiddlewareEngine.php | 26 ++++ src/Middleware/LockMiddlewareEngine.php | 45 +++++++ src/Middleware/MiddlewareEngineInterface.php | 18 +++ src/Middleware/ServiceInvokeEngine.php | 41 ++++++ src/Middleware/StackEngine.php | 59 +++++++++ src/Middleware/StackInterface.php | 18 +++ src/Model/ArgumentsStamp.php | 23 ++++ src/Model/AsyncStamp.php | 9 ++ src/Model/CommandStamp.php | 14 ++ src/Model/LockStamp.php | 30 +++++ src/Model/OutputStamp.php | 23 ++++ src/Model/ScheduleEnvelope.php | 84 ++++++++++++ src/Model/ScheduleStamp.php | 25 ++++ src/OkvpnCronBundle.php | 11 ++ src/Resources/config/services.yml | 62 +++++++++ src/Runner/ScheduleRunner.php | 43 ++++++ src/Runner/ScheduleRunnerInterface.php | 16 +++ 31 files changed, 1141 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Command/CronCommand.php create mode 100644 src/Command/CronExecuteCommand.php create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/OkvpnCronExtension.php create mode 100644 src/Loader/ArrayScheduleLoader.php create mode 100644 src/Loader/ScheduleFactory.php create mode 100644 src/Loader/ScheduleFactoryInterface.php create mode 100644 src/Loader/ScheduleLoader.php create mode 100644 src/Loader/ScheduleLoaderInterface.php create mode 100644 src/Middleware/AsyncProcessEngine.php create mode 100644 src/Middleware/CommandRunnerEngine.php create mode 100644 src/Middleware/CronMiddlewareEngine.php create mode 100644 src/Middleware/LockMiddlewareEngine.php create mode 100644 src/Middleware/MiddlewareEngineInterface.php create mode 100644 src/Middleware/ServiceInvokeEngine.php create mode 100644 src/Middleware/StackEngine.php create mode 100644 src/Middleware/StackInterface.php create mode 100644 src/Model/ArgumentsStamp.php create mode 100644 src/Model/AsyncStamp.php create mode 100644 src/Model/CommandStamp.php create mode 100644 src/Model/LockStamp.php create mode 100644 src/Model/OutputStamp.php create mode 100644 src/Model/ScheduleEnvelope.php create mode 100644 src/Model/ScheduleStamp.php create mode 100644 src/OkvpnCronBundle.php create mode 100644 src/Resources/config/services.yml create mode 100644 src/Runner/ScheduleRunner.php create mode 100644 src/Runner/ScheduleRunnerInterface.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6e3ae89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT). + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7cbd5b --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Okvpn - Cron Bundle + +This bundle provides interfaces for registering scheduled tasks within your Symfony application. + +### Purpose +This is a more simpler alternative of existing cron bundle without doctrine deps, +supporting invoke a service as cron job. +Here also added support middleware for customization handling cron jobs across a cluster install: +(Send jobs to message queue, like Symfony Messenger; locking, etc.) + +Features +-------- + +- Not need doctrine/database. +- Load a cron job from a different storage. +- Support many engines to run cron (in parallel process, message queue, consistently). +- Support many types of cron handlers/command: (services, symfony commands, UNIX commands). +- Middleware and customization. + +Usage +----- + +To regularly run a set of commands from your application, configure your system to run the +oro:cron command every minute. On UNIX-based systems, you can simply set up a crontab entry for this: + +``` +*/1 * * * * /path/to/php /path/to/bin/console okvpn:cron:run --env=prod > /dev/null +``` + +Add cron commands + +``` + +services: + app.you_cron_service: + class: App/Cron/YouService + tags: + - { name: okvpn.cron, cron: '*/5 * * * *', lock: true, arguments: {'arg1': 5}, async: true } + +``` + +where: + +- `cron` - A cron expression. (Optional). If empty, the command will run always. +- `lock` - Prevent to run the command again, if prev. command is not finished yet. (Optional). +To use it required symfony [lock component](https://symfony.com/doc/4.4/components/lock.html) +- `async` - Run command async in the new process without blocking main thread +- `arguments` - Array command of arguments. (Optional). +- `lockName` - Lock name. (Optional). +- `lockTtl` - Set ttl (Time To Live) for expiring locks. (Optional). + +### Cron Handlers + +1. Service. + +```php +factory = $factory; + $this->configuration = $configuration; + } + + /** + * @inheritDoc + */ + public function getSchedules(): iterable + { + foreach ($this->configuration as $config) { + yield $this->factory->create($config); + } + } +} + +``` + +License +--- + +MIT License. + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6f4630c --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "okvpn/cron-bundle", + "description": "Symfony Cron Bundle for registering and execute scheduled tasks", + "type": "symfony-bundle", + "license": "MIT", + "homepage": "https://github.com/vtsykun/cron-bundle", + "support": { + "email": "tsykun314@gmail.com", + "issues": "https://github.com/vtsykun/cron-bundle/issues", + "source": "https://github.com/vtsykun/cron-bundle/releases" + }, + "keywords": [ + "cron-bundle", + "symfony-cron", + "cron", + "symfony", + "bundle" + ], + "authors": [ + { + "name": "Uladzimir Tsykun", + "email": "tsykun314@gmail.com" + } + ], + "require": { + "php": "^7.2.5", + "symfony/framework-bundle": "^3.4|^4.2|^5.0", + "mtdowling/cron-expression": "^1.1" + }, + "autoload": { + "psr-4": { + "Okvpn\\Bundle\\CronBundle\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + } +} diff --git a/src/Command/CronCommand.php b/src/Command/CronCommand.php new file mode 100644 index 0000000..584e1f0 --- /dev/null +++ b/src/Command/CronCommand.php @@ -0,0 +1,58 @@ +scheduleRunner = $scheduleRunner; + $this->loader = $loader; + + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this->setName('okvpn:cron:run') + ->addOption('with', null, InputOption::VALUE_IS_ARRAY, 'StampFqcn to add command stamp to all schedules') + ->addOption('without', null, InputOption::VALUE_IS_ARRAY, 'StampFqcn to remove command stamp from all schedules.') + ->addOption('command', null, InputOption::VALUE_OPTIONAL, 'Run only selected command') + ->setDescription('Runs any currently schedule cron'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $command = $input->getOption('command'); + foreach ($this->loader->getSchedules() as $schedule) { + if (null !== $command && $schedule->getCommand() !== $command) { + continue; + } + + $output->writeln(" > Scheduling run for command {$schedule->getCommand()} ..."); + $this->scheduleRunner->execute($schedule); + } + } +} diff --git a/src/Command/CronExecuteCommand.php b/src/Command/CronExecuteCommand.php new file mode 100644 index 0000000..b2cd468 --- /dev/null +++ b/src/Command/CronExecuteCommand.php @@ -0,0 +1,46 @@ +addArgument('job', InputArgument::REQUIRED, 'Cron job id') + ->setDescription('Execute cron command for job id'); + } + + /** + * {@inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $command = $this->redis->get($input->getArgument('job')); + if (empty($command)) { + $output->writeln('Job not found'); + } + $command = json_decode($command, true); + + $this->cronEngine->run($command['command'], $command['arguments'] ?? []); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..0b3eba3 --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,29 @@ +root('okvpn_cron'); + + // Here you should define the parameters that are allowed to + // configure your bundle. See the documentation linked above for + // more information on that topic. + + return $treeBuilder; + } +} diff --git a/src/DependencyInjection/OkvpnCronExtension.php b/src/DependencyInjection/OkvpnCronExtension.php new file mode 100644 index 0000000..78d0880 --- /dev/null +++ b/src/DependencyInjection/OkvpnCronExtension.php @@ -0,0 +1,28 @@ +processConfiguration($configuration, $configs); + + $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.yml'); + } +} diff --git a/src/Loader/ArrayScheduleLoader.php b/src/Loader/ArrayScheduleLoader.php new file mode 100644 index 0000000..628de65 --- /dev/null +++ b/src/Loader/ArrayScheduleLoader.php @@ -0,0 +1,27 @@ +factory = $factory; + $this->configuration = $configuration; + } + + /** + * @inheritDoc + */ + public function getSchedules(): iterable + { + foreach ($this->configuration as $config) { + yield $this->factory->create($config); + } + } +} diff --git a/src/Loader/ScheduleFactory.php b/src/Loader/ScheduleFactory.php new file mode 100644 index 0000000..4dc3867 --- /dev/null +++ b/src/Loader/ScheduleFactory.php @@ -0,0 +1,56 @@ +withStampsFqcn = $withStampsFqcn; + } + + /** + * {@inheritDoc} + */ + public function create(array $config): ScheduleEnvelope + { + if (!isset($config['command'])) { + throw new \InvalidArgumentException('Command name is a required parameter'); + } + + $commandName = $config['command']; + + $stamps = []; + if (isset($config['cron'])) { + $stamps[] = new ScheduleStamp($config['cron']); + } + if ((isset($config['lock']) && $config['lock']) || isset($config['lockName'])) { + $stamps[] = new LockStamp($config['lockName'] ?? $commandName, $config['ttl'] ?? null); + } + if (isset($config['arguments'])) { + $stamps[] = new ArgumentsStamp($config['arguments']); + } + if (isset($config['async'])) { + $stamps[] = new AsyncStamp(); + } + + foreach ($this->withStampsFqcn as $stampsFqcn) { + $stamps[] = new $stampsFqcn($config); + } + + return new ScheduleEnvelope($commandName, ...$stamps); + } +} diff --git a/src/Loader/ScheduleFactoryInterface.php b/src/Loader/ScheduleFactoryInterface.php new file mode 100644 index 0000000..d4ad0cb --- /dev/null +++ b/src/Loader/ScheduleFactoryInterface.php @@ -0,0 +1,18 @@ +loaders = $loaders; + } + + /** + * @inheritDoc + */ + public function getSchedules(): iterable + { + foreach ($this->loaders as $loader) { + yield from $loader->getSchedules(); + } + } +} diff --git a/src/Loader/ScheduleLoaderInterface.php b/src/Loader/ScheduleLoaderInterface.php new file mode 100644 index 0000000..0ab3700 --- /dev/null +++ b/src/Loader/ScheduleLoaderInterface.php @@ -0,0 +1,15 @@ +tempDir = $sysTempDir ?: sys_get_temp_dir(); + } + + /** + * Run command async. without exit. + * + * {@inheritdoc} + */ + public function handle(ScheduleEnvelope $envelope, StackInterface $stack): ScheduleEnvelope + { + if ($envelope->has(AsyncStamp::class)) { + $envelope = $envelope->without(AsyncStamp::class); + $phpFinder = new PhpExecutableFinder(); + $phpPath = $phpFinder->find(); + + $filename = $this->tempDir . DIRECTORY_SEPARATOR . 'okvpn-cron-' . md5(random_bytes(10)) . '.txt'; + file_put_contents($filename, serialize($envelope)); + + // create command string + $runCommand = sprintf( + '%s %s %s%s', + $phpPath, + $_SERVER['argv'][0], + CronExecuteCommand::$defaultName, + $filename + ); + + // workaround for Windows + if (defined('PHP_WINDOWS_VERSION_BUILD')) { + $wsh = new \COM('WScript.shell'); + $wsh->Run($runCommand, 0, false); + } else { + // run command + shell_exec(sprintf('%s > /dev/null 2>&1 & echo $!', $runCommand)); + } + + return $stack->end()->handle($envelope, $stack); + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Middleware/CommandRunnerEngine.php b/src/Middleware/CommandRunnerEngine.php new file mode 100644 index 0000000..8e42e74 --- /dev/null +++ b/src/Middleware/CommandRunnerEngine.php @@ -0,0 +1,47 @@ +container = $container; + } + + /** + * @inheritDoc + */ + public function handle(ScheduleEnvelope $envelope, StackInterface $stack): ScheduleEnvelope + { + if (!$this->container->has($envelope->getCommand())) { + return $stack->next()->handle($envelope, $stack); + } + + $handler = $envelope->get($envelope->getCommand()); + if ($handler instanceof Command) { + $commandArguments = $envelope->get(ArgumentsStamp::class) ? + $envelope->get(ArgumentsStamp::class)->getArguments() : []; + + $input = new ArrayInput(array_merge(['command' => $handler->getName()], $commandArguments)); + + $output = new BufferedOutput(); + $handler->run($input, $output); + return $stack->end()->handle($envelope->with(new OutputStamp($output->fetch())), $stack); + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Middleware/CronMiddlewareEngine.php b/src/Middleware/CronMiddlewareEngine.php new file mode 100644 index 0000000..137e405 --- /dev/null +++ b/src/Middleware/CronMiddlewareEngine.php @@ -0,0 +1,26 @@ +get(ScheduleStamp::class)) { + if (!$stamp->cronExpression()->isDue()) { + return $stack->end()->handle($envelope, $stack); + } + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Middleware/LockMiddlewareEngine.php b/src/Middleware/LockMiddlewareEngine.php new file mode 100644 index 0000000..dc4c5d6 --- /dev/null +++ b/src/Middleware/LockMiddlewareEngine.php @@ -0,0 +1,45 @@ +factory = $factory; + } + + /** + * @inheritDoc + */ + public function handle(ScheduleEnvelope $envelope, StackInterface $stack): ScheduleEnvelope + { + /** @var LockStamp $stamp */ + if ($this->factory and $stamp = $envelope->get(LockStamp::class)) { + $lock = $this->factory->createLock( + $stamp->lockName(), + $stamp->getTtl() + ); + + if (!$lock->acquire()) { + return $stack->end()->handle($envelope, $stack); + } + + try { + return $stack->next()->handle($envelope->without(LockStamp::class), $stack); + } finally { + $lock->release(); + } + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Middleware/MiddlewareEngineInterface.php b/src/Middleware/MiddlewareEngineInterface.php new file mode 100644 index 0000000..dfe339a --- /dev/null +++ b/src/Middleware/MiddlewareEngineInterface.php @@ -0,0 +1,18 @@ +container = $container; + } + + /** + * @inheritDoc + */ + public function handle(ScheduleEnvelope $envelope, StackInterface $stack): ScheduleEnvelope + { + if (!$this->container->has($envelope->getCommand())) { + return $stack->next()->handle($envelope, $stack); + } + + $handler = $envelope->get($envelope->getCommand()); + if (is_callable($handler)) { + $commandArguments = $envelope->get(ArgumentsStamp::class) ? + $envelope->get(ArgumentsStamp::class)->getArguments() : []; + + $result = $handler($commandArguments); + return $stack->end()->handle($envelope->with(new OutputStamp($result)), $stack); + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/src/Middleware/StackEngine.php b/src/Middleware/StackEngine.php new file mode 100644 index 0000000..fbc84d4 --- /dev/null +++ b/src/Middleware/StackEngine.php @@ -0,0 +1,59 @@ +iterator = $iterator; + } + + /** + * {@inheritdoc} + */ + public function next(): MiddlewareEngineInterface + { + if (null === $iterator = $this->iterator) { + return $this; + } + $iterator->next(); + + if (!$iterator->valid()) { + $this->iterator = null; + + return $this; + } + + return $iterator->current(); + } + + /** + * {@inheritdoc} + */ + public function end(): MiddlewareEngineInterface + { + $this->iterator = null; + return $this; + } + + /** + * {@inheritdoc} + */ + public function handle(ScheduleEnvelope $envelope, StackInterface $stack): ScheduleEnvelope + { + return $envelope; + } +} diff --git a/src/Middleware/StackInterface.php b/src/Middleware/StackInterface.php new file mode 100644 index 0000000..d501480 --- /dev/null +++ b/src/Middleware/StackInterface.php @@ -0,0 +1,18 @@ +arguments = $arguments; + } + + /** + * @return array + */ + public function getArguments(): array + { + return $this->arguments; + } +} diff --git a/src/Model/AsyncStamp.php b/src/Model/AsyncStamp.php new file mode 100644 index 0000000..83b6232 --- /dev/null +++ b/src/Model/AsyncStamp.php @@ -0,0 +1,9 @@ +lockName = $lockName; + $this->ttl = $ttl; + } + + /** + * @return null|string + */ + public function lockName(): string + { + return $this->lockName; + } + + public function getTtl(): ?int + { + return $this->ttl; + } +} diff --git a/src/Model/OutputStamp.php b/src/Model/OutputStamp.php new file mode 100644 index 0000000..45ddbc3 --- /dev/null +++ b/src/Model/OutputStamp.php @@ -0,0 +1,23 @@ +output = $output; + } + + /** + * @return mixed + */ + public function getOutput() + { + return $this->output; + } +} diff --git a/src/Model/ScheduleEnvelope.php b/src/Model/ScheduleEnvelope.php new file mode 100644 index 0000000..7fe665e --- /dev/null +++ b/src/Model/ScheduleEnvelope.php @@ -0,0 +1,84 @@ +command = $command; + + foreach ($stamps as $stamp) { + $stampRefl = new \ReflectionObject($stamp); + while ($stampRefl) { + $this->stamps[$stampRefl->getName()] = $stamp; + $stampRefl = $stampRefl->getParentClass(); + } + } + } + + /** + * @return string + */ + public function getCommand(): string + { + return $this->command; + } + + /** + * @param CommandStamp ...$stamps + * @return $this + */ + public function with(CommandStamp ...$stamps): self + { + $cloned = clone $this; + + foreach ($stamps as $stamp) { + $stampRefl = new \ReflectionObject($stamp); + while ($stampRefl) { + $this->stamps[$stampRefl->getName()] = $stamp; + $stampRefl = $stampRefl->getParentClass(); + } + } + + return $cloned; + } + + /** + * @param string $stampFqcn + * @return $this + */ + public function without(string $stampFqcn): self + { + $cloned = clone $this; + unset($cloned->stamps[$stampFqcn]); + + return $cloned; + } + + /** + * @param string $stampFqcn + * @return CommandStamp|null + */ + public function get(string $stampFqcn): ?CommandStamp + { + return $this->stamps[$stampFqcn] ?? null; + } + + /** + * @param string $stampFqcn + * @return bool + */ + public function has(string $stampFqcn): bool + { + return isset($this->stamps[$stampFqcn]); + } +} diff --git a/src/Model/ScheduleStamp.php b/src/Model/ScheduleStamp.php new file mode 100644 index 0000000..5ae2c05 --- /dev/null +++ b/src/Model/ScheduleStamp.php @@ -0,0 +1,25 @@ +cronExpression = $cronExpression; + } + + public function cronExpression(): CronExpression + { + return CronExpression::factory($this->cronExpression); + } +} diff --git a/src/OkvpnCronBundle.php b/src/OkvpnCronBundle.php new file mode 100644 index 0000000..d53ce02 --- /dev/null +++ b/src/OkvpnCronBundle.php @@ -0,0 +1,11 @@ +middleware = $middleware; + } + + /** + * @inheritDoc + */ + public function execute(ScheduleEnvelope $envelope): ScheduleEnvelope + { + $middleware = $this->middleware instanceof \Traversable ? iterator_to_array($this->middleware) + : $this->middleware; + + $aggregate = new \ArrayObject($middleware); + $handlersIterator = $aggregate->getIterator(); + $handlersIterator->rewind(); + + if (!$handlersIterator->valid()) { + return $envelope; + } + + $stack = new StackEngine($handlersIterator); + + return $handlersIterator->current()->handle($envelope, $stack); + } +} diff --git a/src/Runner/ScheduleRunnerInterface.php b/src/Runner/ScheduleRunnerInterface.php new file mode 100644 index 0000000..99868a4 --- /dev/null +++ b/src/Runner/ScheduleRunnerInterface.php @@ -0,0 +1,16 @@ +