From b7d170b1f763c7352116c5834ddd3da6aaf67aa7 Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Sun, 16 Jan 2022 15:40:12 +0200 Subject: [PATCH 1/7] Support for resumable scheduled tasks --- .../com_scheduler/src/Scheduler/Scheduler.php | 11 ++-- .../com_scheduler/src/Task/Status.php | 13 ++++ .../com_scheduler/src/Task/Task.php | 38 +++++++++--- .../language/en-GB/com_scheduler.ini | 1 + .../language/en-GB/plg_task_demotasks.ini | 4 ++ libraries/src/Console/TasksRunCommand.php | 9 +-- plugins/task/demotasks/demotasks.php | 60 +++++++++++++++++++ 7 files changed, 118 insertions(+), 18 deletions(-) diff --git a/administrator/components/com_scheduler/src/Scheduler/Scheduler.php b/administrator/components/com_scheduler/src/Scheduler/Scheduler.php index a0688d560eaf7..22fd589a13ebe 100644 --- a/administrator/components/com_scheduler/src/Scheduler/Scheduler.php +++ b/administrator/components/com_scheduler/src/Scheduler/Scheduler.php @@ -39,10 +39,11 @@ class Scheduler { private const LOG_TEXT = [ - Status::OK => 'COM_SCHEDULER_SCHEDULER_TASK_COMPLETE', - Status::NO_LOCK => 'COM_SCHEDULER_SCHEDULER_TASK_LOCKED', - Status::NO_RUN => 'COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED', - Status::NO_ROUTINE => 'COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA', + Status::OK => 'COM_SCHEDULER_SCHEDULER_TASK_COMPLETE', + Status::WILL_RESUME => 'COM_SCHEDULER_SCHEDULER_TASK_WILL_RESUME', + Status::NO_LOCK => 'COM_SCHEDULER_SCHEDULER_TASK_LOCKED', + Status::NO_RUN => 'COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED', + Status::NO_ROUTINE => 'COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA', ]; /** @@ -158,7 +159,7 @@ public function runTask(array $options): ?Task if (\array_key_exists($exitCode, self::LOG_TEXT)) { - $level = $exitCode === Status::OK ? 'info' : 'warning'; + $level = in_array($exitCode, [Status::OK, Status::WILL_RESUME]) ? 'info' : 'warning'; $task->log(Text::sprintf(self::LOG_TEXT[$exitCode], $taskId, $duration, $netDuration), $level); return $task; diff --git a/administrator/components/com_scheduler/src/Task/Status.php b/administrator/components/com_scheduler/src/Task/Status.php index 09dddc4493793..16109272b1373 100644 --- a/administrator/components/com_scheduler/src/Task/Status.php +++ b/administrator/components/com_scheduler/src/Task/Status.php @@ -67,6 +67,19 @@ abstract class Status */ public const KNOCKOUT = 5; + /** + * Exit code used when a task needs to resume (reschedule it to run a.s.a.p.). + * + * Use this for long running tasks, e.g. batch processing of hundreds or thousands of files, + * sending newsletters with thousands of subscribers etc. These are tasks which might run out of + * memory and/or hit a time limit when lazy scheduling or web triggering of tasks is being used. + * Split them in smaller batches which return Status::WILL_RESUME. When the last batch is + * executed return Status::OK. + * + * @since 4.1.0 + */ + public const WILL_RESUME = 123; + /** * Exit code used when a task times out. * diff --git a/administrator/components/com_scheduler/src/Task/Task.php b/administrator/components/com_scheduler/src/Task/Task.php index df94e24d8c97c..c7ecbda40fedb 100644 --- a/administrator/components/com_scheduler/src/Task/Task.php +++ b/administrator/components/com_scheduler/src/Task/Task.php @@ -12,6 +12,7 @@ // Restrict direct access \defined('_JEXEC') or die; +use DateInterval; use Joomla\CMS\Application\CMSApplication; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Event\AbstractEvent; @@ -116,9 +117,10 @@ class Task implements LoggerAwareInterface * @since 4.1.0 */ protected const EVENTS_MAP = [ - Status::OK => 'onTaskExecuteSuccess', - Status::NO_ROUTINE => 'onTaskRoutineNotFound', - 'NA' => 'onTaskExecuteFailure', + Status::OK => 'onTaskExecuteSuccess', + Status::NO_ROUTINE => 'onTaskRoutineNotFound', + Status::WILL_RESUME => 'onTaskRoutineWillResume', + 'NA' => 'onTaskExecuteFailure', ]; /** @@ -246,11 +248,29 @@ public function run(): bool // @todo make the ExecRuleHelper usage less ugly, perhaps it should be composed into Task // Update object state. $this->set('last_execution', Factory::getDate('@' . (int) $this->snapshot['taskStart'])->toSql()); - $this->set('next_execution', (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec()); $this->set('last_exit_code', $this->snapshot['status']); - $this->set('times_executed', $this->get('times_executed') + 1); - if ($this->snapshot['status'] !== Status::OK) + if ($this->snapshot['status'] !== Status::WILL_RESUME) + { + $this->set('next_execution', (new ExecRuleHelper($this->taskRegistry->toObject()))->nextExec()); + $this->set('times_executed', $this->get('times_executed') + 1); + } + else + { + /** + * Resumable tasks need special handling. + * + * The are rescheduled as soon as possible to let their next step to be executed without + * a very large temporal gap to the previous step. + * + * Moreover, the times executed does NOT increase for each step. It will increase once, + * after the last step, when they return Status::OK. + */ + $this->set('next_execution', Factory::getDate('now', 'UTC')->sub(new DateInterval('PT1M'))->toSql()); + } + + // The only acceptable "successful" statuses are either clean exit or resuming execution. + if (!in_array($this->snapshot['status'], [Status::WILL_RESUME, Status::OK])) { $this->set('times_failed', $this->get('times_failed') + 1); } @@ -302,7 +322,7 @@ public function acquireLock(): bool $now = Factory::getDate('now', 'GMT'); $timeout = ComponentHelper::getParams('com_scheduler')->get('timeout', 300); - $timeout = new \DateInterval(sprintf('PT%dS', $timeout)); + $timeout = new DateInterval(sprintf('PT%dS', $timeout)); $timeoutThreshold = (clone $now)->sub($timeout)->toSql(); $now = $now->toSql(); @@ -391,7 +411,7 @@ public function releaseLock(bool $update = true): bool ->bind(':times_executed', $timesExecuted) ->bind(':times_failed', $timesFailed); - if ($exitCode !== Status::OK) + if (!in_array($exitCode, [Status::OK, Status::WILL_RESUME])) { $query->set('times_failed = t.times_failed + 1'); } @@ -495,7 +515,7 @@ protected function dispatchExitEvent(): void */ public function isSuccess(): bool { - return ($this->snapshot['status'] ?? null) === Status::OK; + return in_array(($this->snapshot['status'] ?? null), [Status::OK, Status::WILL_RESUME]); } /** diff --git a/administrator/language/en-GB/com_scheduler.ini b/administrator/language/en-GB/com_scheduler.ini index 6c31fa2ce9cf1..efd4818f58825 100644 --- a/administrator/language/en-GB/com_scheduler.ini +++ b/administrator/language/en-GB/com_scheduler.ini @@ -109,6 +109,7 @@ COM_SCHEDULER_SCHEDULER_TASK_ROUTINE_NA="Task#%1$02d has no corresponding plugin COM_SCHEDULER_SCHEDULER_TASK_START="Running task#%1$02d '%2$s'." COM_SCHEDULER_SCHEDULER_TASK_UNKNOWN_EXIT="Task#%1$02d exited with code %4$d in %2$.2f (net %3$.2f) seconds." COM_SCHEDULER_SCHEDULER_TASK_UNLOCKED="Task#%1$02d was unlocked." +COM_SCHEDULER_SCHEDULER_TASK_WILL_RESUME="Task#%1$02d needs to perform more work." COM_SCHEDULER_SELECT_INTERVAL_MINUTES="- Select interval in Minutes -" COM_SCHEDULER_SELECT_TASK_TYPE="Select task, %s" COM_SCHEDULER_SELECT_TYPE="- Task Type -" diff --git a/administrator/language/en-GB/plg_task_demotasks.ini b/administrator/language/en-GB/plg_task_demotasks.ini index 056a9de80e62e..695c0d3048956 100644 --- a/administrator/language/en-GB/plg_task_demotasks.ini +++ b/administrator/language/en-GB/plg_task_demotasks.ini @@ -9,6 +9,10 @@ PLG_TASK_DEMO_TASKS_STRESS_MEMORY_DESC="What happens to a task when the PHP memo PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE_DESC="What happens to a task when the system memory is exhausted?" PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE_TITLE="Stress Memory, Override Limit" PLG_TASK_DEMO_TASKS_STRESS_MEMORY_TITLE="Stress Memory" +PLG_TASK_DEMO_TASKS_RESUMABLE_TITLE="Resumable task" +PLG_TASK_DEMO_TASKS_RESUMABLE_DESC="A simple task to demonstrate resumable task behaviour." +PLG_TASK_DEMO_TASKS_RESUMABLE_STEPS_LABEL="Total number of steps" +PLG_TASK_DEMO_TASKS_RESUMABLE_TIMEOUT_LABEL="Delay per step (seconds)" PLG_TASK_DEMO_TASKS_TASK_SLEEP_DESC="Sleep, do nothing for x seconds." PLG_TASK_DEMO_TASKS_TASK_SLEEP_ROUTINE_END_LOG_MESSAGE="TestTask1 return code is: %1$d. Processing Time: %2$.2f seconds" PLG_TASK_DEMO_TASKS_TASK_SLEEP_TITLE="Demo Task - Sleep" diff --git a/libraries/src/Console/TasksRunCommand.php b/libraries/src/Console/TasksRunCommand.php index 37ddfb1fbb543..f13993457db90 100644 --- a/libraries/src/Console/TasksRunCommand.php +++ b/libraries/src/Console/TasksRunCommand.php @@ -58,10 +58,11 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in * load the namespace when it's time to do that (why?) */ static $outTextMap = [ - Status::OK => 'Task#%1$02d \'%2$s\' processed in %3$.2f seconds.', - Status::NO_RUN => 'Task#%1$02d \'%2$s\' failed to run. Is it already running?', - Status::NO_ROUTINE => 'Task#%1$02d \'%2$s\' is orphaned! Visit the backend to resolve.', - 'N/A' => 'Task#%1$02d \'%2$s\' exited with code %4$d in %3$.2f seconds.', + Status::OK => 'Task#%1$02d \'%2$s\' processed in %3$.2f seconds.', + Status::WILL_RESUME => 'Task#%1$02d \'%2$s\' ran for %3$.2f seconds, will resume next time.', + Status::NO_RUN => 'Task#%1$02d \'%2$s\' failed to run. Is it already running?', + Status::NO_ROUTINE => 'Task#%1$02d \'%2$s\' is orphaned! Visit the backend to resolve.', + 'N/A' => 'Task#%1$02d \'%2$s\' exited with code %4$d in %3$.2f seconds.', ]; $this->configureIo($input, $output); diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/demotasks.php index 1cc862bf72319..fedfd24396332 100644 --- a/plugins/task/demotasks/demotasks.php +++ b/plugins/task/demotasks/demotasks.php @@ -13,6 +13,7 @@ use Joomla\CMS\Plugin\CMSPlugin; use Joomla\Component\Scheduler\Administrator\Event\ExecuteTaskEvent; use Joomla\Component\Scheduler\Administrator\Task\Status; +use Joomla\Component\Scheduler\Administrator\Task\Task; use Joomla\Component\Scheduler\Administrator\Traits\TaskPluginTrait; use Joomla\Event\SubscriberInterface; @@ -44,6 +45,11 @@ class PlgTaskDemotasks extends CMSPlugin implements SubscriberInterface 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_STRESS_MEMORY_OVERRIDE', 'method' => 'stressMemoryRemoveLimit', ], + 'demoTask_r4.resumable' => [ + 'langConstPrefix' => 'PLG_TASK_DEMO_TASKS_RESUMABLE', + 'method' => 'resumable', + 'form' => 'testTaskForm', + ], ]; /** @@ -68,6 +74,60 @@ public static function getSubscribedEvents(): array ]; } + /** + * Sample resumable task. + * + * Whether the task will resume is random. There's a 40% chance of finishing every time it runs. + * + * You can use this is a template to create long running tasks which can detect an impeding + * timeout condition, return Status::WILL_RESUME and resume execution next time they are called. + * + * @param ExecuteTaskEvent $event The event we are handling + * + * @return integer + * + * @throws Exception + * @since 4.1.0 + */ + private function resumable(ExecuteTaskEvent $event): int + { + /** @var Task $task */ + $task = $event->getArgument('subject'); + $timeout = (int) $event->getArgument('params')->timeout ?? 1; + + $lastStatus = $task->get('last_exit_code', Status::OK); + + // This is how you detect if you are resuming a task or starting it afresh + if ($lastStatus === Status::WILL_RESUME) + { + $this->logTask(sprintf('Resuming task %d', $task->get('id'))); + } + else + { + $this->logTask(sprintf('Starting new task %d', $task->get('id'))); + } + + // Sample task body; we are simply sleeping for some time. + $this->logTask(sprintf('Starting %ds timeout', $timeout)); + sleep($timeout); + $this->logTask(sprintf('%ds timeout over!', $timeout)); + + // Should I resume the task in the next step (randomly decided)? + $willResume = random_int(0, 5) < 4; + + // Log our intention to resume or not and return the appropriate exit code. + if ($willResume) + { + $this->logTask(sprintf('Task %d will resume', $task->get('id'))); + } + else + { + $this->logTask(sprintf('Task %d is now complete', $task->get('id'))); + } + + return $willResume ? Status::WILL_RESUME : Status::OK; + } + /** * @param ExecuteTaskEvent $event The `onExecuteTask` event. * From 4e43033b86908d4300a06f24b1ccc677a626aef2 Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Sun, 16 Jan 2022 16:27:33 +0200 Subject: [PATCH 2/7] Language improvement Co-authored-by: Brian Teeman --- administrator/components/com_scheduler/src/Task/Status.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/administrator/components/com_scheduler/src/Task/Status.php b/administrator/components/com_scheduler/src/Task/Status.php index 16109272b1373..50f4673577deb 100644 --- a/administrator/components/com_scheduler/src/Task/Status.php +++ b/administrator/components/com_scheduler/src/Task/Status.php @@ -73,7 +73,7 @@ abstract class Status * Use this for long running tasks, e.g. batch processing of hundreds or thousands of files, * sending newsletters with thousands of subscribers etc. These are tasks which might run out of * memory and/or hit a time limit when lazy scheduling or web triggering of tasks is being used. - * Split them in smaller batches which return Status::WILL_RESUME. When the last batch is + * Split them into smaller batches which return Status::WILL_RESUME. When the last batch is * executed return Status::OK. * * @since 4.1.0 From 332c9d5714609a1f14dc67330b822336b3dca7fb Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Sun, 16 Jan 2022 16:27:40 +0200 Subject: [PATCH 3/7] Language improvement Co-authored-by: Brian Teeman --- administrator/components/com_scheduler/src/Task/Task.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/administrator/components/com_scheduler/src/Task/Task.php b/administrator/components/com_scheduler/src/Task/Task.php index c7ecbda40fedb..fbfc5686fbf6c 100644 --- a/administrator/components/com_scheduler/src/Task/Task.php +++ b/administrator/components/com_scheduler/src/Task/Task.php @@ -260,7 +260,7 @@ public function run(): bool /** * Resumable tasks need special handling. * - * The are rescheduled as soon as possible to let their next step to be executed without + * They are rescheduled as soon as possible to let their next step to be executed without * a very large temporal gap to the previous step. * * Moreover, the times executed does NOT increase for each step. It will increase once, From ae76101df28d2e2e62326b83229aedc65e98eb51 Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Sun, 16 Jan 2022 16:27:45 +0200 Subject: [PATCH 4/7] Language improvement Co-authored-by: Brian Teeman --- plugins/task/demotasks/demotasks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/demotasks.php index fedfd24396332..3912846b95124 100644 --- a/plugins/task/demotasks/demotasks.php +++ b/plugins/task/demotasks/demotasks.php @@ -79,7 +79,7 @@ public static function getSubscribedEvents(): array * * Whether the task will resume is random. There's a 40% chance of finishing every time it runs. * - * You can use this is a template to create long running tasks which can detect an impeding + * You can use this as a template to create long running tasks which can detect an impeding * timeout condition, return Status::WILL_RESUME and resume execution next time they are called. * * @param ExecuteTaskEvent $event The event we are handling From d354d27cfd98b815966f0435a32493dd4cbb1d8f Mon Sep 17 00:00:00 2001 From: Richard Fath Date: Sun, 16 Jan 2022 16:53:56 +0100 Subject: [PATCH 5/7] Change "impeding" to "impending" in code comment --- plugins/task/demotasks/demotasks.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/demotasks.php index 3912846b95124..1eb13b3762cb5 100644 --- a/plugins/task/demotasks/demotasks.php +++ b/plugins/task/demotasks/demotasks.php @@ -79,7 +79,7 @@ public static function getSubscribedEvents(): array * * Whether the task will resume is random. There's a 40% chance of finishing every time it runs. * - * You can use this as a template to create long running tasks which can detect an impeding + * You can use this as a template to create long running tasks which can detect an impending * timeout condition, return Status::WILL_RESUME and resume execution next time they are called. * * @param ExecuteTaskEvent $event The event we are handling From 0975726abf11e7ac89b3aef1a80727ce15f74822 Mon Sep 17 00:00:00 2001 From: Richard Fath Date: Sun, 16 Jan 2022 17:42:10 +0100 Subject: [PATCH 6/7] Revert use DateInterval --- administrator/components/com_scheduler/src/Task/Task.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/administrator/components/com_scheduler/src/Task/Task.php b/administrator/components/com_scheduler/src/Task/Task.php index fbfc5686fbf6c..fdc0b3e987201 100644 --- a/administrator/components/com_scheduler/src/Task/Task.php +++ b/administrator/components/com_scheduler/src/Task/Task.php @@ -12,7 +12,6 @@ // Restrict direct access \defined('_JEXEC') or die; -use DateInterval; use Joomla\CMS\Application\CMSApplication; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Event\AbstractEvent; @@ -266,7 +265,7 @@ public function run(): bool * Moreover, the times executed does NOT increase for each step. It will increase once, * after the last step, when they return Status::OK. */ - $this->set('next_execution', Factory::getDate('now', 'UTC')->sub(new DateInterval('PT1M'))->toSql()); + $this->set('next_execution', Factory::getDate('now', 'UTC')->sub(new \DateInterval('PT1M'))->toSql()); } // The only acceptable "successful" statuses are either clean exit or resuming execution. @@ -322,7 +321,7 @@ public function acquireLock(): bool $now = Factory::getDate('now', 'GMT'); $timeout = ComponentHelper::getParams('com_scheduler')->get('timeout', 300); - $timeout = new DateInterval(sprintf('PT%dS', $timeout)); + $timeout = new \DateInterval(sprintf('PT%dS', $timeout)); $timeoutThreshold = (clone $now)->sub($timeout)->toSql(); $now = $now->toSql(); From 4a39d60d8330d3703f029bc1cdecd2ad687f86d3 Mon Sep 17 00:00:00 2001 From: "Nicholas K. Dionysopoulos" Date: Sun, 16 Jan 2022 23:55:03 +0200 Subject: [PATCH 7/7] Update plugins/task/demotasks/demotasks.php Co-authored-by: Richard Fath --- plugins/task/demotasks/demotasks.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/task/demotasks/demotasks.php b/plugins/task/demotasks/demotasks.php index 1eb13b3762cb5..bea812cf49e53 100644 --- a/plugins/task/demotasks/demotasks.php +++ b/plugins/task/demotasks/demotasks.php @@ -86,8 +86,8 @@ public static function getSubscribedEvents(): array * * @return integer * - * @throws Exception - * @since 4.1.0 + * @since __DEPLOY_VERSION__ + * @throws \Exception */ private function resumable(ExecuteTaskEvent $event): int {