diff --git a/README.md b/README.md index 865dab89c643f784c035d8e6da622acd2ec52cc1..f2416d3cdf4926423132f1ff5f040d111f22210f 100644 --- a/README.md +++ b/README.md @@ -148,3 +148,7 @@ When coding your batch operation, define a `public function getCronTiming()` and You will find a 'Delete all batch operation logs' link on the settings page. - Why do the cron time settings say 'after' instead of 'at'? Cron timings on cron jobs from this module are evaluated every time cron runs. Due to the settings of your cron, you may have a cron that only runs every 2 hours, so the timings of the cron job can only be tested every 2 hours. If you want your times to be more accurate, set cron to run more often (**warning this can have performance issues**). +- How do I have a BatchOperation only have the option to run once? + If you have a process that would be damaging to have it run more than once, add a `public function getAllowOnlyOneCompleteRun() {return TRUE; }` to your BatchOperation. +- I defined my BatchOperation to only run once, but I need it to run again. How do I force it to run again? + The state of it running before is stored in the BatchOpLog that shows as `Completed`. Delete the BatchOpLog that shows as completed and the BatchOperation will be allowed to run again. diff --git a/modules/codit_batch_operations_ui/src/Controller/OperationPage.php b/modules/codit_batch_operations_ui/src/Controller/OperationPage.php index 15752a83c920186721288af5db16351cd21ff8fe..1eb6e16616cc80d39c9958da2a7a57e09a057490 100644 --- a/modules/codit_batch_operations_ui/src/Controller/OperationPage.php +++ b/modules/codit_batch_operations_ui/src/Controller/OperationPage.php @@ -99,6 +99,16 @@ class OperationPage extends OperationsBase { '#value' => "<span class=\"item__label\">$label</span> <span>$item_count_text</span>", ]; + if ($batch_operation->getAllowOnlyOneCompleteRun()) { + $allow_label = $this->t('Can only be run to completion once'); + $affirmative = $this->t('TRUE'); + $page['page']['only-once'] = [ + '#type' => 'html_tag', + '#tag' => 'p', + '#value' => "<span class=\"item__label\">$allow_label:</span> <span>$affirmative</span>", + ]; + } + $cron_time = ($batch_operation->getCronTiming()) ? $batch_operation->getCronTiming() : $this->t('Not set to run on cron.'); $cron_label = $this->t('Cron timing:'); if (is_array($cron_time) && (count($cron_time) > 1)) { @@ -133,6 +143,12 @@ class OperationPage extends OperationsBase { ]; $page['form'] = $this->formBuilder->getForm('\Drupal\codit_batch_operations_ui\Form\BatchOperationRun', $this->getNamespacedClassName($batch_operation_name), $batch_operation->getTitle()); + // Hide the form if it can only be run once and has already run. + if ($batch_operation->getAllowOnlyOneCompleteRun() && !empty($batch_operation->getBatchOpLog()->getMostRecentBatchOpLog($batch_operation::class))) { + if ($batch_operation->getBatchOpLog()->getMostRecentBatchOpLog($batch_operation::class)->getCompleted()) { + unset($page['form']); + } + } $page['table-breaker'] = [ '#markup' => '<hr>', diff --git a/modules/codit_batch_operations_ui/src/Controller/OperationsList.php b/modules/codit_batch_operations_ui/src/Controller/OperationsList.php index 72f4b257f56dc43b5ff2529d453e57ed05fc4806..5a04891578ece5ff8b8b3bc47cda1638ea975029 100644 --- a/modules/codit_batch_operations_ui/src/Controller/OperationsList.php +++ b/modules/codit_batch_operations_ui/src/Controller/OperationsList.php @@ -84,13 +84,15 @@ class OperationsList extends OperationsBase { $title_vars = [ '@class_name' => $class_name, '@description' => $script->getTitle(), + '@count' => count($batOpLogIds), ]; + $instances_text = ($script->getAllowOnlyOneCompleteRun()) ? new FormattableMarkup('@count </br>(Can run once only)', $title_vars) : ''; $row = [ 'title' => new FormattableMarkup('<a href="./operations/@class_name">@class_name</a> </br> @description', $title_vars), 'last-run-date' => ($most_recent_run_log) ? $this->dateFormatter->format($most_recent_run_log->get('last')->value, 'medium') : '-', 'last-run-method' => ($most_recent_run_log) ? $most_recent_run_log->get('executor')->value : '-', 'last-run-by' => ($most_recent_run_log) ? $most_recent_run_log->get('user_id')->entity->get('name')->value : '-', - 'run-instances' => count($batOpLogIds), + 'run-instances' => $instances_text, 'status-of-last-run' => $this->buildCompleteStatus($most_recent_run_log, $total_items), ]; diff --git a/src/BatchOperations.php b/src/BatchOperations.php index 80c9b29f0d987e13d051cc1cc0d7a6aed33b7e0d..85e4455ef47ce735a8d0fdcb2aba39001acc3a13 100644 --- a/src/BatchOperations.php +++ b/src/BatchOperations.php @@ -500,6 +500,7 @@ class BatchOperations implements ContainerInjectionInterface { public function initSandbox(array &$sandbox) { if (empty($sandbox['total'])) { // Sandbox has not been initiated. + $this->checkAndSetCanRun(); $sandbox['items_to_process'] = $this->getItemsToProcess(); $sandbox['total'] = count($sandbox['items_to_process']); $sandbox['current'] = 0; @@ -511,7 +512,7 @@ class BatchOperations implements ContainerInjectionInterface { // There are no items to process, so bail out. return; } - $this->checkAndSetCanRun(); + $pre_msg = $this->preBatchMethod($sandbox); $this->batchOpLog->appendLog("preBatchMethod: {$pre_msg}"); @@ -631,6 +632,8 @@ class BatchOperations implements ContainerInjectionInterface { } catch (\Throwable $th) { $context['results']['error_msg'] = $th->getMessage(); + $context['results']['error_code'] = $th->getCode(); + } $batch_op_log = &$batch_operation->getBatchOpLog(); $context['message'] = "Processing {$sandbox['current']} of {$sandbox['total']}"; @@ -640,26 +643,27 @@ class BatchOperations implements ContainerInjectionInterface { $context['results']['count'] = $sandbox['current']; $context['results']['current'] = $sandbox['current']; // Carry existing errors forward, or check for a new one. - $context['results']['errors'] = ($context['results']['errors']) ? TRUE : $batch_op_log->hasErrors(); + $context['results']['errors'] = (!empty($context['results']['errors'])) ? TRUE : $batch_op_log->hasErrors(); $context['results']['completed'] = $batch_op_log->getCompleted(); if (isset($context['results']['error_msg'])) { // There is an error, end the batch now. - $context['results']['errors'] = TRUE; - // Note: There is a conflict with setting the count. The count + $context['finished'] = 1; + // Note: There is a discrepancy with setting the count. The count // is correct on a first run failure. But, on a re-run failure it will // indicate it got one higher than it did. - $context['finished'] = 1; - // $batch_op_log->appendError($context['results']['error_msg']); - $batch_op_log->save(); + $context['results']['errors'] = TRUE; } else { $context['finished'] = $sandbox['#finished']; } - if (!empty($batch_operation->batchOpLog) && empty($context['results']['log_link'])) { + if (!empty($batch_operation->batchOpLog) && empty($context['results']['log_link']) && $batch_op_log->canSave()) { $context['results']['log_link'] = new Link(t('View log'), $batch_op_log->getUrl()); $context['results']['log_link'] = $context['results']['log_link']->toString(); } + else { + $context['results']['log_link'] = ''; + } } /** @@ -689,6 +693,17 @@ class BatchOperations implements ContainerInjectionInterface { '@name' => $results['batch_operation_name'], '@max' => $results['max'], ]; + // When done, send us back to the BatchOperation page. + $route_params = [ + 'batch_operation_name' => $results['batch_operation_name'], + ]; + + if (!empty($results['error_code']) && $results['error_code'] === 423) { + // Means it was only allowed to complete once, and it already has. + $message = t('@name can only be run once, and has already run.', $msg_vars); + \Drupal::messenger()->addError($message); + return new RedirectResponse(Url::fromRoute('codit_batch_operations_ui.operation', $route_params)->toString()); + } if ($success && !$results['errors']) { $message = t('@name @lead_in. @count out of @max items processed in @elapsed: @link', $msg_vars); @@ -700,10 +715,6 @@ class BatchOperations implements ContainerInjectionInterface { \Drupal::messenger()->addWarning($message); } - // Send us back to the BatchOperation page. - $route_params = [ - 'batch_operation_name' => $results['batch_operation_name'], - ]; return new RedirectResponse(Url::fromRoute('codit_batch_operations_ui.operation', $route_params)->toString()); } @@ -782,18 +793,33 @@ class BatchOperations implements ContainerInjectionInterface { } /** - * Checks if a BatchOperation script is already running. + * Checks if a BatchOperation script is already running or can't run again. * * @throws \RuntimeException - * Prevents two scripts form running at the same time. + * Prevents a Batch Operation from running. */ protected function checkAndSetCanRun(): void { $my_script_name = get_class($this); $script_running = $this->getWhatsRunning(); $not_allowed_concurrent = ['drush', 'UI']; + if ($this->getAllowOnlyOneCompleteRun()) { + // This Batch operation should only run once. See if it has already + // completed. + $batch_op_log = $this->getBatchOpLog(); + $most_recent_run_log = $batch_op_log->getMostRecentBatchOpLog($my_script_name); + if (!empty($most_recent_run_log) && $most_recent_run_log->getCompleted()) { + // The BatchOperation already ran to completion, it can not run again. + // Prevent starting a BatchOpLog, because the process never started. + $batch_op_log->setDoNotSave(); + $vars = [ + '@completed_date' => date('Y-m-d H:i:s', $most_recent_run_log->getLastUpdatedTime()), + ]; + throw new \RuntimeException($this->t('This BatchOperation can only run once to completion. It was already completed on @completed_date', $vars), 423); + } + } if (($my_script_name === $script_running) && (in_array($this->executor, $not_allowed_concurrent))) { // The seems like a double run, which is bad. Run away screaming loudly. - throw new \RuntimeException('This script is already running. Batch Operations scripts should never run at the same time. If you are certain no script is currently running, execute "drush state:delete cbo_running_script" to remove the lock.'); + throw new \RuntimeException('This script is already running. Batch Operations scripts should never run at the same time. If you are certain no script is currently running, execute "drush state:delete cbo_running_script" to remove the lock.', 409); } if (empty($script_running)) { // Nothing is recorded as running, so I am good to go. @@ -804,7 +830,7 @@ class BatchOperations implements ContainerInjectionInterface { // There is a script running and drush or the UI is running it, so bail. // Running by one of the hooks, means it is impossible for scripts to be // running concurrently. - throw new \RuntimeException("The script $my_script_name is already running. Please wait until it completes. If you are certain no script is currently running, execute 'drush state:delete cbo_running_script' to remove the lock."); + throw new \RuntimeException("The script $my_script_name is already running. Please wait until it completes. If you are certain no script is currently running, execute 'drush state:delete cbo_running_script' to remove the lock.", 409); } } @@ -1042,4 +1068,12 @@ class BatchOperations implements ContainerInjectionInterface { return 1; } + /** + * {@inheritdoc} + */ + public function getAllowOnlyOneCompleteRun(): bool { + // Sets this to FALSE by default. + return FALSE; + } + } diff --git a/src/BatchScriptInterface.php b/src/BatchScriptInterface.php index db5b8517dc4ea8f8b8518081c31d1b790dc36c62..26fdb417266b335dbdfae64e33e43c9b536ee59a 100644 --- a/src/BatchScriptInterface.php +++ b/src/BatchScriptInterface.php @@ -25,6 +25,17 @@ interface BatchScriptInterface { */ public function getCompletedMessage(): string; + /** + * Defines whether a BatchOperation can only have one completed run. + * + * It could have multiple incomplete runs because they pick up where they + * leave off. But can only be run once to completion. + * + * @return bool + * True if it can only have one complete run, FALSE otherwise. + */ + public function getAllowOnlyOneCompleteRun(): bool; + /** * Optionally describe details about the purpose or history of this script. * diff --git a/src/Drush/Commands/CoditBatchOperationsCommands.php b/src/Drush/Commands/CoditBatchOperationsCommands.php index 12c026ffb63291d105ec71182e0daa6e331b247f..81fc6360ac1a54a57d77fecab355024c29191629 100644 --- a/src/Drush/Commands/CoditBatchOperationsCommands.php +++ b/src/Drush/Commands/CoditBatchOperationsCommands.php @@ -104,6 +104,7 @@ final class CoditBatchOperationsCommands extends DrushCommands implements Contai 'TestAllCronTimeStrings', 'TestDo10Things', 'TestDo10ThingsOnCron', + 'TestDo10ThingsOnlyOnce', 'TestDo10ThingsWithError', 'TestDo10000Things', ]; diff --git a/src/Entity/BatchOpLog.php b/src/Entity/BatchOpLog.php index 785f97b8aca5e7f8115c4e73a33304508931f134..806ef283a5e23068631b005a1cc1e94e19b7815d 100644 --- a/src/Entity/BatchOpLog.php +++ b/src/Entity/BatchOpLog.php @@ -55,6 +55,13 @@ use Drupal\user\UserInterface; */ class BatchOpLog extends ContentEntityBase implements ContentEntityInterface, BatchOpLogInterface { + /** + * Property that prevents a BatchOpLog from saving. + * + * @var bool + */ + protected $doNotSave = FALSE; + /** * The entity field manager. * @@ -132,6 +139,31 @@ class BatchOpLog extends ContentEntityBase implements ContentEntityInterface, Ba return $this; } + /** + * {@inheritdoc} + */ + public function canSave(): bool { + return !$this->doNotSave; + } + + /** + * {@inheritdoc} + */ + public function setDoNotSave(): BatchOpLogInterface { + $this->doNotSave = TRUE; + return $this; + } + + /** + * {@inheritdoc} + */ + public function save() { + if (!$this->doNotSave) { + return parent::save(); + } + return FALSE; + } + /** * {@inheritdoc} */ @@ -166,7 +198,7 @@ class BatchOpLog extends ContentEntityBase implements ContentEntityInterface, Ba * {@inheritdoc} */ public function getCompleted(): bool { - return ($this->get('completed')) ? TRUE : FALSE; + return (!empty($this->get('completed')->value)) ? TRUE : FALSE; } /** @@ -207,6 +239,13 @@ class BatchOpLog extends ContentEntityBase implements ContentEntityInterface, Ba return $this; } + /** + * {@inheritdoc} + */ + public function getLastUpdatedTime(): ?string { + return $this->get('last')->value; + } + /** * {@inheritdoc} */ diff --git a/src/Entity/BatchOpLogInterface.php b/src/Entity/BatchOpLogInterface.php index a69d0a7f8c4d68abb5fc6cc9e144ca7de7a65aed..a139d52943401baec0421dff189503fd3901d38b 100644 --- a/src/Entity/BatchOpLogInterface.php +++ b/src/Entity/BatchOpLogInterface.php @@ -32,6 +32,22 @@ interface BatchOpLogInterface extends ContentEntityInterface, EntityOwnerInterfa */ public function setName(string $name): BatchOpLogInterface; + /** + * Sets the Log to do not save. To prevent saving a non-started operation. + * + * @return \Drupal\codit_batch_operations\Entity\BatchOpLogInterface + * The called BatchOpLog entity. + */ + public function setDoNotSave(): BatchOpLogInterface; + + /** + * Checks to see if the BatchOpLog can be saved. + * + * @return bool + * TRUE if the BatchOpLog entity can be saved, FALSE otherwise. + */ + public function canSave(): bool; + /** * Sets the executor property. * @@ -108,6 +124,14 @@ interface BatchOpLogInterface extends ContentEntityInterface, EntityOwnerInterfa */ public function setCreatedTime(int $timestamp): BatchOpLogInterface; + /** + * Gets the time that the BatchOpLog was last updated. + * + * @return ?string + * The last updated time of the BatchOpLog, or NULL if never updated. + */ + public function getLastUpdatedTime(): ?string; + /** * Gets the drupal URL for the BatchOpLog. * diff --git a/src/cbo_scripts/TestDo10ThingsOnlyOnce.php b/src/cbo_scripts/TestDo10ThingsOnlyOnce.php new file mode 100644 index 0000000000000000000000000000000000000000..22f3aa1fea13140ebff40c779fcc32b16760248f --- /dev/null +++ b/src/cbo_scripts/TestDo10ThingsOnlyOnce.php @@ -0,0 +1,111 @@ +<?php + +namespace Drupal\codit_batch_operations\cbo_scripts; + +use Drupal\codit_batch_operations\BatchOperations; +use Drupal\codit_batch_operations\BatchScriptInterface; + +/** + * A test & example BatchOperation to show something that can only run once. + */ +class TestDo10ThingsOnlyOnce extends BatchOperations implements BatchScriptInterface { + + /** + * {@inheritdoc} + */ + public function getTitle(): string { + return 'Do 10 things but not actually do anything other than log things. And only allow them once.'; + } + + /** + * {@inheritdoc} + */ + public function getItemType(): string { + return 'launch_step'; + } + + /** + * {@inheritdoc} + */ + public function getAllowOnlyOneCompleteRun(): bool { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function getDescription(): string { + $description = <<<ENDHERE + This is intended to be an example of a BatchOperation that can only run completely once. It has an error + built in so that it can test that it can be run with failures multiple times, but only completed once. + ENDHERE; + return $description; + } + + /** + * {@inheritdoc} + */ + public function getCompletedMessage(): string { + // This message can include the tokens '@completed' and '@total'. + return 'The launce sequence completed @completed out of @total steps.'; + } + + /** + * {@inheritdoc} + */ + public function gatherItemsToProcess(): array { + // Do whatever you need to here to put together the list of items + // to be processed. Can be a keyed array like + // [key1 => item1, key2 => item2 ...] + // or a flat array [item1, item2, item3 ...]. + // 'item' can be something simple like a node id for processOne() to load, + // or could be a loaded entity to act on. + $launch_rocket_steps = [ + '1' => 'Move rocket to launch pad.', + '2' => 'Connect hoses.', + '3' => 'Fill primary fuel tanks.', + '4' => 'Pressure check on primary fuel tanks.', + '5' => 'Fill secondary fuel tanks.', + '6' => 'Pressure check on secondary fuel tanks.', + '7' => 'Insert astronauts.', + '8' => 'Check communications.', + '9' => 'Final system checks', + '10' => 'Blast off.', + ]; + return $launch_rocket_steps; + } + + /** + * {@inheritdoc} + */ + public function processOne(string $key, mixed $item, array &$sandbox): string { + // Do some things in here, then return a message about what was done. + // If you return a non-empty message, it will get logged in the BatchOpLog. + // If you were doing a big process and wanted to add to the log or errors, + // you can log specifically as you go. + if ($key === 'launch_step_4') { + $this->batchOpLog->appendLog('Pressure check failed We have a leak.'); + // Create an error by calling fictional function. + // @phpstan-ignore-next-line + run_for_your_lives_there_is_a_leak(); + } + + return "Step {$item} completed."; + } + +} + +// @codingStandardsIgnoreStart +// Example of how to run this batch from a hook_update_n() +/** + * Run a BatchOperation example with that launches a rocket one time. + */ +// function my_module_update_9012(&$sandbox) { +// $script = \Drupal::classResolver('\Drupal\codit_batch_operations\cbo_scripts\TestDo10ThingsOnlyOnce'); +// return $script->run($sandbox, 'hook_update'); +// } +// +// +// Run with drush: +// drush codit-batch-operations:run TestDo10ThingsOnlyOnce +// @codingStandardsIgnoreEnd