batch.inc 17 KB

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