batch.inc 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. <?php
  2. /**
  3. * @file
  4. * Batch processing API for processes to run in multiple HTTP requests.
  5. *
  6. * Note that batches are usually invoked by form submissions, which is
  7. * why the core interaction functions of the batch processing API live in
  8. * form.inc.
  9. *
  10. * @see form.inc
  11. * @see batch_set()
  12. * @see batch_process()
  13. * @see batch_get()
  14. */
  15. use Drupal\Component\Utility\Timer;
  16. use Drupal\Component\Utility\UrlHelper;
  17. use Drupal\Core\Batch\Percentage;
  18. use Drupal\Core\Form\FormState;
  19. use Drupal\Core\Url;
  20. use Symfony\Component\HttpFoundation\JsonResponse;
  21. use Symfony\Component\HttpFoundation\Request;
  22. use Symfony\Component\HttpFoundation\RedirectResponse;
  23. /**
  24. * Renders the batch processing page based on the current state of the batch.
  25. *
  26. * @param \Symfony\Component\HttpFoundation\Request $request
  27. * The current request object.
  28. *
  29. * @see _batch_shutdown()
  30. */
  31. function _batch_page(Request $request) {
  32. $batch = &batch_get();
  33. if (!($request_id = $request->query->get('id'))) {
  34. return FALSE;
  35. }
  36. // Retrieve the current state of the batch.
  37. if (!$batch) {
  38. $batch = \Drupal::service('batch.storage')->load($request_id);
  39. if (!$batch) {
  40. \Drupal::messenger()->addError(t('No active batch.'));
  41. return new RedirectResponse(\Drupal::url('<front>', [], ['absolute' => TRUE]));
  42. }
  43. }
  44. // We need to store the updated batch information in the batch storage after
  45. // processing the batch. In order for the error page to work correctly this
  46. // needs to be done even in case of a PHP fatal error in which case the end of
  47. // this function is never reached. Therefore we register a shutdown function
  48. // to handle this case. Because with FastCGI and fastcgi_finish_request()
  49. // shutdown functions are called after the HTTP connection is closed, updating
  50. // the batch information in a shutdown function would lead to race conditions
  51. // between consecutive requests if the batch processing continues. In case of
  52. // a fatal error the processing stops anyway, so it works even with FastCGI.
  53. // However, we must ensure to only update in the shutdown phase in this
  54. // particular case we track whether the batch information still needs to be
  55. // updated.
  56. // @see _batch_shutdown()
  57. // @see \Symfony\Component\HttpFoundation\Response::send()
  58. drupal_register_shutdown_function('_batch_shutdown');
  59. _batch_needs_update(TRUE);
  60. $build = [];
  61. // Add batch-specific libraries.
  62. foreach ($batch['sets'] as $batch_set) {
  63. if (isset($batch_set['library'])) {
  64. foreach ($batch_set['library'] as $library) {
  65. $build['#attached']['library'][] = $library;
  66. }
  67. }
  68. }
  69. $op = $request->query->get('op', '');
  70. switch ($op) {
  71. case 'start':
  72. case 'do_nojs':
  73. // Display the full progress page on startup and on each additional
  74. // non-JavaScript iteration.
  75. $current_set = _batch_current_set();
  76. $build['#title'] = $current_set['title'];
  77. $build['content'] = _batch_progress_page();
  78. $response = $build;
  79. break;
  80. case 'do':
  81. // JavaScript-based progress page callback.
  82. $response = _batch_do();
  83. break;
  84. case 'finished':
  85. // _batch_finished() returns a RedirectResponse.
  86. $response = _batch_finished();
  87. break;
  88. }
  89. if ($batch) {
  90. \Drupal::service('batch.storage')->update($batch);
  91. }
  92. _batch_needs_update(FALSE);
  93. return $response;
  94. }
  95. /**
  96. * Checks whether the batch information needs to be updated in the storage.
  97. *
  98. * @param bool $new_value
  99. * (optional) A new value to set.
  100. *
  101. * @return bool
  102. * TRUE if the batch information needs to be updated; FALSE otherwise.
  103. */
  104. function _batch_needs_update($new_value = NULL) {
  105. $needs_update = &drupal_static(__FUNCTION__, FALSE);
  106. if (isset($new_value)) {
  107. $needs_update = $new_value;
  108. }
  109. return $needs_update;
  110. }
  111. /**
  112. * Does one execution pass with JavaScript and returns progress to the browser.
  113. *
  114. * @see _batch_progress_page_js()
  115. * @see _batch_process()
  116. */
  117. function _batch_do() {
  118. // Perform actual processing.
  119. list($percentage, $message, $label) = _batch_process();
  120. return new JsonResponse(['status' => TRUE, 'percentage' => $percentage, 'message' => $message, 'label' => $label]);
  121. }
  122. /**
  123. * Outputs a batch processing page.
  124. *
  125. * @see _batch_process()
  126. */
  127. function _batch_progress_page() {
  128. $batch = &batch_get();
  129. $current_set = _batch_current_set();
  130. $new_op = 'do_nojs';
  131. if (!isset($batch['running'])) {
  132. // This is the first page so we return some output immediately.
  133. $percentage = 0;
  134. $message = $current_set['init_message'];
  135. $label = '';
  136. $batch['running'] = TRUE;
  137. }
  138. else {
  139. // This is one of the later requests; do some processing first.
  140. // Error handling: if PHP dies due to a fatal error (e.g. a nonexistent
  141. // function), it will output whatever is in the output buffer, followed by
  142. // the error message.
  143. ob_start();
  144. $fallback = $current_set['error_message'] . '<br />' . $batch['error_message'];
  145. // We strip the end of the page using a marker in the template, so any
  146. // additional HTML output by PHP shows up inside the page rather than below
  147. // it. While this causes invalid HTML, the same would be true if we didn't,
  148. // as content is not allowed to appear after </html> anyway.
  149. $bare_html_page_renderer = \Drupal::service('bare_html_page_renderer');
  150. $response = $bare_html_page_renderer->renderBarePage(['#markup' => $fallback], $current_set['title'], 'maintenance_page', [
  151. '#show_messages' => FALSE,
  152. ]);
  153. // Just use the content of the response.
  154. $fallback = $response->getContent();
  155. list($fallback) = explode('<!--partial-->', $fallback);
  156. print $fallback;
  157. // Perform actual processing.
  158. list($percentage, $message, $label) = _batch_process($batch);
  159. if ($percentage == 100) {
  160. $new_op = 'finished';
  161. }
  162. // PHP did not die; remove the fallback output.
  163. ob_end_clean();
  164. }
  165. // Merge required query parameters for batch processing into those provided by
  166. // batch_set() or hook_batch_alter().
  167. $query_options = $batch['url']->getOption('query');
  168. $query_options['id'] = $batch['id'];
  169. $query_options['op'] = $new_op;
  170. $batch['url']->setOption('query', $query_options);
  171. $url = $batch['url']->toString(TRUE)->getGeneratedUrl();
  172. $build = [
  173. '#theme' => 'progress_bar',
  174. '#percent' => $percentage,
  175. '#message' => ['#markup' => $message],
  176. '#label' => $label,
  177. '#attached' => [
  178. 'html_head' => [
  179. [
  180. [
  181. // Redirect through a 'Refresh' meta tag if JavaScript is disabled.
  182. '#tag' => 'meta',
  183. '#noscript' => TRUE,
  184. '#attributes' => [
  185. 'http-equiv' => 'Refresh',
  186. 'content' => '0; URL=' . $url,
  187. ],
  188. ],
  189. 'batch_progress_meta_refresh',
  190. ],
  191. ],
  192. // Adds JavaScript code and settings for clients where JavaScript is enabled.
  193. 'drupalSettings' => [
  194. 'batch' => [
  195. 'errorMessage' => $current_set['error_message'] . '<br />' . $batch['error_message'],
  196. 'initMessage' => $current_set['init_message'],
  197. 'uri' => $url,
  198. ],
  199. ],
  200. 'library' => [
  201. 'core/drupal.batch',
  202. ],
  203. ],
  204. ];
  205. return $build;
  206. }
  207. /**
  208. * Processes sets in a batch.
  209. *
  210. * If the batch was marked for progressive execution (default), this executes as
  211. * many operations in batch sets until an execution time of 1 second has been
  212. * exceeded. It will continue with the next operation of the same batch set in
  213. * the next request.
  214. *
  215. * @return array
  216. * An array containing a completion value (in percent) and a status message.
  217. */
  218. function _batch_process() {
  219. $batch = &batch_get();
  220. $current_set = &_batch_current_set();
  221. // Indicate that this batch set needs to be initialized.
  222. $set_changed = TRUE;
  223. // If this batch was marked for progressive execution (e.g. forms submitted by
  224. // \Drupal::formBuilder()->submitForm(), initialize a timer to determine
  225. // whether we need to proceed with the same batch phase when a processing time
  226. // of 1 second has been exceeded.
  227. if ($batch['progressive']) {
  228. Timer::start('batch_processing');
  229. }
  230. if (empty($current_set['start'])) {
  231. $current_set['start'] = microtime(TRUE);
  232. }
  233. $queue = _batch_queue($current_set);
  234. while (!$current_set['success']) {
  235. // If this is the first time we iterate this batch set in the current
  236. // request, we check if it requires an additional file for functions
  237. // definitions.
  238. if ($set_changed && isset($current_set['file']) && is_file($current_set['file'])) {
  239. include_once \Drupal::root() . '/' . $current_set['file'];
  240. }
  241. $task_message = $label = '';
  242. // Assume a single pass operation and set the completion level to 1 by
  243. // default.
  244. $finished = 1;
  245. if ($item = $queue->claimItem()) {
  246. list($callback, $args) = $item->data;
  247. // Build the 'context' array and execute the function call.
  248. $batch_context = [
  249. 'sandbox' => &$current_set['sandbox'],
  250. 'results' => &$current_set['results'],
  251. 'finished' => &$finished,
  252. 'message' => &$task_message,
  253. ];
  254. call_user_func_array($callback, array_merge($args, [&$batch_context]));
  255. if ($finished >= 1) {
  256. // Make sure this step is not counted twice when computing $current.
  257. $finished = 0;
  258. // Remove the processed operation and clear the sandbox.
  259. $queue->deleteItem($item);
  260. $current_set['count']--;
  261. $current_set['sandbox'] = [];
  262. }
  263. }
  264. // When all operations in the current batch set are completed, browse
  265. // through the remaining sets, marking them 'successfully processed'
  266. // along the way, until we find a set that contains operations.
  267. // _batch_next_set() executes form submit handlers stored in 'control'
  268. // sets (see \Drupal::service('form_submitter')), which can in turn add new
  269. // sets to the batch.
  270. $set_changed = FALSE;
  271. $old_set = $current_set;
  272. while (empty($current_set['count']) && ($current_set['success'] = TRUE) && _batch_next_set()) {
  273. $current_set = &_batch_current_set();
  274. $current_set['start'] = microtime(TRUE);
  275. $set_changed = TRUE;
  276. }
  277. // At this point, either $current_set contains operations that need to be
  278. // processed or all sets have been completed.
  279. $queue = _batch_queue($current_set);
  280. // If we are in progressive mode, break processing after 1 second.
  281. if ($batch['progressive'] && Timer::read('batch_processing') > 1000) {
  282. // Record elapsed wall clock time.
  283. $current_set['elapsed'] = round((microtime(TRUE) - $current_set['start']) * 1000, 2);
  284. break;
  285. }
  286. }
  287. if ($batch['progressive']) {
  288. // Gather progress information.
  289. // Reporting 100% progress will cause the whole batch to be considered
  290. // processed. If processing was paused right after moving to a new set,
  291. // we have to use the info from the new (unprocessed) set.
  292. if ($set_changed && isset($current_set['queue'])) {
  293. // Processing will continue with a fresh batch set.
  294. $remaining = $current_set['count'];
  295. $total = $current_set['total'];
  296. $progress_message = $current_set['init_message'];
  297. $task_message = '';
  298. }
  299. else {
  300. // Processing will continue with the current batch set.
  301. $remaining = $old_set['count'];
  302. $total = $old_set['total'];
  303. $progress_message = $old_set['progress_message'];
  304. }
  305. // Total progress is the number of operations that have fully run plus the
  306. // completion level of the current operation.
  307. $current = $total - $remaining + $finished;
  308. $percentage = _batch_api_percentage($total, $current);
  309. $elapsed = isset($current_set['elapsed']) ? $current_set['elapsed'] : 0;
  310. $values = [
  311. '@remaining' => $remaining,
  312. '@total' => $total,
  313. '@current' => floor($current),
  314. '@percentage' => $percentage,
  315. '@elapsed' => \Drupal::service('date.formatter')->formatInterval($elapsed / 1000),
  316. // If possible, estimate remaining processing time.
  317. '@estimate' => ($current > 0) ? \Drupal::service('date.formatter')->formatInterval(($elapsed * ($total - $current) / $current) / 1000) : '-',
  318. ];
  319. $message = strtr($progress_message, $values);
  320. if (!empty($task_message)) {
  321. $label = $task_message;
  322. }
  323. return [$percentage, $message, $label];
  324. }
  325. else {
  326. // If we are not in progressive mode, the entire batch has been processed.
  327. return _batch_finished();
  328. }
  329. }
  330. /**
  331. * Formats the percent completion for a batch set.
  332. *
  333. * @param int $total
  334. * The total number of operations.
  335. * @param int|float $current
  336. * The number of the current operation. This may be a floating point number
  337. * rather than an integer in the case of a multi-step operation that is not
  338. * yet complete; in that case, the fractional part of $current represents the
  339. * fraction of the operation that has been completed.
  340. *
  341. * @return string
  342. * The properly formatted percentage, as a string. We output percentages
  343. * using the correct number of decimal places so that we never print "100%"
  344. * until we are finished, but we also never print more decimal places than
  345. * are meaningful.
  346. *
  347. * @see _batch_process()
  348. */
  349. function _batch_api_percentage($total, $current) {
  350. return Percentage::format($total, $current);
  351. }
  352. /**
  353. * Returns the batch set being currently processed.
  354. */
  355. function &_batch_current_set() {
  356. $batch = &batch_get();
  357. return $batch['sets'][$batch['current_set']];
  358. }
  359. /**
  360. * Retrieves the next set in a batch.
  361. *
  362. * If there is a subsequent set in this batch, assign it as the new set to
  363. * process and execute its form submit handler (if defined), which may add
  364. * further sets to this batch.
  365. *
  366. * @return true|null
  367. * TRUE if a subsequent set was found in the batch; no value will be returned
  368. * if no subsequent set was found.
  369. */
  370. function _batch_next_set() {
  371. $batch = &batch_get();
  372. if (isset($batch['sets'][$batch['current_set'] + 1])) {
  373. $batch['current_set']++;
  374. $current_set = &_batch_current_set();
  375. if (isset($current_set['form_submit']) && ($callback = $current_set['form_submit']) && is_callable($callback)) {
  376. // We use our stored copies of $form and $form_state to account for
  377. // possible alterations by previous form submit handlers.
  378. $complete_form = &$batch['form_state']->getCompleteForm();
  379. call_user_func_array($callback, [&$complete_form, &$batch['form_state']]);
  380. }
  381. return TRUE;
  382. }
  383. }
  384. /**
  385. * Ends the batch processing.
  386. *
  387. * Call the 'finished' callback of each batch set to allow custom handling of
  388. * the results and resolve page redirection.
  389. */
  390. function _batch_finished() {
  391. $batch = &batch_get();
  392. $batch_finished_redirect = NULL;
  393. // Execute the 'finished' callbacks for each batch set, if defined.
  394. foreach ($batch['sets'] as $batch_set) {
  395. if (isset($batch_set['finished'])) {
  396. // Check if the set requires an additional file for function definitions.
  397. if (isset($batch_set['file']) && is_file($batch_set['file'])) {
  398. include_once \Drupal::root() . '/' . $batch_set['file'];
  399. }
  400. if (is_callable($batch_set['finished'])) {
  401. $queue = _batch_queue($batch_set);
  402. $operations = $queue->getAllItems();
  403. $batch_set_result = call_user_func_array($batch_set['finished'], [$batch_set['success'], $batch_set['results'], $operations, \Drupal::service('date.formatter')->formatInterval($batch_set['elapsed'] / 1000)]);
  404. // If a batch 'finished' callback requested a redirect after the batch
  405. // is complete, save that for later use. If more than one batch set
  406. // returned a redirect, the last one is used.
  407. if ($batch_set_result instanceof RedirectResponse) {
  408. $batch_finished_redirect = $batch_set_result;
  409. }
  410. }
  411. }
  412. }
  413. // Clean up the batch table and unset the static $batch variable.
  414. if ($batch['progressive']) {
  415. \Drupal::service('batch.storage')->delete($batch['id']);
  416. foreach ($batch['sets'] as $batch_set) {
  417. if ($queue = _batch_queue($batch_set)) {
  418. $queue->deleteQueue();
  419. }
  420. }
  421. // Clean-up the session. Not needed for CLI updates.
  422. if (isset($_SESSION)) {
  423. unset($_SESSION['batches'][$batch['id']]);
  424. if (empty($_SESSION['batches'])) {
  425. unset($_SESSION['batches']);
  426. }
  427. }
  428. }
  429. $_batch = $batch;
  430. $batch = NULL;
  431. // Redirect if needed.
  432. if ($_batch['progressive']) {
  433. // Revert the 'destination' that was saved in batch_process().
  434. if (isset($_batch['destination'])) {
  435. \Drupal::request()->query->set('destination', $_batch['destination']);
  436. }
  437. // Determine the target path to redirect to. If a batch 'finished' callback
  438. // returned a redirect response object, use that. Otherwise, fall back on
  439. // the form redirection.
  440. if (isset($batch_finished_redirect)) {
  441. return $batch_finished_redirect;
  442. }
  443. elseif (!isset($_batch['form_state'])) {
  444. $_batch['form_state'] = new FormState();
  445. }
  446. if ($_batch['form_state']->getRedirect() === NULL) {
  447. $redirect = $_batch['batch_redirect'] ?: $_batch['source_url'];
  448. // Any path with a scheme does not correspond to a route.
  449. if (!$redirect instanceof Url) {
  450. $options = UrlHelper::parse($redirect);
  451. if (parse_url($options['path'], PHP_URL_SCHEME)) {
  452. $redirect = Url::fromUri($options['path'], $options);
  453. }
  454. else {
  455. $redirect = \Drupal::pathValidator()->getUrlIfValid($options['path']);
  456. if (!$redirect) {
  457. // Stay on the same page if the redirect was invalid.
  458. $redirect = Url::fromRoute('<current>');
  459. }
  460. $redirect->setOptions($options);
  461. }
  462. }
  463. $_batch['form_state']->setRedirectUrl($redirect);
  464. }
  465. // Use \Drupal\Core\Form\FormSubmitterInterface::redirectForm() to handle
  466. // the redirection logic.
  467. $redirect = \Drupal::service('form_submitter')->redirectForm($_batch['form_state']);
  468. if (is_object($redirect)) {
  469. return $redirect;
  470. }
  471. // If no redirection happened, redirect to the originating page. In case the
  472. // form needs to be rebuilt, save the final $form_state for
  473. // \Drupal\Core\Form\FormBuilderInterface::buildForm().
  474. if ($_batch['form_state']->isRebuilding()) {
  475. $_SESSION['batch_form_state'] = $_batch['form_state'];
  476. }
  477. $callback = $_batch['redirect_callback'];
  478. $_batch['source_url']->mergeOptions(['query' => ['op' => 'finish', 'id' => $_batch['id']]]);
  479. if (is_callable($callback)) {
  480. $callback($_batch['source_url'], $_batch['source_url']->getOption('query'));
  481. }
  482. elseif ($callback === NULL) {
  483. // Default to RedirectResponse objects when nothing specified.
  484. return new RedirectResponse($_batch['source_url']->setAbsolute()->toString());
  485. }
  486. }
  487. }
  488. /**
  489. * Shutdown function: Stores the current batch data for the next request.
  490. *
  491. * @see _batch_page()
  492. * @see drupal_register_shutdown_function()
  493. */
  494. function _batch_shutdown() {
  495. if (($batch = batch_get()) && _batch_needs_update()) {
  496. \Drupal::service('batch.storage')->update($batch);
  497. }
  498. }